Projection Methods¶
Overview¶
Actuarial models are fundamentally about projecting values forward through time. Every life insurance product — from simple term life to universal life with dynamic charges — requires stepping through monthly or annual periods, computing cashflows, decrements, and accumulated values at each step.
Gaspatchio's projection accessor (.projection) provides methods for these time-series operations on list columns, where each list represents one policy's projection over time. The accessor follows a simple design philosophy:
- Complex cumulative operations — use projection methods
- Simple arithmetic — use operators (
*,+,-,/) - Terminal/aggregate values — use Polars (
.list.last(),.list.sum())
from gaspatchio_core import ActuarialFrame
data = {"qx": [[0.001, 0.0011, 0.0012], [0.002, 0.0022, 0.0024]]}
af = ActuarialFrame(data)
# Complex cumulative product — use projection method
af.survival_to_t = af.qx.projection.cumulative_survival()
# Simple multiplication — use operators
af.death_benefit = af.face_amount * af.survival_to_t * af.qx
# Terminal value — use Polars
af.maturity_benefit = af.face_amount.list.last()
Method Reference¶
Time-Shifting Methods¶
These methods shift values forward or backward along the projection timeline. They're the building blocks for any calculation that references "last month's value" or "next month's value."
previous_period(fill_value=0.0)¶
Returns the value from the previous time step. Equivalent to referencing the row above in a spreadsheet.
af.last_month_premium = af.premium.projection.previous_period(fill_value=0.0)
# [0.0, 1000, 1000, 1000, 1000] from [1000, 1000, 1000, 1000, 1000]
The first period has no predecessor, so fill_value controls what goes there (typically 0).
next_period(fill_value=0.0)¶
Returns the value from the next time step. Useful for forward-looking calculations like surrender value at next anniversary.
af.next_month_rate = af.interest_rate.projection.next_period(fill_value=0.0)
at_period(offset)¶
Arbitrary time offset — negative for past, positive for future. Generalises previous_period and next_period.
# Two periods back
af.rate_two_months_ago = af.interest_rate.projection.at_period(-2)
# Three periods forward
af.rate_in_three_months = af.interest_rate.projection.at_period(3)
Period Override Methods¶
These methods modify values at specific time steps within a projection. Essential for modeling contractual changes, premium holidays, and benefit adjustments.
with_period(period, value)¶
Override a single period's value. Supports negative indexing (e.g., -1 for the last period).
# Premium holiday at month 12
af.premium_adjusted = af.premium.projection.with_period(12, value=0)
# Maturity benefit at final period
af.benefit = af.death_benefit.projection.with_period(-1, value=10000)
with_periods(updates)¶
Override multiple periods at once. Takes a dictionary of {period: value} pairs.
# Premium holidays at months 6, 12, and 18
af.premium_adjusted = af.premium.projection.with_periods({6: 0, 12: 0, 18: 0})
Decrement Methods¶
cumulative_survival(rate_timing=None, start_at=1.0)¶
Converts period mortality rates (qx) into cumulative survival probabilities using tpx[t] = (1-qx[0]) * (1-qx[1]) * ... * (1-qx[t-1]).
af.survival = af.qx.projection.cumulative_survival()
# qx = [0.01, 0.02, 0.03] → survival = [1.0, 0.99, 0.9702]
The rate_timing parameter controls whether the rate at time t affects survival at time t (end of period) or time t+1 (beginning of period). The start_at parameter sets the initial survival probability (default 1.0).
Present Value Methods¶
prospective_value(discount_rate=None, discount_factor=None, timing="end_of_period")¶
Calculates the present value of future cashflows at each projection period using backward recursion. Essential for reserve calculations, embedded value, and profit testing.
# PV of death benefits at 5% discount rate
af.pv_death = af.death_benefit.projection.prospective_value(discount_rate=0.05)
Specify either discount_rate (constant or per-period) or discount_factor (pre-computed v^t values), but not both.
Accumulation Methods¶
accumulate(initial, multiply, add)¶
Accumulates values using a linear recurrence — the core primitive for account value rollforwards and any projection where the state at time t depends on the state at t-1.
This is the method that bridges the gap between Gaspatchio's vectorised approach and the sequential time-stepping that every other actuarial tool uses. It computes:
State[t] = State[t-1] × multiply[t] + add[t]
The actuary pre-computes the multiplicative and additive components in Python (keeping business logic readable), while the Rust kernel handles the tight sequential loop per policy. Polars parallelises across policies automatically.
Deep Dive: accumulate() and the Rollforward Pattern¶
The Problem¶
In a spreadsheet, an account value rollforward is trivial — each row references the row above:
B3 = (B2 + C3 - D3) * (1 + E3)
Where B is account value, C is premiums, D is charges, E is the interest rate. The value at each step depends on the previous step.
Most actuarial tools handle this with explicit loops (JuliaActuary), recursive memoized functions (lifelib/modelx), or engine-managed formula evaluation. Gaspatchio's vectorised approach — where entire projection vectors are computed in one expression — works beautifully for independent calculations but breaks down when there's feedback between time steps.
accumulate() solves this by expressing the rollforward as a linear recurrence that a Rust kernel can execute at ~5 ns per operation per policy, while Polars parallelises across policies.
Basic Example¶
from gaspatchio_core import ActuarialFrame
# Two policies: different starting AV, same growth and cashflows
data = {
"av_init": [1000.0, 2000.0],
"growth": [[1.01, 1.01, 1.01], [1.02, 1.02, 1.02]],
"net_flow": [[50.0, 50.0, 50.0], [100.0, 100.0, 100.0]],
}
af = ActuarialFrame(data)
af.av = af.growth.projection.accumulate(
initial="av_init",
multiply="growth",
add="net_flow",
)
print(af.collect()["av"].to_list())
# [[1060.0, 1120.6, 1181.806], [2140.0, 2282.8, 2428.456]]
Each time step: AV[t] = AV[t-1] × growth[t] + net_flow[t].
Rearranging the Formula¶
The standard AV rollforward is:
AV[t] = (AV[t-1] + Premium[t] - Charges[t]) × (1 + i[t])
To fit State[t] = State[t-1] × M[t] + A[t], rearrange:
- M[t] =
(1 + i[t])— the growth factor - A[t] =
(Premium[t] - Charges[t]) × (1 + i[t])— net cashflow, grown by interest
growth = 1 + af.interest_rate
net_flow_grown = (af.premiums - af.charges) * growth
af.av = af.growth.projection.accumulate(
initial=af.av_init,
multiply=growth,
add=net_flow_grown,
)
The business logic is visible in Python. The performance comes from Rust.
Performance¶
accumulate() runs a tight Rust loop per policy while Polars distributes policies across CPU cores. Benchmarked on Apple Silicon:
| Scale | Single-threaded | Per-operation | With Polars (8 cores) |
|---|---|---|---|
| 1K policies × 240 months | 0.8 ms | 3.4 ns | < 1 ms |
| 10K policies × 360 months | 18 ms | 5.1 ns | ~3 ms |
| 100K policies × 360 months | 300 ms | 8 ns | ~40 ms |
For comparison, the same calculation in Python (lifelib/modelx pattern) takes ~83 seconds for 100K × 360. JuliaActuary achieves similar per-operation speed (~6.4 ns) but requires writing Julia.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
initial |
str, Expr, ExpressionProxy, ColumnProxy | Initial state per policy (scalar column). Broadcasts when length is 1. |
multiply |
str, Expr, ExpressionProxy, ColumnProxy | Multiplicative factor per time step (list column). |
add |
str, Expr, ExpressionProxy, ColumnProxy | Additive flow per time step (list column). Inner list lengths must match multiply. |
All parameters accept column names as strings, Polars expressions, or gaspatchio proxy objects.