Modifying Decrements#
Lactuca lets you apply actuarial adjustments to any decrement table without reloading the
underlying .ltk file. The adjustment system is uniform across all table types: one method call,
one dict, six possible keys.
table_combination — competing risks#
Combines decrement arrays with the product formula \(q = 1 - \prod_i (1 - q_i)\)
for independent competing risks (default). Optional combination_mode="udd"
declares the UDD multiple-decrement assumption; v1 stores the same collapsed total
\(q_x^{\mathrm{comb}}\) for two or three causes (associated singles \(q'^{(j)}\) are not
returned). Pairwise sums \(q_1 + q_2\) may exceed 1 while the combined probability
remains valid (e.g. \(0.6\) and \(0.6\) → \(0.84\)). Validation rejects only the
combined probability above 1 (numerical tolerance).
Array index \(i\) is integer age \(x = i\) (see Age alignment and table length below).
The same combined \(q_x\) feeds all four calculation modes (discrete_precision,
discrete_simplified, continuous_precision, continuous_simplified). Modes differ in
how present values are computed, not in the underlying decrement table. All four modes remain
actuarially coherent (same product and conventions) but are not required to yield
numerically identical results — see Actuarial coherence across modes in
Calculation Modes.
Optional sibling key combination_mode ("independent" default, or "udd") declares the actuarial assumption when combining tables; see combination_mode below.
Method names per table type#
Each table class exposes a method named after its own rate symbol:
Class |
Method |
Rates modified |
|---|---|---|
|
|
Mortality rates \(q_x\) |
|
|
Disability incidence rates \(i_x\) |
|
|
Exit/turnover rates \(o_x\) |
Calling modify_qx on a DisabilityTable or ExitTable raises NotImplementedError.
Throughout this guide LifeTable / modify_qx are used for brevity; all six keys work
identically for modify_ix and modify_ox.
Core semantics#
Each call replaces, not accumulates. Every modify_* call starts from the original base
rates loaded from the .ltk file, regardless of any previous call. To build a composite
modification, all steps must appear in one dict:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
# CORRECT: all keys applied in sequence within a single call
lt.modify_qx({
"age_shift": 2,
"decrement_multiplier": 1.05,
"aggravated_risk": 1.2,
})
# WRONG: second call silently overwrites the first
lt.modify_qx({"age_shift": 2})
lt.modify_qx({"decrement_multiplier": 1.05}) # starts from base; age_shift is gone
Keys are applied in insertion order (Python dict order, guaranteed since Python 3.7). Different orderings may produce different results; choose the order that matches the intended actuarial transformation.
Modifications propagate automatically. After modify_*, every derived quantity
— lx, tpx, tqx, annuity values (äx, ax), insurance values (Ax),
commutation functions (Dx, Nx, …), life expectancy (ex, ex_continuous)
— reflects the new rates. No manual refresh or re-instantiation is needed.
Atomic on failure. If a modify_* call raises (invalid key, wrong type,
mismatched sex, etc.) the table is left in its pre-call state — either still
unmodified or still with the previously applied modification.
Unknown keys raise immediately. An unrecognised dict key raises ValueError on the
first iteration — it does not silently pass through.
State and lifecycle#
The primary inspection tool is summary(), which always shows the current state:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
lt.modify_qx({"age_shift": 2, "decrement_multiplier": 1.05})
print(lt.summary())
When a modification is active, summary() adds two sections:
Modified: TrueandModifications applied: age_shift=2, decrement_multiplier=1.05Sample qx values showing both the modified value and the original in parentheses:
qx(0) = 0.00012642 (original: 0.0020038)
Without a modification, sample values show the base rates without parenthetical comparison.
For programmatic inspection, use the read-only properties:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
lt.modify_qx({"age_shift": 2, "decrement_multiplier": 1.05})
print(lt.modified) # True
print(lt.modifications_applied) # ['age_shift=2', 'decrement_multiplier=1.05']
modifications_applied is reset at the start of each modify_* call and on
reset_modifications().
lt.w — effective omega after the last modification (decreases after age_shift);
lt.omega — original omega from the file (immutable).
Resetting restores all base rates and clears caches:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
lt.modify_qx({"decrement_multiplier": 1.05})
lt.reset_modifications()
print(lt.qx(65)) # back to base rate
Reassigning table properties resets modifications. Assigning any of sex,
cohort, duration, or unisex_blend to a new value rebuilds the base decrement
from scratch and sets modified = False. Any modification applied before the
reassignment is lost. To re-apply it, call modify_* again after the assignment.
Snapshotting with copy(). To experiment with several modification scenarios
without reinstantiating, take a deep copy before modifying:
from lactuca import LifeTable
lt_base = LifeTable("PASEM2020_Rel_1o", "m")
lt_v1 = lt_base.copy()
lt_v2 = lt_base.copy()
lt_v1.modify_qx({"decrement_multiplier": 1.05})
lt_v2.modify_qx({"aggravated_risk": 1.3})
# lt_base is untouched
print(lt_v1.qx(50), lt_v2.qx(50), lt_base.qx(50))
Available modification keys#
age_shift#
Shift the age axis forward by \(n\) integer years. The first \(n\) entries are dropped:
The effective upper age lt.w decreases by \(n\); lt.omega is never changed.
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
original_omega = lt.w
print(f"Original omega: {original_omega}")
lt.modify_qx({"age_shift": 5})
# A person aged 60 now uses rates originally at age 65.
print(lt.w) # omega_original - 5
age_shift must be a Python int in \([0, \omega]\). Useful for select-and-ultimate
approximations, minimum entry-age products, or simulating older-entry populations.
Public API after age_shift. The stored array is shortened: index \(i\) holds
rates for calendar age \(n + i\) when start_age = 0 (not calendar age \(i\)).
Example: after {"age_shift": 40}, lt.qx(0) returns \(q_{40}\) and lt.qx(10)
returns \(q_{50}\). When combining in the same dict, alignment uses the same offset
(see Age alignment and table length).
decrement_multiplier#
Multiply all rates by a scalar or an age-specific array:
import numpy as np
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
# Uniform 5 % worsening
lt.modify_qx({"decrement_multiplier": 1.05})
# Age-specific: +10 % for ages 50–69, neutral elsewhere
factors = np.ones(lt.w + 1)
factors[50:70] = 1.10
lt.modify_qx({"decrement_multiplier": factors})
Scalar: any positive
int,float, or NumPy scalar.Array: list, tuple, or
ndarrayof positive finite values, length equal to the current rate array (lt.w + 1, which decreases after anage_shift).Values are clipped to 1 at the final safety step; intermediate maxima above \(10^6\) are rejected as likely input errors.
decrement_geometric_increase#
Apply a geometric multiplier to all ages after a given pivot age:
Argument: tuple (c, x_0).
c— per-step proportion, any numeric scalar (int,float, or NumPy numeric) in \([-1.0,\, 1.0]\). Use negative values for a geometric decrease (e.g.-0.02for 2 % annual reduction).x_0— pivot age, must be a Pythonintin \([0, \omega)\). Rates at ages \(\leq x_0\) are unchanged.
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
# Geometric increase: +2 % per year from age 70 onwards
lt.modify_qx({"decrement_geometric_increase": (0.02, 70)})
# Geometric decrease: -1 % per year from age 40 onwards
lt.modify_qx({"decrement_geometric_increase": (-0.01, 40)})
The approximate cumulative growth \((1+c)^{\omega - x_0}\) must be finite and \(\leq 10^{12}\). Rates pushed above 1 are clipped age by age.
aggravated_risk#
Apply the substandard-life transform to survival probabilities:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
lt.modify_qx({"aggravated_risk": 1.5}) # moderately impaired life
lt.modify_qx({"aggravated_risk": 1.2}) # lightly impaired
aggravated_risk accepts any positive numeric scalar (int, float, or NumPy numeric), up to
\(100\). Values above 100 are rejected as likely input errors.
table_combination#
combination_mode#
Optional key in the same dict as table_combination. It declares the actuarial
assumption when merging decrement tables; it does not change which tables may be
combined.
Value |
Meaning |
|---|---|
(omitted) |
Same as |
|
Independent competing risks (product formula below) |
|
Uniform Distribution of Decrements (UDD) — Lactuca applies the correct |
associated-single formula for two or three causes internally |
Default. Omitting the key is equivalent to "independent" and matches pre-v1
behaviour. Pass "independent" explicitly when you need traceability in
summary() / modifications_applied.
Mode "udd". Host + one other table (two causes) uses the two-way UDD
associated singles; host + two others (three causes, e.g. Masa Activa with
[dt, et]) uses the three-way Bowers/Jordan formula. Host + three or more
others raises ValueError in v1.
v1 collapsed total. For two or three causes, "independent" and "udd"
produce the same stored vector \(q_x^{\mathrm{comb}}\) (algebraic identity). The
difference is declarative — which assumption is recorded. Cause-specific
associated rates \(q'^{(j)}\) are not returned in v1 (planned for a future release).
Key order. combination_mode may appear before or after table_combination in
the dict (same result). Other keys (decrement_multiplier, age_shift, …) still
depend on insertion order — see Combining modifications below.
Order of others. For the collapsed total \(q_x^{\mathrm{comb}}\) in v1, permuting
the list of other tables (e.g. [dt, et] vs [et, dt]) yields the same result
up to float64 rounding noise — the product and UDD collapsed formulas are commutative
in the other causes when the host is fixed. List order does not change stored
\(q_x^{\mathrm{comb}}\); dict key order for other modifications still matters (see above).
UDD formulas (v1 collapsed total). For two causes with host \(q^{(0)}\) and one other \(q^{(1)}\):
For three causes (host plus two others), Lactuca sums three associated singles (Bowers/Jordan); the total equals the independent product above.
Note
Under UDD, the summed associated single decrements equal the independent product
formula for two or three causes. Lactuca selects the UDD formula automatically
when combination_mode="udd".
Combine the table with one or more other decrement tables using the independent competitive risks formula:
where the product runs over the base table and every table in the argument. This is the exact result for independent decrements and avoids any intermediate rounding.
Argument forms — all three are equivalent for a single extra table:
# Single DecrementTable
lt.modify_qx({"table_combination": et})
# List (use this when combining with more than one table)
lt.modify_qx({"table_combination": [et, dt]})
# Tuple
lt.modify_qx({"table_combination": (et, dt)})
Allowed type combinations#
The combination matrix is actuarially motivated:
Base table |
Can combine with |
|---|---|
|
|
|
|
|
|
Combinations not in this matrix raise ValueError.
Note
The matrix reflects standard actuarial practice: a life table is the dominant decrement; it makes sense to enrich it with exit or disability causes, but combining two life tables (same cause) has no actuarial meaning. Similarly, an ExitTable is a secondary decrement that can only be combined with another ExitTable (two independent exit causes) or used as the other table from a LifeTable or DisabilityTable perspective.
The matrix is directional. It is the table_type of the base table (the one
you call modify_* on) that determines what is allowed, not the other way around.
For example, lt.modify_qx({"table_combination": et}) is valid (Life absorbs Exit),
but et.modify_ox({"table_combination": lt}) raises ValueError (Exit cannot absorb Life).
Additional constraints#
All tables in the combination must share the same sex. Beyond that, two optional checks apply when both tables carry the relevant metadata:
Cohort mismatch: if both tables have a non-
Nonecohort and they differ, combining is rejected (ValueError). A period table (cohort=None) can always combine with a generational table.Duration mismatch: if both tables have a numeric duration and they differ, combining is rejected.
duration=Noneor"ult"can combine freely.Generational
base_year: when host and other are both generational with differentbase_year, Lactuca emits aUserWarning(each table keeps its own projection before merge; no cross-table base-year validation).Select-ultimate host:
table_combinationmerges the host’s duration-specific base column fixed at construction time (the same rates loaded for thedurationargument passed toLifeTable(...)). It does not re-resolve select columns aftermodify_*. Combine before relying on a different duration column, or build a separate table instance per duration.Duplicate instances: the host cannot appear in
others, and the same table instance cannot be listed twice ([et, et]raisesValueError). Two distinct instances with identical rates remain valid (e.g. sensitivity with two independent exit causes).
Age alignment and table length#
Lactuca aligns tables by calendar age, not by “shortest array wins”.
Index convention. For valid .ltk tables, decrement array index \(i\) corresponds to
integer age \(x = i\). Tables with start_age > 0 (e.g. disability tables starting at
age 20) store zero padding in indices \(0,\ldots,\text{start\_age}-1\); index 25 is
always age 25 for every table type.
Host table. The table you call modify_* on is the host. Its decrement array
length after any prior keys in the same dict (e.g. age_shift) is preserved through
the combination step, except for the terminal rule below.
age_shift before table_combination. When both keys appear in one dict, a prior
age_shift of \(n\) years is tracked as a calendar-age offset: host row index \(i\)
combines with other rates at calendar age \(x = i + n\), not at index \(i\). This
matches the shifted semantics \(q_x^{\text{shifted}} = q_{x+n}\) and makes
{"age_shift": n, "table_combination": …} actuarially equivalent to combining
first and then shifting.
Shorter other tables. If an other table has a lower terminal age \(\omega\) than the host, ages above that \(\omega\) contribute implicit \(q_{\text{other}} = 0\) (no additional competing risk). Example: Life (\(\omega = 108\)) + Exit (\(\omega = 100\)) uses only mortality for ages 101–108 until the combined rate reaches 1.
Per-age formula (host index \(i\), calendar age \(x = i + n\) when a prior
age_shift of \(n\) was applied in the same dict; otherwise \(n = 0\)):
where each \(q^{(j)}_x\) is taken from the other table at calendar age \(x\) when \(x\) is within that table’s array; otherwise the factor \((1 - q^{(j)}_x)\) is \(1\).
Truncation at certainty. After combination, the array is truncated at the first
index where \(q^{\text{comb}} \approx 1\) (tolerance \(10^{-12}\); includes \(\omega\) rows
where the other table has \(q = 1\), e.g. mandatory exit at retirement age). This may
shorten the host below its original \(\omega\) even when implicit zero applied above.
When a prior age_shift of \(n\) was applied in the same dict, the UserWarning and
summary() note report both the truncation index in the shortened array and the
calendar age \(n + \text{index}\) (e.g. index 60, calendar age 100 after
age_shift=40 with exit at age 100).
Combined \(q = 1\) truncation#
Important
Two length rules (do not confuse them).
Shorter other \(\omega\) — ages above the other table’s terminal age use implicit \(q_{\text{other}} = 0\); the host keeps its length until rule 2 applies.
Combined \(q = 1\) — the result is truncated at that age;
wmay drop below the host’s pre-combination \(\omega\).
Worked example (bundled tables). PASEM2010 (male) has \(\omega = 112\); DummyEXIT
has mandatory exit \(o_x = 1\) at age 100 (\(\omega = 100\)). After
lt.modify_qx({"table_combination": et}):
lt.wbecomes 100 (not 112).Ages 101–112 are removed because \(q^{\text{comb}}_{100} = 1\).
Lactuca emits a
UserWarningand records the truncation insummary().
from lactuca import LifeTable, ExitTable
lt = LifeTable("PASEM2010", "m")
et = ExitTable("DummyEXIT", "m")
w_before = lt.w # 112
lt.modify_qx({"table_combination": et})
assert lt.w == et.w == 100
assert w_before > lt.w
print(lt.summary()) # includes Note: table_combination truncated omega (112->100 ...)
Use warnings.filterwarnings in batch pipelines if you intentionally combine with a
table that forces exit before the host’s natural \(\omega\).
Rejected inputs (ValueError):
Condition |
Reason |
|---|---|
Incompatible |
See matrix below |
Different |
|
Both cohorts set and differ |
Period ( |
Both numeric |
|
|
Other table shortened (often by |
Positive rates in other padding below |
Cannot align by age index |
Positive rates in host padding below |
Cannot align by age index (includes partial |
Host or other rate outside \([0, 1]\) before combine |
Invalid input — raised instead of silent clip |
Combined \(q > 1\) at any aligned age |
Invalid competing risks (message reports index and calendar age when |
Host instance in |
Same object twice inflates decrements — use distinct instances |
|
Orphan key |
|
UDD limited to 2–3 causes in v1 |
Unknown |
Invalid literal |
age_shift on the other table. Do not call age_shift on a table you only
pass into table_combination. That shortens the other array and raises
ValueError. To evaluate combined rates from age \(x\) onward on the table you are
modifying, use {"age_shift": x, "table_combination": other} on that table
(e.g. LifeTable.modify_qx), not other.modify_* first.
Beyond-\(\omega\) API vs combination. For an other table shorter than the
table you are modifying, the public accessor other.ix(age) / other.ox(age) may
return 1.0 when age exceeds that table’s \(\omega\) (standard beyond-\(\omega\)
convention). Combination does not use that value: it reads other._decrement
and treats ages above the other \(\omega\) as implicit \(q_{\text{other}} = 0\).
When validating by hand, use aligned base-array values, not beyond-\(\omega\) API
returns. Example: DummySD2015 has \(\omega = 65\); at age 75, dt.ix(75) is 1.0
but combination uses \(i_{75} = 0\), so lt.qx(75) equals life mortality only.
Partial MDDT support (v1). table_combination with
combination_mode="udd" declares the UDD multiple-decrement assumption and stores
the collapsed total decrement \(q_x^{\mathrm{comb}}\) for integer ages.
Cause-specific associated rates \(q'^{(j)}\) are not returned in the public API in v1.
Fractional-year survival. After combination (both independent and udd in v1),
fractional lx, px(m>1), and tpx use config.lx_interpolation on the collapsed
total \(q_x^{\mathrm{comb}}\) stored in the host table. With default linear (UDD)
interpolation this is \({}_s p_x = 1 - s\,q_x^{\mathrm{comb}}\) within each year,
coherent with the annual recursion \(l_{x+1} = l_x(1-q_x^{\mathrm{comb}})\).
The udd mode records the UDD associated-single assumption used to build
\(q^{\mathrm{comb}}\) at combine time; fractional survival matches independent
while v1 stores only the collapsed vector.
Masa Activa (aggregated, not Markov). Combining life + disability + exit treats mortality, disability incidence (\(i_x\)), and exit (\(o_x\)) as mutually exclusive decrements in the same policy year (competing risks on the active population). This is standard for simplified active-member tables; it is not a multi-state Markov model (no disabled-state recovery, no separate disabled-life mortality path).
Data source. Combination reads each other._decrement (the active float64
vector on that table instance), not other._decrement_base. If the other table was
previously modified (e.g. et.modify_ox({"decrement_multiplier": 1.1})), those
adjusted rates are combined. For file/base rates, call other.reset_modifications()
or pass a fresh instance. Combination does not use the public qx() / ix() /
ox() accessors (rounded; beyond-\(\omega\) returns may differ from alignment).
Single-table combination example#
from lactuca import LifeTable, ExitTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
et = ExitTable("DummyEXIT", "m")
# Combine: life + exit (independent risks)
lt.modify_qx({"table_combination": et})
# lt.qx(x) now returns the probability of decrement from EITHER cause
# (death OR exit) — the net single decrement in a multiple-decrement framework.
Note
DummyEXIT and DummySD2015 are bundled test tables provided for experimentation
and unit testing. In production you would load an ExitTable and DisabilityTable
from your own .ltk files using TableBuilder.
Combining three independent decrements#
A common case in pension actuarial work is the active-member table, which combines three independent decrements: mortality, disability onset, and voluntary exit. Pass them in one call:
from lactuca import LifeTable, DisabilityTable, ExitTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
dt = DisabilityTable("DummySD2015", "m")
et = ExitTable("DummyEXIT", "m")
# Single call — q = 1 − (1−q_life)(1−q_disability)(1−q_exit)
lt.modify_qx({"table_combination": [dt, et]})
# Same total q in v1, UDD assumption recorded explicitly (Masa Activa)
lt.modify_qx({"table_combination": [dt, et], "combination_mode": "udd"})
Life + Disability: incidence \(i_x\) at age 25 combines with mortality \(q_x\) at the same index (age 25). Ages above the disability table’s \(\omega\) use only host mortality (implicit \(i_x = 0\)). Ages where the exit table has \(o_x = 1\) yield combined \(q_x = 1\) and truncate the result there.
combination_mode examples#
from lactuca import LifeTable, DisabilityTable, ExitTable
import pytest
lt = LifeTable("PASEM2020_Rel_1o", "m")
et = ExitTable("DummyEXIT", "m")
dt = DisabilityTable("DummySD2015", "m")
# Default — independent; combination_mode not listed in modifications_applied
lt.modify_qx({"table_combination": et})
# UDD two causes
lt.modify_qx({"table_combination": et, "combination_mode": "udd"})
# Explicit independent — appears in summary() / modifications_applied
lt.modify_qx({"table_combination": et, "combination_mode": "independent"})
# Disability host + exit (two causes)
dt.modify_ix({"table_combination": et, "combination_mode": "udd"})
# Errors (ValueError)
lt.modify_qx({"combination_mode": "udd"}) # orphan key
lt.modify_qx({"table_combination": [dt, et, et], "combination_mode": "udd"}) # 4+ causes
See Error Reference for exact message patterns.
Combining modifications#
Any subset of the six keys can appear in a single dict. They are applied in insertion order; order is significant:
# Age-shifted, loaded, and combined — all in one call
from lactuca import LifeTable, ExitTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
et = ExitTable("DummyEXIT", "m")
lt.modify_qx({
"age_shift": 2,
"decrement_multiplier": 1.05,
"table_combination": et,
})
Example showing order dependence:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m")
# Order A: scale first, then aggravated risk
# result = 1 - (1 - 1.05 * qx)^1.5
lt.modify_qx({"decrement_multiplier": 1.05, "aggravated_risk": 1.5})
print(lt.qx(60)) # result A
# Order B: aggravated risk first, then scale
# result = 1.05 * (1 - (1 - qx)^1.5)
lt.modify_qx({"aggravated_risk": 1.5, "decrement_multiplier": 1.05})
print(lt.qx(60)) # result B — different from A
Order matrix (verified behaviour).
Key pair |
Order significant? |
Notes |
|---|---|---|
|
No |
Pre-scan; same collapsed |
|
No |
Calendar-age alignment (host index \(i\) ↔ age \(i+n\)) |
|
No |
Collapsed total \(q\) in v1 (float noise only) |
|
Yes |
Scales host before vs after merge |
|
Yes |
Non-linear survival transform vs linear scale |
|
Yes |
Tail indices depend on pre-shift length |
|
Yes |
Geometric tail applies to pre-merge vs post-merge rates (different \(q_x\)) |
|
Length must match |
Array length follows the table after prior keys (may differ from base \(\omega\)) |
|
Yes (clip policy) |
|
Precision#
All intermediate operations run in float64. The final array is:
Checked for non-finite values — any NaN or Inf raises
ValueError.Clipped to \([0, 1]\) (including rates pushed above 1 by
decrement_multiplierordecrement_geometric_increase;table_combinationraises instead of clipping when host/other rates are outside \([0, 1]\) or combined \(q > 1\) before this step).Truncated at the first index where \(q \approx 1\) and a positive tail survives (\(0 < q_{x+1} < 1\)), keeping
lx/excoherent with \(l_{x+1} = l_x(1-q_x)\). Terminal plates (\(q_\omega \approx 1\) only) are unchanged. AUserWarningis emitted when \(\omega\) drops; see Combined q = 1 truncation.Rounded to the table-type decimal precision:
LifeTable→config.decimals.qxDisabilityTable→config.decimals.ixExitTable→config.decimals.ox
See Decimal Precision and Rounding for how to configure these precision settings.
See also#
Using Actuarial Tables — how to instantiate and configure tables
Numerical Precision — float64 and rounding policy
Decimal Precision and Rounding — how to configure decimal precision