bt-eta-correction-a1
LightGBM regressor that predicts a residual correction to Bloomington Transit's live GTFS-RT arrival delays, measured against inferred actual arrival times.
Produced at the IU Luddy Hacks hackathon (2026-04-18).
Why a residual model?
BT's GTFS-RT feed publishes one trip-level delay per trip, applied identically to every remaining stop (~91 % of trip-snapshots in our audit). There is no arrival.time field and no per-stop refinement. We learned the gap between BT's predictions and ground truth, then ADD that correction to BT's prediction to produce an adjusted ETA.
Intended use
- Live ETA display in a Bloomington Transit client app (see companion Android repo).
- Baseline research reference for other small/medium US transit agencies whose GTFS-RT publishes only trip-level delays.
Out-of-scope / limitations
- Distribution shift: training data spans 2026-04-17 Friday evening through 2026-04-19 Sunday night (~50 h, mixed weekday / weekend). The feature set excludes
service_idand calendar date to encourage generalisation across days; Mon-Thu coverage is absent. - Sample size: 460 unique trips across 12 of BT's 16 routes; routes 12, 13, 14, 122927 are unseen.
- Label noise: 74 % of usable training labels come from
midpointinference (±15 s noise floor); the remaining 26 % are high-confidenceSTOPPED_ATobservations. - No holiday / severe weather handling.
Training data
Labels derived from live GTFS-RT snapshots of BT's position_updates.pb and trip_updates.pb, collected at 10 s cadence from 2026-04-17 through 2026-04-19 (~50 h window covering Friday evening, Saturday full day, and Sunday).
ground_truth_arrivals.parquet- 11,283 (trip, stop) labels; 2,063 high + 6,003 medium + 3,217 excluded (no signal in window).bt_prediction_error.parquet- 693,648 BT predictions scored against those labels; target used for training isactual - bt_predicted(signed seconds).
See the companion dataset card.
Features (13)
All derived from timestamps or static GTFS - no service_id, no calendar date, no feature that memorises weekday identity.
| Feature | Source |
|---|---|
hour_of_day, minute_of_hour, day_of_week, is_weekend |
snapshot_ts_utc -> America/New_York |
route_id (categorical) |
trips.txt via trip_id |
bt_trip_delay_seconds |
trip_updates.pb current stop_time_update.arrival.delay |
trip_progress_fraction |
stop_sequence / total_stops_on_trip |
stops_remaining |
total_stops_on_trip − stop_sequence |
prediction_horizon_seconds |
actual_arrival_utc − snapshot_ts_utc |
upstream_delay_trend_60s, has_upstream_trend |
Δ BT trip delay over prior 60 s |
route_length_km |
average shape polyline length per route |
average_stop_spacing_m |
route_length_km * 1000 / avg_stops_per_trip |
Model
- Architecture: LightGBM regressor
- Params:
learning_rate=0.05, num_leaves=31, min_data_in_leaf=30, feature_fraction=0.9, bagging_fraction=0.9, bagging_freq=3, lambda_l2=1.0, num_boost_round=600 (final), early stop 50 - Target:
target_correction_seconds = inferred_actual - bt_predicted(signed seconds) - Validation: 5-fold
GroupKFoldwithtrip_idas the group key (prevents within-trip leakage).
Evaluation
Against BT's own published-delay passthrough at the 3-5 min prediction horizon (the most decision-relevant horizon for riders), 5-fold GroupKFold OOF on trip_id.
| Metric | BT passthrough | A1 (ours) | Δ |
|---|---|---|---|
| MAE @ 3-5 min horizon (s) | 82.3 | 50.2 | -32.1 (-39.0 %) |
| MAE overall (s) | 136.7 | 72.5 | -64.2 (-47.0 %) |
| Bias @ 3-5 min (s) | - | +0.1 | - |
| RMSE @ 3-5 min (s) | - | 69.9 | - |
Per-route (OOF MAE vs passthrough):
| Route | n | Passthrough | A1 | Δ |
|---|---|---|---|---|
| 3W | 108,920 | 134.0 | 82.3 | -51.7 |
| 3E | 104,775 | 92.9 | 61.9 | -31.0 |
| 7 | 91,211 | 101.1 | 62.0 | -39.1 |
| 9 | 88,530 | 107.0 | 53.1 | -53.9 |
| 6 | 82,852 | 338.5 | 162.3 | -176.2 |
| 4W | 50,200 | 110.3 | 52.2 | -58.1 |
| 5 | 49,137 | 100.8 | 44.3 | -56.5 |
| 1 | 40,477 | 120.1 | 58.0 | -62.0 |
| 4S | 34,372 | 112.4 | 46.8 | -65.5 |
| 2W | 21,556 | 103.8 | 52.6 | -51.1 |
| 2S | 13,387 | 99.9 | 53.3 | -46.6 |
| 11 | 8,231 | 116.2 | 50.9 | -65.3 |
A1 improves every route. Route 6 has the largest absolute gain (-176 s MAE) and is the hardest residual; routes 1, 4S, 11 show the largest relative improvements.
Top features by gain
prediction_horizon_secondsbt_trip_delay_secondsroute_idtrip_progress_fractionhour_of_day
Companion artifact - A2 per-route intercepts
route_intercepts.json holds a simple median(actual − bt_predicted) per route for 12 routes (≥30 samples each). The inference service applies the intercept instead of A1 when the route is unseen by A1, and applies the intercept on top of zero when A1 aborts (falls back to passthrough).
How to use
import joblib, pandas as pd
bundle = joblib.load("a1_delay_correction.joblib")
booster = bundle["booster"]
# build a single-row DataFrame with the 13 feature columns above
# Important: set route_id as pandas.Categorical with categories from bundle["category_maps"]
row = pd.DataFrame([{
"hour_of_day": 17, "minute_of_hour": 32, "day_of_week": 4, "is_weekend": 0,
"route_id": "6",
"bt_trip_delay_seconds": 240.0,
"trip_progress_fraction": 0.5, "stops_remaining": 12,
"prediction_horizon_seconds": 240, "upstream_delay_trend_60s": 0, "has_upstream_trend": 0,
"route_length_km": 14.3, "average_stop_spacing_m": 420.0,
}])
row["route_id"] = pd.Categorical(row["route_id"], categories=bundle["category_maps"]["route_id"])
correction_s = float(booster.predict(row[bundle["feature_cols"]])[0])
adjusted_eta_s = bt_scheduled_epoch_s + bt_trip_delay_s + correction_s
Citation
If this model is useful to you:
@misc{btetacorrection2026,
title = {bt-eta-correction-a1: residual LightGBM for small-agency GTFS-RT ETAs},
author = {Sk, Ayan and Dodia, Chirag and Patel, Omkar and <Naishal>},
year = 2026,
url = {https://huggingface.co/Ayansk11/bt-eta-correction-a1},
note = {IU Luddy Hacks submission}
}
Contact
Repositories: https://github.com/ayansk11/bt-ml (this ML service) and https://github.com/ChiragDodia36/BT_transit_App (Android client).