Skip to content

Commit 1d3577e

Browse files
NoamGaashclaude
andcommitted
Add algorithms docs for busline-usage-anomaly and service-by-operator
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8e8e2b7 commit 1d3577e

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Bus line usage anomaly — over- and under-used lines vs. their peers
2+
3+
**Author:** team — ported from `busline_usage_anomaly.ipynb`, vendored at [busline_usage_anomaly.ipynb](../busline_usage_anomaly.ipynb) in the repo root
4+
**Code:** [analyses/busline_usage_anomaly.py](../analyses/busline_usage_anomaly.py)
5+
**Card:** `busline-usage-anomaly`
6+
**Data:** **data.gov.il** ticketing/validation open data — resource `ef42a264-9da2-41ad-9120-822064fb5433`. The only card here not built on Stride SIRI/GTFS.
7+
8+
## What it answers
9+
10+
Which bus lines carry unusually many or few passengers **for their peer group, at
11+
that hour of day**?
12+
13+
The idea worth keeping: raw passenger counts cannot be compared across lines. A
14+
dense-city line and a suburban one carry wildly different volumes for reasons that
15+
have nothing to do with how well either is running. So lines are compared only
16+
against *peers*, and only within the same hour. A low score then means "carries
17+
fewer riders than comparable lines at the same time of day" — **not** merely "is a
18+
small line".
19+
20+
## Algorithm
21+
22+
1. **Fetch** `sample_rows` (default 15,000) from the data.gov.il datastore API,
23+
paged 5,000 at a time.
24+
2. **Riders per row** — each row is one line × direction × hour × **month**, with
25+
`D1..D31` daily counts. `riders = mean(D1..D31)`, nulls skipped (days the line
26+
did not run, or a short month).
27+
3. **Clean** — drop the rail sentinel `OfficeLineId == -1` (rail rows share the
28+
dataset and dwarf buses in volume) and drop `cluster_nm == "לא מוגדר"`.
29+
4. **Collapse** direction and month duplicates: `groupby(line, operator, cluster,
30+
hour).riders.mean()`.
31+
5. **Peer z-score** — group by `(cluster, hour)`, compute `peer_mean`, `peer_std`,
32+
`peer_count`, then `z = (riders - peer_mean) / peer_std`.
33+
6. **Filter** to `peer_count >= min_peers` (default 3).
34+
7. **Report** each line's single most extreme hour by `|z|`, top 14, sorted by z.
35+
36+
## Reasoning
37+
38+
Two deliberate departures from the notebook, **both because the original could not
39+
work as written** — this is the most substantive porting critique in the repo:
40+
41+
**1. Peer grouping was rebuilt.** The notebook derived a "metro score" (exponential
42+
decay from the nearest of Tel Aviv / Jerusalem / Haifa) from station coordinates —
43+
but read it from data.gov.il resource `3ad014c3` (station passengers), **which has
44+
no line column at all**. Its `get_station_passengers(office_line_id)` silently
45+
ignored its argument and refetched the same global station table for every line, so
46+
**every line ended up scored off identical rows**. The port uses the ministry's own
47+
`cluster_nm` ("אשכול") — a real geographic/service grouping shipped in the same
48+
per-line dataset, which is what the metro score was proxying for anyway, and needs
49+
no join.
50+
51+
**2. Hours were simplified.** The notebook mapped 7 coarse Hebrew time bands to an
52+
hour. The per-line resource carries a true `hour_a` (0–23), so no mapping is needed
53+
and the resolution is better.
54+
55+
**Why `inputs=[]`.** Ticketing data keys on the ministry's own line ids, which do not
56+
line up with the SIRI/GTFS line + operator + date pickers in the global filter bar.
57+
Rather than accept filters it would silently ignore, the card declares it takes none.
58+
Given [route-divergence's finding 1](route-divergence.md) — a filter accepted and
59+
ignored producing a plausible wrong answer — this is exactly the right instinct.
60+
61+
**Why `peer_std` gets an epsilon.** A peer group of one has no spread; replacing 0
62+
with `1e-5` keeps the division finite and lands its z at ~0, the honest reading for
63+
"nothing to compare".
64+
65+
## Findings
66+
67+
### 1. data.gov.il resource `3ad014c3` has no line column, and the notebook's per-line metro score was therefore meaningless — **confidence: High**
68+
69+
`get_station_passengers(office_line_id)` ignored its argument and refetched the same
70+
global table for every line. Every line was scored off identical rows, so the
71+
resulting "metro score" varied not at all between lines. A schema fact plus a code
72+
path, both checkable; it is why the peer grouping was replaced rather than ported.
73+
74+
**This is a finding about an analysis, not about buses** — recorded here because the
75+
whole point of these documents is that dead ends should not be rediscovered.
76+
77+
### 2. `cluster_nm` is a usable ministry-defined peer grouping — **confidence: High**
78+
79+
Shipped in the same per-line dataset, requires no join, and is the ministry's own
80+
geographic/service grouping. Directly observed in the data.
81+
82+
### 3. The per-line resource carries a true `hour_a` (0–23) — **confidence: High**
83+
84+
Better resolution than the 7 Hebrew time bands the notebook mapped from. Schema fact.
85+
86+
### 4. Ticketing counts undercount anyone not validating — **confidence: High**
87+
88+
Stated in the card's notes. Structural to validation data everywhere, not specific to
89+
Israel; the size of the undercount is unknown and unmeasured here.
90+
91+
### 5. Which specific lines are over- or under-used vs. peers — **confidence: Low**
92+
93+
The card's actual output. Three compounding reasons:
94+
95+
- It is a **sample** (15,000 rows by default) of a much larger dataset, and the
96+
sample is whatever the datastore returns first — **not random**. Which lines even
97+
appear is an artifact of dataset ordering.
98+
- Peer groups need only 3 members by default. A z-score against 2 other lines has
99+
almost no distributional meaning.
100+
- `riders` is a mean over a month's daily counts, so a line that ran 4 days scores
101+
the same way as one that ran 30.
102+
103+
The *method* is sound; the numbers it currently produces are not quotable.
104+
105+
### 6. Counts of under- and over-performing line-hours (`z ≤ -1.5`, `z ≥ +1.5`) — **confidence: Low**
106+
107+
Reported in the notes. With peer groups as small as 3, |z| ≥ 1.5 is not a rare event
108+
under any null hypothesis, and no multiple-comparison correction is applied across
109+
thousands of line-hours.
110+
111+
## Criticism
112+
113+
**"Sample the first 15,000 rows" is not sampling.** `offset` walks from 0 in dataset
114+
order, so the card analyses whichever lines the resource happens to list first.
115+
Everything downstream — which clusters have enough peers, which lines appear as
116+
extremes — inherits that ordering. A random sample, or full pagination with a longer
117+
cache TTL, would fix this and is the single highest-value change to the card.
118+
119+
**Z-scores assume roughly normal peer distributions.** Ridership within a cluster is
120+
strongly right-skewed (a few trunk lines, many feeders), so z overstates how unusual
121+
the top end is and compresses the bottom. A rank-based or log-transformed score would
122+
suit the distribution better. The card's own framing — "not merely a small line" — is
123+
what makes this matter: skew is precisely the effect the z-score was meant to remove.
124+
125+
**Peer groups mix operators and route types.** `cluster_nm` is geographic. Within one
126+
cluster, an express line and a local circulator are peers, and one of them will
127+
always look anomalous. The notes do not mention this.
128+
129+
**The `min_peers = 3` default is far too low** for the statistic being computed, and
130+
the option's own help text ("a z-score against one other line is noise") suggests the
131+
author knew where the line was and drew it one step too permissively. With
132+
`peer_count = 3`, `peer_std` is estimated from three points.
133+
134+
**Direction and month are averaged away** at step 4. A line that is packed inbound at
135+
08:00 and empty outbound at 08:00 shows as average. That is a real signal — arguably
136+
*the* signal for identifying under-served corridors — and it is discarded before
137+
scoring.
138+
139+
**No date scoping at all.** The dataset spans multiple months; the card pools them
140+
with no window and no indication of which months are in play. Two runs with different
141+
`sample_rows` cover different time periods and are not comparable.

algorithms/service-by-operator.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Planned vs actual rides — the worked example
2+
3+
**Author:** example (repo scaffolding, by Noam Gaash)
4+
**Code:** [analyses/example_service_by_operator.py](../analyses/example_service_by_operator.py)
5+
**Card:** `service-by-operator`
6+
**Data:** Stride `/gtfs_rides_agg/group_by` + `/gtfs_agencies/list`
7+
8+
## What it answers
9+
10+
Daily count of rides planned (GTFS) against how many were actually observed (SIRI),
11+
for the selected operators. **The gap is unrun service.**
12+
13+
This file exists to be copied — it is the template a hackathon participant reads
14+
before writing their own analysis. It hits the real API, handles the empty case,
15+
returns two different result kinds, and is deliberately short.
16+
17+
It has therefore ended up in an awkward position: **it is the clearest statement of
18+
the question the whole hackathon is about, built on the one endpoint that cannot
19+
answer it.**
20+
21+
## Algorithm
22+
23+
1. `stride.gtfs_rides_agg(date_from, date_to, group_by="operator_ref,gtfs_route_date")`
24+
— pre-aggregated server-side, much kinder than paging every individual ride.
25+
2. Merge `stride.agencies()` for human-readable names; fall back to the numeric
26+
`operator_ref` where the name is missing.
27+
3. Filter to `req.operators` if any are selected.
28+
4. Resolve the planned and actual columns **by trying several names**
29+
`_first_col(agg, ["total_planned_rides", "planned_rides", "num_planned_rides"])`
30+
and the same for actual.
31+
5. Sum per day, melt to long form, render as a two-series line chart.
32+
33+
## Reasoning
34+
35+
**Why `gtfs_rides_agg` at all.** For a template, the pedagogically right move is to
36+
show the cheap, pre-aggregated, server-side path rather than teach newcomers to page
37+
raw rides against a shared community API. The instinct is correct and the guidance
38+
in [CLAUDE.md](../CLAUDE.md) repeats it.
39+
40+
**Why `_first_col` tolerates renames.** *"Tolerate small API column renames instead
41+
of hard-failing mid-demo."* The whole repo is built around one broken analysis not
42+
taking the demo down, and this is that principle at the column level.
43+
44+
**Why it degrades to planned-only.** If no actual column is found it notes
45+
*"No actual-rides column returned by /gtfs_rides_agg — showing planned only"* rather
46+
than charting a misleading zero.
47+
48+
**Why `inputs=["operators", "dates"]` and not lines.** It aggregates across whatever
49+
operators are selected, so a line filter would be misleading.
50+
51+
## Findings
52+
53+
### 1. The aggregate's actual-rides column is present but always zero — **confidence: High**
54+
55+
This card's own graceful degradation does not trigger, because the problem is not a
56+
missing column. `total_actual_rides` **exists and is populated with 0** for every
57+
row, network-wide, on every date sampled Nov 2025 → Jul 2026. See
58+
[days-with-no-cancellations](days-with-no-cancellations.md) finding 1 for the
59+
measurements and the control that rules out ingestion lag, and
60+
[service-violations](service-violations.md) finding 2 for the independent
61+
confirmation.
62+
63+
**The consequence for this card specifically:** the note it prints reads
64+
*"Overall 0.0% of planned rides were observed"* and the chart draws a flat line at
65+
zero beneath the planned series. To a viewer, that is a total collapse of Israeli bus
66+
service. It is a broken column.
67+
68+
### 2. Graceful degradation designed against renames does not protect against wrong values — **confidence: High**
69+
70+
A design lesson worth stating plainly. `_first_col` guards the schema; nothing guards
71+
the semantics. The card is *most* wrong precisely where it looks most confident,
72+
because the defensive path it was given never fires.
73+
74+
### 3. Planned-ride counts from the aggregate are trustworthy — **confidence: Medium**
75+
76+
`num_planned_rides` is populated and, per orion's cross-check, **agrees closely with
77+
the ride-level endpoint** (line 2259 / 2026-07-29: aggregate 123 planned vs 126 from
78+
`/rides_execution/list`). That write-up also carries an explicit correction of an
79+
earlier claim that the two disagreed. Medium: one line, one date, and 123 vs 126 is
80+
close but not equal.
81+
82+
So the endpoint is usable **as a planned-ride denominator** — which is exactly the
83+
recommendation orion's method doc lands on.
84+
85+
## Criticism
86+
87+
**As shipped, this card tells the audience that no bus in Israel ran.** It is the
88+
example card, so it is the one most likely to be looked at first, and it has no note
89+
warning that the actual series is a known-broken column. Every other card that
90+
touched this endpoint documented the defect at length in its notes; the template did
91+
not. Given the hackathon's presentation is the deliverable, that is the most
92+
consequential single fix in the repo:
93+
94+
- add a note when `actual_total == 0` and `planned_total > 0` saying the aggregate's
95+
actual column is unpopulated on this deployment, linking the evidence; **or**
96+
- drop the actual series and retitle the card to what the endpoint can honestly
97+
deliver — planned service volume per operator per day.
98+
99+
**It teaches the wrong first step.** A newcomer copying this file learns to reach for
100+
`gtfs_rides_agg` for planned-vs-actual, which is the one thing it cannot do here.
101+
`/rides_execution/list` (see [days-with-no-cancellations](days-with-no-cancellations.md))
102+
is the endpoint that works, and the template does not point at it.
103+
104+
**No SIRI lag clamp.** Unlike essentially every other card, this one uses
105+
`req.date_from`/`req.date_to` directly. With a healthy actual column it would show a
106+
sharp fake decline over the last 3 days — a second way to read a data artifact as a
107+
service collapse.
108+
109+
**`daily["Actual"] = agg.groupby(...).sum().to_numpy()`** assigns by position, not by
110+
join key. It happens to be safe because both aggregations group the same frame by the
111+
same column in the same sorted order, but it is a positional assignment in the file
112+
that exists to be copied.

0 commit comments

Comments
 (0)