Dynamic Threshold Tuning for Assumption Drift

Static tolerance bands, once sufficient for stable macroeconomic environments, now fracture under interest-rate volatility, demographic transitions, and rapidly iterating product architectures. When published mortality tables age, policyholder lapse behavior reacts to shifting rate environments, or yield curves invert, rigid validation thresholds either trigger alert fatigue or conceal material deviations until a filing window closes. This page solves one specific problem: how a validation engine keeps its tolerance limits statistically honest as experience emerges, so that a drifting assumption is caught the quarter it moves rather than at the next examination. It sits inside the Assumption Validation & Rule Engine Design architecture as the adaptive-thresholding layer that VM-20 Section 9 and the ongoing-monitoring expectation of SR 11-7 both demand — an engine that recalibrates warning bands from rolling experience while holding hard-fail limits fixed for material, filing-impacting deviations.

Adaptive threshold tuning flow Left to right: schema ingestion of typed experience feeds a rolling baseline built with EWMA or a Bayesian update; a regime detector recalibrates the warning band; then a drift-versus-band decision gate splits the deviation three ways — a minor move is logged as info, a sustained move is routed to secondary actuarial review as a warning, and a material move raises a critical filing flag that blocks promotion of the assumption. minor sustained material Schema ingestion typed experience Rolling baseline EWMA · Bayesian Regime detect recalibrate band Drift vs band INFO Logged WARN Secondary review CRIT Filing flag

The Problem: Why Fixed Bands Break Under Drift

A fixed tolerance — “flag any qx more than 5% from the prudent estimate” — encodes a single, static assumption about variance that almost never holds across a portfolio. Early-duration mortality carries far higher sampling variance than late-duration experience, so a band tight enough to catch a real shift in mature business drowns new business in false positives. Lapse experience behaves worse still: surrender rates are elastic to the interest-rate environment, and a band calibrated in a low-rate regime becomes meaningless the moment yields spike and disintermediation pressure changes the baseline. The regulatory consequence is asymmetric. A band that is too wide lets a material assumption error accumulate silently into reserve inadequacy — precisely the failure that VM-20 Section 7 reserve substantiation and OSFI E-23 Principle 4 independent validation exist to prevent. A band that is too narrow buries the one signal that matters under thousands of benign fluctuations, and alert fatigue is itself an audit finding when a governance committee cannot show it acted on the deviations it was warned about.

Dynamic threshold tuning replaces the single number with a distribution. The engine maintains a rolling model of what “normal” variation looks like for each assumption segment, widens or narrows the warning band as that variation changes, and reserves an immovable hard-fail limit for deviations large enough to touch a capital or pricing model. Crucially, the adaptation itself must be deterministic and logged: every band that moves, and the experience window that moved it, is serialized so an examiner can reconstruct the exact tolerance state on any historical valuation date.

Architecture of the Drift-Detection Subsystem

The subsystem is a stateless evaluator over versioned state. Each run pulls the current experience window and the last validated baseline, computes drift statistics, recalibrates the warning band, classifies the deviation into a severity tier, and emits an immutable record. It never mutates the baseline in place — a recalibration writes a new baseline version, so the sequence of thresholds is itself an append-only history.

Stateless drift evaluator over versioned baselines Inputs on the left — the current experience window (actual-to-expected ratios and bucketed distribution) and the prior baseline version vN — fan out to three statistics computed in one pass: EWMA detects a level shift, CUSUM detects a small sustained shift, and PSI detects a change in the whole distribution. The three converge on a recalibration node that recomputes the warning band and emits a new baseline version vN+1, which feeds back into the next cycle. The evaluator then classifies the deviation three ways: a minor move logs an info verdict, a sustained move raises a warning routed to secondary review, and a material move raises a critical verdict with a filing flag. Every recalibration, verdict, and override is hash-chained into an immutable audit log at the bottom. emit new baseline version vN+1 → next cycle Experience window ratios · buckets Prior baseline version vN EWMA level shift weighted centre of experience CUSUM sustained shift accumulated small drift PSI distribution shift whole-shape movement Recalibrate band emit baseline vN+1 append-only, versioned Classify severity INFO minor → logged WARN sustained → review CRIT material → filing flag Immutable audit log every recalibration, verdict, and override hash-chained

Three statistics do the work, each answering a different question. An exponentially weighted moving average (EWMA) answers “where is the centre of experience now, weighting recent periods more heavily?” A cumulative sum (CUSUM) chart answers “has a small but sustained shift accumulated past a decision threshold, even if no single period looked alarming?” And the Population Stability Index answers “has the whole distribution of the assumption moved relative to its validated baseline?” The engine runs all three because they fail independently: EWMA reacts to level shifts, CUSUM to slow structural breaks, and PSI to changes in dispersion that leave the mean untouched.

Prerequisites

Before wiring this subsystem in, three things must already be true upstream.

Python dependencies are deliberately minimal: numpy for the vectorized statistics, pydantic for the config contract, and the standard library hashlib/json for audit serialization. No heavyweight statistical framework is required for the core loop.

The Statistics

The EWMA of an observed statistic (say the actual-to-expected ratio rtr_t for a segment) updates recursively with smoothing factor λ(0,1]\lambda \in (0, 1]:

zt=λrt+(1λ)zt1z_t = \lambda \, r_t + (1 - \lambda)\, z_{t-1}

Its control limits widen or narrow with the estimated process standard deviation σ\sigma and a multiplier LL (typically 2.5–3.0):

UCLt, LCLt=μ0±Lσλ2λ(1(1λ)2t)\text{UCL}_t,\ \text{LCL}_t = \mu_0 \pm L\,\sigma \sqrt{\frac{\lambda}{2 - \lambda}\left(1 - (1-\lambda)^{2t}\right)}

The tabular CUSUM accumulates one-sided deviations beyond a slack value kk (in units of σ\sigma) and signals a sustained shift once either arm crosses a decision interval hh:

Ct+=max ⁣(0, Ct1++(rtμ0)k),Ct=max ⁣(0, Ct1(rtμ0)k)C_t^{+} = \max\!\left(0,\ C_{t-1}^{+} + (r_t - \mu_0) - k\right), \qquad C_t^{-} = \max\!\left(0,\ C_{t-1}^{-} - (r_t - \mu_0) - k\right)

And the Population Stability Index compares the incoming assumption distribution against its validated baseline across BB buckets:

PSI=i=1B(aiei)ln ⁣(aiei)\mathrm{PSI} = \sum_{i=1}^{B} \left( a_i - e_i \right) \, \ln\!\left( \frac{a_i}{e_i} \right)

where aia_i and eie_i are the actual and expected proportions in bucket ii. The governance convention this engine enforces treats PSI<0.10\mathrm{PSI} < 0.10 as stable, 0.10PSI<0.250.10 \le \mathrm{PSI} < 0.25 as a warning routed to secondary review, and PSI0.25\mathrm{PSI} \ge 0.25 as a material shift that forces recalibration and a filing flag.

Core Implementation

The canonical pattern is a single stateless evaluator that takes the current experience window and the prior baseline, computes all three statistics, and returns a typed drift verdict. It never decides policy inline — severity tiers and multipliers come from configuration.

import numpy as np
from dataclasses import dataclass


@dataclass(frozen=True)
class DriftVerdict:
    segment_id: str
    ewma: float
    ewma_upper: float
    ewma_lower: float
    cusum_high: float
    cusum_low: float
    psi: float
    severity: str  # "info" | "warning" | "critical"


def ewma_bands(ratios: np.ndarray, mu0: float, sigma: float,
               lam: float, L: float) -> tuple[float, float, float]:
    """Return the current EWMA statistic and its two-sided control limits."""
    z = mu0
    for r in ratios:
        z = lam * r + (1.0 - lam) * z
    t = len(ratios)
    spread = L * sigma * np.sqrt((lam / (2.0 - lam)) * (1.0 - (1.0 - lam) ** (2 * t)))
    return z, mu0 + spread, mu0 - spread


def tabular_cusum(ratios: np.ndarray, mu0: float, sigma: float,
                  k: float) -> tuple[float, float]:
    """Accumulate one-sided CUSUM arms in units of sigma."""
    slack = k * sigma
    c_high = c_low = 0.0
    for r in ratios:
        c_high = max(0.0, c_high + (r - mu0) - slack)
        c_low = max(0.0, c_low - (r - mu0) - slack)
    return c_high, c_low


def population_stability_index(actual: np.ndarray, expected: np.ndarray,
                               eps: float = 1e-6) -> float:
    """PSI of an actual vs. baseline bucketed distribution."""
    a = np.clip(actual, eps, None)
    e = np.clip(expected, eps, None)
    a = a / a.sum()
    e = e / e.sum()
    return float(np.sum((a - e) * np.log(a / e)))


def evaluate_drift(segment_id: str, ratios: np.ndarray,
                   actual_hist: np.ndarray, baseline_hist: np.ndarray,
                   cfg: "DriftConfig") -> DriftVerdict:
    z, ucl, lcl = ewma_bands(ratios, cfg.mu0, cfg.sigma, cfg.ewma_lambda, cfg.ewma_L)
    c_high, c_low = tabular_cusum(ratios, cfg.mu0, cfg.sigma, cfg.cusum_k)
    psi = population_stability_index(actual_hist, baseline_hist)

    ewma_breach = z > ucl or z < lcl
    cusum_breach = c_high > cfg.cusum_h * cfg.sigma or c_low > cfg.cusum_h * cfg.sigma

    if psi >= cfg.psi_material or (ewma_breach and cusum_breach):
        severity = "critical"
    elif psi >= cfg.psi_warning or ewma_breach or cusum_breach:
        severity = "warning"
    else:
        severity = "info"

    return DriftVerdict(segment_id, z, ucl, lcl, c_high, c_low, psi, severity)

The classification logic is intentionally conservative: a critical verdict requires either an outright material distributional shift (PSI at or above the material threshold) or corroborating level-and-persistence evidence (both EWMA and CUSUM breaching). A single-statistic breach only ever escalates to warning, which throttles false criticals while never suppressing a genuine material move.

Configuration and Tuning

Every parameter that decides policy lives in a validated config object, never in the evaluator. This is what lets a compliance owner retune bands through a version-controlled file without a code deploy, and it is what makes the retune auditable.

from pydantic import BaseModel, Field


class DriftConfig(BaseModel):
    """Per-segment tuning contract; serialized alongside every filing package."""
    segment_id: str
    mu0: float = 1.0                                   # target actual-to-expected ratio
    sigma: float = Field(gt=0.0)                       # segment process std dev
    ewma_lambda: float = Field(default=0.2, gt=0.0, le=1.0)
    ewma_L: float = Field(default=3.0, gt=0.0)         # control-limit multiplier
    cusum_k: float = Field(default=0.5, ge=0.0)        # slack, in sigma units
    cusum_h: float = Field(default=5.0, gt=0.0)        # decision interval, in sigma units
    psi_warning: float = Field(default=0.10, gt=0.0)
    psi_material: float = Field(default=0.25, gt=0.0)
    regime_multiplier: float = Field(default=1.0, ge=1.0)  # widens bands in high-vol regimes

Two tuning decisions dominate behavior. The smoothing factor ewma_lambda trades responsiveness against noise: a high-variance segment such as early-duration mortality wants a smaller lambda (near 0.1) so short-term noise cannot yank the band, while a stable mature block tolerates 0.3 for faster reaction to real shifts. The regime multiplier couples the band to the economic state: when the Economic Scenario Mapping & Yield Curve Alignment layer reports a high-volatility regime — a Markov-switching or GARCH signal on the yield curve — regime_multiplier scales sigma upward so that lapse bands legitimately widen during rate stress instead of firing on the expected elevated surrenders.

def apply_regime(cfg: DriftConfig, high_volatility: bool) -> DriftConfig:
    """Widen the effective process sigma when the economic regime shifts."""
    if not high_volatility:
        return cfg
    return cfg.model_copy(update={"sigma": cfg.sigma * cfg.regime_multiplier})

Hard-fail limits are deliberately not in the adaptive path. A separate, immutable material threshold (for example, any qx deviation that would move statutory reserves by more than a fixed basis-point tolerance) is evaluated outside this loop and can never be widened by a regime signal — regime awareness relaxes warnings, never the floor that protects the filing.

Step-by-Step Walkthrough

  1. Load the validated experience window. Pull the current segment’s exposure-weighted actual-to-expected ratios array and the bucketed actual_hist distribution from the ingestion feed. Assume schema validation has already succeeded upstream.
  2. Load the prior baseline version. Retrieve the last validated baseline_hist distribution and the segment’s DriftConfig. Both are addressed by version hash so the run is reproducible.
  3. Apply the regime multiplier. Ask the economic layer whether the current period is a high-volatility regime and call apply_regime to scale sigma accordingly before any statistic is computed.
  4. Compute the three statistics. Call evaluate_drift, which returns the EWMA level and bands, both CUSUM arms, and the PSI in one pass.
  5. Classify and route. Map the severity field to an action: info is logged only; warning routes to secondary actuarial review; critical writes a filing flag and blocks promotion of the assumption to the projection layer until an authorized override or recalibration clears it.
  6. Recalibrate as a new version. If the verdict is critical because of a genuine, accepted regime change (not an error), write a new baseline version from the recent window rather than editing the old one — the previous threshold state stays intact and auditable.
  7. Serialize the audit record. Emit a hash-chained record of the inputs, the config version, the verdict, and any override, then hand it to the Actuarial Audit Trail Architecture sink.

Validation and Testing

The tuning layer is itself a model component under SR 11-7, so it needs its own tests, not just the assumptions it guards. Three assertions matter most: that a clean, stationary series never escalates past info; that an injected material shift is caught; and that the same inputs always produce the same verdict.

import numpy as np


def test_stable_series_stays_info():
    cfg = DriftConfig(segment_id="MORT_DUR10PLUS", sigma=0.03)
    rng = np.random.default_rng(20260703)          # fixed seed -> deterministic test
    ratios = 1.0 + rng.normal(0.0, 0.01, size=24)  # tight, on-target experience
    baseline = np.array([0.2, 0.3, 0.3, 0.2])
    verdict = evaluate_drift("MORT_DUR10PLUS", ratios, baseline, baseline, cfg)
    assert verdict.severity == "info"
    assert verdict.psi < cfg.psi_warning


def test_material_shift_escalates_to_critical():
    cfg = DriftConfig(segment_id="LAPSE_UL", sigma=0.03)
    ratios = np.linspace(1.0, 1.30, num=18)        # sustained upward lapse drift
    baseline = np.array([0.40, 0.30, 0.20, 0.10])
    shifted = np.array([0.10, 0.20, 0.30, 0.40])   # distribution has clearly moved
    verdict = evaluate_drift("LAPSE_UL", ratios, shifted, baseline, cfg)
    assert verdict.severity == "critical"
    assert verdict.psi >= cfg.psi_material


def test_verdict_is_deterministic():
    cfg = DriftConfig(segment_id="MORT_DUR1", sigma=0.05)
    ratios = np.array([1.02, 0.99, 1.01, 1.03, 0.98])
    base = np.array([0.25, 0.25, 0.25, 0.25])
    first = evaluate_drift("MORT_DUR1", ratios, base, base, cfg)
    second = evaluate_drift("MORT_DUR1", ratios, base, base, cfg)
    assert first == second        # frozen dataclass equality -> byte-stable audit output

Beyond unit tests, wire a Great Expectations checkpoint on the experience feed (non-null exposures, ratios within a sane physical range) so a corrupted upstream batch fails loudly before it can poison a baseline, and assert on the audit log itself — that every warning and critical verdict produced exactly one immutable record, and that recalibration always incremented the baseline version rather than overwriting it.

Failure Modes and Gotchas

  • Recalibrating on your own alarm. The most dangerous bug is a loop that folds a genuine deviation straight back into the baseline, so the band chases the drift and the alarm silences itself. Recalibration must be gated on an explicit, logged human or governance decision — never automatic on a critical verdict. This is exactly the “effective challenge” SR 11-7 requires.
  • Seed non-determinism. Any stochastic step (bootstrapped control limits, Monte Carlo bucket edges) that is not seeded makes the verdict irreproducible, which fails the examination requirement for a byte-identical rerun. Seed every generator, as the tests above do, and treat an unseeded RNG in this path as a release blocker.
  • PSI on sparse buckets. With small exposure, empty or near-empty buckets send ln(ai/ei)\ln(a_i/e_i) toward infinity and produce a spurious material verdict. The eps clip in population_stability_index guards the arithmetic, but the real fix is a minimum-exposure floor per bucket, credibility-blended toward the prior in thin segments — the same credibility discipline ASOP No. 25 expects of the underlying assumption.
  • Regime multiplier applied to the wrong sign. Widening bands in a high-volatility regime is correct for elastic behavioral assumptions but wrong if applied blindly to a hard-fail reserve limit. Keep the multiplier strictly on the warning path; a config that lets it touch the material floor is a finding waiting to happen.
  • Segment leakage. Running one global DriftConfig across mixed-variance segments reproduces the exact static-band failure this page exists to eliminate. Every distinct variance profile — issue-year cohort, distribution channel, product line — needs its own tuned config, versioned independently.

Up a level: Assumption Validation & Rule Engine Design — the reference architecture for the code-first validation engine this page extends.