chc.decision¶
Stability tier: stable.
One call from a panel of logs to a certified intervention schedule.
Every layer this needs already exists --- chc.graph for which covariates to adjust for,
chc.dynamics_id for a control channel that is interventional rather than observational,
chc.plan for the constrained solve and its error tube, and chc.plan.certify_safety
for what the plan survives under confounding. What did not exist was a way to run them in that
order without re-deriving the wiring, so the demo in every README was five imports and forty lines.
prescribe is that wiring and nothing more: no new estimator, no new solver, no new
guarantee.
The module is chc.decision rather than chc.prescribe for one reason: the headline export is
a function named prescribe, and a module of the same name inside the same package is shadowed
by it --- after import chc.prescribe, chc.prescribe is the function and
chc.prescribe.Lever raises AttributeError. It was the only such collision in the library.
The causal assumption is a required argument. adjustment= takes either a
CausalGraph, from which the adjustment set is derived and can come back
not_identified, or an explicit sequence of column names, which asserts it. There is no
default, because the default would be "adjust for nothing", which is a causal claim this library
exists to stop people making by accident.
Two questions, never conflated, following chc.plan.CausalPlan. Whether the effect is
identified is about the data and the graph; whether the plan is certified is about the model
error and the barrier. DecisionCertificate reports both, and an unidentified effect
produces no schedule at all --- Prescription.schedule raises rather than hand back actions
computed from a channel nothing in the log pins down.
SCHEMA_VERSION = 2
module-attribute
¶
to_json's schema version. Bumped when a field changes meaning, not when one is added: 2
writes a number that is not finite as null (ADR 0055).
POLICY_SCHEMA_VERSION = 1
module-attribute
¶
PrescribedPolicy.to_json's schema version, which PrescribedPolicy.from_json
reads, and no other.
IdentificationStatus = Literal['identified', 'asserted', 'not_identified']
module-attribute
¶
How the set was arrived at. asserted means a caller named it and nothing here checked it.
TubeRate = Literal['global', 'local']
module-attribute
¶
Where the error tube's rate bounds the field's slope in the state: at every state, the levers anywhere in their box, or at the plan's start alone.
TubeMode = Literal['exact', 'bound']
module-attribute
¶
What each radius of the error tube is. Both add a one-standard-error scale of the fitted
field's error, the channel's and the drift's, to the radius within which the plant's own noise
leaves the plan at that step with probability 0.95 (ADR 0080). exact: on a field affine in the
state, both carried through RK4's step matrix of the field's slope at each planned action, so the
noise's radius is its exact 0.95 quantile at that step under the fitted field. bound: both
carried by a bound on the norm of the slope, so the noise's radius holds at least 0.95, exactly at
the first step.
TimeZero = Literal['calendar', 'unit']
module-attribute
¶
Where Prescription.evaluate's windows start: on one calendar for every unit, or cut back
from each unit's own latest period.
Estimability = Literal['estimable', 'held_to_log', 'not_estimable']
module-attribute
¶
Whether the log determines the plan's predicted path (ADR 0054). estimable: the log moved
every direction of the channel. held_to_log: it left some unmoved, and the plan keeps to what
the log did along them, so every fit the log cannot tell from the fitted one predicts the same
path. not_estimable: some such fit predicts another path from
DecisionCertificate.first_loaded_step on, or no plan could keep to the log and none was
made.
GammaStarStatus = Literal['finite', 'every_level', 'no_level']
module-attribute
¶
What DecisionCertificate.gamma_star reads. finite: the level past which some step's
barrier no admissible action keeps. every_level: inf, every step's barrier is kept however
strong the confounding. no_level: nan, some step's barrier no admissible action keeps, not even
at gamma = 1.
DecisionError
¶
Bases: ValueError
A decision could not be set up: a lever, a target, a constraint or an adjustment is wrong.
Subclasses ValueError so it stays catchable the way it always was, and exists so a caller
wrapping prescribe can tell its own mis-specification apart from a ValueError
thrown out of jax, numpy or the solver. Every message names the offending column or bound.
Columns that are simply absent still raise KeyError, matching Panel
lookup: asking for a name that is not there is a lookup failure, not a bad decision.
NotIdentifiedError
¶
Bases: DecisionError
The effect is not identified by adjustment, so there is no schedule to hand back.
Its own type because it is the one failure this library exists to produce. A caller may want to
fall back to chc.sensitivity's partial-identification path on exactly this and on
nothing else; catching it by message would be catching it by accident.
LeverRelation
dataclass
¶
A combination of the levers the log kept at one level, sum_j weights[j] u_j = level,
which the plan keeps at every step. weights has one entry per lever and unit length.
Lever
dataclass
¶
One actionable column and the box it may move in.
unit_cost is the quadratic price of using it, and defaults to free: with a box constraint a
free lever is still bounded, so the default is safe rather than merely convenient.
cap_per_step bounds how far the lever may move between consecutive planned steps. None
lets it jump anywhere in its box from one step to the next, which is a schedule a media plan, a
rota or a price list often cannot execute. The move from the level already in force to the
first planned step is not capped, since the planner is not told what that level is.
Target
dataclass
¶
The column to steer and where to steer it.
value is one level for the whole plan, or a schedule with one level per step:
value[k] is the level for the state after k + 1 actions, so a schedule has horizon
entries. A schedule is how a set point that moves inside the horizon reaches the plan --- a
comfort band that tightens at eight is steered for before eight, not re-planned for after it.
Constraint
dataclass
¶
A state that must stay inside a range --- what the safety certificate is priced against.
Each finite bound is a barrier of its own, x - lo or hi - x, and a step is certified
where the condition of every bound holds, the far one's as well as the near one's: the state
approaches neither faster than the class-K gain times its distance to it.
state may be the target column: the steered state is then bounded as well as steered, which
is how a comfort band around a set point is stated. A target value outside its own bound is
allowed and means "as close as the bound permits"; holding the bound is what keeps the plan
from crossing it on the way.
Driver
dataclass
¶
An exogenous column that pushes the state, and its forecast over the plan.
forecast[k] is the level at t = k * dt, so a forecast has horizon + 1 entries: one
at the start of every step and one at the end of the last. Between two entries the driver moves
linearly, which is how the fit reads it off the log (chc.dynamics.DrivenDynamics).
Nothing the plan does moves a driver --- weather, a published demand forecast; a column the
levers can move is a state, not a driver.
InterventionSchedule
dataclass
¶
What to set each lever to, step by step.
windows(*, tol=1e-06)
¶
First and last step at which each lever is active, or None if it never is.
Derived rather than stored: a start/stop pair that can disagree with the magnitudes it summarises is a worse footgun than computing it twice.
PrescribedPolicy
dataclass
¶
The decision as a rule to run, step by step, in the state each step reaches.
A lever the plan moves takes the schedule's level at the step. A lever the log set from the
state alone (DecisionCertificate.rule_levers) takes the log's rule at the state it is
given, as the plan's field and its price read it (ADR 0054), so its column of
schedule is nan. A lever the plan holds to the log, or one the selection left out,
keeps the level the schedule holds it at. Along the path the plan predicts, the actions are
Prescription.schedule's. Experimental.
horizon
property
¶
How many steps the policy runs.
ruled
property
¶
The levers set from the state on the log's rule, in the order of levers.
actions_at(step, state)
¶
Each lever's level at step in state, in the order of levers.
state is the states' values in the order of states, or a mapping that names
each of them; a name it holds besides is not read.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a step that is not an integer from 0 to |
to_json()
¶
The policy as plain JSON-safe values, which from_json reads back to the bit.
No number is infinite or nan (ADR 0055). A ruled lever's column of schedule is None,
as its rule sets it; rule names those levers and holds the rule, an infinite bound of
its as None: lo's is -inf, hi's inf. precision names the floats the rule
reads the state in, None where there is no rule.
from_json(record)
classmethod
¶
The policy to_json wrote.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a record of another schema version, or one that does not run: names that are not distinct, a schedule that is not a finite number for each lever at each step, a ruled lever's None excepted, a rule that does not fit the levers and the states, or a rule read in a precision JAX does not now run in. |
DecisionCertificate
dataclass
¶
What is known about the decision, on the two axes that must not be merged.
Identification is about the data and the graph; certification is about model error and the barrier. A plan can be fully certified against a channel nothing identifies --- that is a trustworthy tube around a meaningless action --- so both are reported and neither is derived from the other.
stationarity_status
property
¶
How stationarity reads against its tolerance (chc.plan.DiagnosticStatus);
None where no plan was solved.
violation_status
property
¶
How constraint_violation reads against its tolerance; None where no plan was
solved.
trustworthy_steps
property
¶
The prefix that survives both axes --- the number an operator can act on.
Zero whenever the effect is not identified, whatever the tube says, and never longer than
the shorter of the two certified prefixes, nor past the first step a fit the log cannot
tell apart predicts differently. The two None values mean different things.
certified_horizon is None means the tube was not evaluated, so nothing bounds the
trajectory's error and it contributes zero rather than infinity --- a barrier cleared by an
unbounded trajectory proves nothing. barrier_certified_steps is None means no state was
bounded, so there is no safety prefix to respect and the tube alone decides.
gamma_star_status
property
¶
What gamma_star reads (GammaStarStatus); None where no bound was audited.
SelectionStep
dataclass
¶
One step of the greedy lever selection: the lever it added, and what the plan cost with it.
regret_bound is chc.plan.plan_regret_bound on that step's plan, priced against
every lever's box, the levers not yet selected included. So it bounds how far below
task_cost any plan the boxes allow can go: what the levers left out at this step could
still buy, plus what the solve left on the table. inf when the objective was not convex
over the boxes, as in DecisionCertificate.regret_bound; regret_status says which.
LeverSelection
dataclass
¶
Which levers prescribe kept under max_levers, in the order it added them.
What a step bought is the drop in planned cost from the step before it; idle_cost is the
planned cost with every lever held at zero, which is what the first step is measured against.
StartState
dataclass
¶
The state the plan starts from, or would have, and where it came from.
source is "given" for a caller's x0 and "panel" for the panel's own: the mean
over its units of each unit's last logged state. A mean over units that sit far apart is a
start no unit is at, so the panel's start keeps how many units it averages, the periods their
last states were logged at, and each state's spread over them.
RunProvenance
dataclass
¶
What the run used, beside the panel's Provenance (ADR 0063).
The panel's record is made when the panel is built, and a run need not match it: a panel built
under JAX's x64 and planned on with it off fits on float32 transitions while its record
reads True. So each field here is read where the run used it: x64 as prescribe ran;
the device and the fit's dtype off the transitions the fit ran on; the solve's dtype, why it
stopped and after how many steps off the plan the planner returned; each setting as the fit and
the planner were given it; and each version from the installed distribution's metadata.
to_json()
¶
A plain dict, ready for json.dumps(..., allow_nan=False): the versions an object
keyed by distribution, and an infinite tolerance null.
Prescription
dataclass
¶
The decision, the evidence for it, and what it took to get there.
schedule
property
¶
The actions to take.
Raises:
| Type | Description |
|---|---|
ValueError
|
if the effect is not identified. Returning a schedule here is the exact
failure this library exists to prevent: the numbers would look like every other
schedule and mean nothing. Read
|
policy()
¶
The decision as a rule to run step by step in the states it reaches: the schedule for the levers the plan moves, the log's rule of the state for those the log set from it.
Raises:
| Type | Description |
|---|---|
NotIdentifiedError
|
if the effect is not identified, as |
ValueError
|
on a prescription |
budget_prices(tolerance=None)
¶
What each budget is worth to the plan: one price per period it holds in the plan.
A period's price is how much the planned cost falls per unit more it may spend, in the
cost's units per unit of spend; zero where the period has room. Read as
chc.plan.CausalPlan.shadow_prices reads every row, whose tolerance this is.
Raises:
| Type | Description |
|---|---|
NotIdentifiedError
|
if the effect is not identified, so no plan was made. |
ValueError
|
if the plan was held under the constraints' barrier
( |
evaluate(panel, *, logger=None, smoothing=None, model_error=1.0, min_effective=100.0, time_zero='calendar', n_resamples=200, seed=0, max_refused=0.05)
¶
The schedule's expected cost over its horizon, estimated from panel before it is
deployed, beside the cost of the policy that logged the panel:
chc.evaluation.evaluate_plan by "pdis", whose keywords the first four are, with
its intervals read from a bootstrap over the units.
What it is handed: the episodes are windows of H + 1 consecutive periods of a unit,
over the plan's states and the levers; the schedule is the plan's actions, open loop; the
cost is the plan's own, less its terminal term; the plant is the plan's model over one
step, linearised at the episodes' mean state and action, with the covariance of its
one-step residuals on them as the noise. panel may be the one the plan was fitted on,
and the plan was chosen on it, so a value read off it can be optimistic; a later one is
not.
A window starts at its time zero, where a target trial starts each unit's follow-up: when
the unit is eligible and is assigned a strategy (Hernán and Robins, Causal Inference:
What If, section 22.4). Under "calendar", the default, the time zeros are the panel's
periods H, 2H, ... before its last, the same for every unit, and a unit gives
every window it was observed throughout. Under "unit" each unit's windows are cut back
from its own latest period, a gap ending a run: where a unit's record ends then sets where
each of its windows starts, so when a unit leaves the panel because of what happened to
it, its windows are chosen by their own outcomes, and the value read off them is biased.
One unit's windows follow each other, dependent through its state, so the intervals come
from n_resamples draws of the units with replacement, from seed, every window of a
drawn unit kept. Each draw runs the evaluation again: the plant is linearised again, the
logger fitted again unless it is given, the smoothing chosen again unless it is given, and
the certificate and the values computed again. An interval is its estimate plus or minus
1.96 standard deviations of its draws, widened by the model's correction as
evaluate_plan widens its own; a draw the certificate refuses is
left out and counted (PlanEvaluation.bootstrap). With few units the draws are
few distinct panels and the intervals too narrow.
Near the certificate's edge a draw whose units start wider or weigh heavier than the
panel's is refused, so the draws read are the narrow side of the spread: the intervals
are then conditional on certification, and reading
says so, "conditional". Where more than max_refused of the draws are refused it
is "inconclusive", logged as a warning: the intervals are too narrow to read.
accepted and accepted_share count the draws
read.
Windows of one unit leave nothing to resample, and they follow each other through its
state and its noise. Each window's influence on the estimate, the delta method's, is read
across the windows' time zeros by the equal-weighted cosine estimate, nu cosines of
E windows, nu the most whole ones at or below 0.4 E^(2/3), and each interval is
Student's t(nu) on it (Lazarus, Lewis, Stock and Watson 2018), widened by the model's
correction; PlanEvaluation.degrees_of_freedom is nu and bootstrap is
None. A window's time zero takes its place on the grid of H periods, so a gap leaves
its places empty. The logger and the smoothing are held at the estimate's. Measured on one
simulated unit (a loop keeping 0.5 or 0.8 of its state a step, its noise AR(0), AR(0.5) or
AR(0.9), horizons 4 and 10, 25 to 1600 windows, 1000 logs a cell), the 95 % intervals
covered 0.87 to 0.99, where the windows taken as independent covered 0.75 to 0.99; where
the loop keeps 0.8 and the noise is AR(0.9), 0.82 to 0.95 against 0.64 to 0.85. They were
1.04 to 2.3 times as wide, 1.66 at 25 windows of fresh noise (ADR 0083).
PlanEvaluation.versus_logger holds the plan's value less the logged policy's, on
the same windows, its interval read off the difference within each draw. The policy in
place can cost less than the plan; the difference is what switching to the plan would
change.
Scope: what chc.evaluation states, and two things more. The logs' actions must
depend on the state and a randomisation of their own alone, so a plan whose levers were
logged on a column outside its state is refused: the weights need the logger's propensity
given that column, and no policy of the state is that. The graph says what the levers were
logged on, their parents; an asserted adjustment is taken to name it. A covariate adjusted
for only because it moves the target is no reason to refuse. And a window is read only
when its unit was observed to its end. A unit that leaves the panel because of what
happened to it after a window's time zero takes that window with it, and the windows left
are not those an unselected panel would hold; weighting them by the chance of staying,
which would correct that, is not built. Leaving on what happened before a time zero
moves only the law the windows start from, which the value is read at.
What the graph cannot say, the panel is asked: whether the levers read a column besides the
state, or the state's past (PlanEvaluation.logger_check, as
Prescription.logger_check on the panel the plan was fitted on). On enough rows it
also flags levers that read the state through more than a quadratic, such as a logger that
switches at a threshold; the logger fitted here is affine, so that premise fails too. A
rejection is logged as a warning and changes nothing else. Experimental.
Raises:
| Type | Description |
|---|---|
NotIdentifiedError
|
if the effect is not identified, so there is no plan. |
DecisionError
|
on a |
InfeasibleEvaluation
|
when the evaluation's certificate refuses, on the panel or on every draw but one. |
PanelError
|
when a state's or a lever's column of |
reach()
¶
Per lever, how far it can move the target's rate across its own box, where the plan starts; None where the log does not identify the lever's own effect.
The fitted control channel on the target's row, read at the state the plan starts from, or
would have where the effect is not identified, times the width of the lever's box. A
channel affine in the state has no one value: up to 0.12 this read its constant term, its
value at x = 0, which a log of rooms at 20 °C never comes near, and a fit whose channel
was negative everywhere in the log read positive there. A large coefficient on a lever that
may barely move is not a large lever, which is why the range is in the number and not only
in the footnote.
A lever the log set from the state, or moved only together with others, reads None. Its
entry of the channel there leans on a direction the log never moved
(chc.dynamics_id.CausalDynamicsFit.unmoved), where the fit holds the channel by a
convention, not an estimate: on a log whose second lever was always twice the first, up to
0.14 this read 2.05 and 1.02, a split of their joint effect that any other split fits as
well.
Read from the same fit that produced the plan, deliberately. Ranking by a second estimator --- local projections, say --- invites an ordering that contradicts the schedule printed beside it, and two disagreeing orderings on one page is worse than one.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a prescription that records no start, one built other than by
|
explain()
¶
The levers ranked by reach, widest first, as text, and those whose own effect
the log does not identify, by name.
report()
¶
A Markdown summary: the decision, both certificate axes, and the provenance.
Text only, from string templates. No plotting dependency, so the output is deterministic and can be asserted on in a test rather than eyeballed.
to_json()
¶
The schedule, both certificate axes and the provenance, as plain JSON-safe values.
No number is infinite or nan: one that is not finite is None (ADR 0055). Where None has
more than one reading, a status beside it says which: regret_status refused for an
infinite regret_bound, a selection step's own regret_status for its bound,
gamma_star_status for gamma_star (GammaStarStatus), and
stationarity_status and violation_status for stationarity and
constraint_violation (chc.plan.DiagnosticStatus).
prescribe(panel, *, levers, target, horizon, adjustment, constraints=(), hold_constraints=False, budgets=(), max_levers=None, known=None, dt=1.0, gamma=1.0, tolerance=None, x0=None, folds=2, seed=0, integrator='rk4', drivers=())
¶
Fit the causal control channel from panel and plan a certified schedule on it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
panel
|
Panel
|
long-format logs. Consecutive periods within a unit become the |
required |
levers, target, constraints
|
the decision, in the domain's own names. States are the target
column followed by each other constrained column, in that order. A constraint may name
the target column itself: it then bounds the steered state, which gets the barrier and
|
required | |
hold_constraints
|
bool
|
hold |
False
|
budgets
|
Sequence[PeriodBudget]
|
what the plan may spend ( |
()
|
max_levers
|
int | None
|
plan with at most this many levers, chosen by greedy forward selection with
|
None
|
adjustment
|
CausalGraph | Sequence[str]
|
a |
required |
known
|
Dynamics | None
|
physics kept fixed and not estimated. |
None
|
dt
|
float
|
the time step one panel period represents. |
1.0
|
gamma
|
float
|
the marginal sensitivity model's level the barrier is priced at (§40), passed to
|
1.0
|
tolerance
|
float | None
|
the trajectory error above which the plan stops being certified. Omitting it
switches the tube off rather than setting it to infinity: this library cannot know
how much error a caller accepts, and a certificate with an infinite tolerance passes
over the whole horizon while proving nothing. The tube carries two errors over the
plan's RK4 steps at the rate |
None
|
x0
|
ArrayLike | None
|
the state to plan from, one finite value per state: the target's, then each
constrained column's. Defaults to the mean over units of each unit's last observed
state, the pooled "where we are now". |
None
|
integrator
|
Integrator
|
the one-step map the fit is made consistent with, passed to
|
'rk4'
|
drivers
|
Sequence[Driver]
|
exogenous columns the plan can forecast but not move, each with its forecast over
the horizon ( |
()
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Prescription
|
class: |
Prescription
|
attr: |
|
Prescription
|
attr: |
Raises:
| Type | Description |
|---|---|
DecisionError
|
the decision is mis-specified --- no lever, a lever named twice or also
the target or a constraint, a column constrained twice, a target schedule whose
length is not |
KeyError
|
a lever, target, constraint, driver or asserted covariate names a column the panel does not have. The message lists the panel's columns. |
PanelError
|
a column the fit reads as numbers --- a lever's, a state's, a covariate's or
a driver's --- holds text, dates, times, durations, complex numbers or any other value
that is not a real number, or a number finite in its own type that is an infinity in
float64, as |
A direction of the channel the log never moves apart from what the states and the covariates
predict (unmoved) is not identified on this log, and
the plan keeps to what the log did along it (ADR 0054). A lever whose whole channel is so is
held at its mean logged level, clipped to its box, where the log kept it at one level, and
follows the log's least-squares rule of the state inside the plan's field where the log set it
from the state alone; DecisionCertificate.unmoved_levers and
rule_levers name them, and the schedule's column carries the rule
read along the predicted path. Among the other levers, a combination the log kept at one level
is held there by an equality row at every step (relations). A lever
or a combination the log set from anything else gives no plan, and neither does a log that
moves no lever: the identification is then not_identified, its reason naming the levers and
the columns they were set from. The plan is checked exactly: the fits the log cannot tell apart
differ along directions in which the field is linear, and
first_loaded_step is the first step at one of whose RK4 stages any
of them moves the field, where the trustworthy prefix ends.
estimability says which case holds.
Each decision point emits one logging record on chc.decision, keyed by chc_event
(see _log). Nothing is configured here; a caller that wants them calls
logging.basicConfig itself. An unidentified effect, a lever the log never moved, one on its
logged rule, a combination kept at its logged level, a plan the log does not determine, a run
in single precision, a forecast outside the logged range and levers that read more than the
record says come through at WARNING.
Before fitting, the panel is asked whether the levers read anything but the states and their
recorded parents: Prescription.logger_check (experimental). It reports; it changes
nothing else.