Skip to main content

Vectorized life actuarial cash-flow and BEL engine

Project description

lifeflow

Python library for life actuarial cash-flow calculations and BEL.

Built on numpy and numba — all calculations are vectorized as N × T grids, where N is the number of policies and T is the time horizon. The decrement engine is JIT-compiled with numba, and cash flows can be compiled too.

A worked end-to-end example — computing the BEL of a portfolio of mixed endowments — is published at https://sergiactuary.github.io/lifeflow/ (source in docs/example.qmd).


What it does

lifeflow lets the actuary define their own cash flows as plain Python functions. The library then automates:

  • tINFx derivation — policy in-force grid from any combination of decrements
  • Exit grids — per-cause exit rates under UDD or constant force, exact for any number of causes, computed once and cached alongside the in-force
  • Decrements — mortality, lapse, disability or any rate vector, indexed by age, duration or any policy variable
  • Portfolio extension — cash flows written for a single policy broadcast automatically across the whole portfolio
  • Interest-rate sensitivity — Macaulay and Hicks duration, and convexity, over a spot curve

The result of every object is a plain numpy array of shape N × T, inspectable at any step.


Installation

pip install lifeflow

Usage

import numpy as np
import polars as pl
import lifeflow as lf

df = pl.DataFrame({
    "POLICY_ID":     [1, 2, 3],
    "age_months":    [480, 540, 600],
    "term_months":   [120, 180, 240],
    "death_capital": [100_000.0, 250_000.0, 50_000.0],
})

portfolio = lf.Portfolio(df, id_col="POLICY_ID")
tl        = lf.Timeline("term_months")

Timeline takes the column holding each policy's contract boundary. It sets the horizon T (the longest policy) and masks every grid past each policy's own end.

Rates already on the projection frequency are wrapped as decrements, indexed by a policy variable:

qx = lf.Decrement(qx_monthly, tl, index_var="age_months")
wx = lf.Decrement(wx_monthly, tl, index_var="age_months")

Splitting annual rates to a monthly grid is a one-line numpy operation and is left to the caller — 1 − (1−q)**(1/12) repeated over each year for a decrement, (1+i)**(1/12) − 1 for a rate that compounds upward.

Probabilities derives the in-force grid and the exits by cause. Both are computed lazily and cached:

prob = lf.Probabilities(portfolio, [qx, wx])

tINFx      = prob.inforce       # N × T
exit_death = prob.exit_by(qx)   # N × T
exit_lapse = prob.exit_by(wx)   # N × T

inforce is P(in-force): survival across all decrements (stochastic) combined with contract vigency (deterministic), so it is zero once the policy has expired.

exit_by() with no argument returns all causes stacked as K × N × T.

Cash flows are written for a single policy at a single instant. The @grid decorator extends them to the full N × T portfolio — every parameter after t is matched by name against a portfolio column:

@lf.grid(portfolio, tl, jit=True)
def death_benefit(t, death_capital):
    return death_capital

@lf.grid(portfolio, tl, jit=True)
def discount(t):
    return 1.0 / (1.0 + 0.03) ** (t / 12)

bel = (exit_death * death_benefit() * discount()).sum(axis=1)

t runs from 1 to T, not from 0. The present is never a projection period: the first column is one period into the future, already discounted. Every part of the library follows this — @grid, @extend_t, and the duration functions in alm.

Use jit=True for purely mathematical flows: the function is compiled with numba.vectorize and runs over the grid with no Python loop. For flows that need arbitrary Python (lookups, branching on objects), leave the default jit=False and the rows are computed as plain sequential Python. A flow returning a tuple yields one N × T grid per element (supported on the default jit=False path).

Prepayable flows

A prepayable flow is collected at the start of the period, so its last payment falls one period before the contract expires. The formula stays yours — write t + 1 if that is what the product does — but the shortened vigency does not: it depends on each policy's own duration, so with staggered terms the cut-off lands on a different column for every row. That is what payable="pre" handles:

@lf.grid(portfolio, tl, payable="pre")
def premium(t):
    return 400.0 + 30.0 * t        # the t+1 shift is written by you

payable="post" is the default and leaves the grid untouched — no masking is needed there, since inf already carries the vigency into the product. Under "pre", skipping the cut-off leaves a payment in the last vigent cell where the in-force is still positive: the result comes out plausible and roughly 40% high, with no warning.

To build a single 1-D vector of length T rather than a portfolio grid, use @extend_t:

@lf.extend_t(240)
def v(t, rate):
    return 1.0 / (1.0 + rate) ** t

v(0.03)  # shape (240,)

Any grid can be dumped to a polars DataFrame for inspection, with real dates as column headers:

lf.audit_grid(tINFx, portfolio, id_col="POLICY_ID", present_date="31/12/2025")

Interest-rate sensitivity

Duration and convexity are portfolio-level ALM measures, so they take the cash flow of the whole book — collapse the grid first — together with a vector of spot rates on the same frequency and of the same length:

flow = (exit_death * death_benefit()).sum(axis=0)   # N × T  →  T

lf.duration_macaulay(flow, spot_monthly)   # months
lf.duration_hicks(flow, spot_monthly)      # months
lf.convexity(flow, spot_monthly)           # months²

Discounting is spot by maturity, v_t = (1 + s_t)^-t, not chained — an EIOPA curve split to monthly gives spot rates, and compounding them as if they were forwards overstates the present value.

duration_hicks and convexity differentiate with respect to a parallel shift of the whole curve, so they need no single IRR: each term is divided by its own (1 + s_t) once or twice. With a flat curve they collapse to the textbook D/(1+i) and its second derivative.

These are deliberately not computed per policy. Portfolio duration is not the average of policy durations — it is the present-value-weighted average — so returning a vector would invite an incorrect .mean(). Filter the rows you want and collapse those instead.


Notes

exit_by splits the period's exits between the competing causes under one of two assumptions, chosen with method:

method assumption survival within the period
"udd" (default) uniform distribution of decrements 1 − s·q (linear)
"cf" constant force (1−q)^s (exponential)

Both are exact, not approximations. Which one to use is an actuarial choice, not a numerical one: within a period you only observe who entered and who left, never when. The two agree to about 1e-6 at typical monthly rates and diverge as rates grow.

Under UDD the exit probability is

(aq)_j = q_j · ∫₀¹ Π_{m≠j} (1 − s·q_m) ds

which the engine evaluates by Gauss–Legendre quadrature. The integrand is a polynomial of degree K−1, so ⌈K/2⌉ nodes integrate it exactly — no error is introduced. The cost is O(K³) rather than the O(K·2^K) of the equivalent closed form, so there is no practical limit on the number of decrements.

docs/udd_integral.tex derives the equivalence between the two formulations and measures the difference in cost. The closed form itself is kept in tests/reference_udd.py and used to validate the engine.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lifeflow-0.1.0.tar.gz (73.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lifeflow-0.1.0-py3-none-any.whl (13.1 kB view details)

Uploaded Python 3

File details

Details for the file lifeflow-0.1.0.tar.gz.

File metadata

  • Download URL: lifeflow-0.1.0.tar.gz
  • Upload date:
  • Size: 73.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for lifeflow-0.1.0.tar.gz
Algorithm Hash digest
SHA256 cf240e56a721078b244c11594bb0c9635fc434e61a9401dbe9ff2fd4a398d211
MD5 1a9afd0faa9e1f0970d4c454ec3da38d
BLAKE2b-256 fba2010120b2bb903a8a8b4bbe1ecc3ea2e245f4908734c400d08a10b1a47045

See more details on using hashes here.

File details

Details for the file lifeflow-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: lifeflow-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 13.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for lifeflow-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 39dda545fe38dbf3385a0b8178d1fdd8adf25fe9fe4bbd7906bc5d14ade2ca8f
MD5 7550c4e5dacb182aaf681d01d6fadb6d
BLAKE2b-256 4c6d6b807c28da09b2516b5e678f9fb88e91ee0811f3140d94ca19f34c649053

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page