|
| 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. |
0 commit comments