Skip to content
ML Academy

project-017 · Forecasting · aviation

Route-day passenger backtest and the capacity/price what-if: forecasts are not causal answers

Network planning asks two different questions about the synthetic carrier: (1) how many passengers will each route carry on each day 1-28 days ahead (ground handling and catering lead times), and (2) how many extra passengers would we carry if IST-TZX were up-gauged from 160 to 180 seats, or if domestic fares were cut by 5%? Answer (1) with a validated forecast and show, against the simulator's true effect, why the same model cannot answer (2).

Dataset
Synthetic airline simulator output (flight_day aggregated to route x day; latent truth via simulate())
Licence
Synthetic data generated by the academy's own simulator (project code); no third-party data.
Reference run
executed

At a glance

  • Data

    Synthetic airline simulator output (flight_day aggregated to route x day; latent truth via simulate()) (synthetic) · 20,824 flight-days aggregated to 12,056 route-days (11 routes x 1,096 days), 2022-01-01 to 2024-12-31.

    Details on data
  • Baseline

    Naive (last observed route-day value) and seasonal naive (same weekday one week earlier, repeated for longer horizons).

    Details on baseline
  • Validation

    Rolling origin, expanding window: 26 fortnightly origins through 2024; at each origin fit on all route-days before the origin and forecast h = 1-28.

    Details on validation
  • Metric

    MAE and MASE per horizon h (scaled by in-sample seasonal-naive error), bias, and the per-horizon error curve;

    Details on metric

Success criterion

(a) A per-horizon table (h = 1-28) where the chosen model beats seasonal naive on MASE at every horizon bucket (1-7, 8-14, 15-28) across 2024 rolling origins, with slice results attached. (b) A what-if table that puts, side by side for each scenario, the forecast-model answer, a naive observational regression answer and the simulator's true effect, and a one-paragraph explanation of each gap. Success is a correct explanation, not a model that matches the true effect.

Context

This is the specification for reference implementation r04. The simulator fixes true price elasticities (-0.6 to -1.35 by route), an RM pricing rule that raises fares when demand is expected or observed to be high, and an overbooking limit that truncates bookings. Because the data are synthetic, the counterfactual is computable: capstone.airline_sim.true_effect() reruns the simulator with the intervention and the same random draws. Real data never offers this check, which is exactly why the project uses a simulator.

Data

Dataset
Synthetic airline simulator output (flight_day aggregated to route x day; latent truth via simulate()) (synthetic)
Licence
Synthetic data generated by the academy's own simulator (project code); no third-party data.
Size
20,824 flight-days aggregated to 12,056 route-days (11 routes x 1,096 days), 2022-01-01 to 2024-12-31.
Verification
executed

Access

No network or login. from capstone.airline_sim import simulate, true_effect, SimParams; simulate(SimParams(end="2024-12-31")) returns flight_day, truth and booking_curve in seconds on a laptop. The truth frame (unconstrained demand, unobserved shock, elasticity) is for evaluation only and must never enter features.

Grain

Modelling grain: route x departure date, target = sum of passengers over the route's flights. The truth frame has the same flight_no x date grain as flight_day; aggregate both the same way before comparing.

ColumnMeaning
passengersBoarded passengers = bookings minus cancellations and no-shows, capped at capacity; the forecast target
bookings_finalBookings at departure, capped at floor(1.06 x capacity); equality with the cap marks a censored flight-day
capacitySeats per flight; constant within each route across the whole sample
avg_fareRealised average fare, set by an RM rule that reacts to expected and unobserved demand (confounded)
competitor_capacityCompetitor seats; steps up about 25% on IST-ADB, IST-AYT, IST-FRA from 2024-03-01
truth.demand_realLatent unconstrained demand (simulator only); used to measure censoring, never as a feature

EDA and data-quality rules

  • Aggregate flights to routes only after checking each route has the same number of flights on every date (no missing flight-days).

  • Report the share of flight-days at the bookable limit per route and month; compare it with the share where truth.demand_real exceeds the limit (they should agree in the simulator).

  • Confirm capacity has zero within-route variance; any model coefficient on capacity is then identified only across routes.

  • Plot avg_fare against passengers per route: a positive association is expected because of RM pricing, and it is not an elasticity.

Baseline

Naive (last observed route-day value) and seasonal naive (same weekday one week earlier, repeated for longer horizons). Both are computed per route with lags that never cross route boundaries.

Features

Entity-safe lags: sort by route and date, then groupby("route").shift(k) with k at least the horizon h (direct strategy: one model per horizon bucket, using lags h, h+7, h+14 and rolling means ending at the issue date). Calendar known in advance (weekday, holiday window, event flag, month), route and market as categoricals. No same-day outcomes (avg_fare, revenue, load_factor) and no truth columns. For the what-if comparison only, a separate "naive observational regression" of log passengers on log avg_fare and capacity is fitted to show what a careless analyst would report.

Candidate methods

  • ETS per route (statsforecast AutoETS, weekly season 7)

    A strong local statistical model for daily data with weekly seasonality; cheap enough to refit at every origin and a fair opponent for ML.

  • LightGBM global direct model with entity-safe lags and calendar features

    Shares information across 11 routes and handles holidays and events as features; tests whether a global ML model adds value over ETS at longer horizons.

Validation design

Rolling origin, expanding window: 26 fortnightly origins through 2024; at each origin fit on all route-days before the origin and forecast h = 1-28. Report MAE, MASE and bias per horizon h and per bucket, pooled across origins with standard errors. The 2024-03-01 break falls inside the evaluation period on purpose.

Leakage checks

Unit test: for a forecast with horizon h issued at T, every lag feature refers to dates at or before T. A cross-route leak test shuffles route order before feature building and asserts identical features. Assert no truth column is reachable from the feature function.

Bias and representativeness

Bias per route and market, and separately on route-days with any flight at the bookable limit, where the target itself understates demand.

Robustness checks

Recompute the 2024 results with the three break routes excluded; rerun with seeds 1-3 for LightGBM to show seed variation is smaller than the gap between candidates.

Technical metrics

MAE and MASE per horizon h (scaled by in-sample seasonal-naive error), bias, and the per-horizon error curve; for the what-if: estimated vs true change in passengers per scenario and route.

Business interpretation

Passengers mis-planned per day for ground handling (absolute error summed over routes). For the what-if: the planning error in extra passengers that the wrong method would have promised, priced with a hypothetical average fare to show its revenue scale.

Error slices

  • Horizon buckets 1-7, 8-14, 15-28 days.

  • Holiday windows and destination events.

  • Break routes after 2024-03-01.

  • Route-days at the bookable limit vs not.

  • Market (domestic vs international) for the price what-if.

Implementation steps

  • Simulate, aggregate to route-day, write the grain and availability table.

  • Build entity-safe lag features with tests (horizon rule, cross-route shuffle).

  • Run baselines, AutoETS and LightGBM through one rolling-origin harness; save per-horizon and slice tables.

  • Compute the true effects with true_effect(base, capacity_override={'IST-TZX': 180}) and true_effect(base, price_change=-0.05).

  • Compute the forecast-model answer (unchanged inputs give unchanged forecasts for capacity; avg_fare is not a feature) and the naive observational regression answer.

  • Explain each gap: capacity has no within-route variation and binds only on censored days; fares are confounded by RM pricing.

  • Write report.md with the per-horizon table, slices, what-if table and limitations.

Reproduction

cd python && uv run python -m reference.r04_airline_demand_backtest.run

Design target: CPU-only, no network, under five minutes on a laptop, deterministic (seed 42 simulator, fixed LightGBM seed). Outputs: metrics_by_horizon.csv, slices.csv, whatif.csv, report.md under the module's outputs/ folder, plus a pytest smoke test on a three-month window.

Limitations

  • The true effect exists only because the data are simulated; the conclusion (forecasts trained on observational, confounded, censored data do not answer interventions) transfers, the numbers do not.

  • Route-day aggregation hides flight-level censoring: a route can be below its total cap while one flight is full.

  • ETS and LightGBM are point forecasts here; interval behaviour is covered by project-022.

  • The naive observational regression is a strawman by design; a defensible causal estimate would need an experiment or an identification strategy (Day 5), not a better forecaster.

Production and monitoring

The route-day forecast would run nightly with per-horizon monitoring on matured labels. The what-if must not be served from the forecast endpoint: route it to an analysis process that states its identification assumptions, and log every planning decision that used it so the realised effect can be checked after the change.

Interview questions

  • Your forecast model has capacity as a feature. Why does changing it from 160 to 180 barely change the prediction, and is that right?

  • The regression of passengers on fare has a positive slope. Explain why, and what you would do to estimate elasticity.

  • How do you build lag features for 11 routes so that no lag crosses from one route into another, and how do you test it?

Postmortem and alternatives

Likely findings: LightGBM wins at short horizons and ETS at long horizons, or the reverse after the break; entity-unsafe lags inflated early results; the capacity what-if was answered with a cross-route regression that confused large markets with large aircraft. Alternative worth recording: a censoring-aware demand model (estimate demand on censored days, then simulate capacity) validated against the simulator truth before any real-data use.

Reference implementation

Status: executed — outputs below are from the recorded run

cd python && uv run python -m reference.r04_airline_demand_backtest.run

r04 results - route-day demand backtest and the forecast-versus-causal what-if

Recorded run of cd python && uv run python -m reference.r04_airline_demand_backtest.run (full mode). Every number below is copied from the files in outputs/ written by that run (metrics_by_horizon.csv, metrics_by_route.csv, slices.csv, paired_vs_baseline.csv, whatif.csv, whatif_coefficients.csv, true_price_elasticity.csv, censoring.csv, robustness_*.csv, summary.json). The data are synthetic (academy airline simulator, seed 42); the 100 EUR per passenger that appears in the what-if table is a hypothetical scale, not a fare.

ItemValue
Run date2026-10-11
Seedssimulator 42; LightGBM random_state 42 (robustness seeds 1, 2, 3 on 9 of the 26 origins); AutoETS is deterministic
Python / numpy / pandas / scipy3.12.13 / 2.5.3 / 3.0.6 / 1.18.1
scikit-learn / LightGBM / statsforecast / statsmodels1.9.1 / 4.7.0 / 2.0.1 / 0.15.0
Runtime of the recorded run282.7 s inside run.py (4 min 46 s wall including start-up and import; single process, LightGBM n_jobs=1, AutoETS n_jobs=1; Apple M5, 10 cores, 16 GB; no other heavy job was running during this run)
Smoke mode--smoke: 6 months of data, 3 origins, no robustness seeds, 8.6 s wall when it was timed after the final code change
Panel12,056 route-days = 11 routes x 1,096 dates (complete grid, checked); 20,824 flight-days before aggregation
Rolling origins26 fortnightly origins, issue dates 2023-12-19 to 2024-12-03, expanding window, horizons 1 to 28: 8,008 scored forecasts (26 x 11 routes x 28)
Target dates scored2023-12-20 to 2024-12-31 (the first origin is in December 2023, so 12 of the first origin's target days fall in 2023)
Leakage self-checks inside the runfeatures at origin 2024-06-18 unchanged when every later value is replaced by 1e6 (308 rows); route-shuffle identical: True

Findings

  1. The grain is clean and the features are leakage-safe. Every route has the same number of flights on every date, so the route-day grid is complete and groupby("route").shift(k) is a true k-day lag. Seasonal lags are weekday-aligned with the target date and never newer than the issue date; the 28-day trailing mean (the normaliser) ends at the issue date. Replacing every observation after an origin by 1e6 leaves the feature rows of that origin unchanged, and shuffling the route order before building features changes nothing. The unit tests also check each lag column against the documented source date on a panel whose values encode (route, day).
  2. Both candidates beat seasonal naive in every horizon bucket, on every origin. Pooled over all horizons MASE is 0.988 for naive, 0.949 for seasonal naive, 0.733 for AutoETS and 0.721 for LightGBM (origin-level standard errors 0.010 to 0.024). The paired per-origin MASE difference to seasonal naive is between -0.207 and -0.246 in all six candidate-by-bucket cells, and the candidate is better on 26 of 26 origins in every cell. Success criterion (a) is met by both models. The chosen candidate by pooled MASE is LightGBM, but the margin to AutoETS is small (finding 3).
  3. LightGBM and AutoETS are close; the only supported gap is at 15 to 28 days. LightGBM minus AutoETS in paired MASE is +0.006 at 1-7 days (SE 0.007, LightGBM better on 11 of 26 origins), -0.013 at 8-14 days (SE 0.013, 13 of 26) and -0.022 at 15-28 days (SE 0.009, t = -2.3, 19 of 26). On the nine-origin subset used for seeds, the gap AutoETS minus LightGBM is 0.002 / 0.017 / 0.016 MASE for the three buckets while the LightGBM seed standard deviation is about 0.002: at 1-7 days the gap is no larger than seed-to-seed variation, at 8-14 and 15-28 days it is about eight times larger. Excluding the three break routes does not change the picture (table in the robustness section).
  4. The error curve is flat in the horizon. LightGBM MASE by single horizon ranges from 0.661 (h = 26) to 0.772 (h = 6) with no upward trend; the single-horizon values of the two candidates carry an origin-level standard error of 0.020 to 0.036, so differences between neighbouring horizons are mostly noise. Naive and seasonal naive coincide at h = 7, 14, 21 and 28 by construction. A flat curve is what one expects when the forecastable structure is calendar and season rather than recent level; I did not test that explanation separately.
  5. Calendar information, not the algorithm, explains the holiday and event advantage. On holiday-window route-days (n = 1,243) LightGBM has MASE 0.660 against 0.772 for AutoETS, and ETS under-forecasts by 5.5% on average while LightGBM is within 0.7%. On event days (n = 132) the figures are 0.572 and 0.716. Outside holiday windows the two are level (0.732 against 0.726). LightGBM receives the holiday-window and event flags; AutoETS (one univariate model per route) does not, so this comparison mixes model class with information.
  6. The structural break hurts both models and no ordering survives it. On the break routes from 2024-03-01 (n = 1,794) MASE is 0.781 (LightGBM) and 0.826 (ETS), against 0.685 and 0.693 on the other routes in the same period, and both over-forecast (+1.3% and +0.6%) because the level they learned predates the step in competitor capacity. LightGBM leads there by 0.046 MASE, but on the same routes before the step (n = 390) ETS leads by 0.040. With 390 and 1,794 scored forecasts and one break, I would not draw a conclusion about which model handles a break better.
  7. Markets and routes. MASE for LightGBM / ETS is 0.718 / 0.742 domestic, 0.742 / 0.749 intl-short and 0.683 / 0.680 intl-long. By route LightGBM has the lower MASE on 8 of 11 routes and ETS on 2 (IST-DIY, IST-JFK); IST-ATH is level at three decimals. The largest route-level relative bias for LightGBM is -2.1% (IST-CDG) and no route exceeds 2.1% in absolute value.
  8. Route-days at the bookable limit look biased, but that split is made on the outcome. On the 2,775 route-days with at least one flight at the bookable limit both models under-forecast (-7.7% for LightGBM) and on the other 5,233 they over-forecast (+5.1%). Because the slice is defined by a realised outcome (high bookings on the target day), a forecast that is unbiased on average must look low on one side and high on the other; the pair does not show that the model could have known at issue time that a full flight was coming. What the slice does show is the censoring itself: in the simulator's truth frame 23.0% of 2024 flight-days sit at the bookable limit and 22.3% have latent demand above it, a total of 44,369 bookings turned away in 2024, so the target systematically understates demand on those days (finding 11 shows what that does to interventions).
  9. Entity-unsafe lags barely matter in this panel: there is no score inflation to report. The deliberately wrong version shifts the stacked frame, so the first 27 days of routes 2 to 11 read the tail of the previous route: 7,560 of 336,812 feature rows (2.2%) differ, all of them early-2022 training rows; no scored row is affected. MASE changes by -0.0025, -0.0044 and +0.0022 for the three buckets (mixed sign, same size as the seed range of 0.004 to 0.005). With this layout the bug is a correctness defect, not a score inflater; a panel stacked date-by-date would be far worse and was not measured.
  10. The deployed forecaster cannot answer any intervention, by construction, and the code verifies that numerically. Its feature code only reads the route, the date, the target history and calendar flags. Re-running the forecaster on counterfactual inputs (capacity 180 on IST-TZX, fares x 0.95, competitor seats x 1.25) changes none of the 308 forecasts of the final origin (maximum absolute change 0.0 passengers in all three scenarios). Its answer is zero against true effects of +3,029, +37,297 and -93,669 passengers in 2024. That is the right behaviour for a forecaster, and the wrong thing to serve to a planner who asks a what-if question.
  11. Capacity 160 to 180 on IST-TZX. The simulator's true effect over 2024 is +3,029 passengers on a base of 97,080 (+3.1%), although seats rise 12.5%: extra seats only help on days when bookings were at the limit (31.6% of IST-TZX flight-days in 2024). The naive pooled regression answers +8,575 (2.83 times the truth) because its capacity coefficient (0.72) is identified only by differences between routes, and routes with bigger aircraft are also different in many other ways. Capacity has zero within-route variance, so with route fixed effects it is not identified at all and the adjusted regression reports it as undefined. The driver LightGBM gives +2,032 (0.67 times): the sign is right, but with route in the model the number comes from cross-route splits and I would not trust it. An estimator that does not model the seat limit has no way to reproduce an effect that arises only on days when latent demand exceeded the old limit.
  12. Price -5% on all routes. True effect +37,297 passengers (+3.7% of 1,018,336); the realised arc elasticity is -0.70 (domestic -0.84, intl-short -0.61, intl-long -0.40), smaller in magnitude than the configured route elasticities (-0.6 to -1.35); that is consistent with censoring clipping the gain on the flight-days at the limit, though I did not isolate how much of the difference is censoring. The simulator's own revenue change is -4,845,833 EUR (fares fall 5% and the extra passengers do not compensate). The pooled regression has the right sign but 0.43 of the magnitude, with a fare coefficient of -0.31; once route fixed effects, weekday, month, holiday and event flags and a trend are added the fare coefficient flips to +0.57 and the implied effect is -29,426 (-0.79 times: wrong sign). The driver LightGBM is also wrong in sign (-22,357). The reason is in the generator: the revenue-management rule sets higher fares when demand is expected or observed to be high, including a shock that no feature contains, so within a route high fares and high passenger counts occur together. The pooled model's negative sign therefore reflects differences between routes and seasons rather than a price response: it changes sign as soon as route and calendar controls are added.
  13. Competitor capacity +25% on all routes. True effect -93,669 passengers (-9.2%). The naive pooled regression returns +190,615, the wrong sign (+0.77 coefficient, which is consistent with large markets attracting more competitor seats). The adjusted regression returns -99,671 (1.06 times the truth, right sign, within 6%) and by scope between 0.96 and 1.18 times; the driver LightGBM is right in sign but 0.34 of the size. So the lesson is not that regression always fails: the adjusted regression works for competitor capacity because, in the simulator, competitor seats vary within a route by independent noise plus a dated step and fares do not respond to them (see capstone/airline_sim.py), whereas fares do respond to the hidden demand shock. Whether a real competitor's capacity decision is that clean is an assumption that has to be argued, not estimated.

Backtest by horizon bucket

Pooled over 26 origins and 11 routes. MASE is scaled by the in-sample seasonal-naive MAE of the same route up to the origin; bias is forecast minus actual in passengers per route-day; the standard error is the standard deviation of the per-origin value divided by the square root of 26 (adjacent origins overlap for horizons beyond 14 days, so the standard errors are somewhat optimistic).

horizonmodelnMAEMASE (origin SE)bias (forecast - actual)relative bias
1-7AutoETS2,00226.30.722 (0.015)-0.68-0.27%
1-7LightGBM2,00226.50.729 (0.015)-1.29-0.51%
1-7naive2,00235.20.968 (0.025)-4.37-1.73%
1-7seasonal naive2,00234.30.938 (0.021)-0.84-0.33%
8-14AutoETS2,00226.60.729 (0.014)-0.07-0.03%
8-14LightGBM2,00225.90.716 (0.014)+0.22+0.09%
8-14naive2,00235.90.996 (0.034)-3.76-1.49%
8-14seasonal naive2,00235.10.961 (0.016)-0.23-0.09%
15-28AutoETS4,00427.00.741 (0.011)-0.21-0.08%
15-28LightGBM4,00426.20.719 (0.012)-0.66-0.26%
15-28naive4,00436.10.995 (0.023)-3.89-1.54%
15-28seasonal naive4,00434.50.948 (0.018)-0.36-0.14%
allAutoETS8,00826.70.733 (0.010)-0.29-0.12%
allLightGBM8,00826.20.721 (0.010)-0.59-0.23%
allnaive8,00835.80.988 (0.024)-3.98-1.57%
allseasonal naive8,00834.60.949 (0.015)-0.44-0.18%

Paired per-origin MASE differences (negative favours the first model):

comparisonhorizon bucketpaired MASE differenceSE over originstorigins where first model is better
AutoETS minus seasonal naive1-7-0.2160.015-14.526 of 26
AutoETS minus seasonal naive8-14-0.2330.013-18.326 of 26
AutoETS minus seasonal naive15-28-0.2070.012-17.926 of 26
LightGBM minus seasonal naive1-7-0.2090.015-13.626 of 26
LightGBM minus seasonal naive8-14-0.2460.015-15.826 of 26
LightGBM minus seasonal naive15-28-0.2290.015-15.626 of 26
LightGBM minus AutoETS1-7+0.0060.007+1.011 of 26
LightGBM minus AutoETS8-14-0.0130.013-1.013 of 26
LightGBM minus AutoETS15-28-0.0220.009-2.319 of 26

Backtest by single horizon (h = 1 to 28)

Each row pools 286 forecasts (26 origins x 11 routes). metrics_by_horizon.csv also carries WAPE, relative bias and standard errors.

hMASE naiveMASE snaiveMASE ETSMASE LightGBMMAE ETSMAE LightGBMbias ETSbias LightGBM
10.9601.0710.7580.74727.227.0-1.41-0.47
20.8920.9040.6910.68425.024.7-0.93+0.31
30.9330.8560.6780.68624.825.2-6.73-5.69
41.0340.8830.7540.74927.827.4-0.06-1.46
50.9780.9040.7220.74326.026.6-3.84-3.57
61.0040.9690.7600.77228.228.4+4.70+2.17
70.9760.9760.6910.71925.426.3+3.52-0.30
80.9980.9820.7460.75827.427.8+5.84+3.65
91.0420.9720.7300.73026.626.6-2.09-1.58
100.9830.8790.7390.69027.024.8-2.46-0.46
111.0091.0460.7210.71426.225.9+1.09+0.26
120.9860.9310.7070.69725.725.3-1.83-0.01
130.9260.8940.6680.67324.624.6-2.30-2.39
141.0241.0240.7890.74728.426.6+1.24+2.07
150.9691.0220.7900.74228.527.1-1.48-0.37
160.9360.9450.7160.69926.025.3-0.96+0.36
171.0290.8620.7310.70126.725.7-5.87-5.40
180.9900.9490.7610.75828.027.6-0.79-2.06
191.0240.9090.7480.73726.726.4-2.70-2.63
201.0121.0050.7660.77028.328.4+4.41+1.77
210.9720.9720.6940.72725.626.8+3.96+0.37
220.9490.9460.7360.73126.926.6+5.29+2.65
231.0030.9280.7370.69426.825.2-2.88-1.50
240.9700.9150.7810.73228.526.4-1.70-0.83
251.0240.9210.7300.72026.526.2+0.71-0.26
261.0010.9190.6980.66125.524.2-1.71-0.92
270.9860.9200.6970.66125.724.4-1.33-2.13
281.0661.0660.7930.73628.826.3+2.17+1.76

Backtest by segment

All horizons pooled; relative bias is the sum of forecast errors divided by the sum of actuals. The last two rows are defined by a realised outcome and are discussed in finding 8.

slicenMASE naiveMASE snaiveMASE ETSMASE LightGBMrel. bias ETSrel. bias LightGBM
market: domestic3,6401.0140.9650.7420.718-0.1%+0.0%
market: intl-short2,9121.0250.9790.7490.742+0.0%-0.5%
market: intl-long1,4560.8510.8490.6800.683-0.5%-0.6%
holiday window1,2431.0000.9610.7720.660-5.5%+0.7%
outside holiday windows6,7650.9860.9470.7260.732+0.9%-0.4%
destination event day1320.8170.9010.7160.572-7.0%-0.3%
break routes, from 2024-03-011,7941.0601.0740.8260.781+0.6%+1.3%
break routes, before 2024-03-013901.1211.0620.7500.791-0.7%-1.2%
other routes, from 2024-03-014,7840.9170.8910.6930.685-0.4%-0.9%
route-day with a flight at the bookable limit2,7750.9830.9010.7720.757-8.1%-7.7%
route-day with no flight at the limit5,2330.9910.9740.7130.701+5.5%+5.1%

By route, all horizons pooled (728 forecasts per route):

routemarketMASE snaiveMASE ETSMASE LightGBMbias ETSbias LightGBMrel. bias LightGBM
IST-ADBdomestic1.0210.8060.775+2.10+4.08+1.0%
IST-ATHintl-short1.0110.7870.787+0.29-1.10-0.9%
IST-AYTdomestic1.0700.8110.775-0.10+2.87+1.1%
IST-BKKintl-long0.8470.6800.679-1.19-1.92-0.9%
IST-CDGintl-short0.8700.6930.690-0.55-2.70-2.1%
IST-DIYdomestic0.8720.6850.690-0.47-1.37-1.1%
IST-ESBdomestic0.9680.7360.714-2.76-5.22-1.2%
IST-FRAintl-short1.1250.8210.797+1.21+1.14+0.4%
IST-JFKintl-long0.8510.6800.688-1.05-0.79-0.4%
IST-LHRintl-short0.9110.6960.694-0.63-1.50-0.5%
IST-TZXdomestic0.8920.6720.639-0.05-0.02-0.0%

What-if: forecast answers against the simulator's true effect (2024, 366 days)

true_effect re-runs the simulator with the intervention and the same random draws, so the true change is exact for this world and includes the seat limit. All three interventions are applied to the whole network except the capacity change (IST-TZX only). Route demand in the simulator does not interact across routes, so a domestic-only price cut would have exactly the domestic row below. The estimators are fitted on all 2022-2024 route-days and are evaluated in sample on the 2024 route-days; they are estimators of an effect, not out-of-sample forecasts. The last column prices the error at a hypothetical 100 EUR per passenger only to give it a scale.

scenarioanswertrue changeestimated changeerrorestimate / truthsignerror at 100 EUR per passenger (hypothetical)
IST-TZX capacity 160 to 180deployed LightGBM forecaster, counterfactual inputs+3,029+0-3,0290.00none (exactly zero)-302,900
IST-TZX capacity 160 to 180naive OLS (pooled)+3,029+8,575+5,5462.83correct+554,566
IST-TZX capacity 160 to 180adjusted OLS (route fixed effects, calendar, trend)+3,029undefinedundefinedundefinedundefinedundefined
IST-TZX capacity 160 to 180driver LightGBM (re-predicted)+3,029+2,032-9970.67correct-99,747
network price -5%deployed LightGBM forecaster, counterfactual inputs+37,297+0-37,2970.00none (exactly zero)-3,729,700
network price -5%naive OLS (pooled)+37,297+16,138-21,1590.43correct-2,115,888
network price -5%adjusted OLS (route fixed effects, calendar, trend)+37,297-29,426-66,723-0.79wrong-6,672,322
network price -5%driver LightGBM (re-predicted)+37,297-22,357-59,654-0.60wrong-5,965,404
competitor capacity +25%deployed LightGBM forecaster, counterfactual inputs-93,669+0+93,6690.00none (exactly zero)+9,366,900
competitor capacity +25%naive OLS (pooled)-93,669+190,615+284,284-2.04wrong+28,428,434
competitor capacity +25%adjusted OLS (route fixed effects, calendar, trend)-93,669-99,671-6,0021.06correct-600,238
competitor capacity +25%driver LightGBM (re-predicted)-93,669-31,926+61,7430.34correct+6,174,300

Simulator revenue change in 2024 (not hypothetical; the simulator's own fares): IST-TZX capacity +330,284 EUR, price -5% -4,845,833 EUR, competitor capacity +25% -18,388,790 EUR.

By scope (estimated against true change in passengers):

scenarioscopetrue changenaive OLSadjusted OLSdriver LightGBM
network price -5%domestic+24,121+8,633-15,742-11,760
network price -5%intl-short+9,670+4,855-8,853-6,312
network price -5%intl-long+3,506+2,649-4,831-4,285
network price -5%break routes (ADB, AYT, FRA)+16,733+5,440-9,920-7,370
network price -5%other routes+20,564+10,698-19,506-14,987
competitor capacity +25%domestic-52,290+101,973-53,321-18,097
competitor capacity +25%intl-short-27,472+57,349-29,987-10,231
competitor capacity +25%intl-long-13,907+31,293-16,363-3,598
competitor capacity +25%break routes (ADB, AYT, FRA)-34,836+64,260-33,601-13,312
competitor capacity +25%other routes-58,833+126,355-66,070-18,614

Regression coefficients (log passengers on log of the driver; HC1 standard errors):

modeltermcoefficient95% CI low95% CI highnote
ols_naivelog avg_fare-0.306-0.320-0.293
ols_naivelog capacity0.7190.6650.772
ols_naivelog competitor_capacity0.7690.7560.782
ols_adjlog avg_fare0.5720.5350.609
ols_adjlog capacityundefinedundefinedundefinednot identified
ols_adjlog competitor_capacity-0.462-0.509-0.414

Realised effect of the -5% price change (includes the capacity cap):

scopetrue change in passengers (%)implied arc elasticity
all3.663-0.701
domestic4.428-0.845
intl-long2.097-0.405
intl-short3.156-0.606

Censoring in the simulator's truth frame, 2024 (evaluation only; never a feature): 6,954 flight-days, 23.0% at the bookable limit, 22.3% with latent demand above it, 44,369 bookings of latent demand above the limit.

routeflight_daysshare at bookable limitshare with latent demand over limitagreementmean spill when overtotal spill (bookings)
IST-ADB1,0980.1280.1220.99420.2692,716
IST-ATH3660.2350.2210.98623.5311,906
IST-AYT7320.0940.0930.99924.3231,654
IST-BKK3660.2950.2951.00041.1944,449
IST-CDG3660.2730.2680.99526.5312,600
IST-DIY3660.2210.2130.99217.9101,397
IST-ESB1,0980.2480.2430.99525.6036,836
IST-FRA7320.2040.1890.98520.8412,876
IST-JFK3660.3310.3311.00053.4966,473
IST-LHR7320.3320.3220.99031.8437,515
IST-TZX7320.3160.3030.98826.7885,947

Robustness

LightGBM seeds (42, 1, 2, 3) on every third origin (9 of 26); the ETS column uses the same origins:

bucketmean MASE over seeds 42,1,2,3sd over seedsminmaxETS MASE, same 9 originsETS minus LightGBM (seed 42)
1-70.72930.00200.72650.73100.73220.0016
8-140.73940.00220.73640.74170.75880.0171
15-280.72500.00210.72280.72700.73940.0157

Break routes excluded (8 routes, 5,824 scored forecasts), all 26 origins, MASE:

bucketMASE naiveMASE snaiveMASE ETSMASE LightGBM
1-70.9330.8850.6960.707
8-140.9680.9270.7010.696
15-280.9650.8990.7090.694

Entity-unsafe lags (stacked-frame shift) against the safe construction, same 9 origins, LightGBM MASE:

bucketmase_safe_lagsmase_unsafe_lagsdifference
1-70.73060.7282-0.0025
8-140.74170.7374-0.0044
15-280.72370.72590.0022

What this does not prove

  • The true effect exists only because the data are simulated. The conclusion (a forecaster trained on observational, confounded, censored data does not answer interventions) transfers; the numbers do not. One simulated world (seed 42) was used; the simulator seed and the generator's parameters were not varied, so the ranking of AutoETS and LightGBM and the size of each regression error are properties of this draw as well as of the method.
  • The model comparison is not clean. LightGBM uses calendar and event flags that AutoETS does not, LightGBM has a fixed 120-tree setting and no tuning, and AutoETS has the default model search. A tuned ETS with regressors, or a gradient-boosting model without calendar features, would answer a different question.
  • Route-day aggregation hides flight-level censoring. A route-day counts as "at the limit" when any of its flights is; a route can be well below its total cap while one flight is full. Censoring was not modelled at all, so the demand the forecasts track is boardings.
  • Point forecasts only. Interval behaviour is project-022 (r05). Origins are 14 days apart and horizons run to 28, so adjacent origins share target days and the origin-level standard errors treat them as independent.
  • The regression what-if answers are deliberately simple. They multiply realised passengers by exp(coefficient x change) and apply no seat limit, so even a correct elasticity would overstate capacity and price gains on censored days. The "naive" pooled regression is a strawman by design; the adjusted regression and the driver model are in-sample fits and were not tuned. A defensible causal estimate on real data would need an experiment or an identification strategy, not a better forecaster.
  • The at-limit and not-at-limit slices are defined by the outcome, so their opposite biases are partly mechanical (finding 8).
  • The entity-unsafe demonstration covers one bug layout (route-major stack). Its effect here is within seed noise because the contaminated rows are early training rows.
  • Only 10 months of the break are scored (2024-03-01 to 2024-12-31), on three routes, so break-route results are weakly supported.
  • No censoring-aware demand model (estimate latent demand on censored days, then simulate capacity) was tried; it is the natural next step named in the project's postmortem.

Reproduce

cd python && uv run python -m reference.r04_airline_demand_backtest.run          # full run, about 5 minutes
cd python && uv run python -m reference.r04_airline_demand_backtest.run --smoke  # about 10 seconds, writes to a temp dir
cd python && uv run pytest tests/test_r04_airline_demand_backtest.py             # 14 tests, about 10 seconds

Builder-authored reference. It is not learner evidence and is never credited to your portfolio.

Rubric3 criteria

CriterionMeetsExceeds
Entity-safe featuresLags respect horizon and route boundaries and are covered by tests.Shows the error inflation an entity-unsafe version would have reported.
Per-horizon evidencePer-horizon MASE with origin-level uncertainty for baselines and both candidates.Explains the shape of the error curve with the data-generating process.
Forecast vs causal reasoningWhat-if table with three answers per scenario and a correct explanation of each gap.Proposes a concrete identification strategy for the real-world version and states its assumptions.

References6