featured image

Flight Disruption Intelligence: A Full-Stack Delay Predictor on Public Data and Free Infrastructure

An end-to-end flight delay prediction platform spanning medallion lakehouse, calibrated LightGBM with SHAP explanations, Spark Structured Streaming, dbt reliability marts, FastAPI serving, and a SvelteKit dashboard — built entirely on public BTS/NOAA/OpenSky data, self-hostable for $0/year, with a contract module that enforces leakage prevention across every pipeline stage.

Published

Mon Aug 10 2026

Technologies Used

Python Apache Spark dbt LightGBM ONNX SHAP FastAPI SvelteKit NATS Kafka DuckDB Airflow Databricks MLflow Docker TypeScript
View on GitHub

Live Demo

Loading demo...

Every flight delay dataset has a trap. The features that best predict whether a departure was late are often computed from information that wouldn’t exist at prediction time — same-day gate-change status, arrival delays from the same tail number earlier that morning, or rolling aggregate statistics that include the flight you’re predicting. A model built with those features looks excellent on a held-out test set and fails the moment you ask it about a flight that hasn’t landed yet. The problem isn’t the algorithm; it’s that you gave it the answer as input.

That trap is where this project starts. Building a trustworthy delay predictor from public data isn’t interesting because flight delays are hard to model — they’re not particularly. It’s interesting because doing it correctly requires an architecture that enforces temporal discipline across every layer: ingestion, feature engineering, training, serving, and evaluation.

The Shared Contract Module

The enforcement mechanism is a Python package called flight_contracts that every stage imports. It pins four things in one place: the prediction label (dep_del15, whether departure delay exceeds 15 minutes), the temporal split boundary (train on 2022–2024, test on 2025 — never random), the feature list the model is allowed to see, and a list of banned leaky columns that no stage is permitted to pass downstream.

# shared/flight_contracts/leakage_contract.py
LABEL = "dep_del15"
TRAIN_CUTOFF = date(2025, 1, 1)       # everything before → train
TEST_START   = date(2025, 1, 1)       # everything from → test

BANNED_LEAKY_COLS = [
    "dep_delay",           # the delay itself
    "arr_delay",           # arrives after departure
    "actual_elapsed_time", # only known post-flight
    "air_time",
    "taxi_out",            # gate departure to wheels-up
    "wheels_off",
    "wheels_on",
    ...
]

If a feature engineer adds a column that’s in BANNED_LEAKY_COLS, the pipeline fails at schema validation before any data moves. If a model training script tries to use any feature not in the explicit allowlist, same result. The contract makes leakage a compile-time problem rather than a debugging problem that surfaces months later when prediction calibration drifts.

The Lakehouse Stack

Four public sources feed a medallion lakehouse: BTS on-time performance records (the historical backbone), NOAA/Meteostat weather archives keyed to airport locations and departure hours, OpenSky live aircraft positions for real-time congestion signals, and OpenFlights reference data for airport and carrier metadata. Each Python ingester handles its own rate-limit strategy, writes partitioned Parquet to a bronze layer, and resumes safely from the last partition on failure — no reprocessing from scratch.

Bronze is raw fidelity: schema is enforced but values aren’t corrected. Silver normalizes across sources — weather joined to departures on airport and hour, carrier codes standardized, tail numbers cleaned — and is where the contract’s banned-column list is applied. Gold is the analytics layer: the feature set ready for training, plus reliability aggregates at the airport, route, carrier, and hour granularity.

A parallel dbt project builds the reliability marts directly in DuckDB from the gold layer, with not-null, uniqueness, relationship, and accepted-range tests on every model. The dbt path is the analytics-first layer; the Spark path is the ML-first layer. They read from the same bronze, they just walk the transformation differently. On Databricks, both layers are registered in Unity Catalog so lineage is tracked end to end — from source file to trained model artifact.

Leakage-Safe Rolling Features

The hardest feature engineering problem isn’t what to include — it’s what to exclude at what time boundary. Route reliability, carrier delay rate, and origin-airport congestion are genuinely predictive signals, but only if computed from information available before the flight departs.

The Spark window functions that build rolling reliability features enforce this with an explicit ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING frame, bounded to strictly prior days:

w = (
    Window
    .partitionBy("origin", "dest")
    .orderBy("fl_date")
    .rowsBetween(Window.unboundedPreceding, -1)  # never same-day
)

df = df.withColumn(
    "route_prior_delay_rate",
    F.avg(F.col("dep_del15").cast("double")).over(w)
)

A flight on January 15th sees only January 1–14 data in its route delay rate. Same for carrier reliability and airport congestion — every rolling feature is bounded to days strictly before the flight date. The temporal split (TRAIN_CUTOFF) then ensures the test set contains only 2025 flights, which never appeared in training windows.

The ML Lifecycle

Algorithm choice was deliberately unremarkable. LightGBM on tabular data with proper temporal splitting beats most other choices without requiring much tuning, and I wanted to spend engineering time on the parts that actually matter: calibration, leakage prevention, and SHAP-based explainability.

dep_del15 is a binary label on imbalanced data — most flights aren’t delayed more than 15 minutes. Class-imbalance weighting in LightGBM handles the majority/minority imbalance during training. Isotonic calibration handles a different problem: a model that predicts “32% delay probability” needs to actually be right 32% of the time, not just rank flights correctly. ROC-AUC tells you the ranking quality; Brier score tells you the calibration quality. A well-ranked but poorly calibrated model is useless for the risk gauge in the dashboard, which needs the probability to mean something.

MLflow tracks every experiment: hyperparameters, feature importance, ROC-AUC, PR-AUC, Brier score, and a baseline comparison against the trivial “predict the base delay rate for this route” model. Every training run registers the model artifact in the MLflow Model Registry before exporting.

ONNX export is validated by a parity test that runs the same input through both the native LightGBM model and the ONNX runtime and asserts prediction agreement within floating-point tolerance. The serving layer loads the ONNX bundle — model, calibrator, and SHAP explainer — so it doesn’t depend on LightGBM being installed in the serving container.

Two Streaming Paths

The streaming architecture has two tracks that serve different purposes.

The Kafka track is the batch-complement showcase: ingesters publish raw events to Kafka topics, and a Spark Structured Streaming job consumes them with event-time windowed aggregation — tumbling windows over live aircraft positions to compute airport-congestion signals (how many aircraft are currently in approach or taxi at each airport). This is the reference architecture for production ingestion if you’re already running Kafka.

The NATS JetStream track drives the live demo. Aircraft positions stream into a NATS subject, a lightweight subscriber normalizes them, publishes to Valkey for the serving layer to blend into real-time predictions, and the SvelteKit frontend’s live map consumes position updates directly from the NATS WebSocket bridge. The demo map shows current aircraft in flight; the risk lookup blends live congestion into the delay probability alongside the historical features.

The two paths aren’t redundant — they’re for different contexts. Kafka for durable, high-throughput production ingestion. NATS for the real-time demo where the simplicity of the pub/sub model matters and message durability is less critical than low latency.

Serving and Explainability

The FastAPI serving layer loads the ONNX model bundle on startup and keeps the DuckDB gold store open as a read-only connection. A delay risk lookup for a given route and departure time runs three things in parallel: the model inference on the precomputed feature set, a DuckDB query for historical reliability stats on that route and carrier, and a SHAP explanation that decomposes the prediction into per-feature contributions.

@router.post("/predict")
async def predict(req: PredictRequest) -> PredictResponse:
    features = await feature_store.get_features(req)
    prob = float(onnx_session.run(None, {"X": features})[1][0, 1])
    prob_cal = float(calibrator.predict_proba([[prob]])[0, 1])
    shap_vals = explainer.shap_values(features)[0]
    reliability = await gold_store.route_reliability(req.origin, req.dest)
    return PredictResponse(
        delay_probability=prob_cal,
        factors=build_factor_bars(shap_vals, features),
        reliability=reliability,
    )

The SHAP values power the “factor bars” in the dashboard — which signals pushed the probability up (departure hour, origin congestion, carrier history) and which pulled it down. This is the part that makes the prediction legible rather than just a number. A 41% delay probability with “departure at 7am from ATL, low carrier delay rate” tells you something; a bare number doesn’t.

The SvelteKit dashboard surfaces three views: risk lookup with the gauge and SHAP factor bars, airport and route reliability rankings pulled from the dbt gold marts, and the live aircraft map. The entire stack runs from npm run up and the serving API and dashboard are live in under a minute against the bundled sample model.

What I’d Change

The reliability mart refresh is currently a full recompute — dbt runs the whole DAG from silver, which is fine for weekly batch runs but unnecessary for daily incremental updates where only the new day’s flights need to fold in. Incremental dbt models with appropriate unique_key and merge strategy would reduce the nightly job from minutes to seconds at scale.

The model retraining DAG exists in Airflow but training and serving aren’t connected to a monitoring loop. Deploying prediction monitoring — tracking calibration drift as new flight data arrives — would close the feedback loop that makes the calibration investment actually pay off in production. Right now, whether the model is still well-calibrated six months later requires manually running the evaluation notebook.

Try It Out

Check out the live demo or explore the source code on GitHub.

Respecting your privacy.

← View All Projects

Related Tutorials

Making Data Leakage a Pipeline Failure, Not a Debugging Session

A rolling delay rate that includes same-day flights is leakage. A temporal split that shuffles randomly is leakage. In a flight delay prediction pipeline, leakage produces models that look great on held-out data and fail immediately in production. This tutorial covers the shared contract module, Spark window functions bounded to strictly prior days, schema assertions that catch banned features before training, and a temporal split that respects the direction of time.

Intermediate
25 minutes
Aug 10, 2026

Calibration, SHAP, and ONNX: Building a Delay Predictor That's Actually Honest

ROC-AUC tells you how well a model ranks flights by delay risk. It doesn't tell you whether a 40% prediction means 40% of flights actually get delayed. Those are different properties, and the second one matters when you're building a risk gauge. This tutorial covers isotonic probability calibration, SHAP explanations per prediction, ONNX export with a parity test, and why all three belong in the same ML lifecycle.

Advanced
40 minutes
Aug 10, 2026

SQL Analytics You Can Actually Trust: Building Reliability Marts with dbt

dbt turns SQL SELECT statements into tested, documented, dependency-aware data models. This tutorial builds the flight reliability marts used by the dashboard — airport, route, and carrier delay rates — starting from a staging view and layering on not-null, uniqueness, relationship, and range tests that catch data quality problems before they reach the API.

Beginner
10 minutes
Aug 10, 2026

DuckDB as an Analytical Read Layer: Querying a Gold Store Without a Database Server

DuckDB is an in-process analytical database that runs inside your Python application, reads Parquet files natively, and handles complex SQL including window functions, QUALIFY, and MEDIAN — with no server to manage. This tutorial covers the read-only connection pattern used in the flight disruption serving layer, thread-safety in a FastAPI threadpool, and the queries that power the reliability API.

Beginner
9 minutes
Aug 10, 2026

FastAPI for ML Serving: Lifespan, Pydantic Validation, and Graceful Degradation

FastAPI's type annotations, Pydantic models, and lifespan context manager solve three common ML serving problems: loading expensive artifacts before requests start, validating inputs without manual checks, and returning useful errors when dependencies aren't ready. This tutorial walks through the patterns used in the flight disruption prediction API.

Beginner
10 minutes
Aug 10, 2026
    Ask me anything!