Skip to content

Projections: Time-Dependent Calculations

Actuarial models are about projecting values forward through time. Every life product — term, whole life, universal life — steps through monthly or annual periods, computing cashflows, decrements, and accumulated values at each step. This page covers how Gaspatchio expresses those time-dependent calculations on list columns, where each list represents one policy's projection over time.

Two .projection accessors

This page covers the column-level accessor — af.qx.projection.cumulative_survival(), af.growth.projection.accumulate(), and so on — for list-column time-series operations. The frame-level af.projection accessor (af.projection.set(...), af.projection.rollforward(...), af.projection.year_fractions()) is covered in Schedules and Rollforward.

The snippets on this page all operate on the same small portfolio: two universal-life policies, each projected over a 24-month grid. Premiums, mortality rates, interest rates, and a starting account value are carried per policy, with the time axis held as a list in each cell.

from gaspatchio_core import ActuarialFrame

N = 24  # 24 monthly projection periods

af = ActuarialFrame({
    "policy_id": ["P001", "P002"],
    "issue_age": [45, 52],
    "premium": [[1000.0] * N, [1500.0] * N],
    "interest_rate": [[0.04 / 12] * N, [0.04 / 12] * N],
    "qx": [[0.0008] * N, [0.0012] * N],           # monthly mortality rate
    "mort_rate_mth": [[0.0008] * N, [0.0012] * N],
    "death_benefit": [[100_000.0] * N, [150_000.0] * N],
    "av_init": [5_000.0, 8_000.0],                # starting account value (scalar)
    "av_pp_init": [5_000.0, 8_000.0],
    "maint_fee_rate": [0.012, 0.012],             # annual maintenance fee
    "inv_return_mth": [[0.003] * N, [0.003] * N], # monthly investment return
    "premiums": [[1000.0] * N, [1500.0] * N],
    "charges": [[50.0] * N, [75.0] * N],          # monthly fixed charges
})

print(af.collect().select(["policy_id", "issue_age", "av_init"]))

Why Not Recursion?

Many actuarial modelling frameworks use recursive functions for time-dependent calculations. A typical recursive style uses timing strings such as "BEF_PREM" to mark sub-period checkpoints in an evaluation graph:

# Recursive style — function calls itself
def av_pp_at(t, timing):
    if timing == "BEF_PREM":
        if t == 0:
            return av_pp_init()
        else:
            return av_pp_at(t-1, "BEF_INV") + inv_income_pp(t-1)

This is mathematically elegant. But recursive functions are confusing to debug — every change means stepping through layers of nested calls and mentally unwinding the recursion to see what's happening.

The question that trips people up

"Can you build a Universal Life model where Cost of Insurance depends on Account Value, which depends on Credited Interest, which depends on Account Value from last month?"

This sounds like a circular reference. Excel warns you about these. The FAST Standard says "never release a model with purposeful circularity."

But it's not circular at all. Account Value at time t depends on Account Value at time t-1 — a chain through time, not a circle.

For most time-dependent calculations — survival probabilities, discount factors, simple account accumulation — the chain can be expressed as a closed-form cumulative product or sum. Consider survival probability:

ₜpₓ = p₀ × p₁ × p₂ × ... × pₜ₋₁

That's a cumulative product. No recursion needed. Gaspatchio lets you write time-dependent calculations this way.

There is a genuine escalation: when a charge at time t depends on the current account value (COI on net amount at risk, IUL floor/cap), the cumulative form breaks down. For these products, Rollforward Methods handles within-period state-dependent calculations. The arc on this page leads up to that handover.

The Insight

What looks like recursion in actuarial models is usually one of three things:

Recursive form What it really is Gaspatchio equivalent
f(t) = f(t-1) × factor Cumulative product cum_prod()
f(t) = f(t-1) + amount Cumulative sum cum_sum()
f(t-1) Reference prior period projection.previous_period()

No loops. No circular references. Just express the math directly.

Time-Shifting

These methods shift values forward or backward along the projection timeline. They are 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 — 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 the next anniversary.

at_period(relative_period, fill_value=0.0)

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)

Cumulative Operations

cum_prod() — Cumulative Product

Multiplicative accumulation — survival probabilities, compound growth, discount factors.

from gaspatchio_core import ActuarialFrame

surv = ActuarialFrame({
    "policy_id": ["P001"],
    "annual_survival": [[0.99, 0.98, 0.97, 0.96, 0.95]],
})

# Cumulative survival probability: ₜpₓ = p₀ × p₁ × ... × pₜ
surv.cum_survival = surv.annual_survival.cum_prod()

print(surv.collect())
# cum_survival: [0.99, 0.9702, 0.9411, 0.9035, 0.8583]

cum_sum() — Cumulative Sum

Additive accumulation — cumulative premiums, cumulative claims, account balances with deposits.

paid = ActuarialFrame({
    "policy_id": ["P001"],
    "monthly_premium": [[100, 100, 100, 150, 150]],
})

paid.total_paid = paid.monthly_premium.cum_sum()
# total_paid: [100, 200, 300, 450, 600]

cumulative_survival() — Mortality to Survival

The most common cumulative product in actuarial work: converting 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()
# survival starts at 1.0, then compounds (1 - qx) each period:
# 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).

Period Overrides

These methods modify values at specific time steps within a projection. Essential for modelling 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})

Worked Example: Universal Life Account Value

This is the "circular reference" problem traced end-to-end. The dependency:

COI[t] depends on → Account Value[t]
Account Value[t] depends on → Credited Interest[t-1]
Credited Interest[t-1] depends on → Account Value[t-1]  ← PREVIOUS period

A chain through time, not a circle.

When the per-period growth and cashflows can be pre-computed as rates, the cumulative form expresses this directly:

# Growth factor per period: after fees, after investment return
af.growth_factor = (1.0 - af.maint_fee_rate / 12.0) * (1.0 + af.inv_return_mth)

# Cumulative growth from t=0 to each t
af.cumulative_growth = af.growth_factor.cum_prod()

# Account value = initial × cumulative growth (shifted by one period)
af.av_pp_bef_prem = af.av_pp_init * af.cumulative_growth.projection.previous_period(fill_value=1.0)

# Survival probability — use the helper
af.tpx = af.mort_rate_mth.projection.cumulative_survival()

The whole accumulation reads in three lines instead of a recursive callback graph.

When charges depend on the current AV

The example above works when fees and returns can be pre-computed as rates. For products where charges depend on the current AV — like COI on net amount at risk — use Rollforward Methods instead. The rollforward handles within-period state-dependent charges that can't be factored into a cumulative product.

Escalation: accumulate() — the Linear Recurrence

The cumulative operations above cover pure multiplicative or additive accumulation. The standard AV accumulation mixes both:

AV[t] = (AV[t-1] + Premium[t] − Charges[t]) × (1 + i[t])

That is a linear recurrenceState[t] = State[t-1] × M[t] + A[t]. accumulate() expresses this in a single call. You pre-compute the multiplicative and additive components in Python so the business logic stays readable; the per-policy time walk and the parallelism across policies are handled for you.

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]],
}
demo = ActuarialFrame(data)

demo.av = demo.growth.projection.accumulate(
    initial="av_init",
    multiply="growth",
    add="net_flow",
)

print(demo.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 AV formula

The standard AV accumulation AV[t] = (AV[t-1] + Premium[t] − Charges[t]) × (1 + i[t]) fits State[t] = State[t-1] × M[t] + A[t] after a small algebra step:

  • 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.interest_rate.projection.accumulate(
    initial=af.av_init,
    multiply=growth,
    add=net_flow_grown,
)

The business logic is what you see — three column expressions and one accumulate(...) call.

Performance

A single accumulate() call carries the per-policy time axis, and the portfolio runs in parallel across all policies. Headline runtimes on a typical workstation:

Scale Wall time
1,000 policies × 240 months under 1 millisecond
10,000 policies × 360 months a few milliseconds
100,000 policies × 360 months tens of milliseconds

That covers the AV accumulation step itself; full models layer further calculations on top. The point is that the time-step loop is no longer the bottleneck — building inputs, running scenarios, and writing outputs dominate the wall time.

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.

When accumulate() Isn't Enough

accumulate() handles State[t] = State[t-1] × M + A — the case where every input can be pre-computed before the time walk starts. For products with state-dependent charges — where the charge at time t depends on the accumulated value at t — see Rollforward Methods. Common examples: COI on net amount at risk, tiered management charges, IUL crediting with floor and cap, and multi-state products like VA + GMDB.

The rollforward declares within-period steps as a chain that reads like the product spec. It sits on an explicit projection grid (af.projection.set(...)), and the chain is compiled before the per-period values are read back onto the frame:

from datetime import date
from gaspatchio_core import (
    ActuarialFrame,
    RollforwardCollector,
    Schedule,
    compile_rollforward,
)

ul = ActuarialFrame({
    "policy_id": ["P001", "P002"],
    "av_init": [5_000.0, 8_000.0],
    "premium": [[1000.0] * N, [1500.0] * N],
    "coi_rate": [[0.001] * N, [0.0015] * N],
    "sum_assured": [[100_000.0] * N, [150_000.0] * N],
    "admin_rate": [[0.012 / 12] * N, [0.012 / 12] * N],
    "interest_rate": [[0.04 / 12] * N, [0.04 / 12] * N],
})
sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=N, frequency="1M"
)
ul = ul.projection.set(schedule=sched)

b = ul.projection.rollforward(states={"av": ul["av_init"]})
(
    b["av"]
    .add(ul["premium"], label="Premium")
    .deduct_nar(ul["coi_rate"], death_benefit=ul["sum_assured"], label="COI")
    .charge(ul["admin_rate"], label="Admin")
    .grow(ul["interest_rate"], label="Interest")
    .floor(value=0.0)
)

compiled = compile_rollforward(b)
ul.av = RollforwardCollector(compiled).expr_for("av")
print(ul.collect().select(["policy_id", "av"]))

The Other Direction: prospective_value()

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. The timing parameter controls whether cashflows fall at the end or beginning of each period.

Why This Works (For the Curious)

The recursive form and cumulative form are algebraically identical. The proof for a simple accumulation:

Recursive definition:

AV[0] = AV_init
AV[t] = AV[t-1] × factor[t-1]

Expanding the recursion:

AV[1] = AV[0] × factor[0]
AV[2] = AV[1] × factor[1] = AV[0] × factor[0] × factor[1]
...
AV[t] = AV[0] × factor[0] × factor[1] × ... × factor[t-1]

Closed form:

AV[t] = AV_init × ∏(factor[i] for i in 0..t-1)

That product is exactly what cum_prod() computes. The previous_period() shift handles the "up to t-1" part.

The same pattern applies to survival probabilities:

ₜpₓ = ₜ₋₁pₓ × pₓ₊ₜ₋₁           (recursive)
ₜpₓ = ∏(pₓ₊ᵢ for i in 0..t-1)  (cumulative product)

Actuaries have used the closed form in theory for centuries. Gaspatchio just lets you write it that way in code.