chc.benchmark¶
Stability tier: experimental.
Benchmark v0: confounded, constrained control tasks with oracle regret.
Each task ships a confounded offline dataset, a true plant with a computable oracle controller, and
an evaluation reporting regret vs oracle, constraint violations, and out-of-support action
rate. The point is to measure where causal control beats predictive control — and to be honest
where it does not. v0 has pricing (steering), inventory (newsvendor), support-shift (pessimism),
model-uncertainty (calibrated ensemble) and confounding-robust (sensitivity radius under a hidden
confounder) tasks, plus causal-dynamics (the confounding sits in the plant's own control channel),
all in the same TaskResult / leaderboard shape.
TaskResult
dataclass
¶
One controller's score on one task.
MultiSeedResult
dataclass
¶
One controller's regret aggregated across seeds, with a bootstrap confidence interval.
PricingTask
dataclass
¶
Confounded linear steering: drive x to a target; effect of u is confounded in the logs.
The oracle is the controllers' own rule, the one-step certainty-equivalent action, at the true
effect and on the same noise path, so a regret is what the estimate costs under that rule. It
is not the best policy: the rule ignores control_weight, which the finite-horizon LQR policy
weighs, and over 4 000 noise paths that policy costs 0.00098 less on average (standard error
0.0001), 0.0095 less on the path the task draws, in float64. A rule tuned to the drawn path, an
effect of 1.03, costs 0.029 less there and 0.018 more on average, and a plan that reads the
path's noise 0.29 less; neither is open to a controller. A controller can therefore land a
little below the oracle on one path.
run(seed_data=0, seed_eval=1, estimator=None)
¶
Fit the effect (oracle / causal / predictive) from logs and score each controller.
The CHC controller uses the pluggable estimator (default BackdoorOLS, the linear
adjustment); pass DoubleML() / EconMLDoubleML() to swap the causal backend. The
predictive baseline stays the fixed naive (unadjusted) fit.
InventoryTask
dataclass
¶
Newsvendor ordering under a confounded demand-response model (holding / stockout costs).
A fixed-intensity promo lifts demand; in the logs the promo was correlated with a demand driver
z (a confounder), so the promo effect is biased. The retailer orders to a newsvendor level
from its estimated demand model, so a wrong estimate systematically over- or under-orders.
The oracle orders the critical fractile of the true demand, the order of least expected cost,
and is scored on the same n_eval draws as every controller. On those 5 000 draws the order
at their own fractile costs 5.7e-5 less in float64, an order that reads the draws it is scored
on.
run(seed_data=0, seed_eval=1, estimator=None)
¶
Estimate demand response (oracle / causal / predictive) and score the induced order.
estimator is the pluggable causal backend for the CHC order (default BackdoorOLS).
SupportShiftTask
dataclass
¶
Model exploitation under support shift — where pessimism, not causality, is the safeguard.
A linear model matches the true plant on the offline action support, but the plant's control effectiveness collapses for large actions. The greedy controller extrapolates off-support to chase gains the model promises and stalls; pessimism keeps actions in-support and stays safe.
The oracle is the planner's descent from zero on the true plant, and it is the best plan. Past
u_sat / sqrt(2) an action buys an effect a smaller one gives for less, and inside it the
effect rises with the action and the action's cost is convex in the effect, while the plant is
linear in the effect: there the problem is convex. The oracle's actions all lie inside, so
where it stops on its rule, its stationarity 8.4e-7 in float64, is the problem's minimum.
Descents from 73 starts -- zero, each side of the box throughout, at each step, and for the
first 1, 2, 3, 5 or 10 steps, the other two plans and 8 seeded draws -- end on 39 plans, none
below the oracle by more than 2.2e-12.
run(seed_data=0)
¶
Optimise on the model (greedy/pessimistic) and the plant (oracle); score on the plant.
ModelUncertaintyTask
dataclass
¶
Model exploitation where the safeguard is calibrated model uncertainty, not support distance.
A residual is fit from offline data on a narrow action support; its deep-ensemble members agree
there and disagree off it. The greedy controller trusts the ensemble mean and pushes into
that high-uncertainty region, where the true plant's cubic drag backfires; calibrated pessimism
penalises the ensemble disagreement and stays where the learned model is trustworthy -- the
chc.uncertainty U term, complementing the density-distance D of SupportShift.
The oracle is the cheapest of seven descents on the true plant, from zero and from each side of
the box held for the first one, two or three steps (_best_descent), searched once per task
and shared by its seeds. The plant acts through u - drag u^3, which turns over at
|u| = 2.58 and reaches -68.8 on the box's edge, so the best plan pushes through the reversed
effect: at 3.773697 in float64 its first four actions are 8, 6.63, -8 and -6.81, and the rest
stay inside the turn. The descent from zero alone stops at the effect's peak, at 15.520831, and
regrets read against it were 11.75 too low. No plan found costs less: descents from 152 starts,
each run to 210 000 steps and polished by L-BFGS-B, and a dynamic programme over the state, end
on many plans, none below 3.773697. Every plan costs at least 3.76084, the minimum of the
problem relaxed to the effects, in which the plant is linear and the action's cost is replaced
by its convex envelope (tests/test_benchmark.py), so the oracle is within 0.0129 of the best
plan there is. Against it the calibrated plan's regret is 12.40 and greedy's 1360.23: the row
measures what planning on the learned model costs against a plan that knows the plant's turn,
which no model fit on the logged support can see.
run(seed_data=0)
¶
Fit an ensemble on the support, then score greedy/calibrated/oracle on the plant.
ConfoundingRobustTask
dataclass
¶
Control under HIDDEN confounding: the safeguard is a sensitivity radius, not adjustment.
The actuator gain is calibrated from an observational log whose action was driven by a
latent disturbance that also moved the transition. It is never recorded and there is no
instrument, so the gain is only partially identified -- the adjustment fix of PricingTask
is unavailable by construction, which is why this task takes no estimator argument. Here the
confounding attenuates the gain, so the greedy plan believes a weak actuator, over-commands,
overshoots the target on the true plant, and demands actions far outside the logged range. The
safeguard is chc.uncertainty.ConfoundingRobustPenalty in the lam_unc channel: the
partial-identification radius times the action magnitude, bounding the transition error a
confounded gain can inject per step.
HONEST TRAPS. (1) The penalty is ONE-SIDED -- it can only shrink actions, so it rescues an
understated gain and strictly hurts when the confounding inflates it instead (a test pins
this). (2) gamma and the cvar_gap := b_hat calibration are the analyst's inputs, not
learned; the row scores the controller at the assumed sensitivity, not an oracle one.
(3) With no confounding the robust controller pays a strict premium, and it only wins beyond a
problem-dependent confounding threshold -- at half the default confounding it still loses.
(4) This is an OPEN-LOOP plan scored on the plant; the closed-loop counterpart is
chc.sensitivity.confounding_robust_tracking_benchmark.
The oracle is the planner's descent from zero on the true plant. The plant is linear, so the cost is a convex quadratic in the plan and the descent's plan is the best one: the exact minimiser over the box, by bounded-variable least squares (Stark and Parker 1995), is 6.9e-11 below it in float64, and descents from 73 starts end on the same plan.
run(seed_data=0)
¶
Calibrate the gain on a confounded log, then score greedy/robust/oracle on the plant.
CausalDynamicsTask
dataclass
¶
Confounding inside the dynamics model, not beside it -- the chc.dynamics_id consumer.
Every other task here estimates a scalar effect and hands it to a controller. In this one the confounded object is the plant's own control channel: the log's action was chosen from a covariate that also moved the state rate, so a residual fitted by prediction error learns the observational response and the planner inherits it. Three ways in, scored on the same plant:
mse-id-- unadjusted, which is wherechc.train's prediction-error fit lands.causal-id-- the confounder is logged and adjusted for (the orthogonal moment).causal-iv-- the confounder is latent; only an exogenous action shifter is available.
HONEST TRAPS. (1) The failure is silent on the safety channels. The confounding attenuates
the channel to ~0.02 of its true 1.0, so mse-id prices the actuator as useless against the
control_weight penalty and gives up: measured max|u| 0.235 against the oracle's 2.516,
so it reaches x = 0.135 instead of 0.712. It never approaches the box, never leaves the
logged action support (99th percentile of |u| is 4.4), and reports viol = ood = 0 while
conceding most of the achievable improvement. Regret is the only column that sees it -- a
reminder that constraint and support diagnostics do not detect a mis-scaled channel. Shrinking
control_weight flips the same bias into over-commanding, where those columns do fire.
(2) The two identified rows are not interchangeable. Adjusting for a logged confounder gives
regret 0.014; the instrument gives 0.131, because it explains only ~18% of the action's variance
and identification rides on that share alone. Both beat the 6.20 of not identifying at all, but
an instrument is a weaker substitute for the confounder than the word "identified" suggests.
(3) Only the channel is identified; a_θ stays an observational-conditional drift, so
this row scores planning, not forecasting. (4) The plant is control-affine by construction,
which is the class the estimator and chc.plan.certify_safety share -- a general
nonlinear residual gets no orthogonality guarantee and no row here.
The oracle is the planner's descent from zero on the true plant. The plant is linear in the state and the action, so the cost is a convex quadratic in the plan and the descent's plan is the best one: the exact minimiser over the box, by bounded-variable least squares, is 3.4e-13 below it in float64, and descents from 74 starts end on the same plan.
run(seed_data=0)
¶
Fit the channel three ways from one confounded log, then score each plan on the plant.
DelayOscillationTask
dataclass
¶
A delay-blind controller walks into a Hopf bifurcation -- chc.delay's consumer.
Generic marketplace shape: an incentive moves supply, but participants respond tau later, so
the plant is x' = channel * u(t - tau) -- the delay sits on the actuation path. Under
proportional feedback u = -K x that closes to x' = -channel*K*x(t - tau), whose exact
stability boundary is channel*K*tau = pi/2 (chc.delay.delay_margin at pole 0). Three
controllers, all minimising the same quadratic cost by the same grid search, differing only
in the delay they assume:
delay-blind-- assumestau = 0. Its optimum is the memorylesssqrt(q/r), which for the shipped weights is3.10against an analyticsqrt(q/r) = 3.162-- the 2% gap is explicit Euler, whose effective decay-ln(1 - dt K)/dtexceedsKand so shifts the optimum down. The arm fails on its own terms, not because it was handed a bad gain:3.10 * channel * tauis 1.97x pastpi/2, so the loop rings up instead of down.delay-aware-- estimates the delay from the log withchc.irf.delay_estimate, turns the interval into a design withchc.delay.robust_delay_design, then runs the same grid search at that delay.oracle-- the same grid search at the truetau.
HONEST NOTES. (1) This is a row where the safety columns fire. CausalDynamicsTask
documents the opposite trap -- a mis-scaled channel that concedes regret while reporting
viol = ood = 0. Here the failure is loud: the blind arm leaves the safe set and the logged
action support on most steps. Both traps are real; neither column is a general detector.
(2) The blind arm's cost magnitude is an artefact of state_cap, which clips the
diverging trajectory so the number stays finite and precision-independent. Read it as a verdict,
not a quantity; the ordering and the constraint columns carry the content.
(3) The delay is estimated from the rate, not the level. This plant is an integrator, so the
level's impulse response is a step -- a plateau with no peak, on which delay_estimate
correctly returns an interval spanning it rather than inventing a mode. Differencing turns the
step back into a spike. That is a modelling obligation on the caller, not a fallback.
(4) The log is sampled every observe_every plant steps, putting tau at 3.33 samples --
deliberately off the observation grid, because an on-grid delay makes the estimate exact and
the row uninformative. That is also the case peak_lag(refine=True) exists for, so this task
turns it on; it recovers 0.947 of a true 1.0 where the integer argmax can only say
0.900. The residual 5.3% is not noise -- it is the parabola's documented shrinkage, which
for a fraction f = 1/3 predicts 0.950.
(5) The estimate lands below the truth, which by chc.delay.delay_ball is the
destabilising direction. It is harmless here only because that ball has 76% of relative slack;
on a plant with a tighter one the same 5.3% would matter.
The oracle's gain is the best of the grid: on its 400 gains the cost has one minimum, at 0.7175, and 20 001 gains between that one's neighbours find 0.7199, 1.6e-5 cheaper, less than the 8.3e-5 the cost rises to the nearer neighbour. It is the best proportional gain to the grid's resolution; no other feedback law is searched.
run(seed_data=0)
¶
Estimate the delay from one log, then score three gains on the true delayed plant.
BenchmarkTask
¶
Bases: Protocol
A benchmark task: one data seed in, one TaskResult per controller out.
leaderboard(results)
¶
Format task results as a table sorted by regret (best first).
run_multiseed(task, seeds)
¶
Aggregate task across seeds into per-controller regret with a bootstrap CI.
task.run(seed_data=s) is called once per seed and the results grouped by controller. The
interval is a percentile bootstrap over the seeds -- honest error bars on "does this controller
actually win", replacing a single-seed point regret that could be luck of the draw.
leaderboard_multiseed(results)
¶
Format multi-seed results sorted by mean regret (best first), with 95% bootstrap CIs.