Cookbook#

Practical, self-contained recipes for common actuarial tasks using Lactuca.

Each snippet below is copy-paste ready: imports, table identifiers, and parameters match the bundled catalogue. Run them in an activated Python session (see Activation Guide).

Tables are loaded by string identifier and sex code ('m', 'f', or 'u'). See Bundled Actuarial Tables for the full catalogue of available tables. For bulk portfolio work, see Bulk portfolio calculations in Using Actuarial Tables.

Recipes#

#

Topic

Pattern

1

Term life insurance

Ax(x, n=…)

2

Whole-life annuity-due

äx(x)

3

Monthly pension liability

äx(x, m=12) + benefit scaling

4

Joint-life / last-survivor pension

vectorized LifeTable + inclusion–exclusion

5

Interest-rate sensitivity

multi-scenario InterestRate

6

Static vs generational mortality

GRMF95 vs PER + cohort=

7

Tiered benefit schedule

payment_times + tiered_amountsax + cashflow_*

8

Complete expectation of life

ex / ex_continuous

9

Portfolio liability (plain Python)

loop + cohort setter

10

Portfolio liability (Polars)

iter_rows + cohort setter

11

Polars map_elements PV column

fixed table, expression pipeline

12

Portfolio liability (Pandas)

itertuples + cohort setter

13

Portfolio liability (batch API)

functional äx + benefits=

14

Net annual premium

Ax / äx (equivalence principle)

15

Deferred pension

äx(x, d=…)

16

Prospective reserve

ts= + Ax - P * äx

17

Group loop with deferred construction

pending=True + configure()

18

Heterogeneous batch with TableRegistry

get_or_create() + functional ax


1. Price a term life insurance#

Net single premium for a 20-year term life insurance on a male aged 45, using the PASEM2020 sector aggregate general life-risk table NoRel (first order) and a 3 % flat rate.

\[A^1_{x:\overline{n}|} = \sum_{k=0}^{n-1} v^{k+1} \cdot {}_k p_x \cdot q_{x+k}\]

Note

The classical summation above uses end-of-year discounting (\(v^{k+1}\)). Lactuca’s default config.mortality_placement is "mid" (\(f=0.5\)), so lt.Ax(...) values the benefit at \(k+f\) unless you set config.mortality_placement = "end". See Commutation Functions.

Pass n=20 to Ax() for a temporary insurance; omit n for whole-life.

from lactuca import LifeTable

lt = LifeTable('PASEM2020_NoRel_1o', 'm', interest_rate=0.03)

term_pv = lt.Ax(x=45, n=20)
print(f"Term insurance APV (A^1_45:20) = {term_pv:.6f}")

2. Whole-life annuity-due#

Present value of a unit whole-life annuity-due for a female aged 60 (born 1966; valuation year ≈ 2026), using the Spanish longevity table PER2020_Ind_1o (first order):

\[\ddot{a}_x^{(m)} = \frac{1}{m} \sum_{k=0}^{\infty} v^{k/m} \cdot {}_{k/m}p_x\]
from lactuca import LifeTable

lt = LifeTable('PER2020_Ind_1o', 'f', cohort=1966, interest_rate=0.03)

a_due = lt.äx(x=60)
print(f"Whole-life annuity-due (a_dd_60) = {a_due:.6f}")

3. Pension liability (monthly payments, generational mortality)#

Value a pension of €1 000 per month for a male aged 65 born in 1961, using the generational individual table PER2020 (first order):

from lactuca import LifeTable

lt = LifeTable('PER2020_Ind_1o', 'm', cohort=1961, interest_rate=0.02)

monthly_annuity = lt.äx(x=65, m=12)   # unit annual benefit, m-thly (IAA ä_x^{(12)})
liability = 1_000 * 12 * monthly_annuity   # EUR 1 000/month = EUR 12 000/year
print(f"Pension PV = EUR {liability:,.2f}")

Note

äx(..., m=12) follows standard actuarial notation: it values one currency unit per year payable in 12 instalments (not one unit per monthly payment). Scale by the annual pension — here 1_000 * 12 — or equivalently 12_000 * monthly_annuity.

4. Joint-life pension (couple, vectorized instantiation)#

Create male and female tables in a single call. The last-survivor annuity follows from the standard identity:

\[\ddot{a}_{\overline{xy}} = \ddot{a}_x + \ddot{a}_y - \ddot{a}_{xy}\]
from lactuca import LifeTable

# Vectorized instantiation: two LifeTable objects in one call
# Ages 65/62 in 2026 → cohorts 1961 / 1964
lt_m, lt_f = LifeTable(
    'PER2020_Ind_1o', ('m', 'f'), cohort=[1961, 1964], interest_rate=0.02
)

a_x     = lt_m.äx(x=65, m=12)
a_y     = lt_f.äx(x=62, m=12)
a_joint = lt_m.äxy(ages=[65, 62], table_y=lt_f, m=12)   # both survive
a_ls    = a_x + a_y - a_joint                            # last survivor

print(f"Individual (male  65): {a_x:.4f}")
print(f"Individual (female 62): {a_y:.4f}")
print(f"Joint-life (both alive): {a_joint:.4f}")
print(f"Last-survivor:           {a_ls:.4f}")

See Joint-Life Calculations for first-death insurances (Axy(), Afirst()) and other derivable joint-life formulas.

5. Interest rate sensitivity#

Compare annuity values across named interest rate scenarios using a multiscenario InterestRate — the natural pattern for sensitivity analyses and Solvency II stress tests.

Pattern A — iterate sub-curves. Extract each simple scenario and pass it as ir=:

from lactuca import LifeTable, InterestRate

ir = InterestRate({
    'base':      0.03,
    'adverse':   0.01,
    'stressed':  0.00,
})

lt = LifeTable('GRMF95', 'm')

for name, scenario in ir.scenarios.items():
    value = lt.äx(x=65, ir=scenario)
    print(f"{name:<10}  i = {scenario.rate:.2%}  ->  a_dd_65 = {value:.4f}")

Pattern B — switch active_scenario on a shared container. Attach the multi-scenario object once (to LifeTable or via ir=) and switch scenarios in place:

from lactuca import LifeTable, InterestRate

ir = InterestRate({
    'base':      0.03,
    'adverse':   0.01,
    'stressed':  0.00,
})

lt = LifeTable('GRMF95', 'm', interest_rate=ir)

ir.active_scenario = 'base'
bel_base = lt.äx(65)

ir.active_scenario = 'stressed'
bel_stressed = lt.äx(65)

print(f"base: {bel_base:.4f}  stressed: {bel_stressed:.4f}")

Both patterns yield the same PV per scenario. Pattern B avoids allocating separate InterestRate wrappers in a loop; Pattern A makes each scenario explicit at the call site. See Scenarios with LifeTable and batch methods for copy() snapshotting and batch semantics.

Note

scenario.rate is defined for constant sub-curves (as in this example). For piecewise scenarios, use scenario.get_rate(t) or inspect scenario.rates.

6. Static vs. generational mortality#

Compare a static (period) table with a generational longevity table. A 65-year-old in 2026 was born in 1961. The generational table PER2020_Ind_2o embeds projection improvements; GRMF95 is a classical static table (no cohort):

from lactuca import LifeTable

lt_static = LifeTable('GRMF95', 'm', interest_rate=0.03)
lt_cohort = LifeTable('PER2020_Ind_2o', 'm', cohort=1961, interest_rate=0.03)

a_static = lt_static.äx(x=65)
a_cohort = lt_cohort.äx(x=65)

print(f"Static annuity:       {a_static:.4f}")
print(f"Generational annuity: {a_cohort:.4f}")
print(f"Difference:           {a_cohort - a_static:.4f}")

7. Custom benefit schedule#

Value a pension that pays €12 000/year for 10 years, then €8 000/year thereafter, using ax (annuity-immediate), which supports custom cashflow schedules:

from lactuca import LifeTable, payment_times, tiered_amounts

lt = LifeTable('PER2020_Ind_1o', 'm', cohort=1961, interest_rate=0.03)

times   = payment_times(n=40, m=1)
amounts = tiered_amounts(times, breakpoints=[10], values=[12_000.0, 8_000.0])

pv = lt.ax(x=65, cashflow_times=times, cashflow_amounts=amounts)
print(f"Custom benefit PV = EUR {pv:,.2f}")

Note

cashflow_times is supported only on immediate (postpayable) methods such as ax() and requires calculation_mode='discrete_precision' (the default). Omit n and keep m=1 when supplying a custom schedule. See Irregular Cashflows.

8. Complete expectation of life#

Compute \(\mathring{e}_x\) at an integer age and at a fractional age. ex_continuous only accepts fractional ages; pass 65.5, not 65:

from lactuca import LifeTable

lt = LifeTable('PASEM2020_Rel_1o', 'm')

ex_int  = lt.ex(65)
ex_frac = lt.ex_continuous(65.5)

print(f"e_65   = {ex_int:.2f} years  (integer age, discrete)")
print(f"e_65.5 = {ex_frac:.2f} years  (fractional age, continuous)")

9. Portfolio liability valuation#

Compute the present value of a pension portfolio from a plain Python list of records — no external dependencies beyond Lactuca. Ages are derived with alb, the cohort setter is updated inside the loop only when it changes, following the bulk-portfolio approach from Using Actuarial Tables.

from lactuca import LifeTable, GrowthRate, alb

VALUATION_DATE = '2026-04-09'

# Pension portfolio: birth date, sex, annual pension,
# term in years (n=None -> whole-life), escalation rate.
portfolio = [
    {'id': 'P001', 'birth': '1955-03-15', 'sex': 'm', 'pension': 18_000, 'n': 25,   'g': 0.020},
    {'id': 'P002', 'birth': '1958-11-22', 'sex': 'f', 'pension': 12_000, 'n': None, 'g': 0.010},
    {'id': 'P003', 'birth': '1950-07-04', 'sex': 'm', 'pension': 24_000, 'n': 20,   'g': 0.025},
    {'id': 'P004', 'birth': '1962-01-30', 'sex': 'f', 'pension':  9_600, 'n': None, 'g': 0.000},
    {'id': 'P005', 'birth': '1957-09-10', 'sex': 'm', 'pension': 15_000, 'n': 28,   'g': 0.015},
]

# Vectorized age and cohort (birth year) derivation
births   = [p['birth'] for p in portfolio]
ages_alb = alb(births, VALUATION_DATE)            # NDArray[float64]
for p, age in zip(portfolio, ages_alb):
    p['age']    = int(age)
    p['cohort'] = int(p['birth'][:4])             # birth year from ISO date string

# One LifeTable per sex; cohort updated per policy inside the loop
tables = {
    'm': LifeTable('PER2020_Ind_1o', 'm', cohort=1950, interest_rate=0.03),
    'f': LifeTable('PER2020_Ind_1o', 'f', cohort=1950, interest_rate=0.03),
}

# Sort by (sex, cohort) to minimise cohort-setter rebuilds
for p in sorted(portfolio, key=lambda r: (r['sex'], r['cohort'])):
    lt = tables[p['sex']]
    if lt.cohort != p['cohort']:
        lt.cohort = p['cohort']
    gr  = GrowthRate(p['g']) if p['g'] else None
    pv  = p['pension'] * lt.äx(x=p['age'], n=p['n'], m=12, gr=gr)
    p['pv'] = round(pv, 2)

total_liability = sum(p['pv'] for p in portfolio)
print(f"{'ID':<6} {'Sex':>3} {'Age':>4} {'Cohort':>6} {'Pension':>10} {'g':>5} {'PV':>14}")
print('-' * 52)
for p in portfolio:
    print(f"{p['id']:<6} {p['sex']:>3} {p['age']:>4} {p['cohort']:>6} "
          f"{p['pension']:>10,.0f} {p['g']:>5.1%} {p['pv']:>14,.2f}")
print('-' * 52)
print(f"{'Total liability':>42}  {total_liability:>14,.2f}")

10. Portfolio liability valuation with Polars#

Compute the present value of a pension portfolio loaded as a Polars DataFrame (simulating a read from Excel or any other tabular source). Ages are derived with alb from the birth date column; the cohort setter is updated inside the loop only when it changes, following the bulk-portfolio approach from Using Actuarial Tables.

import polars as pl
from lactuca import LifeTable, GrowthRate, alb

VALUATION_DATE = '2026-04-09'

# In production: df = pl.read_excel("portfolio.xlsx", sheet_name="Policyholders")
df = pl.DataFrame({
    'id':      ['P001', 'P002', 'P003', 'P004', 'P005'],
    'birth':   ['1955-03-15', '1958-11-22', '1950-07-04', '1962-01-30', '1957-09-10'],
    'sex':     ['m', 'f', 'm', 'f', 'm'],
    'pension': [18_000.0, 12_000.0, 24_000.0, 9_600.0, 15_000.0],
    'n':       [25, None, 20, None, 28],         # None -> whole-life annuity
    'g':       [0.020, 0.010, 0.025, 0.000, 0.015],
}).with_columns(
    pl.col('birth').str.to_date()               # parse ISO 8601 strings -> pl.Date
)

# Add age-at-last-birthday (vectorized) and cohort (birth year) columns
ages_alb = alb(df['birth'].to_list(), VALUATION_DATE)   # NDArray[float64]
df = df.with_columns(
    pl.Series('age', ages_alb).cast(pl.Int32),
    pl.col('birth').dt.year().alias('cohort'),
)

# One LifeTable per sex; cohort will be updated inside the loop only when it changes
tables = {
    'm': LifeTable('PER2020_Ind_1o', 'm', cohort=1950, interest_rate=0.03),
    'f': LifeTable('PER2020_Ind_1o', 'f', cohort=1950, interest_rate=0.03),
}

# Sort by (sex, cohort) to minimise cohort-setter rebuilds
df_sorted = df.sort(['sex', 'cohort'])

pv_list = []
for row in df_sorted.iter_rows(named=True):
    lt = tables[row['sex']]
    if lt.cohort != row['cohort']:
        lt.cohort = row['cohort']
    gr  = GrowthRate(row['g']) if row['g'] else None
    pv  = row['pension'] * lt.äx(x=row['age'], n=row['n'], m=12, gr=gr)
    pv_list.append(round(pv, 2))

result = df_sorted.with_columns(pl.Series('pv', pv_list))

print(result.select(['id', 'sex', 'age', 'cohort', 'pension', 'g', 'pv']))
print(f"\nTotal liability:  {result['pv'].sum():>14,.2f}")

11. Adding a present-value column with Polars map_elements#

Use Polars’ map_elements on a struct column to add a pv column inline within an expression pipeline. This pattern is ideal when the table is fixed (single sex, no cohort updates) and you want to keep the transformation inside a Polars chain.

import polars as pl
from lactuca import LifeTable, GrowthRate

lt = LifeTable('PER2020_Ind_1o', 'f', cohort=1961, interest_rate=0.03)

df = pl.DataFrame({
    'id':      ['A001', 'A002', 'A003'],
    'age':     [60, 62, 65],
    'pension': [15_000.0, 12_000.0, 20_000.0],
    'n':       [30, 28, 25],
    'g':       [0.02, 0.01, 0.00],
})

result = df.with_columns(
    pl.struct(['age', 'pension', 'n', 'g']).map_elements(
        lambda row: row['pension'] * lt.äx(
            x=row['age'],
            n=row['n'],
            m=12,
            gr=GrowthRate(row['g']) if row['g'] else None,
        ),
        return_dtype=pl.Float64,
    ).alias('pv')
)

print(result)

Note

map_elements applies a Python function to each element of the struct Series — it is equivalent to a row-wise loop and does not unlock Polars parallelism. Use it when you need the result as a Polars expression inside a pipeline (with_columns, select, lazy frames, etc.). For portfolios with cohort updates or multiple tables, prefer iter_rows(named=True) as in recipe 10 (or the pure-Python variant in recipe 9).

12. Portfolio valuation with Pandas#

The Pandas equivalent of recipe 9. Use itertuples (faster than iterrows) after sorting by (sex, cohort) to minimise cohort-setter rebuilds.

import pandas as pd
from lactuca import LifeTable, GrowthRate, alb

VALUATION_DATE = '2026-04-09'

# In production: df = pd.read_excel("portfolio.xlsx", sheet_name="Policyholders")
df = pd.DataFrame({
    'id':      ['P001', 'P002', 'P003', 'P004', 'P005'],
    'birth':   ['1955-03-15', '1958-11-22', '1950-07-04', '1962-01-30', '1957-09-10'],
    'sex':     ['m', 'f', 'm', 'f', 'm'],
    'pension': [18_000.0, 12_000.0, 24_000.0, 9_600.0, 15_000.0],
    'n':       [25, None, 20, None, 28],         # None -> whole-life annuity
    'g':       [0.020, 0.010, 0.025, 0.000, 0.015],
})
df['birth'] = pd.to_datetime(df['birth'])

# Vectorized age and cohort columns
ages_alb     = alb(df['birth'].tolist(), VALUATION_DATE)   # NDArray[float64]
df['age']    = ages_alb.astype(int)
df['cohort'] = df['birth'].dt.year

# One LifeTable per sex
tables = {
    'm': LifeTable('PER2020_Ind_1o', 'm', cohort=1950, interest_rate=0.03),
    'f': LifeTable('PER2020_Ind_1o', 'f', cohort=1950, interest_rate=0.03),
}

# Sort to minimise cohort-setter rebuilds, then iterate with itertuples
df_sorted = df.sort_values(['sex', 'cohort']).reset_index(drop=True)
pv_list = []
for row in df_sorted.itertuples():
    lt = tables[row.sex]
    if lt.cohort != row.cohort:
        lt.cohort = row.cohort
    gr    = GrowthRate(row.g) if row.g else None
    n_val = None if pd.isna(row.n) else row.n
    pv    = row.pension * lt.äx(x=row.age, n=n_val, m=12, gr=gr)
    pv_list.append(round(pv, 2))

df_sorted['pv'] = pv_list
print(df_sorted[['id', 'sex', 'age', 'cohort', 'pension', 'g', 'pv']].to_string(index=False))
print(f"\nTotal liability:  {df_sorted['pv'].sum():>14,.2f}")

Note

df['birth'].tolist() on a datetime64 column yields pd.Timestamp objects, which are a valid date type for alb. Passing the list directly avoids any intermediate conversion. itertuples iterates as named tuples (fields accessed as row.sex, row.age, etc.) and is significantly faster than iterrows for large DataFrames.

13. Portfolio liability valuation (batch API)#

Vectorized alternative to recipes 9–12: one functional call prices the entire portfolio without a Python loop over policies. Build one LifeTable per unique (sex, cohort) pair, then pass a per-policy table list to äx() with benefits=.

from lactuca import LifeTable, TableKey, GrowthRate, alb, äx

VALUATION_DATE = '2026-04-09'

portfolio = [
    {'id': 'P001', 'birth': '1955-03-15', 'sex': 'm', 'pension': 18_000, 'n': 25,   'g': 0.020},
    {'id': 'P002', 'birth': '1958-11-22', 'sex': 'f', 'pension': 12_000, 'n': None, 'g': 0.010},
    {'id': 'P003', 'birth': '1950-07-04', 'sex': 'm', 'pension': 24_000, 'n': 20,   'g': 0.025},
    {'id': 'P004', 'birth': '1962-01-30', 'sex': 'f', 'pension':  9_600, 'n': None, 'g': 0.000},
    {'id': 'P005', 'birth': '1957-09-10', 'sex': 'm', 'pension': 15_000, 'n': 28,   'g': 0.015},
]

births   = [p['birth'] for p in portfolio]
ages_alb = alb(births, VALUATION_DATE)
for p, age in zip(portfolio, ages_alb):
    p['age']    = int(age)
    p['cohort'] = int(p['birth'][:4])

# One LifeTable per unique (sex, cohort) — not one per policy row
unique_pairs = sorted({(p['sex'], p['cohort']) for p in portfolio})
tables_by_key = LifeTable(
    'PER2020_Ind_1o',
    [s for s, _ in unique_pairs],
    cohort=[c for _, c in unique_pairs],
    return_dict=True,
    interest_rate=0.03,
)

table_list = [
    tables_by_key[TableKey('PER2020_Ind_1o', p['sex'], p['cohort'])]
    for p in portfolio
]
ages     = [p['age'] for p in portfolio]
n_list   = [p['n'] for p in portfolio]   # None -> whole-life (same as scalar)
gr_list  = [GrowthRate(p['g']) if p['g'] else None for p in portfolio]
benefits = [p['pension'] for p in portfolio]

pv_arr = äx(table_list, ages, n=n_list, m=12, gr=gr_list, benefits=benefits)

for p, pv in zip(portfolio, pv_arr):
    p['pv'] = round(float(pv), 2)

total_liability = float(pv_arr.sum())
print(f"Total liability (batch):  {total_liability:>14,.2f}")

Note

benefits=. In batch mode, benefits= scales each policy’s unit annuity factor by the annual pension (replacing the manual pension * lt.äx(...) from recipes 9–12). Unit PVs are rounded per decimals.annuities before scaling.

Row vs column layout. portfolio is a list of row dicts (as in recipe 9) because it matches typical JSON/CSV records and keeps per-policy enrichment (age, cohort, pv) simple. The list comprehensions above extract parallel columns for the batch call. If your data is already column-oriented — {'age': [...], 'n': [...], ...} — pass portfolio['age'], portfolio['n'], and so on directly; gr still needs a GrowthRate wrapper per element. For Excel or database sources, prefer recipes 10–12 (Polars or Pandas DataFrames).

Important

In both scalar and batch mode, n=None means whole-life — including None or missing values in a per-policy n list or DataFrame column (Series). You may also write np.inf explicitly; both forms are equivalent. Pure endowments (nEx, …) require a finite term and reject whole-life sentinels. See Batch Calculations for per-policy parameters, on_error='nan', and aggregate cash-flow patterns.

14. Net annual premium (equivalence principle)#

Under the equivalence principle, the net level annual premium for a unit benefit is the ratio of the insurance APV to the premium annuity-due APV:

\[P = \frac{A_{x:\overline{n}|}}{\ddot{a}_{x:\overline{n}|}}\]

Complements recipe 1 (term insurance Ax); adjust x, n, and interest_rate as needed.

from lactuca import LifeTable

lt = LifeTable('PASEM2020_NoRel_1o', 'm', interest_rate=0.03)

x, n = 45, 20
benefit_pv  = lt.Ax(x=x, n=n)
premium_ann = lt.äx(x=x, n=n)
net_premium = benefit_pv / premium_ann

print(f"Benefit APV  A^1_{x}:{n} = {benefit_pv:.6f}")
print(f"Premium APV  a_dd_{x}:{n} = {premium_ann:.4f}")
print(f"Net annual premium P      = {net_premium:.6f}")

15. Deferred pension to retirement age#

Value an annual pension of EUR 24 000 starting at age 65 for a member currently aged 52. Pass the deferment in years as d= — payments begin at age x + d:

\[\ddot{a}_{x:\overline{n}|}^{(m)} \text{ with deferment } d \quad\Rightarrow\quad {}_d|\ddot{a}_x^{(m)}\]
from lactuca import LifeTable

lt = LifeTable('PER2020_Ind_1o', 'm', cohort=1974, interest_rate=0.03)

current_age    = 52
retirement_age = 65
defer_years    = retirement_age - current_age

unit_annuity = lt.äx(x=current_age, d=defer_years, m=12)
annual_pension = 24_000
liability = annual_pension * unit_annuity

print(f"Unit deferred annuity (m=12) = {unit_annuity:.4f}")
print(f"Deferred pension PV          = EUR {liability:,.2f}")

Note

d= is future deferment before the first payment. For valuation after issue, use ts= instead (see recipe 16 and Deferred Life Contingencies). Benefit is annual; äx(..., m=12) uses standard IAA notation (one unit per year in 12 instalments) — scale by the annual amount directly.

16. Prospective reserve at policy anniversaries#

The prospective reserve at elapsed time \(t\) is future benefits minus future net premiums, both evaluated from attained age \(x + t\). In Lactuca, pass the issue age x, full term n, and elapsed years ts=t:

\[{}_t V_x = A_{x+t:\overline{n-t}|} - P \cdot \ddot{a}_{x+t:\overline{n-t}|}\]
from lactuca import LifeTable

lt = LifeTable('PASEM2020_Rel_1o', 'm', interest_rate=0.03)

x, n = 40, 25

# Net level premium at issue (ts = 0)
P = lt.Ax(x, n=n) / lt.äx(x, n=n)
print(f"Net level premium P = {P:.6f}")

# Prospective reserve at selected anniversaries
print(f"\n{'t':>4}  {'A(x+t)':>12}  {'a_dd(x+t)':>12}  {'tV':>12}")
for t in (0, 5, 10, 15, 20, 25):
    At = lt.Ax(x, n=n, ts=t)
    at = lt.äx(x, n=n, ts=t)
    tV = At - P * at
    print(f"{t:>4}  {At:>12.6f}  {at:>12.6f}  {tV:>12.6f}")

Note

At ts=0 the reserve is zero by construction (equivalence principle). At ts=n both benefit and premium APVs are zero, so ${}_n V_x = 0. See {doc}user_guide/prospective_reservefor fractionalts, interaction with d=`, and joint-life products.


17. Group loop with deferred construction#

Process a portfolio grouped by cohort without creating a new LifeTable instance per group. pending=True loads the base table once; configure(cohort=c) applies the cohort projection and rebuilds the decrement in one step.

from lactuca import LifeTable, ax

# Portfolio: list of (cohort, age) pairs sorted by cohort
portfolio = [
    {"cohort": 1960, "age": 65},
    {"cohort": 1960, "age": 62},
    {"cohort": 1970, "age": 55},
    {"cohort": 1975, "age": 50},
    {"cohort": 1975, "age": 48},
]
portfolio.sort(key=lambda p: p["cohort"])

# One shell — base data loaded once, cohort projection rebuilt per group
lt = LifeTable("PER2020_Ind_1o", "m", pending=True)

from itertools import groupby
results = {}
for cohort, group in groupby(portfolio, key=lambda p: p["cohort"]):
    group = list(group)
    lt.configure(cohort=cohort)                        # one rebuild per cohort
    ages = [p["age"] for p in group]
    pvs  = ax(lt, ages, ir=0.03)                       # vectorised batch for this group
    for p, pv in zip(group, pvs):
        p["ax"] = float(pv)

print([p["ax"] for p in portfolio])

Note

configure() returns self, so you can chain: lt.configure(cohort=c).ax(65, ir=0.03). If you need both LifeTable.ax (scalar call) and the result immediately, the chain is the most compact form. For batch calls on a group, store the configure() result in the loop and call the functional API separately.


18. Heterogeneous batch with TableRegistry#

When different policies require different cohorts at the same time (they cannot be grouped sequentially), TableRegistry caches one configured instance per TableKey and passes a stable per-policy list to the functional API.

from lactuca import LifeTable, TableRegistry, ax

reg = TableRegistry(LifeTable)

# Per-policy data: cohort and sex vary per row
policies = [
    {"sex": "m", "cohort": 1960, "age": 65, "term": 20},
    {"sex": "f", "cohort": 1975, "age": 55, "term": 15},
    {"sex": "m", "cohort": 1960, "age": 62, "term": 20},
    {"sex": "m", "cohort": 1982, "age": 42, "term": 25},
    {"sex": "f", "cohort": 1975, "age": 50, "term": 15},
]

# Build per-policy table list — registry caches by (table_name, sex, cohort)
tables = [reg.get_or_create(None, "PER2020_Ind_1o", p["sex"], cohort=p["cohort"]) for p in policies]
ages   = [p["age"]  for p in policies]
terms  = [p["term"] for p in policies]

pvs = ax(tables, ages, n=terms, ir=0.03)

for p, pv in zip(policies, pvs):
    p["ax"] = float(pv)

print([p["ax"] for p in policies])
print(f"Distinct table instances cached: {len(reg)}")

The registry reuses the same instance whenever the same (sex, cohort) key appears — here the two male-1960 and the two female-1975 policies each share one instance. No mutation happens between batch calls, so there is no aliasing.

See also#