Sensitivity Analysis & Stress Testing

Sensitivity analysis and stress testing measure how far a life insurer’s reserves and required capital move when a single assumption — mortality, lapse, or the interest curve — is bumped away from its best estimate, and they do it by revaluing the whole in-force block under each shock rather than trusting a first-order approximation. This is the discipline that turns a point-estimate reserve into a defensible range, satisfies the sensitivity-test expectations woven through NAIC VM-20 and an insurer’s ORSA, and gives the appointed actuary a quantified answer to “what breaks if we are wrong?” It sits inside the broader Actuarial Model Ingestion & Testing Workflows reference architecture as the assumption-risk quantification phase, and it leans on the same seeded projection engine that Stochastic Scenario Generation Frameworks drive for stochastic reserves.

Bump-and-revalue sensitivity grid: base run fanning out into shocked revaluations that collapse into a sensitivity table A base valuation on the left feeds a vectorized bump grid gate. The grid fans out to three shocked revaluation boxes — mortality plus fifteen percent, lapse plus twenty-five percent, and interest minus one hundred basis points — and all three converge into a highlighted sensitivity table holding the reserve delta for each shock. Base valuation reserve + capital Vectorized bump grid (one pass) Mortality +15% revalue block Lapse +25% revalue block Interest -100bp revalue block Sensitivity table ΔReserve per shock
A single base run is bumped across a grid of shocks and revalued in one vectorized pass, collapsing into a table of reserve deltas.

The Problem: A Point Estimate Hides Its Own Fragility

A best-estimate reserve answers exactly one question — what liability do we book if every assumption is right — and regulators have long since stopped accepting that as sufficient. The reserve is a function of a vector of assumptions θ=(qx,wx,i,)\theta = (q_x, w_x, i, \dots) covering mortality rates, lapse rates, and the valuation interest curve, and the quantity examiners and risk committees actually care about is not R(θ)R(\theta) but its gradient: how sharply the reserve responds when each component of θ\theta is pushed to the edge of its credible range. Two blocks can carry an identical best-estimate reserve while one is nearly flat under a mortality shock and the other doubles its required capital; only a revaluation exposes the difference.

The naive way to estimate that response is a first-order approximation — a duration or a delta — that assumes the reserve moves linearly with the assumption. For an interest shock on a block of long-duration whole-life liabilities that assumption is badly wrong, because the present value of future benefits is convex in the discount rate and the linear term understates the loss on a downward shock. The honest method is bump-and-revalue: perturb one assumption, re-run the full cash-flow projection, and read off the actual reserve, capturing convexity and the second-order interaction between, say, a mortality deterioration and a lapse-supported guarantee. The cost of honesty is compute. A block that takes minutes to value once must now be valued dozens of times, once per shock in the grid, and if that grid is built as a Python for loop calling the valuation engine repeatedly the sensitivity run can outlast the reporting window it was meant to inform. The engineering problem, then, is to run an entire sensitivity grid in a single vectorized pass so the cost of a hundred shocks approaches the cost of one.

Architecture of the Sensitivity Grid

The grid is a three-stage transformation. First, a base assumption set is captured as the anchor — the reserve and capital the filed valuation already produced. Second, a bump specification is expanded into a dense grid: every shock is a named, signed perturbation applied to exactly one assumption axis (or a small combination, for a joint stress). Third — and this is where the design earns its keep — the base cash-flow projection is broadcast against the full grid of bumps so that all shocked revaluations happen in one NumPy pass, and the results collapse into a tidy sensitivity table of reserve deltas indexed by shock name. The diagram above traces that fan-out and collapse.

Two design decisions make the pattern hold up under audit. The bump specification is data, not code: each shock is a row in a table with a name, a target assumption, a shock type (relative or absolute), and a magnitude, so the grid that ran can be serialized into the filing package verbatim. And the revaluation is deterministic — the same base seed and the same bump grid must reproduce the same table bit-for-bit — which means the sensitivity run inherits the reproducibility discipline of the parent Actuarial Model Ingestion & Testing Workflows pipeline rather than inventing its own. The heavy lifting of turning the loop into a broadcast is the subject of the companion walkthrough on Vectorized Sensitivity Analysis for Reserve Assumptions, which builds the shock cube one array axis at a time.

Prerequisites

Before assembling the grid, the following should be in place:

  • Python 3.11+ with numpy and pandas for the vectorized projection and the tidy result table, and optionally scipy.stats if shocks are expressed as percentiles of an assumption’s own distribution.
  • A vectorized base projection. The revaluation kernel must already accept assumptions as arrays and return a reserve without a Python loop over policies; the techniques in Optimizing Pandas DataFrames for Actuarial Cash Flow Projections are the foundation the grid multiplies across.
  • A pinned base assumption set — best-estimate mortality_table, lapse_rate, and discount_curve — carrying a version identifier so the anchor reserve is traceable.
  • A calibrated economic scenario set where interest shocks are needed as deterministic parallel or non-parallel curve moves, produced by Stochastic Scenario Generation Frameworks.
  • A regulatory frame. VM-20 expects the actuary to test the sensitivity of the modeled reserve to material assumptions and to document margins; ORSA and the NAIC’s own stress-testing guidance expect deterministic adverse scenarios run against capital; and downstream, the deltas this run emits feed the drift monitoring in Dynamic Threshold Tuning for Assumption Drift.

Core Implementation

The pattern separates a shock catalog (data) from a single vectorized revaluation (compute) and a collapse into a labelled table. Each shock names one assumption axis, a mode, and a magnitude; the grid builder stacks the shocked assumption vectors; the kernel revalues all of them against the base cash flows in one pass; and the result is a DataFrame of reserve and capital deltas keyed on shock name.

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd


@dataclass(frozen=True)
class Shock:
    """One named perturbation of a single assumption axis."""
    name: str
    axis: str                 # "mortality" | "lapse" | "interest"
    mode: str                 # "relative" | "absolute"
    magnitude: float          # +0.15 relative, or -0.01 absolute (bp as decimal)


BASE = {
    "mortality": 1.00,        # multiplier on the base mortality_table
    "lapse": 1.00,            # multiplier on the base lapse_rate vector
    "interest": 0.00,         # additive parallel shift on the discount_curve
}

CATALOG = [
    Shock("base", "mortality", "relative", 0.00),
    Shock("mort_up_15", "mortality", "relative", 0.15),
    Shock("mort_down_15", "mortality", "relative", -0.15),
    Shock("lapse_up_25", "lapse", "relative", 0.25),
    Shock("lapse_down_25", "lapse", "relative", -0.25),
    Shock("rates_down_100bp", "interest", "absolute", -0.01),
    Shock("rates_up_100bp", "interest", "absolute", 0.01),
]


def build_bump_grid(catalog: list[Shock]) -> tuple[list[str], np.ndarray]:
    """Expand the catalog into an (n_shocks, 3) matrix of shocked assumption factors."""
    axes = ("mortality", "lapse", "interest")
    grid = np.tile([BASE[a] for a in axes], (len(catalog), 1)).astype(np.float64)
    for row, shock in enumerate(catalog):
        col = axes.index(shock.axis)
        if shock.mode == "relative":
            grid[row, col] = BASE[shock.axis] * (1.0 + shock.magnitude)
        else:  # absolute shift (interest is additive)
            grid[row, col] = BASE[shock.axis] + shock.magnitude
    return [s.name for s in catalog], grid


def revalue_grid(
    base_qx: np.ndarray,        # (n_policies, n_years) base mortality rates
    base_wx: np.ndarray,        # (n_policies, n_years) base lapse rates
    base_spot: np.ndarray,      # (n_years,) base spot discount rates
    benefit: np.ndarray,        # (n_policies, n_years) benefit cash flow if decrement
    grid: np.ndarray,           # (n_shocks, 3) shocked factors
) -> np.ndarray:
    """Revalue every shock against the base cash flows in a single vectorized pass."""
    m = grid[:, 0][:, None, None]        # mortality multiplier per shock
    l = grid[:, 1][:, None, None]        # lapse multiplier per shock
    di = grid[:, 2][:, None]             # additive rate shift per shock

    qx = np.clip(base_qx[None] * m, 0.0, 1.0)          # (S, P, Y)
    wx = np.clip(base_wx[None] * l, 0.0, 1.0)          # (S, P, Y)
    inforce = np.cumprod(1.0 - qx - wx, axis=2)        # survival to each year
    inforce = np.concatenate(
        [np.ones_like(inforce[:, :, :1]), inforce[:, :, :-1]], axis=2
    )
    years = np.arange(base_spot.size)
    disc = (1.0 + (base_spot[None] + di)) ** -(years + 1)   # (S, Y)
    pv = (inforce * qx * benefit[None] * disc[:, None, :]).sum(axis=(1, 2))
    return pv                                            # (S,) reserve per shock


def sensitivity_table(names: list[str], reserves: np.ndarray) -> pd.DataFrame:
    """Collapse the shocked reserves into deltas against the base run."""
    base_reserve = reserves[names.index("base")]
    return pd.DataFrame(
        {
            "shock": names,
            "reserve": reserves,
            "delta": reserves - base_reserve,
            "pct_change": (reserves - base_reserve) / base_reserve,
        }
    ).set_index("shock")

The single call to revalue_grid is the whole point: the leading shock axis (S) is broadcast across the policy (P) and year (Y) axes, so a grid of seven shocks costs one array pass, not seven engine invocations. The base row is carried inside the grid so the delta is computed against a reserve produced by the identical code path — never against a separately cached number that might have drifted.

Configuration and Tuning

The two levers are the shock magnitudes and the shape of the shock cube. Magnitudes should come from a documented policy — a fixed regulatory set (for example a mandated one-percent parallel rate move), an experience-derived set (one standard error of the mortality experience study), or a percentile of the assumption’s own fitted distribution — not from a round number chosen for tidiness. Encoding them in the catalog keeps the choice auditable.

def catalog_from_policy(mort_se: float, lapse_se: float, rate_bp: float) -> list[Shock]:
    """Derive shock magnitudes from documented statistics rather than round numbers."""
    return [
        Shock("base", "mortality", "relative", 0.00),
        # One and two standard errors of the mortality experience study.
        Shock("mort_1se", "mortality", "relative", mort_se),
        Shock("mort_2se", "mortality", "relative", 2.0 * mort_se),
        Shock("lapse_1se", "lapse", "relative", lapse_se),
        # Prescribed deterministic rate stress, expressed in absolute decimal.
        Shock("rates_down", "interest", "absolute", -rate_bp / 10_000.0),
        Shock("rates_up", "interest", "absolute", rate_bp / 10_000.0),
    ]

The shape lever is memory. The shock cube is n_shocks × n_policies × n_years floats; a hundred shocks against a million policies over forty years in float64 is a cube no container will hold. The practical tuning is to keep the shock axis modest (dozens, not hundreds) and to stream the policy axis in blocks, revaluing each block against the full grid and summing the present values — the reserve is additive across policies, so block-summing is exact. Choose the block size the same way the batch executor does: peak memory is roughly n_shocks × block_rows × n_years × 8 bytes, so size block_rows to sit under a fraction of available RAM. Keep the magnitudes and the block size as recorded configuration so a dress-rehearsal run and the filed run reconcile exactly.

Step-by-Step

  1. Anchor the base. Capture the filed best-estimate reserve and capital as the base row of the catalog, produced by the same kernel the shocks will use.
  2. Author the shock catalog. Express each shock as a named row — axis, mode, magnitude — sourced from a documented policy, so the grid that ran is serializable into the filing.
  3. Expand the bump grid. Turn the catalog into an (n_shocks, 3) matrix of shocked assumption factors, applying relative multipliers and absolute rate shifts on the correct axes.
  4. Broadcast the revaluation. Add a leading shock axis to the base cash flows and revalue the whole grid in one NumPy pass, clipping decrement rates into [0, 1] so an aggressive bump cannot produce a negative survival probability.
  5. Collapse to deltas. Subtract the base reserve from every shocked reserve to build the sensitivity table of absolute and percentage changes, indexed by shock name.
  6. Rank and flag. Sort the table by absolute delta to surface the assumptions the block is most exposed to, and flag any shock whose percentage move exceeds the risk appetite recorded for the product.
  7. Record for the filing. Serialize the catalog, the base assumption version, the seed, and the resulting table together so the sensitivity exhibit is reproducible on demand.

Validation and Testing

A sensitivity run is only trustworthy if three properties hold: the base row must reproduce the independently filed reserve, the grid revaluation must equal a one-shock-at-a-time loop, and the sign of each delta must match actuarial intuition. Test all three.

import numpy as np
import pandas as pd
import pytest


def test_base_row_reproduces_filed_reserve(sample_block, filed_reserve):
    names, grid = build_bump_grid(CATALOG)
    reserves = revalue_grid(*sample_block, grid)
    table = sensitivity_table(names, reserves)
    assert table.loc["base", "reserve"] == pytest.approx(filed_reserve, rel=1e-10)


def test_grid_matches_serial_loop(sample_block):
    """Vectorized revaluation must equal shocking one axis at a time."""
    names, grid = build_bump_grid(CATALOG)
    vec = revalue_grid(*sample_block, grid)
    serial = np.array([revalue_grid(*sample_block, grid[i:i + 1])[0]
                       for i in range(grid.shape[0])])
    np.testing.assert_allclose(vec, serial, rtol=1e-12)


def test_rate_down_raises_reserve(sample_block):
    """A downward interest shock must increase a long-duration reserve."""
    names, grid = build_bump_grid(CATALOG)
    table = sensitivity_table(names, revalue_grid(*sample_block, grid))
    assert table.loc["rates_down_100bp", "delta"] > 0

Beyond unit tests, reconcile the vectorized deltas against a first-order duration and convexity estimate: the linear term should predict the small shocks well and diverge on the large ones, and a large gap on a small shock signals a bug in the broadcast rather than genuine convexity. The stability of these deltas across successive valuations is then monitored with the drift thresholds in Dynamic Threshold Tuning for Assumption Drift, which treats a sudden change in a block’s sensitivity profile as a signal worth investigating.

Failure Modes and Gotchas

  • Trusting the linear approximation. A duration-only sensitivity understates the loss from a downward rate shock on convex liabilities. Bump-and-revalue captures the convexity; do not substitute a delta for the full revaluation on material assumptions.
  • The base drifting from the shocks. Computing deltas against a cached base reserve that was produced by a different code path or assumption version silently corrupts every number in the table. Always carry the base as a row in the same grid.
  • Decrement rates escaping [0, 1]. A large relative mortality or lapse bump can push a rate above one, producing a negative survival probability and a nonsense reserve. Clip after bumping, and log any shock that required clipping because it may be outside the credible range the exhibit implies.
  • Silent assumption interactions. Shocking mortality and lapse independently and adding the deltas is not the same as shocking them jointly when the product has a lapse-supported guarantee. When interactions are material, add explicit joint-shock rows rather than inferring the combined effect by superposition.
  • The shock cube overrunning memory. A dense n_shocks × n_policies × n_years cube is easy to overflow. Stream the policy axis in blocks and sum present values, and keep the shock count modest.
  • Unrecorded shock magnitudes. A table whose magnitudes live in a notebook cell cannot be reproduced. Serialize the catalog with the results so an examiner can re-run the exact grid.

Frequently Asked Questions

Why revalue the whole model instead of using a duration or delta?

Because reserves are non-linear in their assumptions. A duration is a first-order approximation that is accurate only for infinitesimal moves; for the sizeable shocks a stress test applies, convexity and assumption interactions matter, and only a full revaluation captures them. The vectorized grid makes the full revaluation cheap enough that there is no reason to settle for the approximation.

What shocks does VM-20 expect a sensitivity analysis to cover?

VM-20 expects the actuary to test the modeled reserve’s sensitivity to the assumptions that materially affect it — mortality, lapse and other policyholder behavior, and the economic scenarios — and to support the margins held for each. The exact magnitudes are a matter of actuarial judgment documented in the report, which is why encoding them as an auditable catalog matters.

How is stress testing different from sensitivity analysis?

Sensitivity analysis isolates the effect of moving one assumption at a time to understand the model’s local behavior. Stress testing applies severe, often joint, adverse scenarios to test capital resilience under conditions like a pandemic-level mortality shock combined with a rate collapse. The same revaluation engine serves both; stress tests are simply larger, correlated rows in the shock catalog.

Can the sensitivity grid run without materializing the whole shock cube?

Yes. Because the reserve is additive across policies, you can stream the in-force block in row chunks, revalue each chunk against the full shock grid, and sum the present values. This keeps peak memory proportional to the chunk size times the number of shocks rather than the whole portfolio, so a large grid stays within a fixed memory ceiling.

Up one level: Actuarial Model Ingestion & Testing Workflows