A worked, end-to-end example: computing the Best Estimate Liability (BEL) of a life-insurance portfolio (mixed endowment) with lifeflow, step by step in Python.
The product
We value a mixed endowment: a policy that pays
a survival benefit if the insured is alive at maturity, and
a death benefit if they die before then.
The insured pays premiums while the contract is in force, and the insurer incurs management expenses. We assume no lapses (no surrenders). The Best Estimate Liability (BEL) is the present value of what the insurer expects to pay out, less what it expects to collect:
Every term has the same shape —amount × probability × discount, summed over time— and they differ only in four choices, which we’ll walk through.
Data and assumptions
We load the portfolio and keep the men. Each row is a policy; its columns (sums assured, premiums, technical rate, age in months, duration…) are what the library projects from.
A key idea when using lifeflow: every policy in a Portfolioshares the same actuarial assumptions — the same survival/death table, the same lapse assumption, and so on. An assumption is a single vector; per policy it is adjusted only at its start (where it begins reading the table) and its end (where the contract expires), but every policy draws from that same vector. That is why we split the book before projecting: policies that need a different table belong to a different Portfolio.
import polars as plimport numpy as npimport lifeflow as lfdf = pl.read_parquet("../data/example_lifeflow.parquet")df_m = df.filter(pl.col("sexo") =="H")df_m.head(10)
shape: (10, 17)
poliza_id
numero_poliza
producto_codigo
sexo
frecuencia_pago
capital_supervivencia_inicial
capital_fallecimiento_inicial
prima_anual_inicial
tipo_interes_tecnico
tabla_mortalidad
tasa_revalorizacion_supervivencia
tasa_revalorizacion_fallecimiento
tasa_revalorizacion_prima
tasa_gasto_sobre_capital
duration_if
duration_end
age
i64
str
str
str
i64
f64
f64
f64
f64
str
f64
f64
f64
f64
i32
i32
i32
1
"MS0000001"
"MIXTO_SIMPLE"
"H"
12
25278.44
19511.67
2204.42
0.01
"GK80"
0.01
0.01
0.02
0.002
106
217
430
2
"MS0000002"
"MIXTO_SIMPLE"
"H"
2
147406.09
88907.65
3619.45
0.025
"GK80"
0.01
0.01
0.02
0.002
250
133
706
3
"MS0000003"
"MIXTO_SIMPLE"
"H"
12
193060.67
53539.67
3344.33
0.025
"GK80"
0.01
0.01
0.02
0.002
139
40
463
4
"MS0000004"
"MIXTO_SIMPLE"
"H"
1
148910.76
31949.81
4692.7
0.025
"GK80"
0.01
0.01
0.02
0.002
210
209
534
5
"MS0000005"
"MIXTO_SIMPLE"
"H"
12
56580.67
35964.13
8597.31
0.01
"GK80"
0.01
0.01
0.02
0.002
112
223
676
8
"MS0000008"
"MIXTO_SIMPLE"
"H"
4
72560.46
51796.88
8491.2
0.04
"GK80"
0.01
0.01
0.02
0.002
355
52
787
9
"MS0000009"
"MIXTO_SIMPLE"
"H"
12
231765.99
126432.31
6392.36
0.025
"GK80"
0.01
0.01
0.02
0.002
161
78
749
10
"MS0000010"
"MIXTO_SIMPLE"
"H"
2
109992.19
41885.65
2614.9
0.025
"GK80"
0.01
0.01
0.02
0.002
202
49
802
12
"MS0000012"
"MIXTO_SIMPLE"
"H"
12
164404.45
54318.48
3294.65
0.01
"GK80"
0.01
0.01
0.02
0.002
59
72
563
13
"MS0000013"
"MIXTO_SIMPLE"
"H"
1
265679.58
194905.23
4791.04
0.01
"GK80"
0.01
0.01
0.02
0.002
84
119
696
The mortality table comes at annual frequency, but the projection is monthly. We monthlyize it under constant force —convert the rate first, then repeat it, never the other way around— so that the annual mortality is preserved exactly:
Three objects build the engine. Portfolio wraps the df; Timeline reads each policy’s term from a column (it sets the horizon and masks out policies that have already matured); Decrement is the mortality table, indexed by each policy’s age in months.
Probabilities combines everything and gives us the two probabilities we need, each a grid of N policies × T months:
prob = lf.Probabilities(pfl, [qx])tpx = prob.inforce # P(still in force) at the end of each monthexit_death = prob.exit_by(qx) # P(dies) during each monthtpx.shape, exit_death.shape
((722, 392), (722, 392))
The cash flows
Each flow is written with @lf.grid: a function for one policy in one month, which the library extends to the whole book. The arguments after t are matched by name against the columns of the df. We set jit=True to compile with numba —all five functions are pure arithmetic, so it flies.
First the discount factor, which depends on each policy’s technical rate:
The survival benefit. It is paid only at maturity (t == duration_end); every other month is zero. The amount is the initial sum assured with its annual increase, the year anchored at the policy’s issue date:
@lf.grid(pfl, tl, jit=True)def capital_sup(t, capital_supervivencia_inicial, tasa_revalorizacion_supervivencia, duration_if, duration_end):if t != duration_end:return0.0 year = (duration_if + t -1) //12return capital_supervivencia_inicial * (1+ tasa_revalorizacion_supervivencia) ** yearcap_sup = capital_sup()cap_sup
The premiums. A premium is due only in the months when an installment falls, and they are payable in advance (payable="pre"): the installment that would fall at maturity is not collected, and the library takes care of that.
@lf.grid(pfl, tl, payable="pre", jit=True)def prima(t, prima_anual_inicial, tasa_revalorizacion_prima, frecuencia_pago, duration_if): periodo =12// frecuencia_pagoif (duration_if + t) % periodo !=0:return0.0 year = (duration_if + t -1) //12return prima_anual_inicial * (1+ tasa_revalorizacion_prima) ** year / frecuencia_pagopremium = prima()
The expenses: an annual percentage of the (revalued) survival sum assured, converted to a monthly charge.
@lf.grid(pfl, tl, jit=True)def gasto(t, capital_supervivencia_inicial, tasa_revalorizacion_supervivencia, tasa_gasto_sobre_capital, duration_if): year = (duration_if + t -1) //12 capital = capital_supervivencia_inicial * (1+ tasa_revalorizacion_supervivencia) ** yearreturn capital * ((1+ tasa_gasto_sobre_capital) ** (1/12) -1)expenses = gasto()
In short, each flow answers four questions:
flow
amount
probability
active months
survival
revalued sum assured
still in force
maturity only
death
revalued sum assured
dies that month
every month
premiums
premium / frequency
still in force
installment months (in advance)
expenses
% of sum assured
still in force
every month
The BEL
We weight each amount by its probability, discount it, and sum over time. The result is the BEL of each policy (axis=1 collapses the months):