multiclass — Multiclass-user equilibrium (Dafermos 1972)¶
What. Two or more user classes share one network, each with its own OD demand and a
class-specific link cost t_a^i = t_BPR(v_a) + Σ_j M_ij v_a^j coupling the classes
through a per-link interaction M. multiclass solves the block-structured equilibrium
by diagonalization (per-class Frank–Wolfe, relax, repeat) and emits per-class link
flows ([dafermos1972traffic], docs/REFERENCES.md).
Why it is in the benchmark. It is the multiclass generalization of the asymmetric VI (ADR-013): the harness certifies the class-summed VI residual from a first-class per-class flow object, not a self-report. See the model compendium and docs/ARCHITECTURE.md (P1).
Scope. Runs on the built-in 2-class (cars, trucks) two-route anchor with a symmetric (integrable) interaction and certifies the class-summed VI residual.
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 are shown only as
provenance and diffed against the certificate as an honesty check, exactly as the
harness treats them (README, Certified, not self-reported).
# Setup. `multiclass` is a core 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,
MulticlassModel,
RngBundle,
Trace,
multiclass_two_route_scenario,
viz,
)
The scenario¶
Two classes — cars (demand 4) and trucks (demand 2) — on two disjoint routes, coupled by a symmetric per-link interaction M = [[0.5, 0.25], [0.25, 0.5]]. The closed-form equilibrium is cars (2.5, 1.5), trucks (1.5, 0.5), aggregate (4, 4, 2, 2). Content-hashed (P2).
scenario = multiclass_two_route_scenario()
net = scenario.network
print(f"scenario : {scenario.name}")
print(f"content hash : {scenario.content_hash()[:16]}…")
print(f"classes : {scenario.multiclass.n_classes} (cars, trucks)")
print(f"class demands : {scenario.multiclass.matrices.sum(axis=(1, 2))}")
print(f"interaction M : {scenario.multiclass.interaction.tolist()} (symmetric -> integrable)")
scenario : multiclass
content hash : 039cd30a4f67fcd6…
classes : 2 (cars, trucks)
class demands : [4. 2.]
interaction M : [[0.5, 0.25], [0.25, 0.5]] (symmetric -> integrable)
Solve¶
The model contract (CONTRIBUTING.md): a model receives
(scenario, budget, rng, trace), records checkpoints, and respects the budget.
Budgets are hardware-free (iterations / shortest-path calls; wall-clock is recorded
but never the ranking axis, P7). Whatever the model writes into self_report is
provenance, not a score.
model = MulticlassModel()
bundle = model.solve(scenario, Budget(iterations=100), RngBundle(0), Trace())
final = bundle.final
print(f"model : {model.name}")
print(f"aggregate link flows : {np.round(final.link_flows, 6)}")
print(f"per-class link flows : {np.round(final.class_link_flows, 6).tolist()} (cars, trucks)")
print(f"self-reported resid : {final.self_report['relative_gap']:.3e} (provenance only)")
model : multiclass
aggregate link flows : [3.999999 3.999999 2.000001 2.000001]
per-class link flows : [[2.5, 2.5, 1.5, 1.5], [1.5, 1.5, 0.5, 0.5]] (cars, trucks)
self-reported resid : 9.004e-11 (provenance only)
Certify (P1)¶
The scored quantity is the class-summed VI residual, recomputed by the harness from
the model’s per-class link flows (FlowState.class_link_flows, a first-class object —
NOT a self-report). We pass those class flows to the evaluator and recompute the
closed-form per-class anchor in-cell.
evaluator = Evaluator(scenario)
metrics = evaluator.evaluate(final.link_flows, final.class_link_flows)
residual = metrics["relative_gap"]
print(f"certified VI residual : {residual:.3e}")
print(f"feasible : {metrics['feasible']:.0f}")
assert metrics["feasible"] == 1.0
assert abs(residual) < 1e-8
# Honesty diff (P1).
assert np.isclose(final.self_report["relative_gap"], residual, rtol=1e-6, atol=1e-9)
# Analytic anchor RECOMPUTED: [p, q] = [g_cars/2, g_trucks/2] + (a2/4) M^-1 [1, 1].
M = scenario.multiclass.interaction
g_cars, g_trucks, a2 = 4.0, 2.0, 1.5
pq = (a2 / 4.0) * np.linalg.solve(M, np.ones(2))
p, q = g_cars / 2.0 + pq[0], g_trucks / 2.0 + pq[1]
cars = np.array([p, p, g_cars - p, g_cars - p])
trucks = np.array([q, q, g_trucks - q, g_trucks - q])
print(f"cars route-A p : {p:.3f}; trucks route-A q: {q:.3f} (recomputed)")
assert np.allclose(final.class_link_flows[0], cars, atol=1e-4)
assert np.allclose(final.class_link_flows[1], trucks, atol=1e-4)
ref_flows = cars + trucks
assert np.allclose(final.link_flows, ref_flows, atol=1e-4)
certified VI residual : 9.004e-11
feasible : 1
cars route-A p : 2.500; trucks route-A q: 1.500 (recomputed)
Visualize¶
Both figures come from tabench.viz. Left/top: the AGGREGATE link flows on the network.
Right/bottom: the emitted aggregate flows against the recomputed analytic aggregate —
on-diagonal at (4, 4, 2, 2); the per-class split is certified separately above.
display(viz.plot_network_flows(net, final.link_flows))
display(viz.plot_flow_scatter(("multiclass UE (analytic)", ref_flows), {"multiclass": final.link_flows}))
Takeaways & pointers¶
Per-class flows are first-class. The harness certifies the class-summed VI residual from
class_link_flows, not a self-report.Coupled classes. Cars and trucks route distinctly under the interaction M; the closed form recomputed here.
Where next. The single-class VI special case:
vi-asym; ADR-013 in the model compendium.