chc.gate¶
Stability tier: evolving.
A deployment gate that can be read at any time: per zone, a candidate policy runs in shadow of the baseline until the evidence says deploy, hold, experiment or roll back.
The baseline acts, and each decision is logged with its propensity. After every batch -- every
fifteen minutes, or every decision -- DeploymentGate.update returns a Verdict per
zone:
"deploy": the candidate beats the baseline by more thandelta;"hold": the candidate is worse than the baseline by more thandelta_harmand retires, or the zone's channel drifted and its evidence starts again;"experiment": shadow evidence would come too slowly, so the zone logs under a mixture of the two policies, which bounds both weights;"rollback": a deployed candidate turned out worse than the baseline, or its channel drifted;"shadow": not yet, or, with a futility stop, no longer in an experiment whose candidate is not worthmin_effect.
Each verdict is an e-process crossing a threshold: a mixture over constant bets of
prod(1 + lam Y_t), with Y_t a decision's weighted contrast less the margin, scaled so that
Y_t >= -1. Only the baseline's weight needs a bound, and the logging design supplies it
(Waudby-Smith et al. 2024, ACM/IMS Journal of Data Science 1(3), doi:10.1145/3643693). Across
zones, e-BH over e-values that freeze when their zone stops gathering keeps the false-deploy rate
at every read (Wang, Dandapanthula and Ramdas 2025, arXiv:2502.08539).
What the guarantee needs, and the gate cannot check:
- Propensities logged at decision time. A behaviour law fitted to the logs afterwards, as
chc.offpolicy.fit_behavior_policydoes, took a lab gate's type-I error from 0.013 to 1.000. The gate takes the logged propensity and refuses a batch whose propensities are not those of the policy it asked to log, but it cannot tell a logged propensity from a refitted one. ADecisionLogis the record that carries it: read back from storage, it refuses a record that does not say which version it is, or lacks a field, rather than filling one in. - No spillover between zones: each zone's null holds given every zone's past.
- No carryover: a decision's reward does not depend on earlier decisions. On a plant with
memory, per-decision increments certify the myopic contrast; in the lab they deployed, with
probability 1.000, a candidate that was +0.029 better myopically and -0.010 worse in the long run.
On such a plant, run the gate in blocks (below), or estimate a plan's value with
chc.evaluation.evaluate_plan. - Rewards in
[0, 1].
Plants with memory: the gate in blocks (experimental). With GateConfig(block_length=L,
burn_in=b), a zone's evidence comes from an experiment on policies, not on decisions: each block
of L periods draws one policy, the candidate with probability rho, runs it for the whole
block, and logs the probability of that draw. A BlockBatch carries the blocks. The gate
drops the first b periods of each block, averages the reward over the last r = L - b, and
weighs the block by the inverse of its logged probability. One block is one increment of the same
e-processes, and e-BH reads them as before. The inference is design-based: the only randomness it
uses is the gate's own draw, so the dependence between one zone's blocks does not matter (Bojinov
and Shephard 2019; Ham, Bojinov, Lindon and Tingley 2026). What a DEPLOY then claims is that the
candidate's mean reward over the read periods beats the baseline's by more than delta, block by
block, given everything before the block.
- Carryover of order
p <= b(a reward depends on the decisions of the lastpperiods only; Bojinov and Shephard'sm-period causal impact): the read periods see only their own block's policy, so the contrast is the sustained one, that of running the candidate rather than the baseline for good. - Geometric memory: a plant whose expected reward
tperiods after a policy takes over lies withinC a^tof that policy's long-run valueV, whatever the state it took over in. The block's contrast then lies within2 C a^b (1 - a^r) / (r (1 - a)), of ordera^b, ofV_new - V_base(validation/gate_block_contrast.mac), and a DEPLOY claims onlyV_new - V_base > deltaless that bias:deltamust cover it. On the lab's plant above (a = 0.9), withr = 10, the block's contrast is +0.014 from a start at 0.2, the stationary mean underrho = 0.5, and +0.062 from one at -1.1: the wrong sign. Withb = 40it lies between -0.0115 and -0.0069 for every start in[-1.1, 1.5], against -0.0096 in the long run, and the gate in blocks deployed the candidate on 5 of 1000 paths of 20 000 periods, where without blocks it deployed on every path. - The gate cannot test
poraon its own data. - A block is one increment however long it is, and its burned periods read nothing. Under carryover of order 1, a candidate +0.3 better sustained was deployed after a median of 562 periods in blocks of 2 with 1 burned, 978 in blocks of 5 with 1 burned, and 1288 in blocks of 5 with 4 burned.
A zone's channel is watched through per-decision drift e-values. channel_drift_evalues
reads them off the Gaussian dither a DecisionLog records, and DriftAlarm turns
them into an alarm outside the gate as well. Once the channel has moved, channel_move
re-reads it off the same dither, and ChannelMove.price puts the move in a plan's own cost.
A deployed model's one-step innovations are watched too (experimental): MissRateAlarm
alarms when they leave a split-conformal radius more often than its calibration allows, and
OnlineRadius moves a radius per horizon so that each horizon's long-run miss rate stays
near its target on any sequence.
GateConfig
dataclass
¶
What the gate tests, and at what level.
delta is the improvement margin: a DEPLOY claims V_new - V_base > delta. delta_harm
is the harm margin: a HOLD for harm or a ROLLBACK claims V_base - V_new > delta_harm.
alpha bounds the expected share of wrong DEPLOY decisions across zones, alpha_harm each
HOLD for harm and each ROLLBACK, and drift_arl is the average number of decisions between
false drift alarms on an unchanged channel. rho is the candidate's share of the logging
mixture in EXPERIMENT.
min_effect, the smallest improvement beyond delta worth deploying, and horizon, the
decisions per zone the gate may spend, decide when to switch to EXPERIMENT, and no guarantee
depends on them.
alpha_futility (experimental), unset by default, adds a futility stop: an experiment
whose evidence says the candidate does not beat the baseline by delta + min_effect returns
to shadow, and the zone no longer switches to EXPERIMENT until its channel drifts. Each stop is
wrong, stopping a candidate that does beat the baseline by more than delta + min_effect,
with probability at most alpha_futility.
block_length (experimental), unset by default, runs the gate in blocks of that many
periods, for a plant with memory (see chc.gate), and burn_in, the periods at the
start of each block that are not read, must be at least the plant's carryover order. In blocks,
rho is the probability that a block runs the candidate, and horizon and the zones'
ZonePlan are not read.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a margin outside |
ZonePlan
dataclass
¶
What is known about a zone's candidate before any data, used only to decide when to switch to EXPERIMENT.
chi2 is the chi-square divergence of the candidate's action law from the baseline's, per
decision: the variance of the shadow weight. tv is their total variation distance. Compute
both from the two policies, never from logged weights: when the candidate's action law is wider
than the baseline's by a factor of sqrt(2) or more, chi2 is infinite, an estimate from
weights is finite, and a tail index estimated from them reads far heavier than the true one.
For Gaussian actions N(m_new, s_new^2) against N(m_base, s_base^2)::
1 + chi2 = s_base^2 / (s_new sqrt(2 s_base^2 - s_new^2))
* exp((m_new - m_base)^2 / (2 s_base^2 - s_new^2)),
infinite when s_new^2 >= 2 s_base^2.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a negative or NaN |
DecisionLog
dataclass
¶
What each decision recorded when it was taken, one entry per decision.
action is the action as applied, one row per decision for a vector action. propensity
is the probability, or density, of drawing it, computed by the policy that drew it when it drew
it: the number the gate's guarantee is stated on. saturated flags a decision whose action
was clipped, so that what was applied is not what was drawn. dither, optional, is the
Gaussian perturbation drawn for the policy's action, as drawn: on a clipped decision, the draw,
not what the clip left of it.
Stored, a decision is a record with decision_log_version beside those fields.
from_records reads version VERSION and refuses a record with no version, or
with another, rather than guess what an older log meant. What a new version would require of
stored logs is in docs/adr/0015-what-a-logged-decision-records.md.
Raises:
| Type | Description |
|---|---|
ValueError
|
on entries whose number differs between the fields, a non-finite entry, a
propensity that is not positive, a |
from_records(records)
classmethod
¶
The log that stored records hold, such as JSON lines or a table's rows.
Every record needs decision_log_version equal to VERSION, and action,
propensity and saturated, a boolean. dither is on every record or on none. Keys
the log does not define, such as a timestamp, the reward or the caller's own version,
are left to the caller.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a record with no version or with another, a missing field, a
|
to_records()
¶
One record per decision, versioned and ready for JSON, which from_records
reads back to the bit.
dither_draws()
¶
The dither of every decision, for a reader that needs each action to carry its whole
draw, as channel_move does: on a clipped decision, what the product of the residual
and the draw reads is scaled by the chance that the draw was not clipped.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a log with no dither, or with a clipped decision. |
ZoneBatch
dataclass
¶
One zone's decisions since the gate was last read, one entry per decision.
candidate and baseline are the two policies' propensities of the action taken -- a
probability for a discrete action, a density for a continuous one -- and logged is the
propensity recorded when the action was drawn, under the policy the zone's mode asked for;
from_log reads it off a DecisionLog. drift, optional, holds per-decision
e-values for "the channel is unchanged", one column per detector, each with expectation at most
1 given the past while the channel holds: channel_drift_evalues reads them off the
log's dither.
Raises:
| Type | Description |
|---|---|
ValueError
|
on arrays of different lengths, a non-finite entry, a reward outside |
from_log(log, *, reward, candidate, baseline, drift=None)
classmethod
¶
The batch whose logged propensities are the ones log recorded, decision by decision.
Raises:
| Type | Description |
|---|---|
ValueError
|
on what the constructor refuses. |
BlockBatch
dataclass
¶
One zone's blocks since the gate was last read, for a gate in blocks. Experimental.
reward holds one row per block and one column per period of it, burn-in included.
candidate says whether the block ran the candidate, and logged is the probability with
which the block's draw chose the policy it ran, recorded at the draw: rho or 1 - rho in
an experiment, and 1 when the zone's mode left no choice. drift, optional, is a
ZoneBatch's, with one row per period, block by block.
Raises:
| Type | Description |
|---|---|
ValueError
|
on rewards that are not one row per block of at least one period, a non-finite
entry, a reward outside |
DriftAlarm
¶
Shiryaev-Roberts over e-values for "the channel is unchanged": an alarm that sounds, on an
unchanged channel, after arl decisions on average or later.
Each detector's statistic runs R_t = (R_(t-1) + 1) e_t from R_0 = 0, and the alarm
sounds when their average reaches arl. While every e-value has expectation at most 1 given
the past, the average less t is a supermartingale, whatever the dependence between the
detectors, so the average run length is at least arl (Shin, Ramdas and Rinaldo 2024,
arXiv:2203.03532). After an alarm the statistic starts again at the next call, and the call's
e-values after the alarming decision are dropped: they were computed against the model the
alarm rejected. DeploymentGate runs one per zone.
Raises:
| Type | Description |
|---|---|
ValueError
|
on an |
detectors
property
¶
How many detectors the running statistic follows: None before the first e-value and
after an alarm, when any number may start.
statistic
property
¶
The detectors' average statistic, 0 before the first e-value and after an alarm.
update(evalues)
¶
Add e-values, one row per decision and one column per detector, and say whether the alarm sounded.
Raises:
| Type | Description |
|---|---|
ValueError
|
on e-values that are not finite and non-negative, or whose number of detectors is not the running statistic's. |
MissRateAlarm
¶
An alarm that sounds once a deployed model's one-step innovations leave a split-conformal
radius more often than its calibration allows: on a plant the model still describes, it ever
sounds with probability at most delta.
calibration holds one-step residuals, the next state less the model's prediction, of
consecutive steps held out from the fit, one column per state. Each residual is whitened against
the one before, w_t = r_t - persistence r_(t-1), and scored by its Euclidean norm, in the
states' units: scale them first where those differ. radius is the k-th smallest of the
n scores, k = ceil((n + 1)(1 - alpha)), split conformal's radius. Given the calibration,
the share of later innovations outside it is a fixed p, and over calibrations p follows
Beta(n + 1 - k, k) (Vovk 2012; Lin and Bansal 2024): bound, its upper delta / 2
quantile, exceeds p with probability at least 1 - delta / 2.
update whitens each deployed residual against the one before it and counts the
innovations outside radius. After S misses in t innovations, statistic is the
likelihood ratio of a miss rate q against bound, mixed over q uniform on
[bound, 1], in closed form through the incomplete beta function. While each innovation
misses with probability at most bound given the past, it is a nonnegative supermartingale
from 1, so by Ville's inequality it ever reaches 2 / delta with probability at most
delta / 2, and the alarm sounds there. The two halves add up to delta: Podkopaev and
Ramdas's test for a harmful shift (ICLR 2022, Proposition 1), with the source risk's bound read
off the calibration's exact law and no tolerance. After an alarm the counts start again at the
next call, and the call's later residuals are dropped.
On a plant whose model is exact and whose noise is AR(1) at 0.8, with 300 calibration
innovations, alpha = delta = 0.1 and 1000 paths of 1000 steps read every 100, the alarm
sounded on 0.016 of the paths at the noise's persistence, on 0.020 at the lag-1 correlation
fitted on each calibration log, and on 0.209 unwhitened. With the noise's scale 1.5 times the
calibration's, it sounded on every one of 200 paths.
What the guarantee needs, and the alarm cannot check:
- Innovations independent of the past and exchangeable with the calibration's: the model
right, so a residual is the plant's noise, and the noise AR(1) at rate
persistence, such aschc.decision.DecisionCertificate.tube_persistenceor a fit's lag-1 correlation. Without whitening, misses cluster: a miss makes the next one likelier, and the supermartingale fails. - A persistence fixed before the deployed data; one estimated on the calibration log leaves a correlation of the order of its error.
Raises:
| Type | Description |
|---|---|
ValueError
|
on |
misses
property
¶
Innovations outside radius since the start or the last alarm.
steps
property
¶
Innovations read since the start or the last alarm.
statistic
property
¶
The mixture's e-value: 1 before any innovation and after an alarm.
update(residuals)
¶
Add one-step residuals, one row per consecutive step, and say whether the alarm sounded. The first residual after the start or an alarm only whitens the next.
Raises:
| Type | Description |
|---|---|
ValueError
|
on residuals that are not finite, or not one column per calibration state. |
OnlineRadius
¶
A radius per horizon, updated online so that, over any sequence of scores, each horizon's
long-run miss rate comes within a stated bound of alpha (Angelopoulos, Barber and Bates,
ICML 2024).
Horizon h (1 to H, one per entry of initial) has a radius. Each period issues
radius, one per horizon, and closes with update, which takes one score per
horizon: for horizon h, the score of the prediction issued h - 1 periods before this
one, nan where there is none. A score is any number in [0, bound] the caller computes,
such as the error's norm over the tube's scale, and the prediction misses when its score
exceeds the radius it was issued with. Each scored horizon's radius then moves by
eta_n (miss - alpha), with eta_n = step n^(-decay) at its n-th score: up after a
miss, down otherwise.
On any sequence of scores in [0, bound], chosen even by an adversary who sees the radii,
such as a plan steering its own states, the miss rate of horizon h after n scores
lies within miss_bound = (bound + h step) / (eta_n n) of alpha, which shrinks as
n^(decay - 1). For h = 1 it is their Theorem 1. For h > 1, up to h - 1 updates
are in flight while a prediction waits for its score, which widens their Lemma 1's range of the
radius from bound + eta_1 to bound + h eta_1; the proof is otherwise theirs. On every
one of the 2^12 sequences of misses an adversary can force in 12 periods, at a constant
step of 1, horizon 3 came within 0.9 of that bound and past the one without h. The bound
holds for each horizon on its own, and on no other sequence than the one scored; it says
nothing of coverage at any one period. Their step is decay = 0.6.
Raises:
| Type | Description |
|---|---|
ValueError
|
on an empty or non-finite |
radius
property
¶
The radius each horizon issues at the next period.
scored
property
¶
How many predictions each horizon has scored.
miss_rate
property
¶
Each horizon's share of scored predictions that missed, nan before its first.
miss_bound
property
¶
(bound + h step) / (eta_n n): how far each horizon's miss rate may lie from
alpha, inf before its first score.
update(scores)
¶
Close a period: score each horizon's prediction that resolved in it, nan for
none, against the radius it was issued with, and move the scored horizons' radii. Returns
which horizons missed.
Raises:
| Type | Description |
|---|---|
ValueError
|
on scores that are not one per horizon, a score outside |
MovePrice
dataclass
¶
What a move of the one-step channel costs a plan, against the plan that knew it, in expectation. Experimental.
See ChannelMove.price.
ChannelMove
dataclass
¶
How far the plant's one-step channel lies from the model's, read off a log's dither. Experimental.
See channel_move.
price(weight)
¶
What the move costs the plan weight was read off, kept or re-solved on the estimate.
With W the plan's chc.plan.CausalPlan.decision_weight, d the move and S
the covariance, keeping the plan loses d' W d / 2 to the plan that knew d, and a
plan re-solved on the estimate dh loses (dh - d)' W (dh - d) / 2, tr(W S) / 2 on
average. Since E[dh' W dh] = d' W d + tr(W S), keeping is priced at
(dh' W dh - tr(W S)) / 2, and its standard error is sqrt(4 d' W S W d + 2 tr((W S)^2))
/ 2, with d' W S W d read as dh' W S W dh - tr((W S)^2), for the same reason, and
floored at 0: dh put in for d reads its variance three times too large on a channel
that has not moved (validation/dither_channel_move.mac STEPs 4-5,
proofs/dither_channel_move.v).
Two expectations, not a decision rule. Re-planning pays in expectation when
d' W d > tr(W S), but comparing the two prices of one log selects on its error: the
logs whose estimate reads a large move are the ones whose estimate errs most along W,
and they are the ones re-planned. On the budgeted plan of
scripts/bench_channel_move.py, 200 logs of each size, re-planning when the keep price
beat the re-plan price lost 0.149 where always keeping lost 0.095, at 30 decisions, and
0.100 and 0.043 where always re-planning lost 0.080 and 0.024, at 100 and 400. Re-planning
on the estimate shrunk by 1 - tr(W S) / dh' W dh, or choosing on one half of the log
and re-planning on the other, did no better than the better of the two throughout. Re-read
the move on the decisions logged after the choice, such as those after a
DriftAlarm sounds, and the re-plan price is the re-solved plan's.
How far they reach. Both are second order in the move, and hold as far as W does:
while the plan's active set stays as it is, and for a move small against the channel (see
chc.plan.CausalPlan.decision_weight). With a move of 10-15% of the channel's
entries, the kept plan lost 0.095 where the keep price at the true move reads 0.107. A
plan re-solved on the estimate from 30 decisions lost 0.18 where the re-plan price read
0.37, its box capping how far it moved; from 400 and 1600, 0.024 and 0.0060 against 0.026
and 0.0066.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a weight whose channel is not the move's shape. |
DeploymentGate
¶
Shadow evidence per zone that ends in DEPLOY, HOLD, EXPERIMENT or ROLLBACK, read any time.
Whenever the gate is read -- after every batch, or at a time chosen by looking at the data --
and under the conditions in chc.gate:
- the expected share of wrong DEPLOY decisions among those made so far is at most
alpha, under any dependence across zones; a DEPLOY is wrong when the candidate does not beat the baseline by more thandelta; - each HOLD for harm, and each ROLLBACK on evidence, is wrong with probability at most
alpha_harm; - with drift e-values supplied, an unchanged channel alarms at most once per
drift_arldecisions on average; - with
alpha_futility, each futility stop is wrong with probability at mostalpha_futility; a stop is wrong when the candidate beats the baseline by more thandelta + min_effect.
The rules, in order, for each zone at each read:
- a drift alarm: HOLD, and the zone's evidence opens a new epoch; ROLLBACK if deployed;
- selected by e-BH over every zone's e-value: DEPLOY;
- the harm e-value reaches
1 / alpha_harm: HOLD, and the zone retires; - with
alpha_futility, the futility e-value has reached1 / alpha_futilityin this epoch: SHADOW, back from an experiment, and the zone does not switch to EXPERIMENT again; - in shadow, a verdict at
min_effectwould take longer than thehorizonhas left, and the mixture would gather evidence faster: EXPERIMENT; - otherwise the zone stays where it is, in shadow or in the experiment.
A deployed zone is watched by a rollback e-process on the baseline's weight. A retired zone
reads HOLD from then on; a new candidate is a new hypothesis, for a new gate. mode says
how to log a zone's next batch: under the baseline in "shadow", under
(1 - rho) baseline + rho candidate in "experiment", and under the candidate once
"deployed". The gate keeps its evidence in memory, and one caller updates it at a time.
In blocks (GateConfig.block_length), each zone is fed a BlockBatch and starts in
"experiment", where each block runs the candidate with probability rho. "shadow",
after a futility stop, runs the baseline's blocks, and "deployed" the candidate's. Shadow
blocks are no evidence for the candidate, so rule 5 sends a shadow zone back to the experiment
as soon as it may enter one, and a deployed zone's rollback evidence has no baseline block to
read: only a drift alarm rolls it back.
Raises:
| Type | Description |
|---|---|
ValueError
|
on no zones. |
update(batches)
¶
Add each zone's batch to its evidence and return every zone's verdict.
A zone without a batch keeps its evidence and still gets a verdict, since e-BH reads every zone's; a retired zone's batch is ignored. Every batch is checked before any is added. A gate in blocks reads each block as one decision, whose action is the policy drawn.
Raises:
| Type | Description |
|---|---|
KeyError
|
on a zone the gate has no plan for. |
TypeError
|
on a |
ValueError
|
on a batch whose logged propensities are not the mode's, blocks whose
length is not |
channel_drift_evalues(log, residual, *, dither_scale, radius, residual_scale)
¶
Per-decision e-values for "every entry of the one-step channel lies within radius of the
model's", read off the logged dither: a ZoneBatch's drift, or what a
DriftAlarm runs on.
residual holds, for each decision in log, the next state less the model's one-step
prediction at the action as applied, one column per state. dither_scale is the standard
deviation each action's dither was drawn with. radius bounds, entry by entry, how far the
plant's one-step channel d x_(t+1) / d u_t may lie from the model's while nothing has
moved, in the residual's units per unit of action. residual_scale, one per state and fixed
before the data, such as the model's one-step noise, is the unit the bets are placed in. A
scalar stands for every state or action.
For state i and action j, the dither is standardised, xi = dither_j /
dither_scale_j, and the residual is moved to the radius's edge and scaled,
r = (residual_i - radius_ij u_j) / residual_scale_i against a channel that grew and
(residual_i + radius_ij u_j) / residual_scale_i against one that shrank. The e-value at a
bet theta is exp(theta r xi - theta^2 r^2 / 2), with theta among 1, 1/2, ...,
1/128, negated against shrinkage. On a plant whose one-step map is g(x) + B u plus noise,
r = c + k xi, where k is how far B_ij lies past that edge, times
dither_scale_j / residual_scale_i, and c is everything else. When xi is N(0, 1)
and independent of c, E[exp(theta r xi - theta^2 r^2 / 2) | c] = 1 / |1 - theta k|,
which is at most 1 on each side the entry has not crossed, whatever the model's error in g,
the noise's law or the policy. Row t of the result, reshaped to (states, actions, 2,
8), holds decision t's e-values by state, action, growth then shrinkage, and bet.
A clipped decision is read the same way: the bet reads the draw, and the residual moves with
the action as applied. With a box cutting the draw to h = clip(xi, -m1, m2) in dither
units, r = c + k h, and the mean given c is 1 - (Phi(u2) - Phi(u1)) (1 - 1 / s),
with s = 1 - theta k, u1 = -s m1 - theta c and u2 = s m2 - theta c. That is at
most 1 on each side the entry has not crossed, exactly 1 on the radius's edge whatever the
clip, and at least 1 on the side it has (validation/dither_drift_evalue.mac STEP 8). On the
sides not crossed, the mean stays at most 1 for an action applied as any nondecreasing function
of its own draw, such as a saturating actuator. The draw is what the log must carry: the dither
as applied, put in its place, is not an e-value, and on the edge, with the nominal action on a
bound, its mean reaches 1.15.
On the lab's plant, whose model has the drift wrong and whose noise is Laplace, a
DriftAlarm on these e-values ran at least 3.2 and 5.6 times its target on an unchanged
channel, at 10^3 and 10^4, and caught a channel at 1.4 instead of 1.07 in 259 and 456
decisions on average: 1.56 and 1.37 times an oracle that knew which entry moved, which way and
at what rate (scripts/bench_drift.py, 300 paths). With the actions boxed to +-0.5, so
that a quarter of the decisions clipped, it ran at least 2.8 and 4.6 times its target and caught
the move in 438 and 749 decisions, 1.69 and 1.64 times the unboxed delays. With the clipped
decisions' e-values set to 0 instead, which is also valid, it caught the move on none of the
paths within 3000 decisions.
What the guarantee needs, and the function cannot check:
- A plant affine in the action over one step. The model's error in
gand the noise must not depend on the dither drawn at that step. A response nonlinear in the action, such asxi^2 - 1in the next state, is uncorrelated with the dither and still breaks the identity: in the lab, a running product of such e-values passed 20 on 27% of paths, where Ville's inequality allows 5%. A plant integrated over a step has a one-step channel that carries the drift's Jacobian too, to first order in the step. - A dither drawn as stated and logged as drawn: each action's from
N(0, dither_scale_j^2), independently of the other actions' and of the past, and applied whole or cut by a box on that action alone, fixed before the draw. A projection that couples the actions, such as a budget shared across them, moves one action's residual with another's draw, and is not covered. With a fifth more variance than stated, the e-value's mean attheta r = 1isexp(0.1) = 1.105. - A radius that covers the identification error. The e-values hold while every entry lies
inside it. On the same plant, a channel 0.07 from the model's, watched with no radius,
alarmed after 0.36 of the average run length the alarm was set for. A standard error, such as
chc.dynamics_id.CausalDynamicsFit.channel_error, is a scale, not a radius.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a log with no dither; on a residual without one row per decision, a
non-finite entry, a scale that is not positive, a negative radius, or a scale or radius
that is neither a scalar nor of its full shape; and on a dither whose draws, over
|
channel_move(log, residual, *, dither_scale, forgetting=1.0)
¶
How far the plant's one-step channel lies from the model's, read off the logged dither, with the older decisions forgotten at a constant rate. Experimental.
residual and dither_scale are channel_drift_evalues': per decision, the next
state less the model's one-step prediction at the action as applied, and the standard deviation
each action's dither was drawn with. With xi = dither_j / dither_scale_j, decision t
reads entry (i, j) as the product z = residual_i xi_j / dither_scale_j. On a plant whose
one-step map is g(x) + B u plus noise, residual_i = c + sum_k d_ik dither_k, with d
the plant's channel less the model's and c everything else: the model's error in g, the
policy's action through d, and the noise. When the dither is N(0, dither_scale^2) and
independent of c, E[z] = d_ij whatever c holds, and c adds to the product's
variance rather than to its mean (validation/dither_channel_move.mac STEPs 1-2).
The estimate is the products' mean under the weights forgetting^k, k the decisions
since, so the last decision weighs 1. Re-read as decisions arrive, it follows a channel that
moves, and is worth (1 + forgetting) / (1 - forgetting) equally weighted decisions once the
log is long (STEP 3); 1, the default, weighs every decision alike. The covariance is the
products' own, weighted the same way and scaled so that it is unbiased when they share one:
E[sum_t w_t^2 (z_t - dh)(z_t - dh)'] = sum_t w_t^2 [(sum_(s != t) w_s)^2 + sum_(s != t)
w_s^2] Var(z) / (sum w)^2, which is (n - 1) Var(z) for equal weights. The products'
errors are uncorrelated across decisions, since each dither is drawn after everything before
it, so it holds for noise that moves with the state as well; unequal variances leave a bias of
order 1 / effective_size under forgetting, and none under equal weights.
ChannelMove.price reads the move in the cost of a plan: what keeping it costs, and what
re-solving it on the estimate would.
On the drift monitor's plant (scripts/bench_channel_move.py, 2000 paths), whose model has
the drift wrong and whose noise is Laplace, the estimate of a move of 0.07 lay within 0.75 of
its standard errors of it over 500 and 2000 decisions, at dithers of 0.3 and 1.0; its spread
was 0.99-1.03 of its standard error, and estimate +- 1.96 of them covered the move on
0.943-0.955 of the paths. With the channel at 1.4 for the last 1000 of 4000 decisions, the
intervals covered what each estimate follows on 0.960 of the paths without forgetting and
0.951 forgetting at 0.995.
What the estimate needs, and the function cannot check: channel_drift_evalues'
contract. A plant affine in the action over one step, since a response nonlinear in the action
reads into the product; a dither drawn as stated and applied as logged; and, for the move to be
one number per entry, a channel that does not move with the state over the log: the estimate
is the one-step channel's average over the decisions, weighted as above, and a plant integrated
over a step has a channel that carries the drift's Jacobian, to first order in the step.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a log with no dither or with a clipped decision (see
|