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_id and 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 midpoint inference (±15 s noise floor); the remaining 26 % are high-confidence STOPPED_AT observations.
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 is actual - 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.
Validation: 5-fold GroupKFold with trip_id as 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_seconds
bt_trip_delay_seconds
route_id
trip_progress_fraction
hour_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
python
1import joblib, pandas as pd
2bundle = joblib.load("a1_delay_correction.joblib")3booster = bundle["booster"]4# build a single-row DataFrame with the 13 feature columns above5# Important: set route_id as pandas.Categorical with categories from bundle["category_maps"]6row = pd.DataFrame([{7"hour_of_day":17,"minute_of_hour":32,"day_of_week":4,"is_weekend":0,8"route_id":"6",9"bt_trip_delay_seconds":240.0,10"trip_progress_fraction":0.5,"stops_remaining":12,11"prediction_horizon_seconds":240,"upstream_delay_trend_60s":0,"has_upstream_trend":0,12"route_length_km":14.3,"average_stop_spacing_m":420.0,13}])14row["route_id"]= pd.Categorical(row["route_id"], categories=bundle["category_maps"]["route_id"])15correction_s =float(booster.predict(row[bundle["feature_cols"]])[0])16adjusted_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}
}