Pricing a model before you buy it · E87

Pricing a model: should a charity buy a flight-level forecast?

Type
Advanced worked example - read, run, modify. The first notebook of the large-model series
Data
Real: BTS Reporting Carrier On-Time Performance, all 14,080,680 rows of 2024-2025, kept here as 494,547 flight x month cells and one month of 547,271 rows, with integer codes and no names. The timing comparison also uses E86's rainforest trees, hourly bike hires and daily industry returns
Time
About 12-13 minutes on 2 cores, mostly three fits of one model. 8 cores were not timed
You will learn
How to price a model before buying it: time per gradient x gradients x fits, then memory and precision · why a rate belongs to a cost shape · timing a gradient honestly in PyMC and JAX · reading a quote off an iso-cost map · why a cost law fitted on small subsets can mislead · float32 spacing in a large log density · a ladder of forecasts from persistence and a beta-binomial count to a hierarchical beta-binomial · redrawing the month when you forecast one · a decision rule fixed before scoring · writing a price someone can check

Every GPU number here is a stored table, measured once on an RTX 3090 Ti and loaded from results/E87/. A normal run uses the CPU only and never starts a GPU.

The question

US rules single out flights that are late again and again. Under 14 CFR 399.81(c), a chronically delayed flight is a domestic flight operated at least 10 times in a month that arrives more than 30 minutes late, or is cancelled, more than half the time in that month. A carrier's departures in one city-pair market within 30 minutes of the most frequent scheduled time count as one flight, and holding such a flight out for more than four consecutive months is a violation.

Suppose the research lead of a passenger-rights charity wants a monthly watch list: the 50 flights most likely to be flagged next month. A contractor quotes a flight-level model fitted to two years of every US flight, "two hours on one GPU, from a published rate". The charity's own analyst says a table of counts will do. The lead has one workstation for one night a month, about 8 hours. Should the charity approve the quote, buy something smaller, or refuse? And would the bigger model change the list?

The first half of the question is about pricing a model. A gradient-based sampler such as NUTS (Hoffman & Gelman 2014) or ChEES (Hoffman, Radul & Sountsov 2021) spends nearly all its time evaluating the gradient of the log density, so the running time of a fit is

$$\text{time} = (\text{time per gradient}) \times (\text{gradients per fit}) \times (\text{fits}),$$

and the model must also fit in memory and keep enough digits in its arithmetic. The contractor took the rate of E86's forest model and multiplied it by the size of this data. We check whether that rate transfers, what gradient count the quote assumes, how many fits a test needs, and which walls a rate cannot see.

The second half is about value. We build a ladder of forecasts of rising cost:

  • R0, persistence: a flight flagged last month will be flagged again;
  • R1, a beta-binomial count of each flight's late operations, shrunk towards its carrier;
  • R2, a hierarchical beta-binomial on flight x month cells, with effects for origin, destination, flight and month;
  • R3, the contractor's model: a Bernoulli logit on every one of the 14 million rows, with five lookups per row.

A dearer model is worth buying only if it scores better than the cheaper one or changes the list. R0 and R1 run on all carriers in under a second. R2 is fitted on one carrier, because a national fit does not fit in a reader's run. R3 is priced and never fitted.

What counts as a flag

Flagged below means a flight that met this test in a month as we code it: directions kept apart, diversions not counted as late, and flights linked across months by our own rule. It is our estimate, not DOT's, so the list says "likely to be flagged"; carriers, airports and flights carry integer codes, and the carrier of part 6 is called the mid-size carrier.

The plan

part question tool
1 What does the quote assume, and what would R3 cost? measured gradients, an iso-cost map
2 What do the flights look like? flight x month cells, flags per month
3 Does a rate carry over from one model to another? time_gradient on five cost shapes, CPU and GPU
4 Which walls does a rate not show? a VRAM grid, the sampler's own memory, float32 spacing
5 How good are the counting rules? persistence and a beta-binomial, all carriers
6 What does a hierarchical model add on one carrier? R2 in PyMC with nutpie, with the month redrawn
7 Does the bigger model change the list? a decision rule fixed before scoring
8 What should the charity be told? a price table, and a quote that can be checked
import os

for name in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
    os.environ.setdefault(name, "1")  # before NumPy: one BLAS thread per chain
os.environ["JAX_PLATFORMS"] = "cpu"  # the GPU numbers here are stored: a reader's run never starts one

import contextlib
import gc
import logging
import time
import warnings

import arviz as az
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
import nutpie
import pandas as pd
import pymc as pm
import pytensor
import pytensor.tensor as pt
import xarray as xr
from jax.flatten_util import ravel_pytree
from jax.scipy.special import gammaln as jax_gammaln
from matplotlib.colors import LogNorm
from matplotlib.lines import Line2D
from matplotlib.patches import Patch, Rectangle
from matplotlib.ticker import FuncFormatter, NullFormatter
from pymc.sampling.jax import get_jaxified_logp
from scipy import stats
from scipy.special import betaln, expit, gammaln

from pymc_challenges import data
from pymc_challenges.bigrun import PreflightError, Store, banner, estimated, extrapolated, git_hash, measured, time_gradient

RANDOM_SEED = 87
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")
logging.getLogger("pymc").setLevel(logging.WARNING)
warnings.filterwarnings("ignore", message=".*Explicitly requested dtype.*")
jax.config.update("jax_enable_x64", False)  # importing PyMC switches x64 on

LEVEL = os.environ.get("PYMC_CHALLENGES_RUN", "").strip().lower() or "small"
if LEVEL != "small":
    raise PreflightError(f"PYMC_CHALLENGES_RUN={LEVEL!r}: E87 has one level, small. The row-level run was "
                         "priced and never made, so there is nothing to replay or scale up.")
assert jax.default_backend() == "cpu", "this notebook never starts a GPU"

CORES = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
CHAINS_AT_ONCE = min(4, CORES)
TUNE, DRAWS = 1000, 1000  # nutpie's default 400 tuning steps is not assumed: see section 6
TARGETS = (15, 18, 21)  # 2025-04, 2025-07, 2025-10 as month numbers, with 2024-01 as 0
TARGET_NAMES = {15: "2025-04", 18: "2025-07", 21: "2025-10"}
WINDOW = 12  # months of history behind each list
LIST_SIZE, NEEDED = 10, 8  # one carrier's list, and the overlap the rung rule asks for
MEASURED, EXTRA, ALARM, CALM, MUTED = "#0b6e8a", "#a85a00", "#b2182b", "#2a7f62", "#8a8a8a"
RUNG_COLOUR = {"R0": "#8a8a8a", "R1": "#0b6e8a", "R2": "#b2182b"}
PRICE = {}


@contextlib.contextmanager
def stage(name):
    """Add the time spent in this block to the price table printed at the end."""
    start = time.perf_counter()
    yield
    PRICE[name] = PRICE.get(name, 0.0) + time.perf_counter() - start


store = Store("E87")
pilot = store.load_json("pilot_2026-11-02")  # the GPU pilot: see plans/large/E87.md section 13
extra_gpu = store.load_json("gpu_rows_2026-11-02")  # rows 2, 3 and a month of rows on the GPU
summary = store.load_json("data_summary")  # counts over all 24 files; names no carrier, airport or tail
jobs = pilot["jobs"]
print(f"{CORES} cores here: {CHAINS_AT_ONCE} chains at once. JAX backend: {jax.default_backend()}.")
2 cores here: 2 chains at once. JAX backend: cpu.
banner(wall_time=measured("12-13", "min on 2 cores, builds of 2026-11-02"),
       peak_ram=measured("2.3-2.5", "GB, whole process tree, across builds"),
       peak_vram=measured(0, "GB in this run: every GPU number is a stored table"),
       disk=measured(2.8, "MB in data/, and under 0.2 MB of JSON in results/E87/"),
       download=measured(0, "MB: the tables are built by tools/build_e87_data.py from 710 MB of BTS zips"),
       precision=measured("float32 for gradient timing, float64 for the fits", ""),
       chains=measured(4, "chains, 1,000 draws, 3 fits of R2"),
       hardware=measured("Ryzen 5950X on 2 cores; stored GPU tables from an RTX 3090 Ti", ""),
       build_date=measured("2026-11-02", ""),
       git_hash=measured(git_hash() or "unknown", ""),
       smaller_machine=estimated("this is the small problem: no larger level exists", ""))
Value Status
Wall time 12-13 min on 2 cores, builds of 2026-11-02 measured
Peak RAM 2.3-2.5 GB, whole process tree, across builds measured
Peak VRAM 0 GB in this run: every GPU number is a stored table measured
Disk 2.8 MB in data/, and under 0.2 MB of JSON in results/E87/ measured
Download size 0 MB: the tables are built by tools/build_e87_data.py from 710 MB of BTS zips measured
Precision float32 for gradient timing, float64 for the fits measured
Chains 4 chains, 1,000 draws, 3 fits of R2 measured
Hardware Ryzen 5950X on 2 cores; stored GPU tables from an RTX 3090 Ti measured
Build date 2026-11-02 measured
Git hash ba47bc242e56-dirty measured
On a smaller machine this is the small problem: no larger level exists estimated

1 · The price, before anything else

The contractor's sum is one line: E86's rate of 0.336 ns per chain and data cell, times 14 million rows, times 32 chains, gives about 0.15 s a gradient, and two hours of that is the quote. We measured the row-level model R3 itself on the card, at 500,089 and 10,000,174 rows, and scale only the last step, 1.41x, to all 14,080,680 rows. Nobody has measured how many gradients R3 needs, so we carry two counts: 60,000, a plain assumption, and 160,000, the length of E86's whole run. A forecast needs one fit; testing the model on three past months needs three, each on the rows before its month. Everything in this cell loads from stored tables.

ROWS = 14_080_680  # all rows of 24 monthly files
CHAINS = 32
LOOKUPS = 5  # carrier, flight, origin x day, destination x day, tail
E86_NS = 0.86e-3 / (128 * 20_000) * 1e9  # E86's rate per chain and cell
E86_TOTAL, E86_SAMPLING = 160_000, 27_161  # E86: about 137.6 s at 0.86 ms a gradient, and its stored sampling count
GRADIENTS = (60_000, E86_TOTAL)  # a plain assumption, and E86's whole run at 128 chains

cell_500k = jobs["p4_cell"]
cell_10m = jobs["p4_10m"]
ms_10m = cell_10m["rows"][0]["run_ms"]
rows_10m = cell_10m["shape"]["rows"]
ns_lookup = ms_10m * 1e6 / (CHAINS * rows_10m * LOOKUPS)
ms_14m = ms_10m * ROWS / rows_10m
ms_quote = E86_NS * CHAINS * ROWS / 1e6


def hours(ms, gradients, fits=1):
    return ms / 1e3 * gradients / 3600 * fits


by_month = pd.DataFrame(summary["by_month"])
month_totals = by_month["rows"].to_numpy()
fit_rows = {"24 months": [ROWS] * 3, "all earlier rows": [month_totals[:t].sum() for t in TARGETS],
            "12-month window": [month_totals[t - WINDOW:t].sum() for t in TARGETS]}
ms_window = ms_14m * np.mean(fit_rows["12-month window"]) / ROWS


quote = pd.DataFrame({"per gradient (ms)": [ms_quote, ms_14m],
                      "lookups a row": [1, LOOKUPS],
                      "ns per chain-lookup": [E86_NS, ns_lookup]},
                     index=["the contractor's sum, E86's rate", "this model, measured to 10M rows, scaled to 14M"])
print(quote.round(3).to_string())
price_rows = {f"{g // 1000}k gradients": {"one fit, 24 months (h)": hours(ms_14m, g),
                                          **{f"three fits, {k} (h)": sum(hours(ms_14m * r / ROWS, g) for r in v)
                                             for k, v in fit_rows.items()}} for g in GRADIENTS}
print(pd.DataFrame(price_rows).T.round(1).to_string())
print("Rows in each of the three fits:", {k: [f"{r / 1e6:.2f}M" for r in v] for k, v in fit_rows.items()})
two_hour_limit = 7200 / (ms_14m / 1e3)
print(f"Two hours needs at most {two_hour_limit:,.0f} gradients at {ms_14m:.0f} ms each, "
      f"{GRADIENTS[0] / two_hour_limit:.1f}x under 60k")
gradients_32 = E86_TOTAL - E86_SAMPLING + 4 * E86_SAMPLING
print(f"E86 sampled {E86_SAMPLING:,} of about {E86_TOTAL:,} gradients at 128 chains. The same ESS from 32 chains needs "
      f"about {gradients_32:,}: {hours(ms_14m, gradients_32):.1f} h a fit")

r2_gpu_ms = jobs["r2_gpu"]["rows"][0]["run_ms"]
print(f"The same 12 months: {ms_window:.0f} ms as rows, {r2_gpu_ms:.1f} ms as cells, {ms_window / r2_gpu_ms:.1f}x. "
      f"On all 24 months of rows against those cells it looks like {ms_14m / r2_gpu_ms:.0f}x.")
e86_gradients = E86_TOTAL
fig, ax = plt.subplots(figsize=(10, 6.2), layout="constrained")
g = np.geomspace(1e4, 1e6, 50)
budgets = ((2, "2 hours: the quote", ALARM, 8), (8, "8 hours: one night", "k", 8), (24, "24 hours", MUTED, 20))
for budget, label, colour, at in budgets:
    ax.plot(g, budget * 3600e3 / g, color=colour, lw=1.3)
    ax.annotate(label, (g[at], budget * 3600e3 / g[at]), rotation=-17.5, rotation_mode="anchor", color=colour,
                fontsize=9.5, xytext=(0, 5), textcoords="offset points")
ax.fill_between(g, 8 * 3600e3 / g, 1e5, color=ALARM, alpha=0.05, lw=0)
ax.plot(GRADIENTS, [ms_14m] * 2, "-", color=EXTRA, lw=2, zorder=2)
ax.scatter(GRADIENTS, [ms_14m] * 2, s=90, facecolor="w", edgecolor=EXTRA, lw=2, zorder=3)
ax.annotate(f"R3, 14M rows\n{ms_14m:.0f} ms, scaled\n{hours(ms_14m, 60_000):.1f}-{hours(ms_14m, 160_000):.1f} h a fit",
            (GRADIENTS[1], ms_14m), xytext=(12, -49), textcoords="offset points", color=EXTRA, fontsize=9.5)
ax.scatter([7200 / (ms_quote / 1e3)], [ms_quote], marker="X", s=120, color=ALARM, zorder=3)
ax.annotate("The quote: E86's rate,\none lookup, two hours", (7200 / (ms_quote / 1e3), ms_quote), xytext=(-6, -36),
            textcoords="offset points", color=ALARM, fontsize=9.5, ha="right")
ax.plot(GRADIENTS, [r2_gpu_ms] * 2, "-", color=EXTRA, lw=2, zorder=2)
ax.scatter(GRADIENTS, [r2_gpu_ms] * 2, s=90, facecolor="w", edgecolor=EXTRA, lw=2, zorder=3)
ax.annotate(f"R2, cells, all carriers, 3-cell cut\n{r2_gpu_ms:.1f} ms: "
            f"{hours(r2_gpu_ms, 60_000) * 60:.0f}-{hours(r2_gpu_ms, 160_000) * 60:.0f} min a fit, before any extra "
            "warmup restarts", (GRADIENTS[0], r2_gpu_ms), xytext=(-8, -34), textcoords="offset points",
            color=EXTRA, fontsize=9.5)
ax.scatter([e86_gradients], [jobs["p1_a"]["rows"][0]["run_ms"]], s=90, color=MEASURED, zorder=3)
ax.annotate("E86's forest: 128 chains", (e86_gradients, jobs["p1_a"]["rows"][0]["run_ms"]), xytext=(10, -4),
            textcoords="offset points", color=MEASURED, fontsize=9.5)
ax.set(xscale="log", yscale="log", xlim=(1e4, 1e6), ylim=(0.4, 3000), xlabel="gradients in one fit",
       ylabel="milliseconds per gradient, whole ensemble")
ax.set_xticks([1e4, 2e4, 6e4, 1.6e5, 4e5, 1e6], ["10k", "20k", "60k", "160k", "400k", "1M"])
ax.xaxis.set_minor_formatter(NullFormatter())
ax.annotate("more than one night", (2.6e5, 330), color=ALARM, fontsize=9.5)
ax.legend(handles=[Line2D([], [], marker="o", ls="", color=MEASURED, label="rate measured, gradient count inferred from run time"),
                   Line2D([], [], marker="o", ls="", markerfacecolor="w", markeredgecolor=EXTRA, markeredgewidth=2,
                          label="rate measured (R3 scaled 1.41x from 10M rows), gradient count assumed")], loc="lower left", fontsize=9)
ax.set_title("Every point on a diagonal costs the same. A price needs both axes, and the quote checked only one",
             loc="left", fontsize=11);
                                                 per gradient (ms)  lookups a row  ns per chain-lookup
the contractor's sum, E86's rate                           151.367              1                0.336
this model, measured to 10M rows, scaled to 14M            157.720              5                0.070
                one fit, 24 months (h)  three fits, 24 months (h)  three fits, all earlier rows (h)  three fits, 12-month window (h)
60k gradients                      2.6                        7.9                               5.9                              3.9
160k gradients                     7.0                       21.0                              15.7                             10.5
Rows in each of the three fits: {'24 months': ['14.08M', '14.08M', '14.08M'], 'all earlier rows': ['8.72M', '10.53M', '12.32M'], '12-month window': ['7.07M', '7.06M', '7.02M']}
Two hours needs at most 45,650 gradients at 158 ms each, 1.3x under 60k
E86 sampled 27,161 of about 160,000 gradients at 128 chains. The same ESS from 32 chains needs about 241,483: 10.6 h a fit
The same 12 months: 79 ms as rows, 5.4 ms as cells, 14.6x. On all 24 months of rows against those cells it looks like 29x.
Figure

The contractor's rate is right by accident. E86's rate per lookup is about 5x slower than this model's (0.34 against 0.07 ns per chain and lookup), but the sum counts one lookup a row where R3 has five, and the two errors cancel: 151 ms against 158 ms a gradient. What the quote is missing is the other two factors. At 158 ms a gradient, two hours buys at most about 46,000 gradients. At 60,000 the quote misses by 1.3x and one fit takes 2.6 hours; at 160,000 it takes 7.0. A test on three months costs 5.9-16 hours with all earlier rows, or 3.9-10.5 hours on a 12-month window. All of these are extrapolated from one measured gradient at 10M rows, and the gradient counts are assumptions.

On the map, each diagonal is one budget: every point on it costs the same. The hollow orange points use measured rates and assumed gradient counts, and R3's rate is also scaled from 10M rows. The blue dot is E86's measured rate at a count inferred from its run time. The quote is exposed by moving right along a flat line: its rate is fine, but it meets two hours only left of 60,000 gradients.

Neither count is solid. The only measured count in this notebook comes from part 6, where nutpie spends about 60,000 gradients a chain on about 5,000 parameters: a hint from a different sampler, not a measurement of R3. 160,000 is E86's whole run, inferred from its time, on a forest with 24,810 parameters, and only 27,161 of those gradients were sampling; it includes E86's own warmup restart, and any further restart would add to it. R3 has about 747,000 parameters. Nor is 160,000 a ceiling. E86 ran 128 chains and the price uses 32; matching E86's effective sample size with a quarter of the chains needs about four times its sampling gradients, about 240,000 in all, or 10.6 hours, which is more than a night.

R2 sits far below R3 on the map, for two reasons. On the same 12 months, rows would cost about 79 ms a gradient and R2's cells cost 5.4 ms, so collapsing rows into flight x month cells is worth about 15x a gradient, with no hardware bought. The rest of the 29x gap between the two lines comes from setting 24 months of rows against 12 months of cells: from dropping half the data, not from collapsing it.

2 · The flights

The table comes from 24 monthly BTS files, which cover carriers with at least 0.5% of domestic scheduled passenger revenue, so smaller regional operators are absent. Each flight x month cell holds a count of operations and of late or cancelled arrivals, and a cell with 10 or more operations is eligible. We forecast three held-out months, April, July and October 2025, each from the 12 months before it, and treat the target month's operations as known. A real list would have to guess next month's schedule; part 5 shows what that costs. Results are never pooled across months, for a reason the chart makes plain.

data.describe("e87_flights")
data.describe("e87_month_rows")
archive = np.load(data.path("e87_flights"))
flight_carrier, flight_origin, flight_dest = archive["flight_carrier"], archive["flight_origin"], archive["flight_dest"]
cells = pd.DataFrame({"flight": archive["cell_flight"], "month": archive["cell_month"].astype(int),
                      "n": archive["cell_n"].astype(int), "late": archive["cell_late"].astype(int)})
cells["rate"] = cells["late"] / cells["n"]
cells["flagged"] = cells["rate"] > 0.5
cells["carrier"] = flight_carrier[cells["flight"]]
same = (cells["flight"].to_numpy()[1:] == cells["flight"].to_numpy()[:-1]) & (
    cells["month"].to_numpy()[1:] == cells["month"].to_numpy()[:-1] + 1)
cells["state"] = np.r_[-1, np.where(same, cells["flagged"].to_numpy()[:-1].astype(int), -1)]  # last month: 1, 0, or not eligible
cells["last_rate"] = np.r_[np.nan, np.where(same, cells["rate"].to_numpy()[:-1], np.nan)]
print(f"{summary['rows']:,} rows in 24 monthly files, {summary['flight_months']:,} flight x month cells, "
      f"{summary['rows_per_cell']:.1f} rows a cell.")
print(f"Here: {len(cells):,} eligible cells (10 or more operations), {len(flight_carrier):,} flights, "
      f"{cells['flagged'].sum():,} flagged. Late or cancelled: {summary['late_share']:.1%} of rows.")
print(f"Diverted rows not counted late: {summary['diverted_not_late']:,}. "
      f"Target flights with no history: " + ", ".join(
          f"{TARGET_NAMES[t]} {1 - v['target_flights_with_history'] / v['target_flights']:.0%}"
          for t, v in zip(TARGETS, summary["linking"]["training_windows"].values())))

labels = pd.period_range("2024-01", periods=24, freq="M").astype(str)
fig, ax = plt.subplots(figsize=(11, 4.2), layout="constrained")
bar_colour = [ALARM if m in TARGETS else "#9db9c7" for m in by_month["month"]]
ax.bar(by_month["month"], by_month["flagged"], color=bar_colour, width=0.82)
ax.axhline(50, color="k", lw=0.9, ls=":")
ax.annotate("the list has 50 places", (9, 50), xytext=(0, 4), textcoords="offset points", ha="center", fontsize=9)
for m, flagged in zip(by_month["month"], by_month["flagged"]):
    ax.annotate(f"{flagged:,}", (m, flagged), xytext=(0, 3), textcoords="offset points", ha="center", fontsize=7.5,
                color=ALARM if m in TARGETS else "#4a4a4a")
ax.set(yscale="log", ylim=(15, 3500), ylabel="flights flagged that month", xticks=by_month["month"][::3])
ax.set_xticklabels(labels[::3])
ax.legend(handles=[Patch(color=ALARM, label="the three held-out targets"), Patch(color="#9db9c7", label="other months")],
          loc="upper left", ncols=2)
ax.set_title("Flags swing about 67-fold between months, so one good list proves little", loc="left", fontsize=11);
e87_flights: NumPy archive, not a table: open data.path() with np.load. One row per eligible flight x month cell, 494,547 in all: cell_flight, cell_month (0 is 2024-01), cell_n (operations, 10 or more), cell_late (more than 30 minutes late at arrival, or cancelled). One row per flight, 128,423: flight_carrier, flight_origin, flight_dest (most-used airports). A flight is a cluster of departures in the sense of 14 CFR 399.81(c), linked across months as the build script describes, not an official flight. Carrier, airport and flight codes are shuffled integers, with no labels, scheduled times or flight numbers. That removes names, not identity: anyone with the public files can match the codes back. `notice` holds the source notice.
  source: US DOT Bureau of Transportation Statistics, TranStats, Reporting Carrier On-Time Performance (1987-present), the 24 monthly files of 2024-2025. No licence or terms were found for this table, so only re-coded tables are kept, one of them row-level for one month. DERIVED by tools/build_e87_data.py --tables - keep the cached file. No endorsement by BTS or DOT is implied.
  url:    https://transtats.bts.gov/DatabaseInfo.asp?QO_VQ=EFD
e87_month_rows: NumPy archive, not a table: open data.path() with np.load. 547,271 rows in the file's own order, so anyone with the public file can match the codes back: y (late or cancelled), day, carrier, origin, dest, flight, tail, as integers with no names. Flight and tail codes run by first appearance in the file. Tail 0 and any other tail code are not aircraft registrations; rows with no tail share one code. `notice` holds the source notice.
  source: As `e87_flights`: one month, 2024-01. DERIVED by tools/build_e87_data.py --tables - keep the cached file. No endorsement by BTS or DOT is implied.
  url:    https://transtats.bts.gov/DatabaseInfo.asp?QO_VQ=EFD
14,080,680 rows in 24 monthly files, 921,766 flight x month cells, 15.3 rows a cell.
Here: 494,547 eligible cells (10 or more operations), 128,423 flights, 9,711 flagged. Late or cancelled: 15.1% of rows.
Diverted rows not counted late: 36,757. Target flights with no history: 2025-04 22%, 2025-07 11%, 2025-10 25%
Figure

Fourteen million rows collapse to 921,766 flight x month cells, about 15 rows each, and 494,547 of them are eligible. 15% of operations were late or cancelled, and 9,711 eligible cells were flagged. Flags swing about 67-fold between months, from 25 to 1,686, and two things follow. Four months had fewer flags than the list has places, so in a calm month even a perfect list of 50 would hold flights nobody flagged. And the three test months differ 20-fold, so a precision from one of them says little about another.

These flights are short-lived as we link them. Over all 259,558 linked flights, eligible or not, the median one lasts 2 months, and part of that is our linking, which splits some real flights: 28% of new eligible flights had the same number in the same market the month before. In April and October 22-25% of the target flights have no history at all, and 11% in July. Any list here is partly a list of new flights.

3 · What a gradient costs

A rate measured on one model is a rate for its cost shape: the mix of dense algebra, special functions, sequential steps and memory traffic in its gradient. We time five shapes:

  1. E86's forest: dense matrix products on a lattice;
  2. a negative binomial on 17,379 hourly bike hires: a design matrix and three gammaln a row;
  3. stochastic volatility on 5,000 daily returns: a recursion through jax.lax.scan;
  4. R2's likelihood on every carrier's cells: six gammaln a cell, with a flight effect only for flights seen in 3 or more cells (part 4 says why);
  5. R3 on one month of rows: five lookups a row, whose reverse pass is a scatter-add.

E86 had no lookups at all, which is why its rate might not transfer. First, the index pattern of a month of rows; each panel has one point per row.

month = np.load(data.path("e87_month_rows"))
day = month["day"].astype(np.int64)
columns = {"carrier": month["carrier"].astype(np.int64), "flight": month["flight"].astype(np.int64),
           "origin_day": month["origin"] * 32 + day, "dest_day": month["dest"] * 32 + day,
           "tail": month["tail"].astype(np.int64)}
outcome = month["y"]
n_month = outcome.size
by_flight = np.argsort(columns["flight"], kind="stable")
adjacent = (columns["flight"][1:] == columns["flight"][:-1]).mean()
print(f"Adjacent rows share a flight {adjacent:.0%} of the time, and a tail "
      f"{(columns['tail'][1:] == columns['tail'][:-1]).mean():.0%}")

fig, axes = plt.subplots(1, 2, figsize=(11, 4.4), sharey=True, layout="constrained")
for ax, order, title in zip(axes, (np.arange(n_month), by_flight), ("Rows as the file holds them", "Rows sorted by flight")):
    art = ax.hexbin(np.arange(n_month) / 1e3, columns["flight"][order] / 1e3, gridsize=70, cmap="viridis", norm=LogNorm(1, 3000),
                    mincnt=1, linewidths=0)
    ax.set(title=title, xlabel="row, thousands")
axes[0].set_ylabel("flight code, thousands")
fig.colorbar(art, ax=axes, shrink=0.85, label="rows in a hexagon");
Adjacent rows share a flight 35% of the time, and a tail 6%
Figure

The file visits flights in blocks and returns to them day after day: adjacent rows share a flight 35% of the time and a tail only 6%. Sorting by flight puts a flight's rows side by side, which should make the scatter-add of the reverse pass cheaper. We test that in a moment.

Now the timing. time_gradient waits for the result, takes the best of three calls, and checks that every output is finite. Rows are timed on this machine's CPU in this run, at 1 and 8 chains on 2 threads; the GPU column is loaded from the stored tables.

def field_model():
    """Row 1: E86's four-field forest at 20 m, as a flat JAX log density. A PyMC model, priced as it stands."""
    data.describe("bei_trees")
    raw = np.load(data.path("bei_trees"))
    cell, m_fine, m_smooth, half = 20.0, (25, 50), (20, 20), 800.0
    nx, ny = int(1000 / cell), int(500 / cell)
    y = np.histogram2d(raw["y"], raw["x"], bins=[ny, nx], range=[[0, 500], [0, 1000]])[0]
    k = int(cell / 5)

    def to_cells(v):
        c = 0.25 * (v[:-1, :-1] + v[1:, :-1] + v[:-1, 1:] + v[1:, 1:])
        c = c.reshape(ny, k, nx, k).mean((1, 3))
        return ((c - c.mean()) / c.std()).astype("float32")

    def basis(n, half_width, m):
        x = (np.arange(n) + 0.5) * cell
        x = x - x.mean()
        w = np.pi * np.arange(1, m + 1) / (2 * half_width)
        return (np.sqrt(1 / half_width) * np.sin(w[None, :] * (x[:, None] + half_width))).astype("float32"), w

    def density(w2, ell, nu):
        constant = {0.5: 2 * np.pi / ell, 1.5: 6 * np.pi * 3**1.5 / ell**3}[nu]
        return pt.sqrt(constant) * ((2 * nu) / ell**2 + w2) ** (-(nu + 1) / 2)

    elev, grad = to_cells(raw["elev"]), to_cells(np.log(raw["grad"]))
    Py, wy = basis(ny, 312.5, m_fine[0])
    Px, wx = basis(nx, 625.0, m_fine[1])
    Qy, vy = basis(ny, half, m_smooth[0])
    Qx, vx = basis(nx, half, m_smooth[1])
    w2 = (wy[:, None] ** 2 + wx[None, :] ** 2).astype("float32")
    v2 = (vy[:, None] ** 2 + vx[None, :] ** 2).astype("float32")
    pytensor.config.floatX = "float32"
    try:
        with pm.Model() as model:
            alpha = pm.Normal("alpha", np.log(30.0), 1.0)
            b_elev, b_grad = pm.Normal("b_elev", 0.0, 1.0), pm.Normal("b_grad", 0.0, 1.0)
            s_broad = pm.LogNormal("s_broad", np.log(0.7), 0.5)
            ell_broad = pm.LogNormal("ell_broad", np.log(120.0), 0.4)
            s_fine = pm.LogNormal("s_fine", np.log(0.7), 0.5)
            ell_fine = pm.LogNormal("ell_fine", np.log(20.0), 0.6)
            s_slope_elev = pm.LogNormal("s_slope_elev", np.log(0.5), 0.5)
            s_slope_grad = pm.LogNormal("s_slope_grad", np.log(0.5), 0.5)
            ell_slope = pm.LogNormal("ell_slope", np.log(200.0), 0.4)
            z_broad = pm.Normal("z_broad", 0.0, 1.0, shape=m_smooth)
            z_fine = pm.Normal("z_fine", 0.0, 1.0, shape=m_fine)
            z_elev = pm.Normal("z_elev", 0.0, 1.0, shape=m_smooth)
            z_grad = pm.Normal("z_grad", 0.0, 1.0, shape=m_smooth)
            smooth = lambda z, sd: Qy @ (sd * z) @ Qx.T
            sd_slope = density(v2, ell_slope, 1.5)
            eta = (alpha + (b_elev + smooth(z_elev, s_slope_elev * sd_slope)) * elev
                   + (b_grad + smooth(z_grad, s_slope_grad * sd_slope)) * grad
                   + smooth(z_broad, s_broad * density(v2, ell_broad, 1.5))
                   + Py @ (s_fine * density(w2, ell_fine, 0.5) * z_fine) @ Px.T + np.log(cell**2 / 1e4))
            pm.Poisson("y", pt.exp(eta), observed=y)
        logp = get_jaxified_logp(model)
        start = model.initial_point()
        q0, unravel = ravel_pytree([jnp.asarray(start[v.name]) for v in model.value_vars])
    finally:
        pytensor.config.floatX = "float64"
    return (lambda q: logp(unravel(q))), q0, y.size


def negative_binomial():
    """Row 2: bike hours, a design matrix and three gammaln a row."""
    data.describe("bike_hour")
    bike = data.load("bike_hour")
    X = pd.get_dummies(bike[["hr", "weekday", "mnth", "weathersit"]].astype(str), drop_first=True, dtype=float)
    X = np.c_[np.ones(len(X)), X.to_numpy(), bike[["temp", "hum", "windspeed"]].to_numpy(float)].astype(np.float32)
    counts, design = jnp.asarray(bike["cnt"].to_numpy(float), jnp.float32), jnp.asarray(X)

    def f(q):
        log_alpha, beta = q[0], q[1:]
        mu, alpha = jnp.exp(design @ beta), jnp.exp(log_alpha)
        lp = (jax_gammaln(counts + alpha) - jax_gammaln(alpha) - jax_gammaln(counts + 1)
              + alpha * (log_alpha - jnp.log(alpha + mu)) + counts * (jnp.log(mu) - jnp.log(alpha + mu)))
        return jnp.sum(lp) - 0.5 * jnp.sum(q * q) / 25.0

    return f, jnp.zeros(X.shape[1] + 1, jnp.float32).at[1].set(4.0), X.shape[0]


def stochastic_volatility():
    """Row 3: one industry's last 5,000 daily returns, a recursion through `jax.lax.scan`."""
    data.describe("ff_industries12_daily")
    ff = data.load("ff_industries12_daily").iloc[:, :12].apply(pd.to_numeric, errors="coerce")
    ff = ff.loc[[str(i).strip().isdigit() for i in ff.index]]
    r = ff.iloc[:, 0].to_numpy(float)
    r = r[(r > -99) & np.isfinite(r)][-5000:]
    returns = jnp.asarray(r / r.std(), jnp.float32)

    def f(q):
        mu, log_s, raw_phi, z = q[0], q[1], q[2], q[3:]
        s, phi = jnp.exp(log_s), jnp.tanh(raw_phi)

        def step(h, zt):
            h = mu + phi * (h - mu) + s * zt
            return h, h

        _, h = jax.lax.scan(step, mu, z)
        lp = -0.5 * (h + returns**2 * jnp.exp(-h))
        return jnp.sum(lp) - 0.5 * jnp.sum(z * z) - 0.5 * (mu**2 + log_s**2 + raw_phi**2)

    return f, jnp.zeros(returns.size + 3, jnp.float32).at[2].set(2.0), returns.size


def cell_model(target=TARGETS[0]):
    """Row 4: R2 on every carrier's cells, with a flight effect only for flights of 3 or more cells."""
    w = cells[(cells["month"] >= target - WINDOW) & (cells["month"] < target)]
    counts = np.bincount(w["flight"], minlength=flight_carrier.size)
    kept = np.flatnonzero(counts >= 3)
    own = counts[w["flight"]] >= 3
    code = np.where(own, np.searchsorted(kept, w["flight"]), kept.size)  # the last level is held at 0
    look = [np.unique(flight_carrier[w["flight"]], return_inverse=True)[1], np.unique(flight_origin[w["flight"]], return_inverse=True)[1],
            np.unique(flight_dest[w["flight"]], return_inverse=True)[1], w["month"].to_numpy() - (target - WINDOW)]
    sizes = [int(i.max()) + 1 for i in look] + [kept.size]
    cuts = np.cumsum(sizes)[:-1]
    look = [jnp.asarray(i, jnp.int32) for i in look + [code]]
    n, k = jnp.asarray(w["n"].to_numpy(), jnp.float32), jnp.asarray(w["late"].to_numpy(), jnp.float32)

    def f(q):
        log_phi, rest = q[0], q[1:]
        parts = jnp.split(rest, cuts)
        parts[-1] = jnp.concatenate([parts[-1], jnp.zeros(1, q.dtype)])
        eta = sum(p[i] for p, i in zip(parts, look))
        phi, p = jnp.exp(log_phi), jax.nn.sigmoid(eta)
        a, b = p * phi, (1 - p) * phi
        lp = (jax_gammaln(k + a) + jax_gammaln(n - k + b) - jax_gammaln(n + a + b)
              + jax_gammaln(a + b) - jax_gammaln(a) - jax_gammaln(b))
        return jnp.sum(lp) - 0.5 * jnp.sum(rest * rest) - 0.5 * log_phi * log_phi

    return f, jnp.zeros(sum(sizes) + 1, jnp.float32).at[0].set(3.0), len(w)


def row_model(rows):
    """Row 5: Bernoulli-logit with five lookups, on these rows. Codes are remade, so d follows the rows."""
    codes = [np.unique(columns[k][rows], return_inverse=True)[1] for k in columns]
    sizes = [int(c.max()) + 1 for c in codes]
    cuts = np.cumsum(sizes)[:-1]
    look = [jnp.asarray(c, jnp.int32) for c in codes]
    y = jnp.asarray(outcome[rows], jnp.float32)

    def f(q):
        eta = sum(p[i] for p, i in zip(jnp.split(q, cuts), look))
        return jnp.sum(y * eta - jnp.logaddexp(0.0, eta)) - 0.5 * jnp.sum(q * q)

    return f, jnp.zeros(sum(sizes), jnp.float32), rows.size


def timed(row, f, q0, size, chains=(1, 8)):
    rows = time_gradient(f, q0, chains=chains, repeat=3, seed=87)
    assert all(r["finite"] for r in rows), row
    return {"row": row, "size": size, "d": int(q0.size), **{f"cpu_{r['chains']}": r["run_ms"] for r in rows}}


with stage("timing rows 1-5"):
    table = [timed("1 forest, dense products", *field_model()), timed("2 bikes, gammaln", *negative_binomial()),
             timed("3 volatility, scan", *stochastic_volatility()), timed("4 cells, R2 likelihood", *cell_model()),
             timed("5 rows, five lookups", *row_model(np.arange(n_month)))]
rows_cpu = pd.DataFrame(table).set_index("row")
best = lambda job: {r["chains"]: r["run_ms"] for r in job["rows"]}
gpu_8 = {"1 forest, dense products": (min(best(jobs["p1_a"])[8], best(jobs["p1_b"])[8]), 20_000, 8),
         "2 bikes, gammaln": (best(extra_gpu["row2"])[8], 17_379, 8),
         "3 volatility, scan": (best(extra_gpu["row3"])[8], 5_000, 8),
         "4 cells, R2 likelihood": (r2_gpu_ms, jobs["r2_gpu"]["shape"]["cells"], CHAINS),
         "5 rows, five lookups": (extra_gpu["row5_month"]["month_file"][0]["run_ms"], n_month, 8)}
rows_cpu["cpu ns/chain-row"] = rows_cpu["cpu_8"] * 1e6 / (8 * rows_cpu["size"])
rows_cpu["gpu ms"] = [v[0] for v in gpu_8.values()]
rows_cpu["gpu ns/chain-row"] = [v[0] * 1e6 / (v[1] * v[2]) for v in gpu_8.values()]
rows_cpu["cpu / gpu"] = rows_cpu["cpu ns/chain-row"] / rows_cpu["gpu ns/chain-row"]
print(rows_cpu.round(3).to_string())
print("Not matched: GPU row 1 is the 5 m lattice of 20,000 cells against 1,250 on the CPU, and GPU row 4 is 32 chains against "
      "8. Rows 2, 3 and 5 are 8 chains on the same problem.")
print("GPU row 3 by chains (ms):", {k: round(v, 1) for k, v in best(extra_gpu["row3"]).items()})
row3_128 = best(extra_gpu["row3"])[128] * 1e6 / (128 * 5_000)
print(f"Row 3 at 128 chains costs {row3_128:.0f} ns per chain-row on the card, "
      f"{row3_128 / rows_cpu.loc['3 volatility, scan', 'cpu ns/chain-row']:.0f}x the CPU's 8-chain rate.")
month_by_chains = {r["chains"]: r["run_ms"] for r in extra_gpu["row5_month"]["month_file"]}
chain_saving = (month_by_chains[8] / 8) / (month_by_chains[32] / 32)
print(f"Row 5 on the card: cost per chain falls {chain_saving:.2f}x from 8 to 32 chains, so row 4's ratio is flattered "
      f"by about that much: about {rows_cpu.loc['4 cells, R2 likelihood', 'cpu / gpu'] / chain_saving:.0f}x if row 4 behaves like row 5.")

fig, ax = plt.subplots(figsize=(10, 3.8), layout="constrained")
order = rows_cpu.index[::-1]
UNMATCHED = {"1 forest, dense products": "1 forest, dense products\n(not matched: GPU 20,000 cells, CPU 1,250)",
             "4 cells, R2 likelihood": "4 cells, R2 likelihood\n(not matched: GPU 32 chains, CPU 8)"}
for i, row in enumerate(order):
    c, g = rows_cpu.loc[row, ["cpu ns/chain-row", "gpu ns/chain-row"]]
    ax.plot([c, g], [i, i], color=ALARM if g > c else "#b9c6cd", lw=4, solid_capstyle="round", zorder=1)
    ax.annotate(f"GPU {c / g:.0f}x cheaper" if c > g else f"GPU {g / c:.0f}x dearer", (max(c, g), i), xytext=(10, 0),
                textcoords="offset points", va="center", fontsize=9, color=ALARM if g > c else "#33424b")
ax.scatter(rows_cpu.loc[order, "cpu ns/chain-row"], range(5), s=70, marker="o", color=CALM, zorder=2, label="CPU, 2 threads, 8 chains")
ax.scatter(rows_cpu.loc[order, "gpu ns/chain-row"], range(5), s=70, marker="s", color=MEASURED, zorder=2, label="GPU, stored table")
ax.set(xscale="log", yticks=range(5), xlim=(0.04, 4000), xlabel="nanoseconds per chain and row, measured")
ax.set_yticklabels([UNMATCHED.get(row, row) for row in order])
ax.legend(loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=9)
ax.set_title("A rate belongs to a cost shape: the same card wins four rows and loses one by a wide margin", loc="left", fontsize=11);
bei_trees: Exact positions (x, y in metres) of the Beilschmiedia pendula trees in the 1000 x 500 m plot, with elevation (elev, m) and its gradient norm (grad) on a 5 m grid (yrow x xcol). Not a table: open data.path() with np.load.
  source: Hubbell & Foster (1983), Condit, Hubbell & Foster (1996) and Condit (1998), Barro Colorado Island forest dynamics plot, Panama; as shipped with the R package `spatstat.data` 3.1-9 (GPL >= 2), analysed by Moller & Waagepetersen (2007). CONVERTED from the R file by tools/build_e86_bei.py - keep the cached file.
  url:    https://raw.githubusercontent.com/cran/spatstat.data/master/data/bei.rda
bike_hour: Hourly rental counts 2011-2012 with weather and calendar covariates.
  source: Fanaee-T & Gama (2013), Capital Bikeshare (Washington DC), UCI ML Repository.
  url:    https://archive.ics.uci.edu/static/public/275/bike+sharing+dataset.zip
ff_industries12_daily: Daily % returns of 12 US industry portfolios since July 1926 (NoDur ... Other). The file holds TWO blocks, value-weighted then equal-weighted, each headed by a text line: keep the rows before the first non-date index. -99.99 marks missing.
  source: Kenneth R. French Data Library (Fama & French, CRSP), 12 industry portfolios; updated monthly.
  url:    https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/12_Industry_Portfolios_daily_CSV.zip
                            size      d   cpu_1    cpu_8  cpu ns/chain-row  gpu ms  gpu ns/chain-row  cpu / gpu
row                                                                                                            
1 forest, dense products    1250   2460   0.095    0.413            41.266   0.216             1.348     30.602
2 bikes, gammaln           17379     48   0.636    3.849            27.681   0.127             0.911     30.380
3 volatility, scan          5000   5003   0.138    0.311             7.776  62.820          1570.493      0.005
4 cells, R2 likelihood    246528  31503  32.845  284.673           144.341   5.403             0.685    210.771
5 rows, five lookups      547271  65218  11.304   96.099            21.949   2.724             0.622     35.278
Not matched: GPU row 1 is the 5 m lattice of 20,000 cells against 1,250 on the CPU, and GPU row 4 is 32 chains against 8. Rows 2, 3 and 5 are 8 chains on the same problem.
GPU row 3 by chains (ms): {8: 62.8, 32: 62.6, 128: 62.9}
Row 3 at 128 chains costs 98 ns per chain-row on the card, 13x the CPU's 8-chain rate.
Row 5 on the card: cost per chain falls 1.76x from 8 to 32 chains, so row 4's ratio is flattered by about that much: about 120x if row 4 behaves like row 5.
Figure

A rate belongs to a cost shape. On four of the five rows the card costs far less per chain and row than 2 CPU threads, but not every ratio is like for like. Rows 2 and 5 are matched, at about 30-35x in this run. Row 1 sets the 5 m lattice of 20,000 cells on the card against 1,250 cells on the CPU. Row 4 sets 32 GPU chains against 8 CPU chains, and row 5 shows the card's cost per chain falling 1.76x between those counts, so row 4's 210x is flattered: about 120x if it behaves like row 5.

Row 3, the scan, goes the other way. At 8 chains the card costs about 200x more than the CPU, and its time hardly moves with the number of chains: 63 ms at 8, 32 and 128. A recursion runs one step at a time on the card, however many chains share it. The gap shrinks as chains are added, to about 13x at 128 chains against the CPU's 8-chain rate, but no single rate prices all five rows. On the CPU, row 4 is the dearest at about 144 ns a chain and cell; its six gammaln are the likely reason, though they were not timed alone.

The CPU side is 2 threads, so the table shows where the time goes, not that a GPU beats a well-run CPU.

Failure 1: sorting the rows

We expected the reverse pass of a lookup, a scatter-add into scattered places, to be the slow part, and planned to fix it by sorting the rows by flight. The next cell climbs from one day of rows to the whole month, in file order and sorted, at 1 and 8 chains, and fits a power law to the small sizes to predict the month. The right panel loads a different test from the card: one lookup at 10 million rows, in file order, sorted, and sorted with JAX told that the indices are sorted.

def run_ladder(days=(1, 3, 7, 14, 31)):
    """Row 5 on the first `d` days of the month, in file order and sorted by flight, at 1 and 8 chains."""
    out = []
    for last in days:
        rows = np.flatnonzero(day <= last)
        for name, used in (("file", rows), ("sorted", rows[np.argsort(columns["flight"][rows], kind="stable")])):
            f, q0, size = row_model(used)
            for r in time_gradient(f, q0, chains=(1, 8), repeat=3, seed=87):
                out.append({"days": last, "order": name, "chains": r["chains"], "rows": size, "d": int(q0.size), "ms": r["run_ms"]})
    return pd.DataFrame(out)


with stage("row 5 ladder"):
    ladder = run_ladder()
print(ladder.pivot_table(index=["days", "rows"], columns=["order", "chains"], values="ms").round(2).to_string())


def predict_month(fit_days=(1, 3, 7), chains=8, order="file"):
    """Fit a power law on small subsets, and predict the whole month's time."""
    part = ladder[(ladder["chains"] == chains) & (ladder["order"] == order)].set_index("days")
    slope, level = np.polyfit(np.log(part.loc[list(fit_days), "rows"]), np.log(part.loc[list(fit_days), "ms"]), 1)
    predicted = np.exp(level + slope * np.log(part.loc[31, "rows"]))
    return {"slope": slope, "predicted_ms": predicted, "measured_ms": part.loc[31, "ms"],
            "error": predicted / part.loc[31, "ms"] - 1}


print("Power law from 1, 3 and 7 days:", {k: round(v, 2) for k, v in predict_month().items()})
print("Same, sorted:                  ", {k: round(v, 2) for k, v in predict_month(order="sorted").items()})
print("From 1 and 3 days only:        ", {k: round(v, 2) for k, v in predict_month(fit_days=(1, 3)).items()})
week = ladder[(ladder["days"] == 7) & (ladder["order"] == "file") & (ladder["chains"] == 8)].iloc[0]
whole = ladder[(ladder["days"] == 31) & (ladder["order"] == "file") & (ladder["chains"] == 8)].iloc[0]
print(f"Straight line from the week alone: {week['ms'] * whole['rows'] / week['rows']:.1f} ms against {whole['ms']:.1f} ms measured")

p3 = {name: jobs[f"p3_{key}"]["10m"] for name, key in (("file", "unsorted_default"), ("sorted", "sorted_nohint_default"),
                                                      ("hint", "sorted_hint_default"))}
fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained", width_ratios=(1.1, 1))
ax = axes[0]
for (name, chains), part in ladder.groupby(["order", "chains"]):
    ax.plot(part["rows"], part["ms"], "o-" if name == "file" else "s--", color=MEASURED if chains == 8 else CALM, lw=1.2, ms=5,
            label=f"{name} order, {chains} chain{'s' if chains > 1 else ''}")
ax.set(xscale="log", yscale="log", xlabel="rows", ylabel="ms per gradient")
ax.set_title("CPU, 2 threads, a month of rows, measured", fontsize=10.5, loc="left")
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v / 1e3:.0f}k"))
ax.xaxis.set_minor_formatter(NullFormatter())
ax.set_xticks(sorted(ladder["rows"].unique()))
ax.legend(fontsize=8.5)
ax = axes[1]
steps = [("File order", p3["file"]["run_ms"], "total"),
         ("forward", p3["sorted"]["forward_ms"] - p3["file"]["forward_ms"], "step"),
         ("reverse", p3["sorted"]["reverse_extra_ms"] - p3["file"]["reverse_extra_ms"], "step"),
         ("Sorted", p3["sorted"]["run_ms"], "total"),
         ("hint,\nreverse", p3["hint"]["reverse_extra_ms"] - p3["sorted"]["reverse_extra_ms"], "step"),
         ("Sorted\n+ hint", p3["hint"]["run_ms"], "total")]
level = 0.0
for i, (label, value, kind) in enumerate(steps):
    if kind == "total":
        ax.bar(i, value, color=MEASURED, width=0.66)
        level = value
        ax.annotate(f"{value:.2f}", (i, value), xytext=(0, 3), textcoords="offset points", ha="center", fontsize=9)
    else:
        ax.bar(i, value, bottom=level, color=ALARM if value > 0 else CALM, width=0.66)
        level += value
        ax.annotate(f"{value:+.2f}", (i, max(level, level - value)), xytext=(0, 3), textcoords="offset points", ha="center", fontsize=9)
ax.set_xticks(range(len(steps)), [s[0] for s in steps], fontsize=9)
ax.set(ylabel="ms per gradient")
ax.set_title("GPU, one lookup on 10M rows and its reverse, 8 chains, measured", fontsize=10.5, loc="left")
fig.suptitle("Sorting by flight was the planned fix. It costs time on the GPU, and hardly matters on a month", x=0.01, ha="left", fontsize=11);
order         file        sorted        
chains           1      8      1       8
days rows                               
1    17265    0.32   1.61   0.31    1.64
3    54762    0.70   3.98   0.68    3.63
7    126699   2.71  21.81   2.65   20.70
14   248897   5.17  44.42   5.10   40.24
31   547271  11.36  97.51  11.32  100.66
Power law from 1, 3 and 7 days: {'slope': np.float64(1.28), 'predicted_ms': np.float64(111.81), 'measured_ms': np.float64(97.51), 'error': np.float64(0.15)}
Same, sorted:                   {'slope': np.float64(1.24), 'predicted_ms': np.float64(97.86), 'measured_ms': np.float64(100.66), 'error': np.float64(-0.03)}
From 1 and 3 days only:         {'slope': np.float64(0.78), 'predicted_ms': np.float64(24.17), 'measured_ms': np.float64(97.51), 'error': np.float64(-0.75)}
Straight line from the week alone: 94.2 ms against 97.5 ms measured
Figure

The slow scatter did not appear, and the fix made things worse. On the card, sorting cuts the forward pass by 0.52 ms but adds 2.12 ms to the reverse pass, so a gradient goes from 2.70 to 4.30 ms, and telling JAX the indices are sorted adds another 0.76 ms: 1.6-1.9x slower in all. On a month of rows with five lookups, sorting changes nothing, on the card (2.7 ms either way at 8 chains) or on the CPU. We did not find the cause, and because size and the number of lookups changed together between the two tests, we cannot say which matters; the gradients are still exact. The lesson we expected was "collapse, then sort, then buy". The one the data give is shorter: collapse first, then measure.

A cost law needs a warning too. On the CPU, going from 3 days to 7 multiplies the rows by 2.3 and the time by 4-6, at 1 chain and at 8, and the step repeats from run to run. Part of the step is threading, since one pinned core removes it at 1 chain, so the CPU column of row 5 is not a clean 2-core rate. A power law fitted on 1, 3 and 7 days lands within about 15% of the month only by luck, because it straddles the step; fitted on 1 and 3 days alone it misses the month by three quarters. Small subsets are cheap to time, and they can sit on the wrong side of a step.

On the card, 500k and 10M rows ran at 0.074 and 0.070 ns per chain and lookup, so the last step from 10M to 14M rows has evidence behind it: two points on one card, which say nothing about any other card.

4 · The walls behind the quote

Time is one wall. Memory and precision are the other two, and neither shows up in a rate.

per_chain_row_gib = cell_10m["vram"]["peak_gb"] / (CHAINS * rows_10m)
grid_rows = np.array([500_089, 1e6, 5e6, 10_000_174, ROWS])
grid_chains = np.array([8, 16, 32, 64, 128])
vram = per_chain_row_gib * grid_rows[:, None] * grid_chains[None, :]
scaled_500k = vram[0, 2]
vram[0, 2] = cell_500k["vram"]["peak_gb"]  # the other measured cell: scaling from 10M misses it
LIMIT = jobs["p1_a"]["vram"]["limit_gb"]
print(pd.DataFrame(vram, index=[f"{r / 1e6:.1f}M rows" for r in grid_rows], columns=[f"{c} chains" for c in grid_chains]).round(2).to_string())
print(f"Measured: 500k x 32 = {cell_500k['vram']['peak_gb']:.2f} GiB, 10M x 32 = {cell_10m['vram']['peak_gb']:.2f} GiB. Usable VRAM {LIMIT:.2f} GiB.")
print(f"Scaling from 10M would put 500k x 32 at {scaled_500k:.2f} GiB, {vram[0, 2] / scaled_500k:.1f}x too low. "
      "Both measured cells are at 32 chains.")

window_counts = np.bincount(cells.loc[(cells["month"] >= TARGETS[0] - WINDOW) & (cells["month"] < TARGETS[0]), "flight"])
window_cells = cells[(cells["month"] >= TARGETS[0] - WINDOW) & (cells["month"] < TARGETS[0])]
flights_cut, flights_all = jobs["r2_gpu"]["shape"]["levels"]["flight_cut"], int((window_counts > 0).sum())
d_cut = jobs["r2_gpu"]["shape"]["d"]
d_all = d_cut + flights_all - flights_cut
R3_D = 747_000  # plans/large/E87.md section 12: 481k day cells, 260k flights, 6k tails
print(f"The 3-cell cut keeps {flights_cut:,} of {flights_all:,} flights, and "
      f"{(window_counts[window_cells['flight']] < 3).mean():.0%} of cells lose their flight's own effect.")
print("ChEES warmup's d x d table, GiB:", {name: round(d * d * 4 / 2**30, 1) for name, d in
                                          ((f"R2 cut, d {d_cut:,}", d_cut), (f"R2 every flight, d {d_all:,}", d_all), (f"R3, d {R3_D:,}", R3_D))})
per_row = stats.bernoulli.logpmf(outcome, outcome.mean()).mean()
logp_month, logp_14m = abs(per_row) * n_month, abs(per_row) * ROWS
spacing = lambda x: float(np.spacing(np.float32(x)))
print(f"|logp| at the month's own rate: {logp_month:,.0f}, float32 spacing there {spacing(logp_month):.3f} nats. "
      f"At 14M rows with this month's rate: {logp_14m:,.0f}, spacing {spacing(logp_14m):.2f} nats (arithmetic, not a test).")

fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained", width_ratios=(1.15, 1))
ax = axes[0]
mesh = ax.imshow(vram, cmap="YlGnBu", norm=LogNorm(0.1, 100), origin="lower", aspect="auto")
for i in range(grid_rows.size):
    for j in range(grid_chains.size):
        over = vram[i, j] > LIMIT
        ax.text(j, i, f"{vram[i, j]:.2f}" if (i, j) == (0, 2) else f"{vram[i, j]:.1f}", ha="center", va="center", fontsize=9.5,
                color="w" if vram[i, j] > 10 else "k", weight="bold" if over else None)
        if over:
            ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, hatch="///", edgecolor=ALARM, lw=0))
for i in (0, 3):
    ax.add_patch(Rectangle((1.5, i - 0.5), 1, 1, fill=False, edgecolor=EXTRA, lw=2.5))
ax.set(xticks=range(5), yticks=range(5), xlabel="chains", ylabel="rows, five lookups")
ax.set_xticklabels(grid_chains)
ax.set_yticklabels(["500k", "1M", "5M", "10M", "14M"])
ax.grid(False)
ax.set_title(f"VRAM in GiB. Orange boxes are measured, the rest are scaled.\nHatched cells pass the {LIMIT:.2f} GiB limit", fontsize=10.5, loc="left")
fig.colorbar(mesh, ax=ax, shrink=0.8)
ax = axes[1]
x = np.logspace(3, 8, 600)
ax.step(x, np.spacing(x.astype(np.float32)), where="post", color="k", lw=1.2)
points = [("E86 forest", abs(jobs["p1_a"]["logp_at_start"]), True), ("a month of rows", logp_month, True), ("14M rows", logp_14m, False)]
for name, value, known in points:
    ax.scatter([value], [spacing(value)], s=80, color=MEASURED if known else "w", edgecolor=MEASURED if known else EXTRA, lw=2, zorder=3)
    ax.annotate(name, (value, spacing(value)), xytext=(-8, 8), textcoords="offset points", ha="right", fontsize=9.5)
ax.axhline(1.0, color=ALARM, lw=0.9, ls=":")
ax.annotate("1 nat", (1.3e3, 1.0), xytext=(0, 3), textcoords="offset points", color=ALARM, fontsize=9)
ax.set(xscale="log", yscale="log", xlabel="|log density|", ylabel="float32 spacing, nats")
ax.set_title("A total this large has few digits left", fontsize=10.5, loc="left")
fig.suptitle("Two more limits a rate cannot see", x=0.01, ha="left", fontsize=11);
            8 chains  16 chains  32 chains  64 chains  128 chains
0.5M rows       0.05       0.10       0.35       0.40        0.81
1.0M rows       0.10       0.20       0.40       0.81        1.61
5.0M rows       0.50       1.01       2.02       4.04        8.07
10.0M rows      1.01       2.02       4.04       8.07       16.15
14.1M rows      1.42       2.84       5.68      11.37       22.74
Measured: 500k x 32 = 0.35 GiB, 10M x 32 = 4.04 GiB. Usable VRAM 16.56 GiB.
Scaling from 10M would put 500k x 32 at 0.20 GiB, 1.7x too low. Both measured cells are at 32 chains.
The 3-cell cut keeps 30,789 of 72,386 flights, and 24% of cells lose their flight's own effect.
ChEES warmup's d x d table, GiB: {'R2 cut, d 31,503': 3.7, 'R2 every flight, d 73,100': 19.9, 'R3, d 747,000': 2078.7}
|logp| at the month's own rate: 263,495, float32 spacing there 0.031 nats. At 14M rows with this month's rate: 6,779,439, spacing 0.50 nats (arithmetic, not a test).
Figure

Memory for the gradient is not the wall. Scaled from the measured 10M cell, 32 chains at 14M rows need about 5.7 GiB, and only 128 chains pass the 16.56 GiB this card gives JAX. Only the two orange cells are measured, both at 32 chains. Scaling from the 10M cell misses the other one by 1.7x (0.20 against 0.35 GiB), scaling by chains is untested, and nothing above 10M rows was run, so this wall is arithmetic.

The sampler's own memory is the larger wall, and it sits outside this grid. The grid counts the gradient only. E86's ChEES recipe, the source of the 160,000 gradients, also keeps a d x d table during warmup (m2 in blackjax's chees_adaptation). At R3's 747,000 parameters that table is about 2,080 GiB. It is also why R2 must be cut down before ChEES can run it on all carriers: with an effect for every flight it has 73,100 parameters and a table of about 20 GiB, over the limit. Keeping flight effects only for flights seen in 3 or more cells brings it to 31,503 parameters and 3.7 GiB. The price is that only 30,789 of 72,386 flights keep their own effect and 24% of cells lose it, so the cut model is not the R2 fitted in part 6. A private blackjax option, _length_floor=False, skips the table; it is untested here, and its gradient count is unknown. So R3's price joins a gradient count from a recipe that cannot run at this size to a memory figure from one with no count.

Precision is unresolved. At 14 million rows the total log density is near 6.8 million, where neighbouring float32 numbers are 0.5 nats apart; on one month it is about 263,000, with a spacing of 0.03. That is the spacing of the numbers, not the measured error of a real sum, and the check that would measure the error was not run; it cannot be run on a month, where the spacing is small. Rounding of this size changes which proposals are accepted, though it does not by itself cause a divergence. A row-level run must pass this test before anyone relies on its 7.0 hours.

5 · Two counting rules

The analyst's table comes first, because it may win. R0 says a flight flagged last month will be flagged again, with the chance estimated from pairs of consecutive months in the window. R1 adds up each flight's operations and late operations over the 12-month window, shrinks them towards a Beta prior fitted by moments to its carrier, and predicts next month with a beta-binomial:

$$P(\text{flag}) = P\big(Y > n/2\big), \qquad Y \sim \text{BetaBinomial}(n, a, b),$$

where $n$ is next month's operations and $a, b$ the flight's updated Beta. Both run on all carriers, and neither needs a sampler. The 12-month window, and the assumption that last month's file is already in hand, are fixed choices that we do not tune; part 7 shows a shorter window doing better in July.

Failure 2: a rate that never moves

The first version of R1 treated a flight's late rate as the same every month. With a year of history its Beta is tight, so a flight running at 20% late gets almost no chance of passing 50%, and whole months do not behave like that. The fix adds one number per carrier, a month-to-month concentration $\kappa$ estimated in the window, which widens next month's Beta: its concentration $c$ becomes $1/(1/c + 1/\kappa)$. We settled on the fixed version before reading any score; both are printed.

def window(target, months=WINDOW):
    return (cells["month"] >= target - months) & (cells["month"] < target)


def targets_of(target, carrier=None):
    part = cells[cells["month"] == target]
    return part if carrier is None else part[part["carrier"] == carrier]


def above_half(a, b, n):
    """P(Y > n / 2) for Y ~ BetaBinomial(n, a, b), exact, one value of n at a time."""
    a, b, n = np.asarray(a, float), np.asarray(b, float), np.asarray(n)
    out = np.zeros(n.size)
    for size in np.unique(n):
        k = np.arange(size // 2 + 1, size + 1)
        rows = n == size
        choose = gammaln(size + 1) - gammaln(k + 1) - gammaln(size - k + 1)
        out[rows] = np.exp(choose + betaln(a[rows, None] + k, b[rows, None] + size - k) - betaln(a[rows, None], b[rows, None])).sum(axis=1)
    return out


def r0_forecast(target, carrier=None, future=None):
    """Persistence, with a rate from pairs of months inside the window."""
    past = cells[window(target)]
    rate = past.groupby("state")["flagged"].agg(["sum", "size"])
    rate = (rate["sum"] + 0.5) / (rate["size"] + 1)
    part = targets_of(target, carrier) if future is None else future
    return part["state"].map(rate).to_numpy()


def r1_forecast(target, carrier=None, month_term=True, months=WINDOW, future=None):
    """Beta prior per carrier by moments, conjugate update, beta-binomial next month.

    `month_term=False` is the first version: it treats a flight's rate as the same every month.
    `months` shortens the history, and `future` replaces the flights being forecast.
    """
    train = cells[window(target, months)]
    totals = train.groupby("flight")[["n", "late"]].sum()
    totals["carrier"] = flight_carrier[totals.index]
    future = targets_of(target) if future is None else future
    out = pd.Series(0.0, index=future.index)
    for c, part in future.groupby("carrier"):
        mine = totals[totals["carrier"] == c]
        rate = mine["late"] / mine["n"]
        m = rate.mean()
        between = max(rate.var() - (m * (1 - m) / mine["n"]).mean(), 1e-4)
        strength = max(m * (1 - m) / between - 1, 2.0)
        a0, b0 = m * strength, (1 - m) * strength
        late, n = part["flight"].map(mine["late"]).fillna(0).to_numpy(), part["flight"].map(mine["n"]).fillna(0).to_numpy()
        mean, spread = (a0 + late) / (a0 + b0 + n), a0 + b0 + n
        if month_term:
            seen = train[train["carrier"] == c]
            r = ((a0 + seen["flight"].map(mine["late"])) / (a0 + b0 + seen["flight"].map(mine["n"]))).to_numpy()
            excess = np.mean((seen["rate"].to_numpy() - r) ** 2 - r * (1 - r) / seen["n"].to_numpy())
            kappa = max(np.mean(r * (1 - r)) / max(excess, 1e-4) - 1, 2.0)
            spread = 1 / (1 / spread + 1 / kappa)
        out[part.index] = above_half(mean * spread, (1 - mean) * spread, part["n"].to_numpy())
    return out.to_numpy() if carrier is None else out[future["carrier"].to_numpy() == carrier].to_numpy()


def log_score(p, flagged):
    p = np.clip(p, 1e-6, 1 - 1e-6)
    return np.where(flagged, np.log(p), np.log1p(-p))


def tie_break(part, salt):
    """Ranks tie under R0: last month's rate decides, then a fixed draw."""
    noise = np.random.default_rng([RANDOM_SEED, salt]).random(len(part))
    return part["last_rate"].fillna(-1).to_numpy() + 1e-6 * noise


def top(p, k, tie=None):
    return np.lexsort((-(np.zeros(p.size) if tie is None else tie), -p))[:k]


with stage("R0 and R1, all carriers"):
    national = {}
    for t in TARGETS:
        part = targets_of(t)
        began = time.perf_counter()
        r0 = r0_forecast(t)
        r0_s = time.perf_counter() - began
        began = time.perf_counter()
        r1 = r1_forecast(t)
        r1_s = time.perf_counter() - began
        national[t] = {"table": part, "R0": r0, "R1": r1, "R1 first": r1_forecast(t, month_term=False), "seconds": {"R0": r0_s, "R1": r1_s}}

rows = []
for t, f in national.items():
    flagged = f["table"]["flagged"].to_numpy()
    tie = tie_break(f["table"], t)
    rows.append({"target": TARGET_NAMES[t], "flights": flagged.size, "flagged": int(flagged.sum()),
                 **{f"{k} expected": f[k].sum() for k in ("R0", "R1 first", "R1")},
                 **{f"{k} score": log_score(f[k], flagged).mean() for k in ("R0", "R1 first", "R1")},
                 "R0 top 50 hits": int(flagged[top(f["R0"], 50, tie)].sum()), "R1 top 50 hits": int(flagged[top(f["R1"], 50)].sum())})
national_table = pd.DataFrame(rows).set_index("target")
print(national_table.T.round(3).to_string())


def list_from_last_month(target, k=50):
    """Top k from last month's file alone: last month's eligible flights, at last month's operations."""
    last = cells[cells["month"] == target - 1].assign(state=lambda d: d["flagged"].astype(int), last_rate=lambda d: d["rate"])
    now = targets_of(target)
    flagged_flights, eligible_flights = set(now.loc[now["flagged"], "flight"]), set(now["flight"])
    lists = {"R0": last["flight"].to_numpy()[top(r0_forecast(target, future=last), k, tie_break(last, target))],
             "R1": last["flight"].to_numpy()[top(r1_forecast(target, future=last), k)]}
    return {r: sum(f in flagged_flights for f in v) for r, v in lists.items()}


live = {TARGET_NAMES[t]: list_from_last_month(t) for t in TARGETS}
print("Top 50 hits from last month's file alone:", live)
print("Flights in R1's look-ahead top 50 that were not eligible the month before:",
      {TARGET_NAMES[t]: int((f["table"]["state"].to_numpy()[top(f["R1"], 50)] == -1).sum()) for t, f in national.items()})
rolling = []
for t in range(WINDOW, 24):
    part, r0, r1 = targets_of(t), r0_forecast(t), r1_forecast(t)
    flagged = part["flagged"].to_numpy()
    last_month = list_from_last_month(t)
    rolling.append({"target": labels[t], "R0 hits": int(flagged[top(r0, 50, tie_break(part, t))].sum()), "R1 hits": int(flagged[top(r1, 50)].sum()),
                    "R0 hits, last month's file": last_month["R0"], "R1 hits, last month's file": last_month["R1"],
                    "R0 score": log_score(r0, flagged).mean(), "R1 score": log_score(r1, flagged).mean()})
rolling = pd.DataFrame(rolling).set_index("target")
print(rolling.round(3).to_string())
print(f"12 origins. Hits: R0 {rolling['R0 hits'].sum()}, R1 {rolling['R1 hits'].sum()}; from last month's file R0 "
      f"{rolling.iloc[:, 2].sum()}, R1 {rolling.iloc[:, 3].sum()}. R0 led on hits in {(rolling['R0 hits'] > rolling['R1 hits']).sum()} months, "
      f"R1 in {(rolling['R1 hits'] > rolling['R0 hits']).sum()}; from last month's file R0 in {(rolling.iloc[:, 2] > rolling.iloc[:, 3]).sum()}, "
      f"R1 in {(rolling.iloc[:, 3] > rolling.iloc[:, 2]).sum()}. R1 had the better log score in {(rolling['R1 score'] > rolling['R0 score']).sum()}.")

fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharex=True, sharey=True, layout="constrained")
edges = np.r_[0, np.geomspace(1e-4, 1, 11)]
for ax, (t, f) in zip(axes, national.items()):
    flagged = f["table"]["flagged"].to_numpy()
    ax.plot([1e-4, 1], [1e-4, 1], color="k", lw=0.8)
    for key, colour, marker in (("R1 first", ALARM, "s"), ("R1", MEASURED, "o")):
        bin_of = np.digitize(f[key], edges[1:-1])
        shown = [(f[key][bin_of == b].mean(), flagged[bin_of == b].mean(), (bin_of == b).sum()) for b in np.unique(bin_of)]
        x, y, size = map(np.array, zip(*shown))
        ax.scatter(x, np.maximum(y, 1e-4), s=12 + 90 * np.sqrt(size / size.max()), color=colour, marker=marker, alpha=0.8, lw=0, label=key)
    ax.set(xscale="log", yscale="log", title=TARGET_NAMES[t], xlim=(8e-7, 1.3), ylim=(7e-5, 1.3), xlabel="predicted chance of a flag")
axes[0].set_ylabel("share flagged, held out")
axes[0].legend(handles=[Line2D([], [], marker="s", ls="", color=ALARM, label="first version"),
                        Line2D([], [], marker="o", ls="", color=MEASURED, label="with a month-to-month term")], loc="upper left", fontsize=9)
fig.suptitle("All carriers: the first counting rule was too sure of itself. Larger marks hold more flights; a share of 0 sits at 1e-4",
             x=0.01, ha="left", fontsize=11);
target               2025-04    2025-07    2025-10
flights            19770.000  21791.000  20803.000
flagged               85.000   1686.000    195.000
R0 expected          290.585    560.784    366.552
R1 first expected     98.582    451.136    247.040
R1 expected          155.518    548.446    324.834
R0 score              -0.032     -0.276     -0.051
R1 first score        -0.026     -0.329     -0.048
R1 score              -0.026     -0.270     -0.048
R0 top 50 hits         4.000     36.000     20.000
R1 top 50 hits         9.000     35.000      9.000
Top 50 hits from last month's file alone: {'2025-04': {'R0': 4, 'R1': 6}, '2025-07': {'R0': 35, 'R1': 31}, '2025-10': {'R0': 15, 'R1': 6}}
Flights in R1's look-ahead top 50 that were not eligible the month before: {'2025-04': 14, '2025-07': 2, '2025-10': 27}
         R0 hits  R1 hits  R0 hits, last month's file  R1 hits, last month's file  R0 score  R1 score
target                                                                                               
2025-01        1        2                           1                           1    -0.027    -0.020
2025-02       14        6                          16                           5    -0.068    -0.065
2025-03        7        4                           6                           4    -0.027    -0.020
2025-04        4        9                           4                           6    -0.032    -0.026
2025-05       21       21                          15                          12    -0.101    -0.101
2025-06       22       23                          14                           8    -0.266    -0.276
2025-07       36       35                          35                          31    -0.276    -0.270
2025-08       12       13                          10                          12    -0.085    -0.080
2025-09        2        0                           1                           0    -0.046    -0.045
2025-10       20        9                          15                           6    -0.051    -0.048
2025-11        2        5                           1                           2    -0.050    -0.041
2025-12       17       20                          12                          16    -0.139    -0.137
12 origins. Hits: R0 158, R1 147; from last month's file R0 130, R1 103. R0 led on hits in 5 months, R1 in 6; from last month's file R0 in 7, R1 in 4. R1 had the better log score in 11.
Figure

Perfect forecasts sit on the diagonal. Both versions sit above it at the low end, where the first version promised less than one flag in 10,000 flights and saw far more. The fix helps most in July, the worst month, lifting the mean log score from -0.329 to -0.270, and leaves April and October almost unchanged. It is not free: in the calm months the expected number of flags rises further above the truth, 156 against 85 in April and 325 against 195 in October, and July is still far short, 548 against 1,686. Neither version saw a month that bad coming.

Neither rule wins the national top 50. R0 caught 4, 36 and 20 flagged flights in the three months, R1 9, 35 and 9. With 0.4-8% of flights flagged, a random list of 50 would catch 0.2 to 3.9. That is the analyst's case, for under a second of machine time.

Those counts lean on the target month's own schedule. A real list has only last month's file, so it must rank last month's eligible flights at last month's operations. Built that way, R0 caught 4, 35 and 15 and R1 6, 31 and 6; up to 27 of R1's look-ahead 50 were flights a real list could not have named. Over all 12 months with a full window, the two are level when they can see the target month's schedule, 158 hits against 147, with R0 ahead in 5 months and R1 in 6. From last month's file alone R0 leads, 130 against 103, ahead in 7 months against 4. Yet R1 has the better log score in 11 of 12. R0 ranks a list better, and R1 states a chance better.

6 · R2 on one carrier

R2 models each flight x month cell. On the logit scale, a flight's late rate is a carrier level plus effects for origin, destination, flight and month, and a beta-binomial concentration $\phi$ carries the variation that is left:

$$\text{late}_{ft} \sim \text{BetaBinomial}\big(n_{ft},\ p_{ft}\phi,\ (1-p_{ft})\phi\big), \quad \text{logit}\, p_{ft} = a + \sigma_o z_{o[f]} + \sigma_d z_{d[f]} + \sigma_f z_f + m_t .$$

The flight, origin and destination effects are written non-centred, the scales get LogNormal priors (E86 saw half-normal priors on scales run away under MEADS), and the month effect is a pm.ZeroSumNormal. We sample with nutpie, 4 chains of 1,000 tuning steps and 1,000 draws, rather than nutpie's default of 400 tuning steps. The centred form was not compared.

A national R2 does not fit in a reader's run: on 2 CPU cores it prices in hours (part 8), and on the card here a national run would break a 60-second limit on GPU jobs, with no way to checkpoint warmup. So we fit one carrier, the 8th of 15 by eligible cells, chosen by size alone, with every eligible flight it ran in the window. A prior predictive check in NumPy comes first.

sizes = np.bincount(cells.loc[window(TARGETS[0]), "carrier"], minlength=15)
CARRIER = int(np.argsort(-sizes)[7])  # 8th of 15 by eligible cells before the first target: chosen by size alone
print(f"The carrier holds {sizes[CARRIER] / sizes.sum():.1%} of the eligible cells in the first window.")
typical = []
for t in TARGETS:
    seen = cells[window(t)]
    for name, part, group in (("this carrier", targets_of(t, CARRIER), seen[seen["carrier"] == CARRIER]), ("all carriers", targets_of(t), seen)):
        typical.append({"target": TARGET_NAMES[t], "group": name, "window cells per flight": len(group) / group["flight"].nunique(),
                        "target flights with no history": 1 - part["flight"].isin(group["flight"]).mean(),
                        "flags on flights not eligible last month": f"{int((part['flagged'] & (part['state'] == -1)).sum())} of {int(part['flagged'].sum())}"})
print(pd.DataFrame(typical).set_index(["target", "group"]).round(2).to_string())


def r2_cells(target, carrier=CARRIER):
    w = cells[window(target) & (cells["carrier"] == carrier)]
    return w["flight"].to_numpy(), w["month"].to_numpy() - (target - WINDOW), w["n"].to_numpy(), w["late"].to_numpy()


def r2_model(f, m, n, k):
    """R2: late or cancelled in a flight x month cell, beta-binomial, flight effects written non-centred."""
    flights, fi = np.unique(f, return_inverse=True)
    origins, oi = np.unique(flight_origin[flights], return_inverse=True)
    dests, di = np.unique(flight_dest[flights], return_inverse=True)
    coords = {"flight": np.arange(flights.size), "origin": np.arange(origins.size), "dest": np.arange(dests.size),
              "month": np.arange(WINDOW)}
    with pm.Model(coords=coords) as model:
        a = pm.Normal("a", -1.7, 1.0)
        s_o = pm.LogNormal("s_o", np.log(0.2), 0.6)
        s_d = pm.LogNormal("s_d", np.log(0.2), 0.6)
        s_f = pm.LogNormal("s_f", np.log(0.3), 0.6)
        s_m = pm.LogNormal("s_m", np.log(0.3), 0.6)
        z_o = pm.Normal("z_o", 0.0, 1.0, dims="origin")
        z_d = pm.Normal("z_d", 0.0, 1.0, dims="dest")
        z_f = pm.Normal("z_f", 0.0, 1.0, dims="flight")
        month_effect = pm.ZeroSumNormal("m", sigma=s_m, dims="month")
        phi = pm.LogNormal("phi", np.log(30.0), 1.0)
        base = a + s_o * z_o[oi] + s_d * z_d[di] + s_f * z_f
        p = pm.math.invlogit(base[fi] + month_effect[m])
        pm.BetaBinomial("y", n=n, alpha=p * phi, beta=(1 - p) * phi, observed=k)
    return model, flights, origins, dests, oi, di


# Prior predictive, in NumPy on the first window: do these priors allow what this carrier really did?
f_, m_, n_, k_ = r2_cells(TARGETS[0])
flights_, fi_ = np.unique(f_, return_inverse=True)
oi_ = np.unique(flight_origin[flights_], return_inverse=True)[1]
di_ = np.unique(flight_dest[flights_], return_inverse=True)[1]
draws = 400
prior_rng = np.random.default_rng(RANDOM_SEED)
scale = lambda mean: prior_rng.lognormal(np.log(mean), 0.6, size=(draws, 1))
eta = (prior_rng.normal(-1.7, 1.0, size=(draws, 1))
       + (scale(0.2) * prior_rng.normal(size=(draws, oi_.max() + 1)))[:, oi_]
       + (scale(0.2) * prior_rng.normal(size=(draws, di_.max() + 1)))[:, di_]
       + scale(0.3) * prior_rng.normal(size=(draws, flights_.size)))
eta = eta[:, fi_] + (scale(0.3) * prior_rng.normal(size=(draws, WINDOW)))[:, m_]
phi_prior = prior_rng.lognormal(np.log(30.0), 1.0, size=(draws, 1))
mean_prior = expit(eta)
y_prior = prior_rng.binomial(n_, prior_rng.beta(mean_prior * phi_prior, (1 - mean_prior) * phi_prior))
share_prior, flagged_prior = (y_prior.sum(1) / n_.sum()), ((y_prior / n_) > 0.5).mean(1)
print(f"Prior predictive: late share {np.quantile(share_prior, 0.05):.2f}-{np.quantile(share_prior, 0.95):.2f} "
      f"(observed {k_.sum() / n_.sum():.2f}); flagged cells {np.quantile(flagged_prior, 0.05):.1%}-{np.quantile(flagged_prior, 0.95):.1%} "
      f"(observed {(k_ / n_ > 0.5).mean():.1%})")
del eta, mean_prior, y_prior


def fit_r2(target, carrier=CARRIER, tune=TUNE, draws=DRAWS, seed=None):
    """One fit, with real gradient counts, and checks on a stand-in for the list as well as on parameters."""
    f, m, n, k = r2_cells(target, carrier)
    model, flights, origins, dests, oi, di = r2_model(f, m, n, k)
    began = time.perf_counter()
    compiled = nutpie.compile_pymc_model(model)
    trace = nutpie.sample(compiled, chains=4, cores=CHAINS_AT_ONCE, tune=tune, draws=draws, progress_bar=False, save_warmup=True,
                          store_mass_matrix=False, seed=RANDOM_SEED + target if seed is None else seed)
    seconds = time.perf_counter() - began
    post = trace.posterior
    keep = {v: post[v].values.reshape(-1, *post[v].shape[2:]) for v in ("a", "s_o", "s_d", "s_f", "s_m", "phi", "z_o", "z_d", "z_f")}
    base = keep["a"][:, None] + keep["s_o"][:, None] * keep["z_o"][:, oi] + keep["s_d"][:, None] * keep["z_d"][:, di] + keep["s_f"][:, None] * keep["z_f"]
    ranked = np.argsort(-base.mean(0))[:50]
    logit_mean = xr.Dataset({"x": (("chain", "draw", "flight"), base[:, ranked].reshape(4, draws, 50))})
    hyper = xr.Dataset({v: (("chain", "draw"), post[v].values) for v in ("a", "s_o", "s_d", "s_f", "s_m", "phi")})
    out = {"target": target, "flights": flights, "origins": origins, "dests": dests, "fi": np.unique(f, return_inverse=True)[1],
           "month": m, "n": n, "late": k, "keep": keep, "seconds": seconds,
           "stats": {"cells": n.size, "flights": flights.size, "d": sum(v.size for v in model.initial_point().values()),
                     "seconds": seconds, "gradients warmup": int(trace.warmup_sample_stats["n_steps"].values.sum()),
                     "gradients draws": int(trace.sample_stats["n_steps"].values.sum()),
                     "divergences": int(trace.sample_stats["diverging"].values.sum()),
                     "max r_hat top 50": float(np.asarray(az.rhat(logit_mean)["x"]).max()),
                     "min ESS top 50": float(np.asarray(az.ess(logit_mean, method="bulk")["x"]).min()),
                     "max r_hat hyper": max(float(az.rhat(hyper)[v]) for v in hyper.data_vars),
                     "min ESS hyper": min(float(az.ess(hyper, method="bulk")[v]) for v in hyper.data_vars)}}
    del trace, compiled, post, model, base, logit_mean
    gc.collect()
    return out


def effect(rng_, scale_, z, codes, known_codes):
    """Posterior effect for codes seen in training; a new draw from the same sd for those that were not."""
    position = np.searchsorted(known_codes, codes).clip(0, known_codes.size - 1)
    known = known_codes[position] == codes
    out = scale_[:, None] * rng_.normal(size=(scale_.size, codes.size))
    out[:, known] = scale_[:, None] * z[:, position[known]]
    return out


def r2_forecast(fit, carrier=CARRIER, seed=None):
    """Flag probabilities for next month's flights: the plain version, and the one that redraws the month."""
    target, keep = fit["target"], fit["keep"]
    part = targets_of(target, carrier)
    flight, n = part["flight"].to_numpy(), part["n"].to_numpy()
    rng_ = np.random.default_rng([RANDOM_SEED, target] if seed is None else [RANDOM_SEED, target, 1000 + seed])
    base = (keep["a"][:, None] + effect(rng_, keep["s_o"], keep["z_o"], flight_origin[flight], fit["origins"])
            + effect(rng_, keep["s_d"], keep["z_d"], flight_dest[flight], fit["dests"])
            + effect(rng_, keep["s_f"], keep["z_f"], flight, fit["flights"]))
    plain = stats.binom.sf(n // 2, n, expit(base))
    month_shock = keep["s_m"][:, None] * rng_.normal(size=(keep["s_m"].size, 1))
    mean = expit(base + month_shock)
    rate = rng_.beta(mean * keep["phi"][:, None], (1 - mean) * keep["phi"][:, None])
    redrawn = stats.binom.sf(n // 2, n, rate)
    count = (rng_.binomial(n, rate) / n > 0.5).sum(1)
    half = redrawn.shape[0] // 2
    return {"table": part, "plain": plain.mean(0), "R2": redrawn.mean(0), "count": count, "plain_count": (rng_.binomial(n, expit(base)) / n > 0.5).sum(1),
            "half_a": redrawn[:half].mean(0), "half_b": redrawn[half:].mean(0), "sum_by_draw": redrawn.sum(1)}


def replicated_checks(fit, sims=400):
    """Training-window checks that redraw every month and all overdispersion. Flights keep their fitted effects."""
    keep, rng_ = fit["keep"], np.random.default_rng([RANDOM_SEED, fit["target"], 1])
    sims = min(sims, keep["a"].size)
    pick = rng_.choice(keep["a"].size, sims, replace=False)
    oi = np.unique(flight_origin[fit["flights"]], return_inverse=True)[1]
    di = np.unique(flight_dest[fit["flights"]], return_inverse=True)[1]
    base = (keep["a"][pick, None] + keep["s_o"][pick, None] * keep["z_o"][pick][:, oi] + keep["s_d"][pick, None] * keep["z_d"][pick][:, di]
            + keep["s_f"][pick, None] * keep["z_f"][pick])
    mean = expit(base[:, fit["fi"]] + (keep["s_m"][pick, None] * rng_.normal(size=(sims, WINDOW)))[:, fit["month"]])
    phi = keep["phi"][pick, None]
    y = rng_.binomial(fit["n"], rng_.beta(mean * phi, (1 - mean) * phi))
    monthly = lambda late, flag: (np.array([late[..., fit["month"] == t].sum(-1) / fit["n"][fit["month"] == t].sum() for t in range(WINDOW)]),
                                  np.array([flag[..., fit["month"] == t].mean(-1) for t in range(WINDOW)]))
    rep_late, rep_flag = monthly(y, y / fit["n"] > 0.5)
    obs_late, obs_flag = monthly(fit["late"], fit["late"] / fit["n"] > 0.5)
    checks = {"month-to-month sd of late share": (rep_late.std(0), obs_late.std()), "worst month's flagged share": (rep_flag.max(0), obs_flag.max()),
              "mean flagged share": (rep_flag.mean(0), obs_flag.mean())}
    return {name: {"observed": o, "replicated 5%": np.quantile(r, 0.05), "replicated 95%": np.quantile(r, 0.95), "P(rep >= obs)": (r >= o).mean()}
            for name, (r, o) in checks.items()}, obs_flag


with stage("R2, one carrier, 3 fits"):
    forecasts, other_seeds, diagnostics, ppc, training_months = {}, {}, {}, {}, {}
    for t in TARGETS:
        fit = fit_r2(t)
        forecasts[t] = r2_forecast(fit)
        other_seeds[t] = [r2_forecast(fit, seed=s)["R2"] for s in range(12)]  # the same draws, other forecast noise
        diagnostics[TARGET_NAMES[t]] = fit["stats"]
        ppc[TARGET_NAMES[t]], training_months[t] = replicated_checks(fit)
        del fit
        gc.collect()
print(pd.DataFrame(diagnostics).round(3).to_string())
print(pd.concat({k: pd.DataFrame(v).T for k, v in ppc.items()}).round(3).to_string())
The carrier holds 3.9% of the eligible cells in the first window.
                      window cells per flight  target flights with no history flags on flights not eligible last month
target  group                                                                                                         
2025-04 this carrier                     1.91                            0.50                                  8 of 11
        all carriers                     3.41                            0.22                                 40 of 85
2025-07 this carrier                     1.73                            0.66                                 31 of 39
        all carriers                     3.20                            0.11                              297 of 1686
2025-10 this carrier                     1.65                            0.41                                   1 of 3
        all carriers                     3.31                            0.25                                94 of 195
Prior predictive: late share 0.04-0.47 (observed 0.16); flagged cells 0.0%-42.9% (observed 3.2%)
                     2025-04     2025-07     2025-10
cells               9627.000    9313.000    8485.000
flights             5042.000    5398.000    5131.000
d                   5180.000    5535.000    5274.000
seconds              239.760     236.805     211.478
gradients warmup  121034.000  124592.000  119902.000
gradients draws   123248.000  124000.000  124000.000
divergences            0.000       0.000       0.000
max r_hat top 50       1.005       1.006       1.006
min ESS top 50      6120.818    6461.162    6251.626
max r_hat hyper        1.007       1.005       1.010
min ESS hyper        723.364     616.727     610.497
                                         observed  replicated 5%  replicated 95%  P(rep >= obs)
2025-04 month-to-month sd of late share     0.057          0.028           0.080          0.305
        worst month's flagged share         0.151          0.031           0.226          0.120
        mean flagged share                  0.030          0.012           0.046          0.230
2025-07 month-to-month sd of late share     0.055          0.026           0.081          0.268
        worst month's flagged share         0.151          0.032           0.210          0.115
        mean flagged share                  0.031          0.012           0.043          0.180
2025-10 month-to-month sd of late share     0.028          0.017           0.047          0.510
        worst month's flagged share         0.066          0.019           0.078          0.098
        mean flagged share                  0.018          0.009           0.022          0.128

This carrier is not typical, and its size did not show it. It holds 3.9% of the eligible cells, but its flights are short-lived: 1.7-1.9 window cells a flight against 3.2-3.4 nationally. 41-66% of its target flights have no history in the window, and so no fitted effect of their own, against 11-25% nationally, and 31 of July's 39 flags fell on flights that were not eligible the month before. What follows is therefore less a test of flight history than of how R2 treats new flights.

The priors allow a late share of 4-47% and flagged cells up to 43%, wide around the observed 16% and 3%. The fits are clean: no divergences, $\hat R$ at most 1.01 (1.0104 on one October hyperparameter), and bulk ESS above 6,000 on the logit means of the 50 flights ranked highest in training and above 600 on the hyperparameters. Each fit takes about 4 minutes and spends about 60,000 gradients a chain, warmup and sampling together.

The checks on the training window redraw all 12 month effects and all the overdispersion, keeping the flights' fitted effects. Observed values sit inside the 5-95% range on all three measures in all three windows, with $P(\text{rep} \ge \text{obs})$ from 0.10 to 0.51. Keeping the flight effects makes this a weak test: it says the spread between months is plausible, not that the forecasts are good. Only held-out months can say that.

Failure 3: forecasting an average month

The obvious forecast takes each flight's fitted effects, sets the month effect to its mean of zero, and reads off the chance of passing 50%. But next month is a new month. The right forecast draws a new month effect from the fitted spread $\sigma_m$, new overdispersion from $\phi$, and, for flights and airports the window never saw, new effects from the fitted scales.

for t, f in forecasts.items():
    part = f["table"]
    f["R0"] = r0_forecast(t, CARRIER)
    f["R1"] = r1_forecast(t, CARRIER)
    f["tie"] = tie_break(part, t)
rows = []
for t, f in forecasts.items():
    flagged = f["table"]["flagged"].to_numpy()
    rows.append({"target": TARGET_NAMES[t], "flights": flagged.size, "flagged": int(flagged.sum()),
                 "first version, expected": f["plain"].sum(), "first version, score": log_score(f["plain"], flagged).mean(),
                 "redrawn, expected": f["R2"].sum(), "redrawn, 5%": np.quantile(f["count"], 0.05), "redrawn, 95%": np.quantile(f["count"], 0.95),
                 "redrawn, score": log_score(f["R2"], flagged).mean(),
                 "list overlap, two halves of the draws": len(set(top(f["half_a"], LIST_SIZE)) & set(top(f["half_b"], LIST_SIZE)))})
print(pd.DataFrame(rows).set_index("target").T.round(3).to_string())

fig, ax = plt.subplots(figsize=(10.5, 4.6), layout="constrained")
for row, (t, f) in enumerate(forecasts.items()):
    y = 2 - row
    total = len(f["table"])
    for shift, counts, colour in ((0.15, f["count"], MEASURED), (-0.15, f["plain_count"], ALARM)):
        low, mid, high = np.quantile(counts, [0.05, 0.5, 0.95])
        ax.plot([low + 0.5, high + 0.5], [y + shift] * 2, color=colour, lw=6, alpha=0.75, solid_capstyle="butt")
        ax.scatter([mid + 0.5], [y + shift], s=45, color="w", edgecolor=colour, lw=1.6, zorder=3)
    ax.scatter([f["table"]["flagged"].sum() + 0.5], [y], marker="D", s=70, color="k", zorder=4)
    ax.plot(training_months[t] * total + 0.5, np.full(WINDOW, y + 0.37), "|", color="#33424b", ms=9, mew=1.4)
ax.set(xscale="log", xlim=(0.4, 200), ylim=(-0.6, 2.6), yticks=[2, 1, 0], xlabel="flights flagged next month on this carrier (plotted at count + 0.5)")
ax.set_yticklabels(TARGET_NAMES.values())
ax.set_xticks([0.5, 1.5, 5.5, 10.5, 30.5, 100.5], ["0", "1", "5", "10", "30", "100"])
ax.xaxis.set_minor_formatter(NullFormatter())
ax.legend(handles=[Line2D([], [], color=MEASURED, lw=6, alpha=0.75, label="90% interval, month and overdispersion redrawn"),
                   Line2D([], [], color=ALARM, lw=6, alpha=0.75, label="90% interval, an average month"),
                   Line2D([], [], marker="o", ls="", markerfacecolor="w", markeredgecolor="#33424b", markeredgewidth=1.6, ms=7,
                          label="median of either interval"),
                   Line2D([], [], marker="D", ls="", color="k", label="what happened"),
                   Line2D([], [], marker="|", ls="", color="#33424b", ms=9, mew=1.4, label="the 12 training months, scaled to this list")],
          loc="lower right", fontsize=8.5)
ax.set_title("A forecast that keeps an average month cannot see a bad one", loc="left", fontsize=11);
target                                 2025-04  2025-07  2025-10
flights                                675.000  593.000  500.000
flagged                                 11.000   39.000    3.000
first version, expected                  6.753    6.577    3.364
first version, score                    -0.078   -0.257   -0.030
redrawn, expected                       16.061   13.723    6.223
redrawn, 5%                              0.000    0.000    0.000
redrawn, 95%                            58.000   46.000   18.000
redrawn, score                          -0.077   -0.217   -0.033
list overlap, two halves of the draws    9.000   10.000   10.000
Figure

The first version is sure of a narrow count and misses July: it expects about 7 flags where 39 happened. The redrawn forecast admits what it does not know, with 90% intervals from 0 to 18-58 flags, and July's 39 falls inside. Its mean is still only about 14, because a new month is drawn from all months alike: the model does not know July is a bad month, though its window held July 2024 with 1,562 flags and June 2025 with 1,312. A seasonal month effect is the obvious next step, and R2 does not have one. Honesty also has a price in the score: the redrawn forecast is better by 0.040 in July, level in April and 0.003 worse in October.

Two halves of the posterior draws give lists of 10 that share 9-10 flights, so the list is steady under Monte Carlo noise. That does not settle a rule that can turn on one flight, which part 7 tests.

7 · Does the bigger model change the list?

For the carrier's list we use 10 flights, not the charity's 50: 50 of about 600 flights is far less selective than 50 of 20,000 nationally. To choose between R0, R1 and R2 in each test month we take the cheapest model whose mean log score is within one bootstrap standard error of the best (resampling origin airports). It must also share at least 8 of its 10 flights with the next model up. Where the months disagree, two of three decide. The list of 50 is printed too, but it decides nothing.

The rule was fixed before any score was read, but not blind. The flag counts of the test months had already been seen as data checks, and along the way the list it judges was shortened from 50 to 10. The length matters: 10 is the only length we tried where R1 wins any month. A list of 20 gives R2 in all three months, and at 50 R1's overlap of 20-28 misses the 80% bar.

def decide(list_size=LIST_SIZE, needed=NEEDED, resamples=2000):
    """The rung rule, written down before any score was read, applied to each origin in turn."""
    rows, picks = [], {}
    for t, f in forecasts.items():
        part = f["table"]
        flagged = part["flagged"].to_numpy()
        airports = flight_origin[part["flight"].to_numpy()]
        codes, inverse = np.unique(airports, return_inverse=True)
        scores = {r: log_score(f[r], flagged) for r in ("R0", "R1", "R2")}
        means = {r: s.mean() for r, s in scores.items()}
        best = max(means, key=means.get)
        count = np.bincount(inverse)
        boot = np.random.default_rng([RANDOM_SEED, t]).integers(codes.size, size=(resamples, codes.size))
        se = {}
        for r, s in scores.items():
            gap = np.bincount(inverse, s - scores[best])
            se[r] = (gap[boot].sum(1) / count[boot].sum(1)).std()
        lists = {"R0": top(f["R0"], list_size, f["tie"]), "R1": top(f["R1"], list_size), "R2": top(f["R2"], list_size)}
        shared = {"R0": len(set(lists["R0"]) & set(lists["R1"])), "R1": len(set(lists["R1"]) & set(lists["R2"])), "R2": list_size}
        passes = {r: (means[best] - means[r] <= se[r]) and shared[r] >= needed for r in ("R0", "R1", "R2")}
        pick = next((r for r in ("R0", "R1", "R2") if passes[r]), None)
        picks[t] = pick or best
        for r in ("R0", "R1", "R2"):
            rows.append({"target": TARGET_NAMES[t], "rung": r, "log score": means[r], "gap to best": means[best] - means[r], "one SE": se[r],
                         f"hits in top {list_size}": int(flagged[lists[r]].sum()), "shared with next rung": shared[r], "passes": passes[r]})
        rows[-1]["picked"] = picks[t] if pick else f"{best} (none passed)"
    return pd.DataFrame(rows).set_index(["target", "rung"]), picks


with stage("scores"):
    verdict, picks = decide()
print(verdict.round(4).to_string())
print("Picks by origin:", {TARGET_NAMES[t]: r for t, r in picks.items()})
print("With a list of 50, which decides nothing:")
print(decide(list_size=50, needed=40)[0][["hits in top 50", "shared with next rung"]].unstack("rung").to_string())


def hit_gap(t, k=50, resamples=2000):
    """R2's extra hits over R1 in a list of k, with a 5-95% range from resampling origin airports."""
    f = forecasts[t]
    part, flagged = f["table"], f["table"]["flagged"].to_numpy(float)
    gain = np.zeros(flagged.size)
    gain[top(f["R2"], k)] += flagged[top(f["R2"], k)]
    gain[top(f["R1"], k)] -= flagged[top(f["R1"], k)]
    codes, inverse = np.unique(flight_origin[part["flight"].to_numpy()], return_inverse=True)
    boot = np.random.default_rng([RANDOM_SEED, t, 2]).integers(codes.size, size=(resamples, codes.size))
    low, high = np.quantile(np.bincount(inverse, gain)[boot].sum(1), [0.05, 0.95])
    return {"R2 less R1 hits": int(gain.sum()), "5%": low, "95%": high}


print("Top 50, airports resampled:", {TARGET_NAMES[t]: hit_gap(t) for t in TARGETS})
overlaps = {TARGET_NAMES[t]: [len(set(top(forecasts[t]["R1"], LIST_SIZE)) & set(top(p, LIST_SIZE))) for p in other_seeds[t]] for t in TARGETS}
print("R1 and R2 lists of 10 shared, on 12 other forecast seeds:", overlaps)
print("R2 hits in the top 50 on those seeds:", {TARGET_NAMES[t]: sorted({int(forecasts[t]["table"]["flagged"].to_numpy()[top(p, 50)].sum()) for p in other_seeds[t]})
                                                for t in TARGETS})
history = pd.DataFrame([{"target": TARGET_NAMES[t], "R1 12 months, all carriers": national_table.loc[TARGET_NAMES[t], "R1 score"],
                         "R1 3 months, all carriers": log_score(r1_forecast(t, months=3), national[t]["table"]["flagged"].to_numpy()).mean(),
                         "R1 12 months, this carrier": log_score(forecasts[t]["R1"], forecasts[t]["table"]["flagged"].to_numpy()).mean(),
                         "R1 3 months, this carrier": log_score(r1_forecast(t, CARRIER, months=3), forecasts[t]["table"]["flagged"].to_numpy()).mean(),
                         "R2, this carrier": log_score(forecasts[t]["R2"], forecasts[t]["table"]["flagged"].to_numpy()).mean()} for t in TARGETS]).set_index("target")
print(history.T.round(4).to_string())

fig, axes = plt.subplots(1, 3, figsize=(12.4, 6.3), sharey=True, layout="constrained")
LIMIT_RANK = 30
for ax, (t, f) in zip(axes, forecasts.items()):
    flagged = f["table"]["flagged"].to_numpy()
    ranks = {r: np.argsort(np.lexsort((-f["tie"] if r == "R0" else np.zeros(flagged.size), -f[r]))) + 1 for r in ("R0", "R1", "R2")}
    chosen = sorted(set().union(*(np.flatnonzero(v <= LIST_SIZE) for v in ranks.values())), key=lambda i: flagged[i])
    for i in chosen:
        y = [min(ranks[r][i], LIMIT_RANK) for r in ("R0", "R1", "R2")]
        hit = flagged[i]
        ax.plot(range(3), y, "-o", ms=5 if hit else 3.5, lw=2.2 if hit else 1, color=ALARM if hit else "#a9b7bf", alpha=0.95 if hit else 0.8, zorder=3 if hit else 2)
    ax.axhline(LIST_SIZE + 0.5, color="k", lw=0.8, ls=":")
    ax.set(xticks=range(3), xticklabels=["R0", "R1", "R2"], title=f"{TARGET_NAMES[t]}: {int(flagged.sum())} of {flagged.size} flagged", xlim=(-0.25, 2.25))
axes[0].set(ylim=(LIMIT_RANK + 1, 0), ylabel=f"rank on the carrier's list ({LIMIT_RANK} means {LIMIT_RANK} or lower)")
axes[-1].annotate("list\nends\nhere", (1, LIST_SIZE + 0.5), xycoords=("axes fraction", "data"), xytext=(5, 0),
                  textcoords="offset points", va="center", fontsize=9, annotation_clip=False)
fig.legend(handles=[Line2D([], [], color=ALARM, lw=2.2, marker="o", label="flagged, held out"),
                    Line2D([], [], color="#a9b7bf", lw=1, marker="o", label="not flagged")], loc="outside lower center", ncols=2, fontsize=9)
fig.suptitle("Every flight on any rung's list. Red lines are the flights that really were flagged", x=0.01, ha="left", fontsize=11);
              log score  gap to best  one SE  hits in top 10  shared with next rung  passes picked
target  rung                                                                                      
2025-04 R0      -0.0829       0.0057  0.0066               0                      1   False    NaN
        R1      -0.0840       0.0068  0.0094               0                      8    True    NaN
        R2      -0.0772       0.0000  0.0000               0                     10    True     R1
2025-07 R0      -0.2655       0.0488  0.0115               2                      4   False    NaN
        R1      -0.2481       0.0314  0.0091               4                      8   False    NaN
        R2      -0.2167       0.0000  0.0000               4                     10    True     R2
2025-10 R0      -0.0427       0.0098  0.0038               0                      0   False    NaN
        R1      -0.0358       0.0029  0.0049               0                      7   False    NaN
        R2      -0.0329       0.0000  0.0000               0                     10    True     R2
Picks by origin: {'2025-04': 'R1', '2025-07': 'R2', '2025-10': 'R2'}
With a list of 50, which decides nothing:
        hits in top 50         shared with next rung        
rung                R0  R1  R2                    R0  R1  R2
target                                                      
2025-04              0   0   3                     4  20  50
2025-07              4  10  17                     9  25  50
2025-10              0   0   2                     7  28  50
Top 50, airports resampled: {'2025-04': {'R2 less R1 hits': 3, '5%': np.float64(0.0), '95%': np.float64(7.0)}, '2025-07': {'R2 less R1 hits': 7, '5%': np.float64(0.0), '95%': np.float64(14.0)}, '2025-10': {'R2 less R1 hits': 2, '5%': np.float64(0.0), '95%': np.float64(5.0)}}
R1 and R2 lists of 10 shared, on 12 other forecast seeds: {'2025-04': [8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 8, 8], '2025-07': [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8], '2025-10': [7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]}
R2 hits in the top 50 on those seeds: {'2025-04': [3], '2025-07': [17], '2025-10': [2]}
target                      2025-04  2025-07  2025-10
R1 12 months, all carriers  -0.0258  -0.2704  -0.0479
R1 3 months, all carriers   -0.0278  -0.2404  -0.0552
R1 12 months, this carrier  -0.0840  -0.2481  -0.0358
R1 3 months, this carrier   -0.0940  -0.2390  -0.0397
R2, this carrier            -0.0772  -0.2167  -0.0329
Figure

The rule picks R1 in April and R2 in July and October, so R2 for this carrier, but the margins are thin. Only in July is R2's gain larger than its noise: a gap of 0.031 in mean log score against a standard error of 0.009. In April and October R1 is within one standard error, and in October it fails only on overlap, by one flight: 7 shared where 8 are needed. Had one flight moved in October, the answer on this carrier would be R1.

April's pick is fragile too: R1 sits right on the overlap bar, and a few other seeds move it to R2, so R2 for this carrier holds either way.

Only July is evidence that R2 is better, and part of that may be R1's slow 12-month window: a 3-month R1 scores -0.239 against R2's -0.217 on this carrier, where the 12-month R1 scores -0.248. The overlap test measures difference, not benefit, so in October R2 is bought with a score gap inside one standard error and 0 hits against 0. A short window is no free gain either: it is worse in the calm months (-0.055 against -0.048 nationally in October).

So yes, the list changes: R1 and R2 share 7-8 of 10 flights and 20-28 of 50. Whether that helps depends on the length. In a list of 10 both catch the same 0, 4 and 0 flagged flights, and the 11 flags of April and 3 of October fall outside every list of 10. In a list of 50 R2 catches 3, 17 and 2 against R1's 0, 10 and 0. July's gain of 7 is noisy: resampling airports gives a 5-95% range of about 0 to 14. On this carrier 50 is about a tenth of all flights, so the gain does not carry over to a national list of 50.

On the chart R0 barely overlaps anything, because it lists last month's flags. About half the lines run from R0's floor up into R1's list: going from one month of history to twelve is the largest change of rank on the page, for a tenth of a second. It is not the largest change in what the list catches, which comes from R1 to R2.

fig, axes = plt.subplots(1, 3, figsize=(12, 4.3), sharex=True, layout="constrained")
fit_seconds = {t: diagnostics[TARGET_NAMES[t]]["seconds"] for t in TARGETS}
for ax, (t, f) in zip(axes, forecasts.items()):
    seconds = [national[t]["seconds"]["R0"], national[t]["seconds"]["R1"], fit_seconds[t]]
    means = verdict.loc[TARGET_NAMES[t], "log score"].to_numpy()
    ax.step(seconds + [1e4], np.r_[np.maximum.accumulate(means), np.maximum.accumulate(means)[-1]], where="post", color="#b9c6cd", lw=2, zorder=1)
    for x, y, r in zip(seconds, means, ("R0", "R1", "R2")):
        ax.scatter([x], [y], s=95, color=RUNG_COLOUR[r], zorder=3)
        ax.annotate(r, (x, y), xytext=(0, 9), textcoords="offset points", ha="center", fontsize=10)
    ax.set(xscale="log", title=TARGET_NAMES[t])
    ax.margins(y=0.2)
axes[0].set_ylabel("held-out log score")
fig.supxlabel("machine seconds for this rung, 2 cores", fontsize=11)
fig.suptitle("Value for money, one carrier. R0 and R1 timed on all carriers, R2 on this one: a staircase climbs only when a rung pays", x=0.01, ha="left", fontsize=11);
Figure

Each step up the staircase is a purchase, and a higher score is better. R1 costs about a tenth of a second on every carrier, and R2 about 4 minutes on this one. Each panel has its own vertical scale, because the months cannot be compared. In April R1 sits below R0 and buys nothing on score. Note the horizontal axis: R0 and R1 are timed on all carriers and R2 on one carrier, which flatters R2 by about 26x. Its national price is in the next table.

8 · What this run cost, and what to tell the charity

The last cell adds up what this run spent and prices every model, for one forecast and for the three test months. Each extrapolated line says what it rests on.

price = pd.Series(PRICE, name="seconds").round(0).astype(int)
print(price.to_string())
print(f"Total in stages: {price.sum() / 60:.1f} min on {CORES} cores")
assert {d.platform for d in jax.devices()} == {"cpu"}, "a reader's run must not start a GPU"
print("JAX saw CPU devices only, so this run used no GPU.")

r2_mean_s = np.mean([d["seconds"] for d in diagnostics.values()])
r2_cells_share = sizes.sum() / sizes[CARRIER]
gpu_three = f"{hours(r2_gpu_ms, 60_000, 3) * 60:.0f}-{hours(r2_gpu_ms, 160_000, 3) * 60:.0f} min"
window_three = [sum(hours(ms_14m * r / ROWS, g) for r in fit_rows["12-month window"]) for g in GRADIENTS]
ladder_of_prices = pd.DataFrame(
    [("R0, all carriers", f"{np.mean([f['seconds']['R0'] for f in national.values()]):.2f} s",
      f"{np.mean([f['seconds']['R0'] for f in national.values()]) * 3:.2f} s", "measured", "CPU, 2 cores"),
     ("R1, all carriers", f"{np.mean([f['seconds']['R1'] for f in national.values()]):.2f} s",
      f"{np.mean([f['seconds']['R1'] for f in national.values()]) * 3:.2f} s", "measured", "CPU, 2 cores"),
     ("R2, one carrier, nutpie", f"{r2_mean_s / 60:.1f} min", f"{r2_mean_s * 3 / 60:.0f} min", "measured", "CPU, 2 cores"),
     ("R2, all carriers, nutpie, every flight", f"{r2_mean_s * r2_cells_share / 3600:.1f} h or more", f"{r2_mean_s * r2_cells_share * 3 / 3600:.1f} h or more",
      "extrapolated", f"{r2_cells_share:.0f}x the cells, same gradient count; d about 14x larger"),
     ("R2, all carriers, 3-cell cut, ChEES", f"{hours(r2_gpu_ms, 60_000) * 60:.0f}-{hours(r2_gpu_ms, 160_000) * 60:.0f} min, plus extra restarts",
      f"{gpu_three}, plus extra restarts", "extrapolated", "measured gradient x assumed count; ChEES never run on R2"),
     ("R3, all rows, ChEES", f"{hours(ms_14m, 60_000):.1f}-{hours(ms_14m, 160_000):.1f} h, plus extra restarts",
      f"{window_three[0]:.1f}-{window_three[1]:.1f} h on the 12-month window", "extrapolated",
      "no float32 test, gradient count unmeasured, E86's recipe needs a 2,080 GiB table")],
    columns=["rung", "one forecast", "three test months", "label", "basis"]).set_index("rung")
print(ladder_of_prices.to_string())
print(f"One more tree doubling would make nutpie's three months about {r2_mean_s * r2_cells_share * 6 / 3600:.0f} h.")
timing_free = {k: {name: value for name, value in v.items() if name != "seconds"} for k, v in diagnostics.items()}
store.save_json("decide", {"verdict": verdict.reset_index().to_dict("list"), "picks": {TARGET_NAMES[t]: r for t, r in picks.items()},
                           "diagnostics": timing_free, "national": national_table.reset_index().to_dict("list")});
timing rows 1-5              9
row 5 ladder                 9
R0 and R1, all carriers      1
R2, one carrier, 3 fits    725
scores                       0
Total in stages: 12.4 min on 2 cores
JAX saw CPU devices only, so this run used no GPU.
                                                          one forecast                  three test months         label                                                                             basis
rung                                                                                                                                                                                                     
R0, all carriers                                                0.01 s                             0.04 s      measured                                                                      CPU, 2 cores
R1, all carriers                                                0.12 s                             0.36 s      measured                                                                      CPU, 2 cores
R2, one carrier, nutpie                                        3.8 min                             11 min      measured                                                                      CPU, 2 cores
R2, all carriers, nutpie, every flight                   1.6 h or more                      4.9 h or more  extrapolated                            26x the cells, same gradient count; d about 14x larger
R2, all carriers, 3-cell cut, ChEES      5-14 min, plus extra restarts     16-43 min, plus extra restarts  extrapolated                          measured gradient x assumed count; ChEES never run on R2
R3, all rows, ChEES                     2.6-7.0 h, plus extra restarts  3.9-10.5 h on the 12-month window  extrapolated  no float32 test, gradient count unmeasured, E86's recipe needs a 2,080 GiB table
One more tree doubling would make nutpie's three months about 10 h.

The run took about 12 minutes, 725 seconds of it in the three fits of R2. R3's one forecast uses all 24 months, and its three test months the 12-month window. Both national versions of R2 are extrapolations, and neither is the R2 fitted here. ChEES on the cut model is a measured gradient times an assumed count, on a sampler never run on R2. Nutpie with every flight scales this carrier's time by 26x the cells at the same gradient count, with about 14x as many parameters. Timings vary between runs, hence the ranges, and a result as thin as part 7's can move on another machine.

How to write a price someone can check

A quote is a sentence with five parts: what is bought, on what hardware, measured from what, the range, and what would make it wrong. Compare

"Two hours on one GPU."

with

"One fit of the row-level model for one forecast month costs 2.6-7.0 hours on an RTX 3090 Ti at 32 chains, scaled 1.41x from a measured 10-million-row gradient of 112 ms, for 60,000-160,000 gradients. It takes longer if warmup must restart more than E86's did, if R3 needs more gradients than E86, or if float32 fails at 14 million rows. It needs a sampler that fits in memory at 747,000 parameters, which E86's recipe does not. Nobody has shown what it does to the list."

The second can be checked, argued with and refused. It is also the pattern for the rest of the large-model series: each notebook opens with its price and the smallest model that could answer the question, and says whether the big model changed the decision.

What to tell the charity

So the research lead should not sign the quote as written. A price they could check would give a measured gradient count, warmup included, a float32 test at 14 million rows, a sampler that fits in memory at this size, and three test months. Meanwhile the counting rules make a list tonight: rank by R0, print R1's chance beside each flight, and call it "likely to be flagged". Months, not models, will decide how good that list looks, since flags ran from 25 to 1,686 a month and nothing here saw July coming.

The one purchase worth making next is R2 on all carriers for the same three months, about 16-43 minutes on the GPU for the cut model or about 5 hours on 2 cores with every flight. That fits a night, though the CPU version has little room to spare: one more tree doubling makes it about 10 hours. If its national list adds nothing to R1's, the row-level question goes away.

The plain order of work is the lesson: price before you buy, measure before you claim, and let the smallest model that answers the question go first. It may be the answer.

Summary

  • A price has three factors and two walls. Time is time per gradient x gradients x fits, and memory and precision must hold. The contractor's 0.15 s a gradient is right by accident, a rate 5x too slow per lookup applied to 5x too few lookups, but two hours holds only below about 46,000 gradients. One fit of R3 prices at 2.6-7.0 hours for 60,000-160,000 gradients, and a test on three months at 3.9-10.5 hours on a 12-month window. These are extrapolated from a gradient measured at 10M rows, both gradient counts are assumed, and R3 was never fitted.
  • A rate belongs to a cost shape. The same card is about 30-35x cheaper than 2 CPU threads on matched rows and about 200x dearer on a scan. The CPU side is 2 threads of one Ryzen 5950X and the GPU one RTX 3090 Ti, so this is neither a GPU against a well-run CPU nor a speed for your laptop.
  • Collapse first, then measure. Collapsing rows into flight x month cells is worth about 15x a gradient. Sorting rows by flight, the planned fix, made one lookup at 10M rows 1.6-1.9x slower on the card, and a power law from small subsets hit the month only by straddling a step in the CPU timings.
  • The walls a rate cannot see. Gradient memory is modest (about 5.7 GiB at 32 chains and 14M rows, scaled), but E86's ChEES warmup keeps a d x d table of about 2,080 GiB at R3's 747,000 parameters. Near R3's log density, float32 numbers are 0.5 nats apart; whether that harms a fit was not tested.
  • The counting rules make a usable list today. Built from last month's file, R0's national top 50 caught 4, 35 and 15 flagged flights in the three test months and R1's 6, 31 and 6, against 0.2-3.9 for a random list. Over 12 months R0 ranks better (130 hits against 103) and R1 states chances better (11 of 12 log scores).
  • Forecast a new month, not an average one. R2 with the month redrawn gives 90% intervals of 0 to 18-58 flags that cover July's 39, where the average-month version expected 7. It still cannot tell that July is a bad month.
  • R2 changes the list, and helps a little. On one mid-size carrier (3.9% of the cells; no national R2 has run) with unusually many new flights, R1 and R2 share 7-8 of 10 flights and catch the same 0, 4 and 0 flags; in a list of 50 R2 catches 3, 17 and 2 against 0, 10 and 0. The rule fixed before scoring buys R2, but only July shows a clear gain, October turns on one flight and April's pick moves with the seed.

Try it yourself

Each of these runs as printed at the end of the notebook on 2 cores. The first two take under a second, the third about 75 seconds.

  1. A cost law from less. predict_month(fit_days=(3, 7)) and predict_month(fit_days=(7, 14), chains=1) fit the law of part 3 on other sizes. One sits across the step and overshoots the month several times over; the other sits above it and lands within about 10%. Which is which, and could you have known in advance?
  2. A longer list. decide(list_size=20, needed=16)[1] reruns the rule with a list of 20 and the same 80% overlap, and it gives R2 in all three months. Print decide(list_size=20, needed=16)[0] to see what changed for April. Why should a rule be fixed before reading the scores, list length included?
  3. Another carrier. Take the 10th of 15 with other = int(np.argsort(-sizes)[9]), fit it with fewer draws using small_fit = fit_r2(TARGETS[1], carrier=other, tune=400, draws=300), then compare log_score(r2_forecast(small_fit, other)["R2"], targets_of(TARGETS[1], other)["flagged"]).mean() with log_score(r1_forecast(TARGETS[1], other), targets_of(TARGETS[1], other)["flagged"]).mean(). Read small_fit["stats"] first: at these short settings the hyperparameters' $\hat R$ is about 1.06, which fails the usual bar. One more carrier is one more story, not a national answer.