Calculations: Accumulation and Temporal Logic¶
Why Not Recursion?¶
Many actuarial modeling frameworks use recursive functions for time-dependent calculations:
# 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) # calls itself
This is mathematically elegant. But let's be honest: recursive functions are confusing. They require you to trace through call stacks, understand memoization, and mentally unwind the recursion to see what's actually happening. When something goes wrong, debugging means stepping through layers of nested calls.
One of Gaspatchio's core principles is to meet you where you are - ergonomics that read like spreadsheets or pure formulas, not computer science abstractions. We believe there's a simpler way.
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 here's the thing: it's not circular at all. Account Value at time t depends on Account Value at time t-1. That's not a circle - it's a chain through time. Consider survival probability:
ₜpₓ = p₀ × p₁ × p₂ × ... × pₜ₋₁
That's a cumulative product - multiply all the factors together. No recursion needed. Gaspatchio lets you write time-dependent calculations this way.
The Insight¶
What looks like recursion in actuarial models is really two operations:
| lifelib pattern | 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 | previous_period() |
No loops. No circular references. No iteration blocks. Just express the math directly.
The Three Building Blocks¶
Gaspatchio provides three operations that replace recursive patterns. Each maps directly to a common actuarial calculation.
1. cum_prod() - Cumulative Product¶
When to use: Any multiplicative accumulation - survival probabilities, compound growth, discount factors.
The pattern:
result[0] = values[0]
result[1] = values[0] * values[1]
result[2] = values[0] * values[1] * values[2]
...
Example - Survival Probabilities:
from gaspatchio_core import ActuarialFrame
af = ActuarialFrame({
"policy_id": ["P001"],
"annual_survival": [[0.99, 0.98, 0.97, 0.96, 0.95]], # 5-year survival rates
})
# Cumulative survival probability: ₜpₓ = p₀ × p₁ × ... × pₜ
af.cum_survival = af.annual_survival.cum_prod()
print(af.collect())
# cum_survival: [0.99, 0.9702, 0.9411, 0.9035, 0.8583]
# ↑ ↑ ↑
# 0.99 0.99×0.98 0.99×0.98×0.97
2. cum_sum() - Cumulative Sum¶
When to use: Additive accumulation - cumulative premiums, cumulative claims, account balances with deposits.
Example - Cumulative Premiums:
af = ActuarialFrame({
"policy_id": ["P001"],
"monthly_premium": [[100, 100, 100, 150, 150]], # premium increases at month 4
})
# Cumulative premiums paid
af.total_paid = af.monthly_premium.cum_sum()
print(af.collect())
# total_paid: [100, 200, 300, 450, 600]
3. previous_period() - Temporal Shift¶
When to use: When a value depends on the prior period's result - opening balances, lagged values, recursive relationships.
Example - Period-over-Period Change:
af = ActuarialFrame({
"policy_id": ["P001"],
"account_value": [[1000, 1050, 1102, 1157, 1215]],
})
# Get last period's value (for change calculations)
af.prior_av = af.account_value.projection.previous_period(fill_value=0.0)
af.av_change = af.account_value - af.prior_av
print(af.collect())
# prior_av: [0, 1000, 1050, 1102, 1157] ← shifted right, filled with 0
# av_change: [1000, 50, 52, 55, 58] ← period-over-period change
Helper Methods for Common Patterns¶
For the most common actuarial patterns, Gaspatchio provides purpose-built helper methods on the .projection accessor. These wrap the building blocks with sensible defaults and handle edge cases.
cumulative_survival() - Mortality to Survival¶
The most common cumulative product in actuarial work: converting period mortality rates (qx) to cumulative survival probabilities (tpx).
# Using building blocks:
af.survival_factor = 1 - af.mort_rate_mth
af.cum_survival = af.survival_factor.cum_prod()
af.tpx = af.cum_survival.projection.previous_period(fill_value=1.0)
# Using the helper:
af.tpx = af.mort_rate_mth.projection.cumulative_survival()
The helper also handles timing conventions (beginning vs end of period) that are easy to get wrong. See the Projection API docs for details.
Other Projection Helpers¶
| Method | What it does |
|---|---|
previous_period(fill_value) |
Shift values back one period (opening balance) |
next_period(fill_value) |
Shift values forward one period (closing balance) |
at_period(n) |
Extract the value at a specific time point |
with_period(n, value) |
Override the value at a specific time point |
These compose with the cumulative operations to handle most actuarial projection patterns without manual loop logic.
Real-World Example: Universal Life Account Value¶
This is the "circular reference" problem. Let's trace 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, not current
It's a chain through time, not a circle.
The lifelib approach - recursive functions¶
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)
elif timing == "BEF_INV":
return av_pp_at(t, "BEF_FEE") - maint_fee_pp(t) - coi_pp(t)
def inv_income_pp(t):
return inv_return_mth(t) * av_pp_at(t, "BEF_INV")
This reads like the math. Each function calls the previous time step. ModelX handles the dependency resolution and caching.
The Gaspatchio approach - cumulative operations¶
Using building blocks:
# 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 using building blocks
af.survival_factor = 1 - af.mort_rate_mth
af.cum_survival = af.survival_factor.cum_prod()
af.tpx = af.cum_survival.projection.previous_period(fill_value=1.0)
Using helper methods where available:
# Account value - no helper, use building blocks as above
af.growth_factor = (1.0 - af.maint_fee_rate / 12.0) * (1.0 + af.inv_return_mth)
af.cumulative_growth = af.growth_factor.cum_prod()
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()
This reads like the math - just different math. Instead of "each step depends on the previous," we express "the result is initial value times the product of all factors."
Both are valid mental models¶
| Approach | Mental model | Best when... |
|---|---|---|
| lifelib recursive | "Walk through time step by step" | You think about the process sequentially |
| Gaspatchio cumulative | "Apply all factors at once" | You think about the end-to-end formula |
Performance bonus
The cumulative approach also happens to be faster because it vectorizes across time steps, not just policies. But the main benefit is clarity - you can see the entire accumulation logic in one place.
Why This Works (For the Curious)¶
The recursive form and cumulative form are algebraically identical. Here's 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[3] = AV[2] × factor[2] = AV[0] × factor[0] × factor[1] × factor[2]
...
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 been using the closed form in theory for centuries. Gaspatchio just lets you write it that way in code.
When you can't use closed form¶
Not all recursive relationships have clean closed forms. If your accumulation involves conditionals or path-dependent logic that can't be factored out, you may need different approaches. But for the vast majority of actuarial accumulations - account values, survival probabilities, discount factors - the cum_prod / cum_sum pattern works.