lifelib is an open-source Python package designed for actuarial modelling. It's a significant contribution to the actuarial community, offering one of the few — if not the only — open-source actuarial cash flow models. This makes it a valuable tool for enhancing modelling skills.
The package includes multiple libraries, one of which covers basic life insurance products. In this post, we'll focus on BasicTerm_SE, a model for term insurance with in-force policies. lifelib is built on modelx, which handles model execution.
We'll also explore an alternative approach by modelling the same product using cashflower, another actuarial modelling package.
List of content:
Input
Model point set
The policy data is stored in a separate file named model_point_table.csv. We load this data into cashflower using the ModelPointSet class:
# input.py
from cashflower import ModelPointSet
import pandas as pd
policy = ModelPointSet(data=pd.read_csv("input/model_point_table.csv"))
Each row in this table represents a policyholder, with the following attributes:
- policy_id - unique identifier
- age_at_entry - age at policy issue
- sex - male or female
- policy_term - policy term in years
- policy_count - number of policies
- sum_assured - sum assured
- duration_mth - months elapsed from issue to t=0
Here's a preview of the data:
policy_id age_at_entry sex policy_term policy_count sum_assured duration_mth
1 47 M 10 86 622000.0 1
2 29 M 20 56 752000.0 210
3 51 F 10 83 799000.0 15
4 32 F 20 72 422000.0 125
5 28 M 15 99 605000.0 55
... ... .. ... ... ... ...
9996 47 M 20 25 827000.0 157
9997 30 M 15 81 826000.0 168
9998 45 F 20 10 783000.0 146
9999 39 M 20 9 302000.0 11
10000 22 F 15 18 576000.0 166
This dataset defines the policies that will be used in our model. Each policyholder has a unique combination of attributes that influence the cash flow projections.
Assumptions
The model relies on several assumptions, including discount rates, mortality probabilities and premium rates, as well as fixed expense and inflation assumptions. These inputs define the financial and actuarial dynamics of the model.
# input.py
assumption = {
"disc_rate_ann": pd.read_csv(disc_rate_ann_path, index_col="year"),
"mort_table": pd.read_csv(mort_table_path, index_col="Age"),
"premium_table": pd.read_csv(premium_table_path),
"loading_prem": 0.5,
"expense_acq": 300,
"expense_maint": 60,
"inflation_rate": 0.01,
}
Assumption tables
The model reads three key tables:
- disc_rate_ann.csv - annual discount rates
- mort_table.csv - mortality rates by age and year
- premium_table.csv - premium rates based on age at entry and policy term
Annual discount rates
The discount rate table provides the zero-coupon spot rates for each future year. These rates determine how future cash flows are discounted to present value.
year zero_spot
0 0.00000
1 0.00555
2 0.00684
3 0.00788
4 0.00866
... ...
146 0.03025
147 0.03033
148 0.03041
149 0.03049
150 0.03056
Mortality table
The mortality table contains probabilities of death for different ages and policy durations. Each column represents a policy duration, while rows correspond to ages.
Age 0 1 2 3 4 5
18 0.000231 0.000254 0.000280 0.000308 0.000338 0.000372
19 0.000235 0.000259 0.000285 0.000313 0.000345 0.000379
20 0.000240 0.000264 0.000290 0.000319 0.000351 0.000386
21 0.000245 0.000269 0.000296 0.000326 0.000359 0.000394
22 0.000250 0.000275 0.000303 0.000333 0.000367 0.000403
... ... ... ... ... ... ...
116 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
117 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
118 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
119 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
120 1.000000 1.000000 1.000000 1.000000 1.000000 1.000000
Premium rates
The premium rate table provides pricing for policies based on age at entry and policy term. These rates are used to determine policyholder contributions.
age_at_entry policy_term premium_rate
20 10 0.000046
20 15 0.000052
20 20 0.000057
21 10 0.000048
21 15 0.000054
... ... ...
58 15 0.000433
58 20 0.000557
59 10 0.000362
59 15 0.000471
59 20 0.000609
Other assumptions
In addition to the tables, the model incorporates fixed expense and pricing assumptions:
- premium loading = 50% - additional charge on base premium
- acquisition expense = 300 - one-time cost per policy at issue
- maintenance expense = 60 - annual ongoing cost per policy
- inflation rate = 1% - used for expense escalation over time
These assumptions provide the necessary foundation for projecting policyholder cash flows and ensuring the model reflects real-world pricing and risk factors.
Model
Policyholder
Policyholder's attributes
We determine the policyholder's time in force, age and projection length.
@variable()
def duration_mth(t):
if t == 0:
return model_point.get("duration_mth")
else:
return duration_mth(t-1) + 1
@variable()
def duration(t):
return duration_mth(t) // 12
@variable()
def age(t):
return model_point.get("age_at_entry") + duration(t)
@variable()
def proj_len():
return max(12 * model_point.get("policy_term") - model_point.get("duration_mth") + 1, 0)
Premium calculation
The premium per policy is determined based on the sum assured and the premium rate from the assumption table.
@variable()
def premium_pp():
df = assumption["premium_table"]
age_at_entry = model_point.get("age_at_entry")
policy_term = model_point.get("policy_term")
premium_rate = df.loc[(df['age_at_entry'] == age_at_entry) &
(df['policy_term'] == policy_term), 'premium_rate'].values[0]
return round(model_point.get("sum_assured") * premium_rate, 2)
Lapse and mortality rates
The lapse rate is assumed to decrease with duration but remains above a minimum threshold.
@variable()
def lapse_rate(t):
return max(0.1 - 0.02 * duration(t), 0.02)
Mortality rates are read from the mortality table. The function avoids redundant reads for performance optimization.
@variable()
def mort_rate(t):
if t > 0 and age(t-1) == age(t) and (duration(t-1) == duration(t) or duration(t) > 5):
return mort_rate(t-1)
age_t = int(max(min(age(t), 120), 18))
duration_t = str(int(max(min(duration(t), 5), 0)))
return assumption["mort_table"].loc[age_t, duration_t]
@variable()
def mort_rate_mth(t):
return 1-(1-mort_rate(t))**(1/12)
Economic
We define the discount rates and inflation factor used in the model.
Discount rates
The annual discount rate is retrieved from the assumption table, avoiding unnecessary lookups within the same year.
@variable()
def discount_ann(t):
if t > 0 and (t-1)//12 == t//12:
return discount_ann(t-1)
return assumption["disc_rate_ann"].loc[t//12, 'zero_spot']
The monthly discount rate is derived from the annual rate.
@variable()
def discount(t):
return (1 + discount_ann(t))**(-t/12)
Inflation factor
The inflation factor grows exponentially based on the inflation rate assumption.
@variable()
def inflation_factor(t):
return (1 + assumption["inflation_rate"])**(t/12)
Cash flows
The model distinguishes between inflows (premiums) and outflows (claims, commissions, and expenses). The net cash flow is the difference between these components.
Inflows
Premiums are received monthly as long as the policy is active.
@variable()
def premiums(t):
if t >= proj_len():
return 0
return premium_pp() * pols_if_at_bef_decr(t)
Outflows
Claims are paid when policyholders pass away.
@variable()
def claims(t):
if t >= proj_len():
return 0
return model_point.get("sum_assured") * pols_death(t)
Commissions are paid in full when the policy is sold.
@variable()
def commissions(t):
if duration(t) == 0:
return premiums(t)
else:
return 0
Expenses consist of acquisition costs at policy inception and ongoing maintenance costs, adjusted for inflation.
@variable()
def expenses(t):
if t >= proj_len():
return 0
return (assumption["expense_acq"] * pols_new_biz(t) + pols_if_at_bef_decr(t) * assumption["expense_maint"]/12
* inflation_factor(t))
Net cash flow
Net cash flow is the difference between inflows and outflows.
@variable()
def net_cf(t):
return premiums(t) - claims(t) - expenses(t) - commissions(t)
Policy count
This section tracks the number of policies in force (IF) at different stages, considering new business, lapses, deaths and maturities.
Initial policy count
At the start of the projection, policies may already exist.
@variable()
def pols_if_init():
if duration_mth(0) > 0:
return model_point.get("policy_count")
else:
return 0
Policies before decrements
The number of policies before lapses, death and maturities is the sum of last period's in-force policies and new business.
@variable()
def pols_if_at_bef_decr(t):
return pols_if_at_bef_nb(t) + pols_new_biz(t)
Decrements (lapses and deaths)
Lapses occur based on the lapse rate.
@variable()
def pols_lapse(t):
return (pols_if_at_bef_decr(t) - pols_death(t)) * (1 - (1 - lapse_rate(t)) ** (1 / 12))
Deaths occur based on the mortality rate.
@variable()
def pols_death(t):
return pols_if_at_bef_decr(t) * mort_rate_mth(t)
Policies before maturities
After accounting for lapses and deaths, we determine policies before maturities.
@variable()
def pols_if_at_bef_mat(t):
if t == 0:
return pols_if_init()
else:
return pols_if_at_bef_decr(t-1) - pols_lapse(t-1) - pols_death(t-1)
Maturities
Policies mature when they reach the end of their term.
@variable()
def pols_maturity(t):
if duration_mth(t) == model_point.get("policy_term") * 12:
return pols_if_at_bef_mat(t)
else:
return 0
Policies before new business
This represents policies before adding new business for the period.
@variable()
def pols_if_at_bef_nb(t):
return pols_if_at_bef_mat(t) - pols_maturity(t)
New business
New policies are added when they are first issued.
@variable()
def pols_new_biz(t):
if duration_mth(t) == 0:
return model_point.get("policy_count")
else:
return 0
Final policies in force
The final number of in-force policies after all adjustments.
@variable()
def pols_if(t):
return pols_if_at_bef_mat(t)
Present values
Present value (PV) calculations discount future cash flows to today's value. These help in assessing the financial position of a policy over time.
Present value of policies in force
This represents the discounted sum of future in-force policies.
@variable()
def pv_pols_if(t):
if t == settings["T_MAX_CALCULATION"]:
return pols_if(t) * discount(t)
return pols_if(t) * discount(t) + pv_pols_if(t+1)
Present value of premiums
The total value of all future premium payments, discounted to today.
@variable()
def pv_premiums(t):
if t == settings["T_MAX_CALCULATION"]:
return premiums(t) * discount(t)
return premiums(t) * discount(t) + pv_premiums(t+1)
Present value of claims
The total expected claim payouts, adjusted for the time value of money.
@variable()
def pv_claims(t):
if t == settings["T_MAX_CALCULATION"]:
return claims(t) * discount(t)
return claims(t) * discount(t) + pv_claims(t+1)
Present value of commissions
Total commissions payable over time, discounted to present value.
@variable()
def pv_commissions(t):
if t == settings["T_MAX_CALCULATION"]:
return commissions(t) * discount(t)
return commissions(t) * discount(t) + pv_commissions(t+1)
Present value of expenses
The total expected expenses incurred, adjusted for discounting.
@variable()
def pv_expenses(t):
if t == settings["T_MAX_CALCULATION"]:
return expenses(t) * discount(t)
return expenses(t) * discount(t) + pv_expenses(t+1)
Present value of net cash flows
The net position of the policy, factoring in all cash flows.
@variable()
def pv_net_cf(t):
return pv_premiums(t) - pv_claims(t) - pv_expenses(t) - pv_commissions(t)
Net premium per policy
The premium required to cover expected claims, assuming no profit or loss.
@variable()
def net_premium_pp():
if math.isclose(pv_pols_if(0), 0):
return 0
return pv_claims(0) / pv_pols_if(0)
Conclusion
In this post, we built a structured cash flow model, step by step, covering everything from policyholder attributes to present values. We started by defining key variables, such as policy duration and age, and moved on to economic factors like discount rates and inflation. We then outlined cash flows, policy counts, and finally, the present values that form the foundation for financial calculations.
This model serves as a clear and systematic way to project cash flows for insurance policies. The logic behind it is based on lifelib, an excellent open-source library for actuarial modelling. A huge thank you to the developers of lifelib for their work.
If you have any thoughts or questions, feel free to share them in the comments!