Mortality & Morbidity Rate Validation
Mortality and morbidity rate validation is the discipline of proving, deterministically and on every run, that the qx and morbidity incidence rates feeding a reserve or capital model are internally consistent, credible against experience, and anchored to a published industry basis. This guide covers the cohort mathematics, the Python validation subsystem, and the audit artifacts an examiner expects — the rate-side specialization of the broader Assumption Validation & Rule Engine Design architecture.
The Validation Problem
A base mortality table is not a spreadsheet of numbers; it is a load-bearing assumption on the balance sheet. Under NAIC VM-20 Section 9, the prudent-estimate mortality assumption must be built from a company experience study, blended with an industry table through a documented credibility procedure, and loaded with a margin — and every one of those steps must be reproducible during examination. VM-21 extends the same expectation to variable annuity mortality, while IFRS 17 requires that morbidity incidence and termination rates used in the fulfilment cash flows be traceable to their source. When a mortality rate is wrong, nothing throws an exception: the error surfaces years later as reserve inadequacy or a failed asset-adequacy test.
The specific failure modes this subsystem defends against are concrete. A table loaded with an off-by-one age offset shifts every qx by one year and quietly understates reserves. A morbidity table blended at full credibility on a block with 40 claims produces a prudent estimate that experience cannot support. An improvement scale applied in the wrong direction turns mortality deterioration into improvement. Each of these passes a naive “is it a number between 0 and 1” check and fails only under the cohort-level, credibility-aware validation this page describes. The goal is to convert every one of those silent errors into a loud, logged FAIL at the ingestion boundary, long before the rate reaches the reserve calculation.
Three questions define a complete mortality/morbidity validation:
- Is the table structurally valid? Correct age domain, monotonic base rates, valid probabilities, and complete metadata (table basis, effective date, improvement scale reference).
- Is it credible against experience? The actual-to-expected ratio must fall inside a tolerance band whose width scales with the credibility of the underlying exposure.
- Has it drifted from its validated baseline? Successive vintages of the same assumption must be compared so that a material distributional shift triggers recalibration rather than a silent roll-forward.
Validation Architecture
The subsystem is a deterministic pipeline. A raw table enters, is typed and fingerprinted, is measured against both an industry benchmark and the company experience study, and exits either as a PASS bound to a SHA-256 checksum or as a routed exception. The projection engine only ever consumes rates that have cleared this path.
Prerequisites
This page assumes the reader is comfortable with the ingestion and rule-engine layers it sits on. Before implementing the code below, understand:
- Python packages.
pydanticv2 for the data contract,numpyandpandasfor vectorized cohort arithmetic, andpytestfor the regression suite. For portfolios in the millions of policies, the same logic runs on the batch substrate described in Pandas & NumPy for Actuarial Data Pipelines and Async Batch Processing for Large Models. - The data contract. Rates arrive as typed payloads that have already cleared the boundary defined in Schema Validation with Pydantic & Great Expectations. This page adds the actuarial rules that a generic schema check cannot express.
- The governing engine. The
PASS/WARN/FAILclassification, deterministic fingerprinting, and exception routing are defined once in the parent Assumption Validation & Rule Engine Design; this page registers a mortality-specific pipeline into it. - Sibling assumptions. Mortality cannot be validated in isolation from behavior or economics — see Policy Lapse & Surrender Assumption Engines for the lapse cross-check and Economic Scenario Mapping & Yield Curve Alignment for improvement-scale/discount alignment.
The core credibility test rests on the actual-to-expected ratio. For a cohort with observed deaths (or morbidity claims) and expected claims computed from exposure and tabular rates , the ratio is
A validated table is one whose exposure-weighted sits inside a tolerance band. The width of that band is not fixed — it tightens as credibility rises, which is what the next two sections implement.
Core Implementation
The canonical pattern is a cohort validator that (1) computes exposure-weighted expected claims against the industry basis, (2) derives an actual-to-expected ratio per cohort, (3) assigns a credibility factor using the limited-fluctuation square-root rule, and (4) blends the company experience toward the industry table by that factor before emitting a structured result.
from __future__ import annotations
import numpy as np
from dataclasses import dataclass, field
from typing import List
@dataclass(frozen=True)
class Cohort:
"""One experience cell: issue-year / duration / underwriting-class segment."""
cohort_id: str
exposure: np.ndarray # central exposure by age, l_j
actual_claims: np.ndarray # observed deaths or morbidity claims, d_j
tabular_qx: np.ndarray # industry-basis rates, q_j^tab (e.g. 2017 CSO)
@dataclass
class CohortValidation:
cohort_id: str
ae_ratio: float
credibility: float
blended_qx: np.ndarray
status: str # "PASS" | "WARN" | "FAIL"
exceptions: List[str] = field(default_factory=list)
def full_credibility_claims(std_error: float = 0.05, p: float = 0.90) -> float:
"""Claims needed for full credibility under limited-fluctuation theory.
For a 90% chance of being within +/-5% of the true rate, the standard
full-credibility standard is ~1082 expected claims.
"""
from scipy.stats import norm
z = norm.ppf(0.5 + p / 2.0)
return (z / std_error) ** 2
def validate_cohort(
cohort: Cohort,
tolerance_band: float = 0.10,
reg_floor_qx: float | None = None,
full_cred_standard: float = 1082.0,
) -> CohortValidation:
exposure = cohort.exposure
tabular_qx = cohort.tabular_qx
expected_claims = float(np.sum(exposure * tabular_qx))
actual_claims = float(np.sum(cohort.actual_claims))
exceptions: List[str] = []
if expected_claims <= 0.0:
return CohortValidation(
cohort_id=cohort.cohort_id, ae_ratio=float("nan"),
credibility=0.0, blended_qx=tabular_qx, status="FAIL",
exceptions=["Zero expected claims: exposure or tabular rates missing"],
)
ae_ratio = actual_claims / expected_claims
# Limited-fluctuation (square-root) credibility, capped at full credibility.
credibility = float(min(1.0, np.sqrt(actual_claims / full_cred_standard)))
# Credibility-weighted company rate: Z * observed + (1 - Z) * industry basis.
observed_qx = np.divide(
cohort.actual_claims, exposure,
out=np.zeros_like(tabular_qx), where=exposure > 0,
)
blended_qx = credibility * observed_qx + (1.0 - credibility) * tabular_qx
status = "PASS"
# Hard rule: a regulatory minimum rate is never softened.
if reg_floor_qx is not None and np.any(blended_qx < reg_floor_qx):
status = "FAIL"
exceptions.append("Blended qx below prescribed regulatory floor")
# Soft rule: A/E outside the band demands secondary actuarial review.
if status != "FAIL" and abs(ae_ratio - 1.0) > tolerance_band:
status = "WARN"
exceptions.append(
f"A/E {ae_ratio:.3f} outside +/-{tolerance_band:.0%} tolerance"
)
return CohortValidation(
cohort_id=cohort.cohort_id, ae_ratio=ae_ratio,
credibility=credibility, blended_qx=blended_qx,
status=status, exceptions=exceptions,
)
The credibility factor is the limited-fluctuation estimator
where claims is the standard for being within of the true rate at confidence. The blended rate is what carries forward: a thin block leans on the industry basis, a fully credible block trusts its own experience, and the transition is smooth and documented rather than a manual judgement call. Note the two-tier rule discipline inherited from the engine — a rate below a prescribed floor is a hard FAIL that no reviewer can wave through, whereas an out-of-band A/E is a WARN that a qualified actuary may accept on the record.
Configuration and Tuning
Tolerance bands, credibility standards, and regulatory floors must never be hard-coded — term life, whole life, and long-term-care morbidity blocks have fundamentally different volatility and regulatory sensitivity. Externalize them so compliance can retune without redeploying the engine:
# mortality_validation.yaml (loaded at ingestion, version-controlled)
defaults:
full_cred_standard: 1082 # expected claims for full credibility
tolerance_band: 0.10 # +/-10% A/E band before WARN
monotonic_from_age: 55 # enforce non-decreasing qx above this age
product_overrides:
term_life:
tolerance_band: 0.075 # tighter band, larger blocks
ltc_morbidity:
tolerance_band: 0.20 # wider band, sparse incidence data
full_cred_standard: 1500 # incidence is noisier than mortality
reg_floor_qx: null
drift:
# delegated to the drift subsystem; see Dynamic Threshold Tuning
psi_warn: 0.10
psi_fail: 0.25
Two parameters deserve care. full_cred_standard should reflect the variance of the phenomenon being measured — morbidity incidence for long-term care is noisier than mortality, so it needs more claims before the company experience is trusted, hence the raised standard in the ltc_morbidity override. tolerance_band should widen as the block thins; a fixed band that is reasonable for a large term block will generate constant false WARNs on a small annuity cohort. The drift thresholds are intentionally delegated: distributional stability against the validated baseline is measured with the Population Stability Index, whose full treatment (EWMA smoothing and CUSUM change detection) lives in Dynamic Threshold Tuning for Assumption Drift.
Step-by-Step Walkthrough
To stand up mortality/morbidity validation against the code above:
- Type the table at the boundary. Reject any payload whose ages fall outside the actuarial domain, whose
qxare not genuine probabilities, or whose base rates are non-monotonic abovemonotonic_from_age. This is the contract layer detailed in the child guide, Automating Mortality Table Validation Against Industry Standards. - Stratify into cohorts. Segment the experience study by issue year, duration, underwriting class, and distribution channel. Aggregate validation hides anti-selection that only appears at the cohort level.
- Compute expected claims per cohort as against the industry basis (2017 CSO, 2012 IAM, or the relevant morbidity table).
- Derive the A/E ratio and credibility with
validate_cohort, blending observed experience toward the industry table by . - Cross-check behavioral consistency. Reconcile early-duration mortality against the lapse curve from Policy Lapse & Surrender Assumption Engines; high early lapses that suppress observed mortality are a classic calibration trap.
- Run the drift check against the previously validated vintage and route material shifts to recalibration.
- Emit the audit package. Bind each cohort result to a SHA-256 checksum and the experience-study reference, then hand off to the filing layer described in NAIC VM-20 Compliance Frameworks.
Validation and Testing
The validation engine is itself a model, so it must be tested with the same rigor it imposes. Three assertion classes matter:
import numpy as np
import pytest
from mortality_validation import Cohort, validate_cohort
def _flat_cohort(n_ages=50, qx=0.01, exposure=1000.0, claims_scale=1.0):
ages = np.arange(40, 40 + n_ages)
tab = np.full(n_ages, qx)
exp = np.full(n_ages, exposure)
dth = exp * tab * claims_scale
return Cohort("test", exposure=exp, actual_claims=dth, tabular_qx=tab)
def test_on_basis_experience_passes():
"""A/E == 1.00 must PASS with high credibility."""
result = validate_cohort(_flat_cohort(claims_scale=1.0))
assert result.status == "PASS"
assert result.ae_ratio == pytest.approx(1.0, abs=1e-9)
assert result.credibility == 1.0 # 500k exposure => fully credible
def test_material_deviation_warns():
"""A/E 1.30 breaches the 10% band and routes to review."""
result = validate_cohort(_flat_cohort(claims_scale=1.30))
assert result.status == "WARN"
assert result.ae_ratio == pytest.approx(1.30, abs=1e-9)
def test_thin_block_leans_on_industry_basis():
"""A tiny block gets low credibility and blends toward the table."""
thin = _flat_cohort(n_ages=5, exposure=20.0, claims_scale=2.0)
result = validate_cohort(thin)
assert result.credibility < 0.25
# Blended rate stays close to the 0.01 industry basis despite 2x experience.
assert np.all(result.blended_qx < 0.015)
def test_regulatory_floor_is_a_hard_fail():
"""A rate below the prescribed floor can never be softened to WARN."""
result = validate_cohort(_flat_cohort(qx=0.001), reg_floor_qx=0.002)
assert result.status == "FAIL"
Beyond unit tests, wire the pipeline into a data-quality gate. A Great Expectations checkpoint (or an equivalent pandas-based assertion suite) should verify at ingestion that qx is bounded, that ages are contiguous, and that the exposure column contains no negatives. The credibility-blended output feeds the same distributional drift assertion — a PSI over the configured psi_fail threshold fails the checkpoint so the run halts before a shifted table reaches the reserve model. Every assertion outcome is written to the append-only trail defined in Actuarial Audit Trail Architecture, so the test result itself becomes examiner evidence.
Failure Modes and Gotchas
- NumPy dtype overflow on exposure. Summing exposure across a multi-million-policy block in
int32silently overflows and produces a negative denominator, poisoning every A/E in the run. Cast exposure tofloat64(orint64) at load and assertexposure.sum() > 0before dividing. - Divide-by-zero on sparse ages. Cohorts with zero exposure at some ages yield
0/0observed rates. The implementation usesnp.divide(..., where=exposure > 0)precisely so those cells fall back to the tabular rate instead of emittingNaNthat propagates through the blend. - Full credibility on a thin block. Applying because a reviewer “trusts the data” defeats the limited-fluctuation guard. Keep the square-root rule authoritative and require a logged override to raise credibility manually.
- Age-offset table loads. An off-by-one in the age index shifts the whole curve and passes monotonicity and bounds checks. Defend with an anchor assertion — e.g.
qxat a reference age must match the published table value within floating-point tolerance. - Improvement scale applied backwards. A sign error turns deterioration into improvement and understates reserves. Validate the direction of the scale (projected rates below base for improvement) and reconcile against the economic assumptions in Economic Scenario Mapping & Yield Curve Alignment.
- Non-deterministic cohort ordering. Building the fingerprint from a
dictor a set-ordered cohort list produces a different checksum on each run, breaking reproducibility. Sort cohorts bycohort_idbefore serialization so identical inputs always hash identically.
Related
- Automating Mortality Table Validation Against Industry Standards — the contract-first schema layer and log-ratio drift check that feed this validator.
- Dynamic Threshold Tuning for Assumption Drift — PSI, EWMA, and CUSUM detection for the vintage-over-vintage drift step.
- Policy Lapse & Surrender Assumption Engines — the behavioral cross-check that keeps early-duration mortality honest.
- Schema Validation with Pydantic & Great Expectations — the ingestion contract this page builds actuarial rules on top of.
- NAIC VM-20 Compliance Frameworks — how validated rates become filing artifacts under principle-based reserving.
Up a level: Assumption Validation & Rule Engine Design — the governing engine that dispatches this mortality pipeline alongside behavioral and economic validation.