Inspection and Governance¶
Seeing What Your Model Does¶
Before running a rollforward against 100,000 policies, you want to verify the step order matches the product spec. After running it, you want to confirm nothing has changed since last quarter.
.explain() — Formula Table¶
Print the step-by-step formula for any rollforward:
base_ul = (
af.projection.rollforward(initial=af.av_init)
.add(af.premium, "Premium")
.deduct_nar(af.coi_rate, death_benefit=af.sum_assured, label="COI")
.charge(af.admin_rate, "Admin")
.grow(af.interest_rate, "Interest")
.floor(0)
)
print(base_ul.explain())
Rollforward: initial=av_init, 5 steps
Step Operation Label Formula
---- --------------------- --------- -----------------------------------------
1 Add(premium) Premium av[t] = av[t] + premium[t]
2 DeductNAR(coi_rate) COI av[t] = av[t] - coi_rate[t] * max(0, sum_assured[t] - av[t])
3 Charge(admin_rate) Admin av[t] = av[t] * (1 - admin_rate[t])
4 Grow(interest_rate) Interest av[t] = av[t] * (1 + interest_rate[t])
5 Floor(0) Floor(0) av[t] = max(av[t], 0)
Use this to: - Verify step order against the product specification - Check that composed variants have the right steps in the right places - Document the model logic for peer review
.fingerprint() — Change Detection¶
Compute a SHA-256 hash of the model's structural form:
base_ul.fingerprint()
# "sha256:a1b2c3d4e5f6..."
Two rollforwards with the same step types in the same order produce the same fingerprint, regardless of column names or labels. This means:
- Renaming
coi_ratetomonthly_coidoesn't change the fingerprint - Changing the label from
"COI"to"Cost of Insurance"doesn't change the fingerprint - Adding, removing, or reordering a step does change the fingerprint
Use this for: - Model governance: Store the fingerprint alongside results. If the fingerprint changes between runs, the model structure changed. - Version control: Compare fingerprints across releases to detect structural drift. - Audit trail: Log the fingerprint in your run metadata to prove which model produced which results.
.canonical() — Structural Form¶
Get the machine-readable structural description:
base_ul.canonical()
{
"num_states": 1,
"steps": [
{"operation": "add"},
{"operation": "deduct_nar"},
{"operation": "charge"},
{"operation": "grow"},
{"operation": "floor", "value": 0.0},
],
"track_increments": False,
}
The canonical form includes only operation types and structural parameters (floor/cap values). It excludes column names and labels — these are environment-dependent aliases, not structural features of the model.
Common Mistakes¶
Using .charge() when you mean .subtract()¶
.charge(rate) multiplies: AV *= (1 - rate). It takes a rate (e.g., 0.01 for 1%).
.subtract(amount) subtracts a dollar amount (e.g., $15).
If your product spec says "$15 per month", use .subtract(). If it says "0.15% of AV", use .charge().
Forgetting .floor(0) on UL products¶
Without a floor, AV can go negative. Most UL contracts guarantee a non-negative account value. Always end with .floor(0) unless the product spec says otherwise.
Wrong step ordering¶
The steps execute in the order you declare them. If you credit interest before deducting COI, the policyholder earns interest on money that should have been charged. Match the order to the product's within-period calculation convention.
Check with .explain() if you're unsure.
Annual rates without monthly conversion¶
If your projection is monthly but your rates are annual, convert first:
af.monthly_rate = af.annual_rate / 12
Or for compound rates:
af.monthly_rate = (1 + af.annual_rate) ** (1 / 12) - 1