Mortality Tables¶
Mortality Has Conventions Worth Encoding¶
Mortality lookup is more than "row by age". The age you look up at depends on whether the table is quoted on age last birthday, age nearest birthday, or age next birthday. Many product tables are select-ultimate, so the lookup also needs a duration — and the duration must clamp to the select period beyond which all rows roll into the ultimate column. Joint-life products need two ages. None of this lives in the table itself; it lives in the convention that produced the table.
A MortalityTable is a thin wrapper over the underlying Table that records those conventions explicitly. The wrapper does not re-implement table loading — it routes lookups through structure-aware logic so the at(...) call you write matches the convention the table was built under, and so a table built under one convention cannot be silently used as if it were another.
Why Wrap a Table¶
The base Table class is a key-indexed lookup over a parquet or in-memory DataFrame. It will happily return a value for any keys you pass in, regardless of whether those keys make sense under the table's intended convention. Three failure modes that MortalityTable closes:
- Age-basis confusion. A table quoted on age last birthday will return rates that are silently off by half a year if you look it up using attained age nearest birthday. The base Table cannot tell. The wrapper validates the basis you supply against the basis the table was registered under.
- Select-ultimate without clamping. A 25-year select table with
select_period=24rolls duration ≥ 24 into the ultimate column. Looking upduration=30against the raw Table either misses or returns whatever happens to be in row 30. The wrapper clamps duration atselect_periodautomatically. - Joint-life with the wrong API. A joint table needs two ages, in a known order. The wrapper rejects single-age lookups against joint tables and vice versa.
These checks fire at lookup time, against real data. They are the kind of checks an experienced reviewer applies to someone else's model — moving them into the type makes them automatic.
Building a MortalityTable¶
from gaspatchio_core import MortalityTable
from gaspatchio_core.assumptions import Table
# 1. Underlying Table — keys + value column.
mort_source = pl.read_parquet("mortality.parquet")
mort_table = Table(
name="cso_2017_male_smoker",
source=mort_source,
dimensions={"age": "age"},
value="mort_rate",
)
# 2. Wrap it with the conventions the table was built under.
mortality = MortalityTable(
table=mort_table,
age_basis="age_last_birthday",
structure="aggregate",
)
age_basis is one of the standard actuarial bases: "age_last_birthday", "age_nearest_birthday", "age_next_birthday". structure is "aggregate", "select_ultimate", or "joint". For select-ultimate, also pass select_period=N (typically 5, 15, or 25 depending on the table).
The Three Structures¶
Aggregate¶
One rate per age, regardless of how long the policy has been in force.
agg = MortalityTable(
table=mort_table,
age_basis="age_last_birthday",
structure="aggregate",
)
af.mort_rate = agg.at(age=af.age)
This is the simplest case and the most common one for valuation tables that come from regulator publications (e.g., 2017 CSO base tables in aggregate form).
Select-Ultimate¶
Two rates per age — one for the "select" period after issue (where mortality is lower because the policyholder passed underwriting), one for the "ultimate" period after the select effect wears off. The wrapper clamps duration at select_period so durations beyond the select horizon roll into the ultimate row automatically.
sel = MortalityTable(
table=mort_table,
age_basis="age_last_birthday",
structure="select_ultimate",
select_period=24,
)
af.mort_rate = sel.at(age=af.age, duration=af.duration)
The clamp removes the af.duration_capped = af.duration.clip(upper_bound=24) boilerplate that select-ultimate lookups would otherwise require — the table's structure metadata makes the clamp automatic.
Joint¶
Two ages, one rate. The wrapper takes age_1 and age_2 (in the order the table was built) and rejects accidental single-age lookups against a joint table.
jnt = MortalityTable(
table=joint_table,
age_basis="age_last_birthday",
structure="joint",
)
af.joint_mort = jnt.at(age_1=af.male_age, age_2=af.female_age)
Worked Example: Aggregate and Select-Ultimate¶
import polars as pl
from gaspatchio_core import ActuarialFrame, MortalityTable
from gaspatchio_core.assumptions import Table
# --- Aggregate lookup ---
agg_source = pl.DataFrame(
{
"age": [30, 40, 50, 60, 70, 80],
"mort_rate": [0.001, 0.002, 0.005, 0.012, 0.030, 0.080],
}
)
agg_table = Table(
name="illustrative_aggregate",
source=agg_source,
dimensions={"age": "age"},
value="mort_rate",
)
agg = MortalityTable(
table=agg_table,
age_basis="age_last_birthday",
structure="aggregate",
)
af = ActuarialFrame(pl.DataFrame({"age": [30, 40, 50, 60]}))
af.mort_rate = agg.at(age=af.age)
print(af.collect())
shape: (4, 2)
┌─────┬───────────┐
│ age ┆ mort_rate │
│ --- ┆ --- │
│ i64 ┆ f64 │
╞═════╪═══════════╡
│ 30 ┆ 0.001 │
│ 40 ┆ 0.002 │
│ 50 ┆ 0.005 │
│ 60 ┆ 0.012 │
└─────┴───────────┘
# --- Select-ultimate lookup with automatic duration clamping ---
sel_source = pl.DataFrame(
{
"age": [40] * 5 + [41] * 5 + [50] * 5,
"duration": [0, 1, 2, 3, 4] * 3,
"mort_rate": [
0.0010, 0.0012, 0.0015, 0.0019, 0.0023,
0.0011, 0.0013, 0.0016, 0.0020, 0.0024,
0.0040, 0.0044, 0.0048, 0.0053, 0.0058,
],
}
)
sel_table = Table(
name="illustrative_select",
source=sel_source,
dimensions={"age": "age", "duration": "duration"},
value="mort_rate",
)
sel = MortalityTable(
table=sel_table,
age_basis="age_last_birthday",
structure="select_ultimate",
select_period=4,
)
af2 = ActuarialFrame(
pl.DataFrame(
{
"issue_age": [40, 41, 50],
# Per-policy durations 0..6. The wrapper clamps any duration
# > select_period (4) to the ultimate row automatically.
"duration": [list(range(7)), list(range(7)), list(range(7))],
}
)
)
af2.mort_rate = sel.at(age=af2.issue_age, duration=af2.duration)
print(af2.collect())
shape: (3, 3)
┌───────────┬─────────────┬────────────────────────────┐
│ issue_age ┆ duration ┆ mort_rate │
│ --- ┆ --- ┆ --- │
│ i64 ┆ list[i64] ┆ list[f64] │
╞═══════════╪═════════════╪════════════════════════════╡
│ 40 ┆ [0, 1, … 6] ┆ [0.001, 0.0012, … 0.0023] │
│ 41 ┆ [0, 1, … 6] ┆ [0.0011, 0.0013, … 0.0024] │
│ 50 ┆ [0, 1, … 6] ┆ [0.004, 0.0044, … 0.0058] │
└───────────┴─────────────┴────────────────────────────┘
The duration=6 lookup against age 40 returns 0.0023 — the same rate as duration=4, because the wrapper clamped 5 and 6 to the select-period boundary. No af.duration_capped = af.duration.clip(upper_bound=4) step in your model code.
When Conventions Conflict¶
If you supply an explicit age_basis= to at(...) that disagrees with the table's registered basis, the wrapper raises:
agg.at(age=af.age, age_basis="age_nearest_birthday")
# ValueError: requested age_basis='age_nearest_birthday' but table's
# age_basis is 'age_last_birthday'; cross-basis conversion is not yet
# supported.
Cross-basis conversion (apply a half-year adjustment, look up under the converted basis) is a planned addition; until it lands, supplying a different basis is treated as a configuration error rather than silently producing wrong rates.
Governance: Mortality Basis Identity¶
Every MortalityTable produces a source_sha() that pins down the basis — the underlying table together with the conventions you've declared (age basis, structure, select period):
agg.source_sha()
# 'sha256:e7ea7836978956b7ea610f97bcfae63ea039755c2693fa11639c31ce29854ca4'
Record this alongside each valuation. Swap to a different table, change the age basis, or extend the select period and the value changes — so a mortality basis change between quarters is visible the moment you compare runs, not on page seventeen of an experience study. Like the Schedule and Curve, the MortalityTable's identity rolls up into the rollforward's overall version stamp, so mortality basis changes show up in the model-level stamp without separate tracking.
Runnable Companions¶
The patterns above are exercised end-to-end in the mini-VA tutorial:
bindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/01-from-files/model.py— aggregate table loaded frommortality.parquetbindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/02-select-mort/model.py— select-ultimate withselect_period=24, sex-basedtable_idlookup through the third dimension
The tutorial's README captures rough edges discovered while building those steps and is worth reading alongside this page.
See Also¶
- Assumption Tables — the underlying
Tablemechanics that MortalityTable wraps - Inspection — the MortalityTable's basis identity rolls up into the rollforward's overall version stamp