dtd-swap-sue — Smith & Watling’s (2016) route-swap SUE dynamics¶
What. The SUE sibling of dtd-swap: the same proportional route-swap, but driven by the Fisk-generalized cost C_k = c_k + (1/θ) ln h_k, so the rest point is the logit stochastic user equilibrium (Fisk 1980), not deterministic Wardrop UE.
Why it is in the benchmark. It is validated against the analytic logit-SUE split and self-reports the same Dial-STOCH certificate the harness recomputes; Fisk’s convex objective is its Lyapunov function and the generalized-cost disequilibrium vanishes at SUE. See the
model compendium ([smith2016routeswapping], docs/REFERENCES.md) and the certificate design in
docs/ARCHITECTURE.md (P1).
Scope. Runs the process on a built-in scenario and certifies the result; it does not benchmark day-to-day models against each other. Reference: Smith & Watling (2016), Transportation Research Part B 85.
How this notebook is graded¶
A notebook never claims a number it does not compute in that cell. Every scored
quantity below is recomputed live by the P1 Evaluator from the flows the model
emitted, in the cell where it is claimed. Model self-reports (the per-day gap/residual,
the Lyapunov value) are shown only as provenance and diffed against the certificate,
exactly as the harness treats them (README, Certified, not
self-reported).
# Setup. `dtd-swap-sue` is a core day-to-day model: a plain `pip install -e .` suffices —
# no optional extra, so no guard cell. The inline backend is Agg-based (headless CI
# renders into the notebook); NEVER matplotlib.use("Agg") in-kernel — it silently
# suppresses inline figure capture.
%matplotlib inline
import numpy as np
from tabench import (
Budget,
Evaluator,
RngBundle,
RouteSwapSUEModel,
Trace,
two_route_scenario,
viz,
)
The scenario¶
The built-in two-route logit-SUE anchor (demand 4, dispersion θ=0.5): route A cost 2+f_A, route B cost 1.5+2 f_B. Its rest point is the binary-logit stochastic user equilibrium, recomputed analytically in the certify cell — NOT the deterministic Wardrop UE.
scenario = two_route_scenario()
net = scenario.network
print(f"scenario : {scenario.name}")
print(f"content hash : {scenario.content_hash()[:16]}…")
print(f"links : {net.n_links} (tail→head: "
+ ", ".join(f"{i}->{j}" for i, j in zip(net.init_node, net.term_node)) + ")")
print(f"total demand : {scenario.demand.total}")
print("task : logit SUE, θ=0.5")
scenario : tworoute
content hash : 9b98e58b339b702f…
links : 4 (tail→head: 1->3, 3->2, 1->4, 4->2)
total demand : 4.0
task : logit SUE, θ=0.5
Run the adjustment process¶
The model contract (CONTRIBUTING.md): a model receives
(scenario, budget, rng, trace) and records one checkpoint per day — here a budget
iteration is a day. Everything the model writes into self_report (the per-day
gap/residual, the Lyapunov value) is provenance, not a score.
bundle_trace = Trace()
model = RouteSwapSUEModel()
model.solve(scenario, Budget(iterations=500, target_relative_gap=1e-8),
RngBundle(0), bundle_trace)
final = bundle_trace.final
print(f"model : {model.name}")
print(f"days simulated : {final.coords.iterations} "
f"({final.coords.sp_calls} shortest-path calls)")
print(f"emitted flows : {np.round(final.link_flows, 6)}")
print(f"self-reported residual: {final.self_report['sue_fixed_point_residual']:.3e} (provenance only)")
model : dtd-swap-sue
days simulated : 45 (90 shortest-path calls)
emitted flows : [2.299096 2.299096 1.700904 1.700904]
self-reported residual: 9.861e-09 (provenance only)
Certify (P1) — the SUE fixed point AND the descent¶
The harness recomputes the ADR-001 Dial-STOCH residual from the emitted flows. Certified
here: (1) the terminal flows are the logit SUE — residual → 0 with the analytic binary-
logit anchor recomputed in-cell, while the deterministic UE gap stays positive (a
descriptive column, like sue-msa); (2) the model’s Lyapunov / provenance signature.
from scipy.optimize import brentq
evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows)
residual = metrics["sue_fixed_point_residual"]
print(f"certified SUE residual : {residual:.3e}")
print(f"feasible : {metrics['feasible']:.0f}")
print(f"UE relative gap : {metrics['relative_gap']:.3f} (stays >0 — logit SUE ≠ deterministic UE)")
assert metrics["feasible"] == 1.0
assert residual < 1e-5
assert metrics["relative_gap"] > 0.01 # descriptive column: not the deterministic UE
# Analytic binary-logit SUE anchor, recomputed in-cell (θ=0.5, demand 4): the root of
# f_A = D / (1 + exp(θ(c_A − c_B))), c_A = 2 + f_A, c_B = 1.5 + 2(4 − f_A).
def _resid(f_a):
c_a, c_b = 2.0 + f_a, 1.5 + 2.0 * (4.0 - f_a)
return f_a - 4.0 / (1.0 + np.exp(0.5 * (c_a - c_b)))
f_a = brentq(_resid, 0.0, 4.0, xtol=1e-12)
ref_flows = np.array([f_a, f_a, 4.0 - f_a, 4.0 - f_a])
print(f"analytic logit SUE f_A : {f_a:.6f}")
assert np.allclose(final.link_flows, ref_flows, atol=1e-3)
# Honesty (P1): the model self-reports the SAME residual the harness recomputes.
assert np.isclose(
final.self_report["sue_fixed_point_residual"], residual, rtol=1e-9, atol=1e-12
)
# Fisk's convex objective is the Lyapunov function (monotone ↓ to the SUE minimum); the
# generalized-cost disequilibrium V(h) vanishes at the logit SUE. UNLIKE Beckmann
# (dtd-swap/-link/-friesz), the certified Evaluator has no Fisk-objective counterpart
# to diff against -- Fisk's entropy term needs per-ROUTE flows h, which the link-flow-
# only certificate does not carry -- so both series below are PROVENANCE ONLY, never
# scored (the terminal SUE fixed point IS certified above, independently, via the
# residual + the recomputed analytic anchor).
fisk = [s.self_report["fisk_objective"] for s in bundle_trace]
diseq = [s.self_report["sue_disequilibrium"] for s in bundle_trace]
assert all(
fisk[i] >= fisk[i + 1] - 1e-9 * max(abs(fisk[i]), 1.0) for i in range(len(fisk) - 1)
)
assert max(diseq) > 1.0 and diseq[-1] < 1e-6
print(f"Fisk descent (provenance only) : {fisk[0]:.3f} → {fisk[-1]:.3f} (monotone ✓)")
print(f"SUE disequilibrium (provenance only) : {max(diseq):.3g} → {diseq[-1]:.2e} (→ 0 at SUE)")
certified SUE residual : 9.861e-09
feasible : 1
UE relative gap : 0.056 (stays >0 — logit SUE ≠ deterministic UE)
analytic logit SUE f_A : 2.299096
Fisk descent (provenance only) : 25.090 → 10.321 (monotone ✓)
SUE disequilibrium (provenance only) : 55.3 → 6.92e-16 (→ 0 at SUE)
Visualize¶
Both figures come from tabench.viz, the house visualizer — every plotted number is one
certified above. Left/top: the certified terminal link flows on the network. Right/bottom:
the emitted flows against the fixed point recomputed in the certify cell — points on the
y = x guide mean the day-to-day process settled on it link-for-link.
# Certified terminal flows on the network (house style via tabench.viz).
display(viz.plot_network_flows(net, final.link_flows))
# Emitted flows vs the logit SUE recomputed above (off-diagonal == not settled).
display(viz.plot_flow_scatter(("logit SUE", ref_flows), {"dtd-swap-sue": final.link_flows}))
Takeaways & pointers¶
Certified, not self-reported. The SUE residual came from
Evaluator; the model’s self-reported residual matched it to float precision (both call the same pinned Dial-STOCH load).Scoped, not universal. On dense/strongly-congested multi-OD networks this can settle on an EXACT rest point (disequilibrium ~ 0) whose certified residual is O(1): never-pruned stale routes retain a logit share Dial doesn’t assign, and the efficient-route enumeration is capped at
max_routes=4096(aRuntimeWarning, never silent, if a dense OD’s efficient set exceeds it) — “likesue-msa” does NOT hold there; usesue-msafor a tight residual on such instances (both plateaus are regression-pinned, seedocs/MODELS.md).The day-to-day signature is the point. A UE/SUE solver gives you the fixed point; a day-to-day model gives you the adjustment path to it.
Where next. the deterministic route-swap
dtd-swap; the cost-smoothing SUE processdtd-horowitz; the lineage in the model compendium.