Assumptions¶
Tables Carry the Conventions They Were Built Under¶
An assumption table is more than rows of numbers. It carries a shape (wide or tidy), the dimensions you'll look it up by, an overflow convention for "Ultimate" or "Term" columns, and an identity that pins down which version of the table fed your valuation. The Table API records those conventions at registration so a lookup at projection time can't drift from the table's intended use.
For domain-shaped assumptions — mortality with select/ultimate and age-basis conventions, economic curves with term structure and key-rate stresses — Gaspatchio provides structure-aware objects (MortalityTable, Curve) that encode the actuarial conventions on top of (or alongside) the basic Table. Those are covered in Mortality Tables and Curves. This page is the underlying Table machinery they sit on, plus the assumption types that don't have a dedicated wrapper (lapse, expense, premium rates, sensitivity multipliers).
What a Table Is¶
A Table is a named, dimensioned object the actuary registers once and looks up against at any point during the projection.
import polars as pl
from gaspatchio_core import ActuarialFrame
from gaspatchio_core.assumptions import Table
lapse_df = pl.DataFrame(
{
"duration": [1, 2, 3, 4, 5],
"lapse_rate": [0.08, 0.06, 0.04, 0.03, 0.02],
}
)
lapse = Table(
name="lapse_rates",
source=lapse_df,
dimensions={"duration": "duration"},
value="lapse_rate",
)
af = ActuarialFrame(pl.DataFrame({"policy_id": ["P001", "P002"], "duration": [2, 4]}))
af.lapse_rate = lapse.lookup(duration=af.duration)
source accepts a parquet/csv path or an in-memory DataFrame. dimensions maps each lookup key to either a column name in the source ("duration": "duration") or a Dimension object that transforms or computes the key. value names the column the lookup returns.
The lookup is a single call. It works on scalar columns, list columns (returning aligned vectors per period), and Polars expressions.
Wide and Tidy¶
Most actuarial tables come from Excel, regulator publications, or vendor systems in wide shape — ages down the side, sex/smoker codes or durations across the top:
┌─────┬───────┬───────┬───────┬───────┐
│ age │ MNS │ FNS │ MS │ FS │
├─────┼───────┼───────┼───────┼───────┤
│ 30 │0.0011 │0.0010 │0.0021 │0.0019 │
│ 31 │0.0012 │0.0011 │0.0022 │0.0020 │
└─────┴───────┴───────┴───────┴───────┘
Table loads either shape. For wide tables, declare a MeltDimension for the axis sitting across the columns and the wide → tidy reshape happens once, at registration:
from gaspatchio_core.assumptions import Table, MeltDimension
# A wide mortality table as received: age down the side, sex/smoker codes
# (Male Non-Smoker, Female Non-Smoker, Male Smoker, Female Smoker) across the top.
wide_df = pl.DataFrame(
{
"age": [30, 31, 32, 33, 34],
"MNS": [0.0011, 0.0012, 0.0013, 0.0014, 0.0015],
"FNS": [0.0010, 0.0011, 0.0012, 0.0013, 0.0014],
"MS": [0.0021, 0.0022, 0.0023, 0.0024, 0.0025],
"FS": [0.0019, 0.0020, 0.0021, 0.0022, 0.0023],
}
)
mortality = Table(
name="mortality_rates",
source=wide_df,
dimensions={
"age": "age",
"sex_smoker": MeltDimension(
columns=["MNS", "FNS", "MS", "FS"],
name="sex_smoker",
),
},
value="mortality_rate",
)
af = ActuarialFrame(
pl.DataFrame(
{
"policy_id": ["P001", "P002"],
"age": [30, 32],
"sex_smoker": ["MNS", "FS"],
}
)
)
af.mort_rate = mortality.lookup(age=af.age, sex_smoker=af.sex_smoker)
Lookups are always against the tidy form. Keep your source files in whatever shape you receive them — the reshape is one-off, at load time.
Ultimate Rates and Overflow¶
Select-ultimate tables have explicit columns for the first N durations (typically 5, 15, or 25) and one "Ult." column carrying the rate beyond the select period. ExtendOverflow expands that column into explicit durations at registration, so a lookup at duration 50 hits a real row rather than missing or falling through:
from gaspatchio_core.assumptions import (
Table,
MeltDimension,
ExtendOverflow,
DataDimension,
)
# The 2015 VBT as published: issue age down the side, durations 1–25 plus an
# "Ultimate" column across the top. Drop the derived attained-age column.
vbt_df = pl.read_csv("2015-VBT-FSM-ANB.csv").drop("Attained Age")
vbt = Table(
name="vbt_2015",
source=vbt_df,
dimensions={
"issue_age": DataDimension(column="Issue Age", rename_to="issue_age"),
"duration": MeltDimension(
columns=[str(i) for i in range(1, 26)] + ["Ultimate"],
name="duration",
overflow=ExtendOverflow("Ultimate", to_value=120),
),
},
value="qx",
)
Durations 26 through 120 each become real rows carrying the Ultimate rate. Per-policy projections that step past the select horizon look up normally, without duration.clip(upper_bound=25) boilerplate in your model code.
For sex/smoker × age × duration mortality with all of these conventions in one place — plus age-basis validation and automatic select-period clamping — reach for MortalityTable. It wraps Table and adds the structure-aware checks on top.
Vector Lookups Across the Projection¶
The actuary's natural unit is the vector — a rate per period, per policy. lookup() works on list-columns and returns aligned vectors:
# A select-ultimate mortality table keyed on age × sex/smoker × duration.
mort_rows = [
{
"age": age,
"sex_smoker": ss,
"duration": dur,
"mortality_rate": round(0.0010 * (1 + 0.05 * dur) * (1.8 if ss in ("MS", "FS") else 1.0), 6),
}
for age in range(30, 71)
for ss in ["MNS", "FNS", "MS", "FS"]
for dur in range(0, 6)
]
mortality = Table(
name="mortality_select",
source=pl.DataFrame(mort_rows),
dimensions={"age": "age", "sex_smoker": "sex_smoker", "duration": "duration"},
value="mortality_rate",
)
# Two policies, each with a five-period projection (months 0, 12, 24, 36, 48).
af = ActuarialFrame(
pl.DataFrame(
{
"policy_id": ["P001", "P002"],
"issue_age": [30, 35],
"month": [[0, 12, 24, 36, 48], [0, 12, 24, 36, 48]],
"gender": ["M", "F"],
"smoker": ["NS", "S"],
}
)
)
# Per-policy projection vectors
af.attained_age = af.issue_age + af.month // 12 # list[i64] per policy
af.duration = af.month // 12 # list[i64] per policy
af.sex_smoker = af.gender + af.smoker # scalar per policy
# Single call returns a vector of rates aligned to the input vectors
af.mort_rate = mortality.lookup(
age=af.attained_age,
sex_smoker=af.sex_smoker,
duration=af.duration,
)
No exploding model points into one row per period, no per-period joining, no aggregation back. The lookup matches the actuary's mental model: one rate vector per policy, the same length as the projection.
When to Use What¶
| Need | Reach for |
|---|---|
| Tidy source with one or more dimension columns | Table(..., dimensions={"age": "age"}, value="qx") |
| Wide source with codes across columns (MNS/FNS, age bands, scenario ids) | Table(..., dimensions={"code": MeltDimension(columns=[...])}) |
| Select-then-ultimate with an "Ult." column | MeltDimension(..., overflow=ExtendOverflow("Ult.", to_value=120)) |
Dimension derived from other columns (attained_age = issue_age + duration) |
ComputedDimension(...) |
Constant categorical (e.g. table_id stamped at registration) |
CategoricalDimension(...) |
| Multi-step construction or programmatic configuration | TableBuilder(...) — see the API reference |
| Mortality with age basis + select/ultimate + joint conventions | MortalityTable on top of Table |
| Economic curves with key-rate sensitivities | Curve — a separate object, not a Table wrapper |
Governance: Table Identity¶
source_sha() pins down the registered table — its rows, schema, dimension configuration, and overflow strategy — as a single sha256:
mortality.source_sha()
# 'sha256:e4ef39b8be6b3c75f6b6f2a0d7e5e2c1b8a9d6f7e5c4b3a2f1e0d9c8b7a6f5e4'
Record this alongside each valuation. Two tables with the same source data and the same dimensions have the same source_sha(); reload from a different file, swap a MeltDimension for an ExtendOverflow, or drop a row and the value changes — so any drift between quarters is visible the moment you compare runs. The Table's identity rolls up into the rollforward's overall version stamp, so an assumption-table change between valuations shows up at the model level without separate tracking.
Runnable Companions¶
The patterns above run end-to-end in the mini-VA tutorial:
bindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/01-from-files/model.py— mortality and lapse tables loaded from parquet, vector lookups against per-policy projection vectorsbindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/02-select-mort/model.py—ExtendOverflow+ sex-basedtable_idlookup as a third dimension
See Also¶
- Mortality Tables —
MortalityTablewrapsTableand adds age-basis + select/ultimate conventions - Curves —
Curvecarries the discount-rate term structure as a separate object - Worked Examples — end-to-end model integrating mortality, lapse, and premium tables
- Scenarios → Table Sensitivities — shocking assumption tables across scenarios
- API: Assumptions — full surface:
Table,TableBuilder, all Dimension and Strategy types