Ensuring Seed Reproducibility in Monte Carlo Reserves

A CTE 70 stochastic reserve is only defensible if it replays bit-for-bit: hand an examiner the recorded seed and the same 10,000 scenarios must regenerate to the last digit. This guide uses NumPy’s SeedSequence.spawn to give every reserve group its own independent, reproducible generator, so a stochastic reserve is exactly reconstructable from a single sealed integer. It sits within the Stochastic Scenario Generation Frameworks discipline and builds directly on the draws produced in Generating Monte Carlo Scenarios with NumPy and SciPy.

The Problem

VM-20 Section 7 requires the stochastic reserve to be computed as the CTE 70 of scenario reserves, and any principle-based reserve must be reproducible — the appointed actuary has to be able to regenerate the exact scenario set that produced a filed number, potentially years later during an examination. The naive implementation reaches for np.random.seed(42) once at the top of the run and draws from the global generator throughout. It looks deterministic and is anything but: the order in which reserve groups happen to be processed, a stray library call that consumes a random number, or a switch from single-threaded to parallel execution all shift the global stream and silently change the reserve. Reproducibility built on a single shared, mutable generator is reproducibility that survives only until the code around it changes.

A Minimal Working Example

The pattern is to derive one root SeedSequence from a recorded entropy integer, then spawn a statistically independent child for each reserve group. Each group draws from its own Generator, so results are invariant to processing order and safe to parallelize, and the whole run replays from the single root_entropy value:

from __future__ import annotations

import numpy as np

N_SCENARIOS = 10_000
PROJECTION_MONTHS = 360


def group_generators(
    root_entropy: int, cohort_ids: list[str]
) -> dict[str, np.random.Generator]:
    """One independent, reproducible generator per reserve group."""
    root = np.random.SeedSequence(root_entropy)
    children = root.spawn(len(cohort_ids))
    return {
        cohort_id: np.random.default_rng(child)
        for cohort_id, child in zip(sorted(cohort_ids), children)
    }


def scenario_reserves(rng: np.random.Generator) -> np.ndarray:
    """Discounted reserve under each economic scenario for one group."""
    short_rate = 0.03 + 0.01 * rng.standard_normal((N_SCENARIOS, PROJECTION_MONTHS))
    discount = np.cumprod(1.0 / (1.0 + short_rate / 12.0), axis=1)
    monthly_outgo = 1_000.0 * rng.lognormal(0.0, 0.15, (N_SCENARIOS, PROJECTION_MONTHS))
    return (discount * monthly_outgo).sum(axis=1)


def cte(reserves: np.ndarray, level: float = 0.70) -> float:
    """Conditional Tail Expectation: mean of the worst (1 - level) tail."""
    ordered = np.sort(reserves)
    cutoff = int(np.ceil(level * ordered.size))
    return float(ordered[cutoff:].mean())


def stochastic_reserve(root_entropy: int, cohort_ids: list[str]) -> dict[str, object]:
    generators = group_generators(root_entropy, cohort_ids)
    group_cte = {
        cohort_id: cte(scenario_reserves(rng))
        for cohort_id, rng in generators.items()
    }
    return {
        "root_entropy": root_entropy,
        "cte70_by_group": group_cte,
        "aggregate_cte70": sum(group_cte.values()),
    }


if __name__ == "__main__":
    cohorts = ["ISSUE_2019", "ISSUE_2020", "ISSUE_2021"]
    first = stochastic_reserve(root_entropy=20241231, cohort_ids=cohorts)
    second = stochastic_reserve(root_entropy=20241231, cohort_ids=cohorts)
    assert first == second, "Reserve failed to replay bit-for-bit"
    print(first["aggregate_cte70"])
SeedSequence spawn tree for reproducible per-group Monte Carlo reserves A recorded entropy integer seeds a root SeedSequence that spawns one child per cohort. Each child seeds an independent generator drawing that cohort's scenarios, and the scenario reserves aggregate through CTE 70 into the stochastic reserve. Recorded entropy integer Root SeedSequence spawn(n groups) Generator · 2019 independent stream Generator · 2020 independent stream Generator · 2021 independent stream CTE 70 reserve replays bit-for-bit
One recorded integer spawns an independent generator per cohort; the CTE 70 aggregate replays exactly from that integer.

How It Works, Block by Block

SeedSequence is the reproducible root, and spawn is the branch. A single SeedSequence(root_entropy) is a high-quality entropy mixer; calling .spawn(n) derives n children whose streams are statistically independent by construction, without the correlation risk of hand-rolling per-group seeds like base + i. Because the children are a deterministic function of the root entropy, the entire tree of generators is reconstructable from one integer — the only value you must record.

Sorting the cohort ids fixes the spawn order. spawn returns children in call order, so the mapping from cohort to child is only stable if the cohort list is ordered deterministically. sorted(cohort_ids) guarantees ISSUE_2019 always receives the first child regardless of the order the dictionary or database happened to hand them over. Without this, the same root entropy could assign a different stream to a group between runs, breaking replay even though every seed is “pinned”.

Each group draws only from its own Generator. scenario_reserves receives an explicit rng and never touches global NumPy state, so the draws for ISSUE_2020 are wholly unaffected by whether ISSUE_2019 ran first, ran in another thread, or did not run at all. This independence is what makes the computation both order-invariant and safe to distribute across workers.

The CTE 70 is a pure function of the scenario reserves. cte sorts the per-scenario reserves and averages the worst 30% tail, which is the VM-20 Section 7 definition of the stochastic reserve. Because the reserves feeding it are reproducible, the CTE is reproducible; the equality assertion in __main__ is a live proof that two independent invocations from the same root_entropy collapse to identical output. The CTE at confidence level α\alpha is

CTEα=E ⁣[RRqα],\mathrm{CTE}_\alpha = \mathbb{E}\!\left[R \mid R \ge q_\alpha\right],

the expected reserve conditional on exceeding the α\alpha-quantile qαq_\alpha.

Edge Cases and Production Hardening

Legacy np.random.seed contaminates the global stream. The moment any module in the process calls the legacy np.random.seed(...) / np.random.normal(...) API, it shares one mutable global generator with everything else, and reproducibility depends on nobody, anywhere, drawing out of turn. Mixing the legacy functions with the Generator API in the same run is the most common cause of a reserve that replays on a developer’s laptop but not in production. The fix is total abstention from the global API: pass an explicit Generator everywhere and let a lint rule ban the legacy calls.

# Reproducibility-hostile — shared global state, order-dependent:
np.random.seed(42)
draws = np.random.standard_normal(N_SCENARIOS)  # who else drew before this?

# Reproducible — explicit, isolated generator threaded through:
rng = np.random.default_rng(child_sequence)
draws = rng.standard_normal(N_SCENARIOS)

Thread and process pools leak or duplicate seed state. If reserve groups are farmed out to a pool, forking a process that carries an already-seeded global generator makes every worker draw the same stream, silently collapsing the effective scenario count — the opposite of the diversification the stochastic reserve assumes. The SeedSequence.spawn tree avoids this cleanly: spawn one child per task in the parent, then hand each worker only its own child sequence to build a fresh default_rng inside the worker. Each task then holds an independent, reproducible stream and no generator object crosses a process boundary.

The seed must live in the audit payload, not the source code. A seed hard-coded in a script is lost the instant the script changes; a reserve is only reproducible if the exact root_entropy that produced the filed number is recorded alongside it. Persist the entropy, the NumPy version, the bit generator name (PCG64), the scenario count, and the CTE level into the sealed record that goes to the Actuarial Audit Trail Architecture, so an examiner reconstructs the run from the audit trail rather than from a source checkout that may have moved on.

Compliance Note

VM-20 Section 7 defines the stochastic reserve as the CTE 70 of aggregate scenario reserves and, together with the VM-31 PBR Actuarial Report, expects the appointed actuary to be able to reproduce the scenario set behind a filed figure on demand. A recorded root_entropy with SeedSequence.spawn satisfies that expectation concretely: the filed reserve is a deterministic function of one archived integer plus the pinned bit-generator metadata, so the regeneration an examiner asks for is a re-run, not a reconstruction from memory. This is also the reproducibility SR 11-7 has in mind when it treats a model’s outputs as reproducible only if its inputs — including its stochastic inputs — are controlled and documented. Recording the seed in the sealed payload turns “trust our number” into “here is the integer that regenerates it”, which is the standard the broader Actuarial Model Ingestion & Testing Workflows discipline holds every stochastic run to.

Frequently Asked Questions

Why is np.random.seed not enough for a reproducible reserve?

Calling np.random.seed pins a single global generator whose stream is shared by every draw in the process, so the reserve depends on the order groups run, on any stray library call that consumes a random number, and on whether execution is threaded. Any of those can change without touching the seed, which makes the pinning fragile rather than reproducible.

How does SeedSequence.spawn keep reserve groups independent?

SeedSequence.spawn derives child sequences that are statistically independent by construction, so each reserve group draws from its own generator with no shared mutable state. Because the children are a deterministic function of the root entropy, the whole set of generators regenerates from one recorded integer while remaining uncorrelated across groups.

What exactly do I need to record to replay a stochastic reserve?

Record the root entropy integer, the NumPy version, the bit generator name such as PCG64, the scenario count, and the CTE level in the sealed audit payload. Given those values the CTE 70 reserve regenerates bit-for-bit, so an examiner reconstructs the run from the audit trail rather than from a source checkout that may have changed.

Is the spawned-generator pattern safe to parallelize across workers?

Yes, and that is one of its main advantages. Spawn one child sequence per task in the parent process, then hand each worker only its own child to build a fresh generator, so no generator object crosses a process boundary and no two workers ever share or duplicate a stream.

Up a level: Stochastic Scenario Generation Frameworks · Actuarial Model Ingestion & Testing Workflows


Regulatory references: NAIC Valuation Manual VM-20 Section 7 (Stochastic Reserve) and VM-31; Federal Reserve SR 11-7 (Guidance on Model Risk Management).