Skip to content

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.