Given a route and a departure date, is this fare cheap or expensive — and when should you book?
A Streamlit app that pulls live fares from Google Flights across many departure dates and classifies each one as Cheaper or Costlier with a logistic-regression scorecard. No API key required — clone it and it runs.
- Real fares across a date range, not a single snapshot — one query per departure date
- Booking-curve analysis: how fare moves with time to departure
- Logistic Regression classifier with interpretable coefficients
- Leakage-free evaluation: split grouped by departure date, stability reported across 8 splits
- Threshold diagnostics: what the decision cut-off costs and buys
- Frontend: Streamlit, Matplotlib, Seaborn
- Data:
fast-flights(Google Flights), no credentials - ML: scikit-learn — logistic regression (L1), grouped splitting, ROC/PR analysis
Kolkata → Bangalore, 1,067 fares across 90 departure dates:
| metric | value |
|---|---|
| ROC-AUC | 0.930 |
| Accuracy (threshold 0.50) | 82.1% |
| F1 — Cheaper / Costlier | 0.832 / 0.809 |
| Log loss | 0.336 |
Evaluation splits by departure date, so no date appears in both train and test.
1. Time-to-departure has almost no linear relationship with price — but it is the strongest predictor.
| window | correlation of Days Out with the label |
|---|---|
| 30 days | 0.576 |
| 90 days | 0.120 |
The correlation collapses as the window widens, while model performance holds at ~0.93 AUC.
The reason is that the booking curve is U-shaped — expensive last-minute, cheapest around
30–60 days out, expensive again further ahead. Pearson correlation only sees linear
association, so a U-shape reads as roughly zero, and a raw linear term would learn nothing.
Binning Days Out into bands recovers the signal. The fitted coefficients then trace the
curve directly, e.g. relative to booking inside a week: 7–14 days −1.42, 14–21 days −2.05,
21–30 days −3.21 — steadily cheaper, exactly as a traveller experiences it.
2. Tuning the decision threshold did not help — tested twice, rejected.
| run | 0.50 (default) | tuned on train | winner |
|---|---|---|---|
| 90 dates, 1,067 fares | 82.1% acc, F1 0.809 | 0.58 → 80.7%, F1 0.784 | default |
| 30 dates, 344 fares | 91.9% acc, F1 0.901 | 0.58 → 87.2%, F1 0.820 | default |
The cut-off maximising Youden's J on the training set performs worse on held-out dates, in both runs. With a median-split label the classes are balanced by construction, so 0.50 is already near-optimal and the tuning was fitting training noise. The app therefore uses 0.50 and reports the tuned value beside it rather than burying the comparison.
The direction of that choice matters: 0.50 was fixed before seeing any data. Picking whichever threshold scored best on the test set would be selecting an operating point after seeing the answers — the same class of error as the leakage described below, in miniature.
An earlier version of this app reported 90–98% accuracy. That figure was an artifact:
it fetched one day of fares and copied them across up to 90 simulated future days,
changing only the date. A random train_test_split then scattered near-identical rows
across both sides, so the classifier scored highly by recognising rows it had already seen.
The current version fetches each departure date for real and splits by date. The honest number is ~0.93 ROC-AUC, and it holds up on dates the model has never seen.
git clone https://github.com/bravo2024/FLIGHT.git
cd FLIGHT
python -m venv .venv && source .venv/bin/activate # optional
pip install -r requirements.txt
streamlit run classify.pyNo API keys, no secrets.toml. Fares are fetched live at run time.
Tip: in the classification tab, set the day range to 90. Fewer departure dates make the grouped evaluation unstable — at 6 dates a test fold is one or two dates and AUC swings wildly; at 18+ it settles.
On live-request limits. Each departure date is one request to the source, and Google throttles bursts from shared datacentre IPs — measured on Streamlit Cloud, ~90 rapid requests returns almost nothing while ~18 succeeds. The app fetches every date you ask for and adapts its pacing rather than thinning the range: it opens at 0.3s spacing and widens (×2.5 per refusal, up to 3s) only once the source actually starts refusing. A clean 90-date run finishes in about 4 minutes; one that hits throttling slows itself to roughly 8 rather than returning empty.
Refused dates are retried twice with exponential backoff, and anything still missing is named rather than quietly dropped. Successful dates are cached for the session, so re-running the same query is instant.
classify.py # the whole app: live fetch, features, model, diagnostics
requirements.txt
DOCS/ # screenshots
- Single-origin snapshot: all dates are observed on one day, so the model sees the booking curve as it stands today, not how a given flight's price drifts as departure approaches. Running the app on several days would build that panel.
- "Cheap" is defined relative to the median fare of the dates fetched, so the label moves with the query window.
fast-flightsparses Google Flights HTML; upstream markup changes can break collection until the library is updated.
