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}))
../../_images/54d25d86dbd67927e5e5c2ab966912d05c0c6e5822e6dede664cbf15cfe62411.png ../../_images/b20d829e61635ac80444ffbc0a48124969b1ec8ae4881a046dbf0d170613538e.png

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.