H34: Two-Gamma Mixture with Constant Slow Shape¶
An 8-parameter speed model. The mixture mean equals the scheduled speed exactly, and the slow component's mean is structurally bounded below the scheduled speed.
$$ f(V \mid v) = m(v)\;\mathrm{Gamma}(\alpha_1, \theta_1) + (1-m(v))\;\mathrm{Gamma}(\alpha_2, \theta_2) $$
with ensemble mean lock: $m \cdot E[\text{slow}] + (1-m) \cdot E[\text{fast}] = v$
Slow component¶
The slow component has a constant shape $\alpha_1$ and a mean that is a fraction of the scheduled speed:
$$ r(v) = \sigma(c_0 + c_1 v), \qquad \text{mean}_\text{slow} = r \cdot v, \qquad \theta_1 = \frac{r \cdot v}{\alpha_1} $$
Since $\sigma(\cdot) \in (0,1)$, the slow mean is always below $v$. The constant shape is a simplification of H33, which had a speed-dependent CV that turned out to be essentially flat ($d_1 \approx 0$).
Fast component (ensemble mean locked)¶
$$ \alpha_2 = \beta_0(v) \cdot v, \qquad \theta_2 = \frac{c}{\beta_0(v)}, \qquad c = \frac{1 - m \cdot r}{1 - m} $$
Mixture weight¶
$$\text{logit}(m) = e_0 + e_1 v$$
| # | Parameters | Role |
|---|---|---|
| 1--3 | $\beta_0(v; s, e, k)$ | Fast-component shape ramp |
| 4--5 | $c_0, c_1$ | Slow mean ratio $r(v)$ |
| 6 | $\alpha_1$ | Slow shape (constant) |
| 7--8 | $e_0, e_1$ | Mixture weight |
Data and Fit¶
We use multi-span augmented AVL data (spans 1--5) to train. During training, each observation's log-likelihood is weighted by $1/\text{spans}$, so single-span observations (the production regime) count most and longer spans contribute less. This compensates for the fact that multi-span speeds are mean-reverted and tighter than the ballistic single-span distribution the model will face in production.
Parameters are loaded from a full-dataset weighted fit.
import json
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
from transit_speed.calibration.dataset import CalibrationDataset
from transit_speed.calibration.metrics import compute_calibration_metrics
from transit_speed.calibration.hypotheses.h34_2gamma_ratio_constcv import (
H34_2GammaRatioConstCV, _slow_params, _ensemble_locked_params,
)
from transit_speed.speed_model import beta_0_vec
# Load pre-fitted parameters (weighted MLE: weight=1/spans)
with open("h34_weighted_params.json") as f:
params = json.load(f)
a1 = np.exp(params["log_a1"])
print("H34 fitted parameters (full multi-span, weighted 1/spans):")
print(f" Fast beta0: start={params['start_b0']:.4f} end={params['end_b0']:.4f} kink={params['kink']:.1f}")
print(f" Slow ratio: logit(r) = {params['r_intercept']:.4f} + {params['r_slope']:.6f} * v")
print(f" Slow shape: alpha_1 = {a1:.4f} (cv = {1/np.sqrt(a1):.2f})")
print(f" Mix weight: logit(m) = {params['mw_intercept']:.4f} + {params['mw_slope']:.6f} * v")
print("\nMixture at representative speeds:")
for v in [5, 15, 25, 40]:
_a1, s1, r, ms = _slow_params(
np.array([v]), params["r_intercept"], params["r_slope"], params["log_a1"])
mw = 1.0 / (1.0 + np.exp(-(params["mw_intercept"] + params["mw_slope"] * v)))
a2, s2, c = _ensemble_locked_params(
np.array([v]), ms, np.array([mw]),
params["start_b0"], params["end_b0"], params["kink"])
print(f" v={v:2d}: mw={mw:.3f} r={r[0]:.3f} c={c[0]:.3f}"
f" slow(mean={ms[0]:.1f}) fast(mean={a2[0]*s2[0]:.1f})")
H34 fitted parameters (full multi-span, weighted 1/spans): Fast beta0: start=0.5714 end=0.1244 kink=21.9 Slow ratio: logit(r) = 0.0060 + 0.026456 * v Slow shape: alpha_1 = 0.2664 (cv = 1.94) Mix weight: logit(m) = -1.3474 + -0.018838 * v Mixture at representative speeds: v= 5: mw=0.191 r=0.535 c=1.110 slow(mean=2.7) fast(mean=5.6) v=15: mw=0.164 r=0.599 c=1.078 slow(mean=9.0) fast(mean=16.2) v=25: mw=0.140 r=0.661 c=1.055 slow(mean=16.5) fast(mean=26.4) v=40: mw=0.109 r=0.744 c=1.031 slow(mean=29.7) fast(mean=41.3)
Parameter Variation with Scheduled Speed¶
The two key derived quantities -- slow mean ratio $r(v)$ and mixture weight $m(v)$ -- are smooth functions of scheduled speed. The slow shape $\alpha_1$ is a constant. Below we plot how each varies across the range of observed scheduled speeds.
v_range = np.linspace(1, 50, 200)
# Compute derived quantities across scheduled speed
a1_val = np.exp(params["log_a1"])
a1_arr, s1_arr, r_arr, ms_arr = _slow_params(
v_range, params["r_intercept"], params["r_slope"], params["log_a1"])
mw_arr = 1.0 / (1.0 + np.exp(-(params["mw_intercept"] + params["mw_slope"] * v_range)))
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
# --- Slow mean ratio r(v) ---
ax = axes[0]
ax.plot(v_range, r_arr, "tab:orange", linewidth=2)
ax.set_xlabel("Scheduled speed (mph)")
ax.set_ylabel("$r(v)$")
ax.set_title("Slow Mean Ratio $r(v) = \\mathrm{mean_{slow}} / v$")
ax.set_ylim(0, 1)
ax.axhline(1.0, color="gray", linestyle=":", alpha=0.5)
ax.grid(True, alpha=0.3)
# Add secondary y-axis showing absolute slow mean
ax2 = ax.twinx()
ax2.plot(v_range, ms_arr, "tab:orange", linewidth=1, linestyle="--", alpha=0.5)
ax2.set_ylabel("Slow mean (mph)", color="tab:orange", alpha=0.5)
ax2.tick_params(axis="y", labelcolor="tab:orange", colors="tab:orange")
# --- Slow shape (constant) ---
ax = axes[1]
ax.axhline(a1_val, color="tab:red", linewidth=2, label=f"$\\alpha_1$ = {a1_val:.3f}")
ax.set_xlabel("Scheduled speed (mph)")
ax.set_ylabel("$\\alpha_1$")
ax.set_title(f"Slow Shape $\\alpha_1$ = {a1_val:.3f} (CV = {1/np.sqrt(a1_val):.2f})")
ax.set_ylim(0, 0.5)
ax.legend()
ax.grid(True, alpha=0.3)
# --- Mixture weight m(v) ---
ax = axes[2]
ax.plot(v_range, mw_arr, "tab:blue", linewidth=2)
ax.set_xlabel("Scheduled speed (mph)")
ax.set_ylabel("$m(v)$")
ax.set_title("Slow Component Weight $m(v)$")
ax.set_ylim(0, 0.25)
ax.grid(True, alpha=0.3)
fig.suptitle("H34 Parameter Variation with Scheduled Speed", fontsize=13)
plt.tight_layout()
plt.show()
Distribution Shapes¶
def h34_pdf(v, params, x):
"""Return (pdf_mix, pdf_slow, pdf_fast, mw, c) at scheduled speed v."""
a1, s1, r, ms = _slow_params(
np.atleast_1d(v), params["r_intercept"], params["r_slope"], params["log_a1"])
mw = 1.0 / (1.0 + np.exp(-(params["mw_intercept"] + params["mw_slope"] * v)))
a2, s2, c = _ensemble_locked_params(
np.atleast_1d(v), ms, np.atleast_1d(mw),
params["start_b0"], params["end_b0"], params["kink"])
# a1 is scalar (constant shape); s1, a2, s2, c are arrays
a1 = float(a1)
s1, a2, s2, c = s1[0], a2[0], s2[0], c[0]
pdf_slow = stats.gamma.pdf(x, a=a1, scale=s1)
pdf_fast = stats.gamma.pdf(x, a=a2, scale=s2)
pdf_mix = mw * pdf_slow + (1 - mw) * pdf_fast
return pdf_mix, pdf_slow, pdf_fast, mw, c
sched_values = [5, 15, 25, 40]
fig, axes = plt.subplots(1, 4, figsize=(20, 4))
for ax, v in zip(axes, sched_values):
x = np.linspace(0.01, v * 3.5, 500)
pdf_mix, pdf_slow, pdf_fast, mw, c = h34_pdf(v, params, x)
ax.plot(x, pdf_mix, "k-", linewidth=2, label="Mixture")
ax.plot(x, mw * pdf_slow, "--", color="tab:orange", linewidth=1.2,
label=f"Slow (m={mw:.2f})")
ax.plot(x, (1 - mw) * pdf_fast, "--", color="tab:blue", linewidth=1.2,
label=f"Fast (1-m={1-mw:.2f})")
ax.axvline(v, color="tab:red", linestyle=":", alpha=0.7, label="$v_{sched}$ = E[V]")
ax.set_title(f"$v$ = {v} mph (c={c:.3f})", fontsize=10)
ax.set_xlabel("Speed (mph)")
ax.set_xlim(0, v * 3.5)
ax.grid(True, alpha=0.2)
if ax is axes[0]:
ax.set_ylabel("Density")
ax.legend(fontsize=8)
fig.suptitle("H34 Mixture PDF (ensemble mean = $v_{sched}$)", fontsize=13, y=1.03)
plt.tight_layout()
plt.show()
Comparison to Data¶
# Load data and build multi-span augmentation
dataset = CalibrationDataset(
csv_path="data/avl_log_20260310_030816.csv",
gtfs_dir="data/gtfs",
)
ms = dataset.build_multispans(max_spans=5)
print(f"Single-span: {len(dataset.full_df):,}")
print(f"Multi-span: {len(ms.full_df):,}")
print(f" Train: {len(ms.train_df):,} Test: {len(ms.test_df):,}")
print(f"\nSpan counts in test set:")
print(ms.test_df["spans"].value_counts().sort_index().to_string())
Single-span: 631,400 Multi-span: 3,018,109 Train: 2,147,198 Test: 870,911 Span counts in test set: spans 1 182517 2 176558 3 173582 4 170610 5 167644
speed_bands = [(4, 5), (7, 8), (8, 14), (14, 22), (22, 35), (35, 55)]
band_labels = ["4-5", "7-8", "8-14", "14-22", "22-35", "35-55"]
# Use single-span test data for comparison to model PDFs
test_1span = ms.test_df[ms.test_df["spans"] == 1]
fig, axes = plt.subplots(len(speed_bands), 1, figsize=(10, 3 * len(speed_bands)))
for ax, (lo, hi), label in zip(axes, speed_bands, band_labels):
mask = (test_1span["scheduled_speed_mph"] >= lo) & (test_1span["scheduled_speed_mph"] < hi)
obs = test_1span.loc[mask, "speed_mph"].values
if len(obs) < 50:
continue
v_mid = (lo + hi) / 2
x = np.linspace(0.01, hi * 3, 500)
pdf_mix, pdf_slow, pdf_fast, mw, c = h34_pdf(v_mid, params, x)
bins = np.linspace(0, 80, 150)
ax.hist(obs, bins=bins, density=True, alpha=0.4, color="gray", edgecolor="none",
label=f"Data (n={len(obs):,})")
ax.plot(x, pdf_mix, "k-", linewidth=2, label="H34")
ax.plot(x, mw * pdf_slow, "--", color="tab:orange", linewidth=1)
ax.plot(x, (1 - mw) * pdf_fast, "--", color="tab:blue", linewidth=1)
ax.set_title(f"Scheduled {label} mph (n={len(obs):,})", fontsize=10)
ax.set_xlim(0, 80)
ax.set_ylim(0, 0.15)
ax.set_ylabel("Density")
ax.grid(True, alpha=0.2)
if ax is axes[0]:
ax.legend(fontsize=8)
axes[-1].set_xlabel("Speed (mph)")
plt.tight_layout()
plt.show()
Calibration by Span Count¶
The model is trained on multi-span augmented data (spans 1--5), but it does not condition on $\Delta t$ or span count. A key question is whether a single set of parameters produces well-calibrated predictions across different observation timescales:
- 1-span (~25--30 s): raw polling intervals
- 3-span (~75--90 s): moderate extrapolation horizon
- 5-span (~125--150 s): the stalest observations
If calibration degrades at longer spans, it would suggest the model needs $\Delta t$-dependence. If it holds, the two-gamma mixture is capturing the speed distribution well regardless of averaging window.
# Evaluate PIT values on full test set
h34 = H34_2GammaRatioConstCV()
pit_all, logpdf_all = h34._evaluate(ms.test_df, params)
# Split by span count
span_groups = {1: "1-span (~30 s)", 3: "3-span (~90 s)", 5: "5-span (~150 s)"}
span_metrics = {}
for span, label in span_groups.items():
mask = ms.test_df["spans"].values == span
pit_span = pit_all[mask]
pit_clean = pit_span[np.isfinite(pit_span)]
m = compute_calibration_metrics(pit_clean)
span_metrics[span] = m
print(f"{label}: n={m['n']:>10,d} KS={m['ks_stat']:.4f} mean_PIT={m['mean_pit']:.4f}")
# Also compute overall
pit_clean_all = pit_all[np.isfinite(pit_all)]
m_all = compute_calibration_metrics(pit_clean_all)
print(f"\n{'All spans':18s}: n={m_all['n']:>10,d} KS={m_all['ks_stat']:.4f} mean_PIT={m_all['mean_pit']:.4f}")
1-span (~30 s): n= 182,517 KS=0.0922 mean_PIT=0.4893 3-span (~90 s): n= 173,582 KS=0.0882 mean_PIT=0.5255 5-span (~150 s): n= 167,644 KS=0.1426 mean_PIT=0.5348 All spans : n= 870,911 KS=0.0636 mean_PIT=0.5188
fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)
for ax, (span, label) in zip(axes, span_groups.items()):
mask = ms.test_df["spans"].values == span
pit_span = pit_all[mask]
pit_clean = pit_span[np.isfinite(pit_span)]
m = span_metrics[span]
ax.hist(pit_clean, bins=50, density=True, alpha=0.7, edgecolor="white")
ax.axhline(1.0, color="tab:red", linestyle="--", linewidth=1.5)
ax.set_xlabel("PIT value")
ax.set_title(f"{label}\nKS={m['ks_stat']:.4f} mean={m['mean_pit']:.4f} n={m['n']:,}")
ax.set_xlim(0, 1)
ax.grid(True, alpha=0.3)
axes[0].set_ylabel("Density")
fig.suptitle("H34 PIT Histograms by Span Count", fontsize=13)
plt.tight_layout()
plt.show()
PIT ECDFs overlaid¶
A direct overlay of the PIT ECDFs makes it easier to see how calibration shifts across span counts.
fig, ax = plt.subplots(figsize=(8, 5))
colors = {"1-span (~30 s)": "tab:blue", "3-span (~90 s)": "tab:orange", "5-span (~150 s)": "tab:green"}
for span, label in span_groups.items():
mask = ms.test_df["spans"].values == span
pit_span = pit_all[mask]
pit_clean = np.sort(pit_span[np.isfinite(pit_span)])
ecdf = np.arange(1, len(pit_clean) + 1) / len(pit_clean)
# Subsample for plotting performance
step = max(1, len(pit_clean) // 5000)
ax.plot(pit_clean[::step], ecdf[::step], linewidth=1.2, color=colors[label],
label=f"{label} (KS={span_metrics[span]['ks_stat']:.4f})")
ax.plot([0, 1], [0, 1], "k--", linewidth=1, alpha=0.5, label="Ideal (uniform)")
ax.set_xlabel("PIT value")
ax.set_ylabel("Cumulative probability")
ax.set_title("PIT ECDFs by Span Count")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Empirical speed distributions by span count¶
The model does not condition on span count, yet the observed speed distribution changes: longer spans average out momentary stops and accelerations, producing a tighter distribution. Below we compare the empirical speed distributions at 1, 3, and 5 spans against the model PDF for a few representative scheduled speeds.
sched_bands = [(8, 14), (14, 22), (22, 35)]
span_list = [1, 3, 5]
span_colors = {1: "tab:blue", 3: "tab:orange", 5: "tab:green"}
fig, axes = plt.subplots(1, len(sched_bands), figsize=(18, 5))
for ax, (lo, hi) in zip(axes, sched_bands):
v_mid = (lo + hi) / 2
x = np.linspace(0.01, hi * 3, 500)
pdf_mix, _, _, _, _ = h34_pdf(v_mid, params, x)
ax.plot(x, pdf_mix, "k-", linewidth=2.5, label="H34 model", zorder=10)
bins = np.linspace(0, hi * 3, 100)
for span in span_list:
sub = ms.test_df[ms.test_df["spans"] == span]
mask = (sub["scheduled_speed_mph"] >= lo) & (sub["scheduled_speed_mph"] < hi)
obs = sub.loc[mask, "speed_mph"].values
if len(obs) < 50:
continue
ax.hist(obs, bins=bins, density=True, alpha=0.25, color=span_colors[span],
edgecolor="none", label=f"{span}-span (n={len(obs):,})")
ax.set_title(f"Scheduled {lo}--{hi} mph")
ax.set_xlabel("Speed (mph)")
ax.set_xlim(0, hi * 3)
ax.set_ylim(0, 0.12)
ax.grid(True, alpha=0.2)
ax.legend(fontsize=8)
axes[0].set_ylabel("Density")
fig.suptitle("Empirical Speed Distributions by Span Count vs. H34 Model", fontsize=13)
plt.tight_layout()
plt.show()
Summary¶
H34 is an 8-parameter two-gamma mixture speed model with:
Ensemble mean lock: the mixture mean equals the scheduled speed exactly, providing an unbiased position extrapolation.
Mean-ratio slow component: the slow component's mean is $r(v) \cdot v$ where $r \in (0, 1)$ by construction (sigmoid), guaranteeing the slow mean stays below the scheduled speed at all $v$.
Constant slow shape: $\alpha_1$ is a single fitted constant (~0.27), simplifying H33's speed-dependent CV which turned out to be flat. This removes one parameter with no loss in fit quality.
Span-weighted training: observations are weighted by $1/\text{spans}$ during MLE, emphasizing the single-span (production) regime over the mean-reverted multi-span regime.