Guide — computing the BEL of a portfolio

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:

\[ \text{BEL} = \text{PV}_{\text{survival}} + \text{PV}_{\text{death}} + \text{PV}_{\text{expenses}} - \text{PV}_{\text{premiums}} \]

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 Portfolio shares 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 pl
import numpy as np
import lifeflow as lf

df = 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:

qx_m = pl.read_csv("../data/hypotheses/qx_TEST_M.csv")["qx"].to_numpy()
qx_m = np.repeat(1 - (1 - qx_m) ** (1 / 12), 12)
qx_m.shape
(1440,)

The engine: probabilities

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.

pfl = lf.Portfolio(df_m, id_col="poliza_id")
tl = lf.Timeline("duration_end")
qx = lf.Decrement(qx_m, tl, index_var="age")

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 month
exit_death = prob.exit_by(qx)  # P(dies) during each month

tpx.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:

@lf.grid(pfl, tl, jit=True)
def discount(t, tipo_interes_tecnico):
    r = (1 + tipo_interes_tecnico) ** (1 / 12) - 1
    return (1 + r) ** (-t)

v = discount()
v
array([[0.99917115, 0.99834299, 0.99751551, ..., 0.72369463, 0.7230948 ,
        0.72249546],
       [0.9979444 , 0.99589302, 0.99384586, ..., 0.4482026 , 0.44728128,
        0.44636184],
       [0.9979444 , 0.99589302, 0.99384586, ..., 0.4482026 , 0.44728128,
        0.44636184],
       ...,
       [0.99917115, 0.99834299, 0.99751551, ..., 0.72369463, 0.7230948 ,
        0.72249546],
       [0.99917115, 0.99834299, 0.99751551, ..., 0.72369463, 0.7230948 ,
        0.72249546],
       [0.9979444 , 0.99589302, 0.99384586, ..., 0.4482026 , 0.44728128,
        0.44636184]], shape=(722, 392))

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:
        return 0.0
    year = (duration_if + t - 1) // 12
    return capital_supervivencia_inicial * (1 + tasa_revalorizacion_supervivencia) ** year

cap_sup = capital_sup()
cap_sup
array([[0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       ...,
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.]], shape=(722, 392))

The death benefit. Same as above, but without the if: it can be paid in any month, so it returns the amount in all of them.

@lf.grid(pfl, tl, jit=True)
def capital_death(t, capital_fallecimiento_inicial, tasa_revalorizacion_fallecimiento,
                  duration_if):
    year = (duration_if + t - 1) // 12
    return capital_fallecimiento_inicial * (1 + tasa_revalorizacion_fallecimiento) ** year

cap_death = capital_death()
cap_death
array([[ 21128.3426975 ,  21128.3426975 ,  21339.62612448, ...,
         29340.72002319,  29340.72002319,  29340.72002319],
       [108484.22900518, 108484.22900518, 109569.07129523, ...,
        150650.97323269, 150650.97323269, 150650.97323269],
       [ 59732.51510991,  59732.51510991,  59732.51510991, ...,
         82949.95150414,  82949.95150414,  82949.95150414],
       ...,
       [161085.69846276, 161085.69846276, 162696.55544739, ...,
        223698.11234151, 223698.11234151, 223698.11234151],
       [137916.92281628, 137916.92281628, 137916.92281628, ...,
        189627.58743794, 189627.58743794, 189627.58743794],
       [178197.46022377, 178197.46022377, 178197.46022377, ...,
        247461.04624131, 247461.04624131, 247461.04624131]],
      shape=(722, 392))

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_pago
    if (duration_if + t) % periodo != 0:
        return 0.0
    year = (duration_if + t - 1) // 12
    return prima_anual_inicial * (1 + tasa_revalorizacion_prima) ** year / frecuencia_pago

premium = 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) ** year
    return 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):

sup_flow      = cap_sup   * tpx
death_flow    = cap_death * exit_death
premium_flow  = premium   * tpx
expenses_flow = expenses  * tpx

provision_flow = sup_flow + death_flow + expenses_flow - premium_flow
vaa_provision  = provision_flow * v

bel_per_policy = vaa_provision.sum(axis=1)
bel_per_policy[:10]
array([ -22645.77176315,   96718.6649697 ,  192136.5333737 ,
         33071.87769218, -137743.72636635,   24618.23018286,
        186641.39794827,  105853.65399774,  150539.91919296,
        234447.96960602])

The total BEL of the male portfolio:

print(f"Portfolio BEL (men): {bel_per_policy.sum():,.2f} €  ({len(bel_per_policy)} policies)")
Portfolio BEL (men): 73,991,103.75 €  (722 policies)

And the portfolio’s provision profile month by month (axis=0 collapses the policies instead of time), useful to see the run-off:

portfolio_flow = vaa_provision.sum(axis=0)
portfolio_flow[:12]
array([ 402629.3123463 ,   51081.86525042, -255682.10264871,
        -52691.82831665,   81856.70885388,  204932.52673875,
        507405.57873792,  571624.97370704,  349315.37104314,
        453998.1753435 ,    3265.48275309,  255054.29515605])