mixle.analysis.health_risk module¶
Dose-response and population health-risk models (K3, work-plan Workstream K).
Given an exposure/dose – a bare number, an array of dose realisations, or an IC-1 Posterior over a receptor field (K1/K2 transport output) – these push it through a named dose-response curve into an outcome-probability distribution, so downstream liability/constraint code (K6) always has a distribution, never a bare point estimate:
DoseResponse– a named model (loglinear/logit/hill/threshold_linear) with caller-suppliedparams;DoseResponse.probability()maps a dose into an IC-1-shaped DerivedQuantity (samples + credible interval + the prior_dominated honesty flag). Whendoseis a Posterior, the pushforward runs through the posterior’s ownderived_quantityso the flag propagates correctly from the exposure uncertainty.
cumulative_exposure()– trapezoidal time-integration of an exposure series, with an optional first-order biological-decay discount (older exposure counts less toward the current body burden) – the intake feeding a chronic dose-response evaluation.
population_risk()– aggregates per-receptor dose-response probabilities (one draw per posterior sample, or a single point evaluation for a bare array) into an expected-case-count DerivedQuantity.
This module supplies the dose-response machinery; it ships no regulatory/clinical dose-response
table – params are always supplied by the caller or a knowledge lookup (see Non-goals).
K6 (this file’s second addition, appended after K3) turns a K3/K4 risk distribution into the two things J’s objective/optimizer actually need – a priced cost term and a hard feasibility screen:
health_liability()– prices a case-count/exceedance-probability DerivedQuantity (e.g. population_risk’s output, or K4’s safety_risk_surface) into a dollar-liability DerivedQuantity atcost_per_caseper case, discounted – thehealth_costterm J6’spriced_liabilities/risk_adjusted_plan(analysis/valuation.py) sums alongside carbon (L6) and remediation (G-side) liabilities.
exposure_constraints()– screens a list of candidate operating options against named occupational/community exposurelimits, marking eachfeasible(or not, naming thebindinglimit(s)) so H4’stwo_stage_stochastic_plan(stochastic_opt.py) only ever optimizes over the surviving, feasible candidate set.
K4 (this file’s third addition) turns a G4 ground-deformation posterior into a spatial safety-risk surface and a people-weighted incident probability:
safety_risk_surface()– the spatial gradient (tilt) of a deformation field, pushed through the field’s own IC-1 Posterior.derived_quantity pushforward so P(tilt > gradient_limit) per cell comes back as a DerivedQuantity (samples + credible interval + the prior_dominated honesty flag), not a point estimate. An optional static terrain slope adds to the deformation-induced tilt before the exceedance test – a location that is already steep needs less differential settlement to become unsafe.
incident_probability()– combines a per-cell hazard probability surface with a people- exposure_map (who is where) through a logistic link into a per-cell probability that a hazard cell becomes an actual incident (nobody is at risk on a steep-but-empty cell).
safety_risk_surface accepts either a raw np.ndarray deformation field (a deterministic, already-inverted grid – treated as a single degenerate draw with prior_dominated=False) or an IC-1 Posterior over deformation (the intended G4 case). The frozen Posterior.derived_quantity method does the sampling and the honesty-flag bookkeeping; this module only supplies the gradient/exceedance pushforward function, so the uncertainty accounting is always the posterior implementation’s own (A2), never re-derived here.
Non-goals: no deformation physics (mixle_pde.poroelastic owns the InSAR inversion itself), no economic liability (health_liability, K6).
K5 (this file’s fourth addition) turns a scalar monitoring series into a calibrated real-time
exceedance alert – the sibling of mixle.analysis.coverage / mixle.analysis.extreme for
the health & safety pillar. It answers a monitoring-shift question – “is this reading trending
toward, or past, a regulatory/occupational exposure limit, and can I trust the alert?” – without
pretending a single noisy reading settles it:
exposure_exceedance_monitor()– per-timestepP(exposure > limit)treating the OBSERVED reading itself as a noisy measurement of the true exposure level, scaled by a causal (strictly past-only) local predictive fit around it (the same distribution-over-a-threshold idea as IC-8’smixle_pde.decision_quantities.prob_exceed, here applied to a scalar monitoring series rather than a spatial posterior field, since a live sensor stream is not itself an IC-1Posterior) – a sudden extreme reading enters its own score and can trigger at the timestep it happens, not only once later readings drag a lagging rolling mean toward it. The raw probability is then run throughmixle.inference.conformal.split_conformal()against a held-outcalibreference (known-safe, sub-limit history, drawn from a stable reference period and scored by the same past-only rule) so the alert threshold is distribution-free calibrated: under exchangeability with an adequately-sizedcalib, the empirical false-alarm rate is bounded byalpha, not just “probably fine” from an untested normal-theory cutoff. Whencalibis omitted or too small foralphato have real resolution, the monitored series scores itself as a graceful degradation, but the report comes back explicitlycalibrated=Falserather than silently implying the bound holds.
An alert firing (ExceedanceReport.alerts.any()) is the hook the mlops drift/retrain half (G7,
mixle_mlops.drift_retrain) watches for a re-check/retrain trigger – that wiring is a signal
(read this array), not a code dependency: this module never imports anything from mixle_mlops.
- class DoseResponse(model, params=<factory>)[source]
Bases:
objectA named dose-response model:
modelselects the functional form,paramsits coefficients.model in {"loglinear", "logit", "hill", "threshold_linear"}:loglinear:P = 1 - exp(-beta * dose)(params: {"beta"}) – the EPA-style linear low-dose cancer/chronic form.logit:P = sigmoid(a * dose + b)(params: {"a", "b"}, both optional).hill:P = emax * dose^n / (ec50^n + dose^n)(params: {"ec50"},"emax"/"n"optional) – saturating receptor-occupancy form.threshold_linear:P = clip(slope * (dose - threshold), 0, 1)(params: {"slope"},"threshold"optional) – no response belowthreshold.
No regulatory dose-response table ships here –
paramsare supplied by the caller (see the module Non-goals).Frozen, holding its own copy of
params. The eager domain validation below is only meaningful if the validated coefficients are the ones actually used: with a mutable record and a shared dict, a caller could construct a valid model and then swapmodelor editparams– and every laterresponse_fn()would rebuild the closure from coefficients that never passed the construction-time gate.- response_fn()[source]
The elementwise dose -> outcome-probability function for this model + params.
- probability(dose, *, n=2000, rng)[source]
Push
dosethrough the dose-response model into an outcome-probability DerivedQuantity.dosemay be an IC-1 Posterior over exposure (the pushforward runs through the posterior’s ownderived_quantity, so prior_dominated propagates from the exposure uncertainty), an array of dose draws/ensemble members (“array-with-UQ”, resampled tonif its length differs), or a bare scalar (a degenerate point mass – the returned quantity still carries a (trivial) credible interval). Bare ensembles are one-dimensional. Posterior draws explicitly use shape(n,)for scalar dose or(n, n_receptors)for a receptor vector. Every dose must be finite and nonnegative, andnis an exact non-Boolean positive integer.- Parameters:
dose (Any)
n (int)
rng (np.random.Generator)
- Return type:
DerivedQuantity
- cumulative_exposure(series, dt, *, decay=0.0)[source]
Time-integrated exposure (trapezoidal rule), with optional first-order biological decay.
decay=0is the plain trapezoidal integral ofseriesover itsdt-spaced timesteps (area under the exposure-rate curve).decay > 0discounts each sample toward the final timestep byexp(-decay * (t_end - t))before integrating – the way a biological half-life would – so a spike long ago contributes less to the current cumulative body burden than an equally large spike near the end of the series. Feeds a chronic dose-response evaluation (e.g. viaDoseResponse.probability()).seriesmust be a one-dimensional finite nonnegative time series; multidimensional receptor/time data require an explicit aggregation before this scalar integration.dtmust be finite and positive (MXR-080-0098: a zero or negative timestep makes the integral either trivially zero or sign-flipped, silently), anddecayfinite and non-negative (a negative “decay” would amplify, not discount, older readings without bound).
- population_risk(exposure, dr, *, n, rng)[source]
Aggregate per-receptor dose-response probabilities into an expected-case-count DerivedQuantity.
exposureis a per-receptor dose: an IC-1 Posterior whose draws are(n, n_receptors)dose vectors (K1/K2 transport output propagated through UQ), or a plain(n_receptors,)array of point doses. Each posterior draw is pushed throughdr’s response function and summed over receptors, so the returned quantity carries the expected-case-count distribution (not just its mean); a bare array has no exposure uncertainty and yields a degenerate (constant) distribution.- Parameters:
exposure (Posterior | np.ndarray)
dr (DoseResponse)
n (int)
rng (np.random.Generator)
- Return type:
DerivedQuantity
- health_liability(risk, *, cost_per_case, discount=0.0)[source]
Price a K3/K4 risk DerivedQuantity into an expected-liability DerivedQuantity (K6, work-plan §7-K).
riskis any IC-1-shaped DerivedQuantity over an expected case count or exceedance probability (population_risk()’s output, or K4’ssafety_risk_surface); every draw inrisk.samplesis multiplied bycost_per_case(dollars per case/incident) and divided by(1 + discount)– a single-period present-value factor (discount=0is undiscounted; multi-period accounting applies its own per-period factor before summing across periods, this function prices one period/one risk term at a time). Pricing does not change how uncertain the underlying risk is: the returned quantity keepsrisk’s sample count/shape and its prior_dominated flag unchanged, so a liability that is prior-dominated upstream is still honestly flagged as such downstream. Handed to J6’spriced_liabilities/risk_adjusted_plan(analysis/valuation.py) as thehealth_costcallable’s output.risk.samplesmust be a nonempty finite nonnegative(n,)case-count series or(n, n_receptors)risk surface; negative harm is never interpreted as a benefit.cost_per_casemust be finite and non-negative (a dollar price cannot be negative), anddiscountfinite and strictly greater than-1.0(MXR-080-0098: the1 / (1 + discount)present-value factor is only well-defined for a denominator that is positive;discount == -1divides by zero, and anything below it silently flips the liability’s sign).
- exposure_constraints(options, limits, *, treat_unmodeled_as_safe=False)[source]
Screen candidate operating
optionsagainst named exposure/exceedancelimits(K6).limitsmaps an occupational/community exposure metric name (e.g."silica_pm4", an 8-hour TWA, or an exceedance probability) to the regulatory/policy limit for that metric.optionsis a list of plain dicts – each one candidate operating configuration, carrying (among whatever other plan data the caller needs, e.g. block cost or grade) a value for zero or more of those metric keys.Returns a new list (the input dicts are never mutated), one entry per option, each the original option’s key/value pairs plus:
"status": one of"safe","violating", or"unknown"."violating"means at least one limited metric the option DOES carry a finite value for breaches its (finite) limit."unknown"means no limit was confirmed breached, but at least one limited metric could not be evaluated for this option – missing from the option entirely, a non-finite (NaN/Inf) measurement, or a non-finite/missing limit – “not modeled” and “safe” are never the same outcome here, because this is a hard occupational/community exposure screen, not a soft optimization preference (MXR-080-0095). A confirmed violation always dominates an unknown: one metric’s missing data never erases a different metric’s real breach."feasible":Trueiffstatus == "safe"– unlesstreat_unmodeled_as_safe=True, in which casestatus == "unknown"also counts as feasible (see below). Kept as a plain boolean for the existing contract H4’stwo_stage_stochastic_planfiltering relies on."binding": the sorted list of limit names actually confirmed breached (empty unlessstatus == "violating") – naming exactly which limit made the option infeasible."unmodeled": the sorted list of limited metric names that could not be evaluated for this option (empty unlessstatus == "unknown") – naming exactly which evidence was missing.
This is a HARD safety screen, so it FAILS CLOSED by default: an option that breaches no limit it was evaluated against, but leaves one or more limits unevaluated, is
"unknown"and thereforefeasible=False– unmodeled evidence is never silently treated as compliant, and a NaN measurement (which would otherwise compare False, and so silently pass, against> limit) is treated the same as a missing one. Passtreat_unmodeled_as_safe=Trueto explicitly opt into the old permissive behavior; this is a deliberate policy override the caller must request, and it defaults to off. A caller filters the returned list down to the feasible options before handing the survivors’ blocks to H4’stwo_stage_stochastic_plan(stochastic_opt.py) – an infeasible (violating OR, by default, unknown) option is dropped from the candidate set entirely, so the optimizer never has the chance to select it (see the K6 DoD).
- safety_risk_surface(deformation, *, gradient_limit, slope=None)[source]
Map a deformation field into a per-cell P(tilt > gradient_limit) safety-risk surface.
- Parameters:
deformation (Posterior | np.ndarray) – an IC-1 Posterior over a flattened (d,) subsidence/deformation field (the G4 poroelastic InSAR-inversion case), or a plain np.ndarray spatial grid (any shape) of already-point-estimated deformation values.
gradient_limit (float) – the tilt/gradient magnitude above which a cell is considered geotechnically unsafe (e.g. an angular-distortion or differential-settlement limit).
slope (np.ndarray | None) – an optional static terrain-slope field, same spatial shape as the deformation grid. When given, it is added to the deformation-induced gradient magnitude before the exceedance test: a cell that is already steep needs less additional differential movement to cross gradient_limit.
- Returns:
A DerivedQuantity whose samples are the per-cell exceedance indicator (0.0/1.0) drawn over the posterior’s Monte-Carlo replicates (or a single deterministic replicate for an ndarray input), flattened in the grid’s row-major (C) order, together with a credible interval and the prior_dominated flag. The per-cell risk probability is samples.mean(axis=0).
- Return type:
DerivedQuantity
gradient_limitmust be finite and non-negative (a tilt magnitude threshold cannot itself be negative), andslope/anndarraydeformationmust be finite (MXR-080-0098): a NaN either way would otherwise compare False against> gradient_limitand silently mark an unevaluable cell as “not exceeding” instead of raising. Posterior evidence additionally requires a finite flattened mean and exactly(_MC_SAMPLES, n_cells)finite draws; the returned binary risk samples are validated independently before publication.
- incident_probability(hazard, exposure_map, *, model='logit')[source]
Combine a per-cell hazard probability surface with a people-exposure_map into incident risk.
A hazard exceedance is only a safety incident if someone is exposed to it: an empty, unstable cell and a busy, unstable cell are not the same risk. hazard is expected in [0, 1] (e.g. the per-cell mean of safety_risk_surface(…).samples); exposure_map is a non-negative people- density/occupancy weight of the same shape.
exposure_mapis on the expected people present scale: a cell’s value is the mean occupancy of that cell, so0means nobody,1means about one person on average, and larger values are denser. Both models take that scale literally and return0for an unoccupied cell.- Parameters:
hazard (ndarray) – per-cell hazard probability, shape (*grid_shape,), values in [0, 1].
exposure_map (ndarray) – per-cell non-negative expected-occupancy weight, same shape as hazard.
model (str) – “logit” (default) is a logistic link that multiplies the hazard’s own odds by the probability that anybody is actually present, 1 - exp(-exposure_map) (the Poisson occupancy probability for that mean): an unoccupied cell yields 0, denser occupancy monotonically raises the result, and it saturates at the hazard probability itself. “linear” is the simple product hazard * exposure_map clipped to [0, 1].
- Returns:
Per-cell incident probability, same shape as hazard, values in [0, 1].
- Return type:
hazardoutside[0, 1]is REJECTED, not clipped into a falsely-confident boundary probability (MXR-080-0098): the pre-fix"logit"path clipped any hazard value – 5.0, -3.0, whatever – straight into[eps, 1 - eps]before ever checking it was a valid probability to begin with. The narroweps-clip that remains below is purely for the logit transform’s own numerical stability at the exact 0/1 boundary of an ALREADY-valid probability, not a substitute for validating the input range.The
"logit"occupancy factor used to belog1p(exposure_map)added to the log-odds (MXR-080-1589). That has no zero element:log1p(0)is0, so an empty cell added no log-odds at all and hazard0.8at zero occupancy returned incident probability0.8– flatly contradicting this function’s own stated contract that an exceedance is only an incident when someone is exposed. It was also unbounded above, so a dense-enough cell reported MORE incidents than hazard exceedances. Multiplying the odds by an occupancy PROBABILITY fixes both: zero occupancy is the zero element, and the incident probability can never exceed the hazard probability it is conditioned on.
- class ExceedanceReport(alerts, prob_exceed, false_alarm_target, calibrated=True, warmed_up=<factory>)[source]
Bases:
objectPer-timestep exceedance call: which points alert, their raw probability, and the target rate.
alertsis the boolean array the caller (or the mlops drift/retrain wiring) acts on; whencalibratedis True it is already conformal-calibrated – do not re-thresholdprob_exceedagain downstream.calibrated(MXR-080-0097) is False whenever the returned alerts do NOT carry the distribution-free false-alarm-rate guarantee:calibwas omitted (the monitored series – including any anomalies it contains – scored itself, which is neither independent of nor exchangeable with what it is calibrating), or the effective calibration set was too small to givefalse_alarm_targetreal resolution (see_min_adequate_calib_size). The alerts are still computed on a best-effort basis in that case, but the caller must treat them as a heuristic, not a proven bound.warmed_up(MXR-080-0096) marks which timesteps had enough strictly-prior history to be scored at all; an unwarmed timestep always reportsprob_exceed == 0.0and never alerts – an explicit “not yet evaluated”, never a confident “safe”.The record is frozen and owns read-only copies of its three arrays.
calibratedandwarmed_upare the honesty flags that qualifyalerts; while the record was mutable, flippingcalibratedto True or overwritingalertsin place upgraded a best-effort heuristic into an apparently-proven false-alarm bound with nothing else changing.Construction additionally enforces the record’s own domain (MXR-080-1900), which it previously did not check at all –
prob_exceedof5.0,-3.0orNaN, afalse_alarm_targetof7.5, and aprob_exceed/warmed_uparray of a different length thanalertsall constructed silently, so the record could report an alert whose “probability” was not one, at a “target rate” that was not a rate, for timesteps that did not line up with the alerts:prob_exceedfinite and in[0, 1]. It is a survival-function value by construction, so this refuses nothing the producer emits.false_alarm_targetfinite and strictly in(0, 1)– the same rangeexposure_exceedance_monitor()already requires of itsalpha, now also true of a directly constructed report.prob_exceedthe same length asalerts, one probability per alert.warmed_upeither the same length asalertsor EMPTY. Empty is the field’s own default and means “warm-up was not recorded”; refusing it would reject reports the package’s own tests construct, and it is not the same claim as a per-timestep flag that disagrees with the alerts.
- exposure_exceedance_monitor(series, limit, *, alpha=0.05, calib=None)[source]
Flag exceedance excursions in
seriesagainstlimitat a calibrated false-alarm rate.- Parameters:
series (ndarray) –
(n,)monitoring readings (e.g. silica PM4 concentration over time). Must be finite.limit (float) – the occupational/community exposure limit being monitored against. Must be finite.
alpha (float) – target false-alarm rate (
ExceedanceReport.false_alarm_target); the empirical alert rate on exchangeable, non-exceeding data is bounded by this via conformal calibration – but only when the returnedcalibratedis True (seecalibbelow). Must be in(0, 1).calib (ndarray | None) –
(m,)held-out reference readings known to be exposure-compliant (sub-limit), drawn from a stable, anomaly-free reference period and scored by the same past-only rule asseries– the temporal-exchangeability scheme this monitor’s false-alarm-rate guarantee depends on. When omitted,seriesscores itself (a graceful degradation for callers with no separate holdout) – but the monitored series, including any anomalies it contains, is neither independent of nor exchangeable with what it is calibrating, so the returned report is explicitly markedcalibrated=Falseand thealphabound is NOT guaranteed (MXR-080-0097). An explicitly-suppliedcalibthat is too small to givealphareal resolution (fewer than roughly_min_adequate_calib_size(alpha)usable, warmed-up points) is likewise reported uncalibrated rather than silently accepted.
- Returns:
An
ExceedanceReport. Whencalibratedis False the alerts are still a best-effort heuristic, not a proven distribution-free bound.- Return type:
ExceedanceReport
- Algorithm:
At every timestep
tofseries(and, separately, ofcalib), fit a causal (strictly past-only) local Gaussian SCALEstd[t]fromseries[max(0, t-window):t]– neverseries[t]itself, never anything at or aftert– via_causal_local_scale(). Timesteps with too little prior history are not “warmed up” (ExceedanceReport.warmed_up) and are reported as an explicit not-yet-evaluated state (prob_exceed=0.0, never alerting) instead of reaching past the window into a global – and, for an early index, necessarily partly-future – statistic (MXR-080-0096).Score
prob_exceed[t]as the probability that the TRUE exposure level exceedslimit, treating the OBSERVEDseries[t]itself as a noisy measurement of that level with scalestd[t]:P(X > limit)forX ~ Normal(series[t], std[t])(scipy.stats.norm.sf()). Centering on the observation itself – rather than on the local mean of prior readings alone, which is blind to what was actually just measured – is what lets a single sudden extreme reading enter its own score and trigger at the exact timestep it happens, instead of only once later readings drag a lagging rolling mean toward it (MXR-080-0096).Calibrate the alert threshold: treat each WARMED-UP calibration timestep’s
prob_exceed(scored by the identical observed-value rule) as a one-sided conformal nonconformity score (mixle.inference.conformal.split_conformal(),side="upper", against a constant zero “prediction”) on the known-safecalibset. The returned upper bound is the smallest cutoff such that, under exchangeability withcalib, at most analphafraction of non-exceeding timesteps would clear it – a distribution-free false-alarm-rate guarantee, not a normal-theory approximation, PROVIDEDcalibgenuinely satisfies that exchangeability and is large enough (seecalibabove andcalibrated).Alert wherever a warmed-up
seriestimestep’sprob_exceedclears that threshold.