Source code for actuarialmath.lifetable

"""Life Tables - Loads and calculates life tables

MIT License. Copyright 2022-2023 Terence Lim
"""
import math
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from typing import Dict
from actuarialmath import Reserves

[docs]class LifeTable(Reserves): """Calculate life table, and iteratively fill in missing values Args: udd : assume UDD or constant force of mortality for fractional ages verbose : whether to echo update steps Notes: 4 types of columns can be loaded and calculated in the life table: - 'q' : probability (x) dies in one year - 'l' : number of lives aged x - 'd' : number of deaths of age x - 'p' : probability (x) survives at least one year """ def __init__(self, udd: bool = True, verbose: bool = False, **kwargs): super().__init__(udd=udd, **kwargs) self._verbose = verbose self._table = {'l':{}, 'd':{}, 'q':{}, 'p':{}} # columns in life table # Set basic survival functions by interpolating lifetable integer ages def _mu(x: int, s: float) -> float: u = math.floor(s) return self.mu_r(x, s=u, r=s-u) def _l(x: int, s: float) -> float: u = math.floor(s) return self.l_r(x, s=u, r=s-u) def _S(x: int, s, t: float) -> float: u = math.floor(t) # u+r_t_x = u_t_x * return self.p_x(x, s=s, t=u) * self.p_r(x, s=s+u, t=t-u) def _f(x: int, s, t: float) -> float: u = math.floor(t) # f_x(u+r) = u_p_x * f_u(r) return self.p_x(x, s=s, t=u) * self.f_r(x, s=s+u, t=t-u) self.set_survival(mu=_mu, S=_S, f=_f, l=_l, minage=-1, maxage=-1)
[docs] def set_table(self, radix: int = Reserves._RADIX, minage: int = -1, maxage: int = -1, fill: bool = True, l: Dict[int, float] | None = None, d: Dict[int, float] | None = None, p: Dict[int, float] | None = None, q: Dict[int, float] | None = None) -> "LifeTable": """Update life table Args: l : lives at start of year x, or d : deaths in year x, or p : probabilities that (x) survives one year, or q : probabilities that (x) dies in one year fill : whether to automatically fill table cells (default is True) minage : minimum age in table maxage : maximum age in table radix : initial number of lives Examples: >>> life = LifeTable(udd=True).set_table(l={90: 1000, 93: 825}, >>> d={97: 72}, >>> p={96: .2}, >>> q={95: .4, 97: 1}) """ inputs = {k:v for k,v in {'l':l, 'd':d, 'q':q, 'p':p}.items() if v} # infer min and max ages from inputs if minage < 0: minage = min([min(v) for v in inputs.values()]) if self._MINAGE < 0 or minage < self._MINAGE: self._MINAGE = minage else: self._MINAGE = minage if maxage < 0: maxage = max([max(v) for v in inputs.values()]) if self._MAXAGE < 0: self._MAXAGE = maxage + 1 if maxage > self._MAXAGE: self._MAXAGE = maxage else: self._MAXAGE = maxage # update table from inputs for label, col in inputs.items(): self._table[label].update(col) # derive and fill table values if fill: self.fill_table(radix=radix) return self
[docs] def fill_table(self, radix: int) -> "LifeTable": """Iteratively fill in missing table cells (does not check consistency) Args: radix : initial number of lives """ def q_x(x: int) -> float | None: """Helper to try compute one-year mortality rate for (x): 1_q_x""" if x in self._table['q']: return self._table['q'][x] if x in self._table['p']: return 1 - self._table['p'][x] if x in self._table['d'] and x in self._table['l']: return self._table['d'][x] / self._table['l'][x] return None def p_x(x: int) -> float | None: """Helper to try compute one-year survival for (x): 1_q_x""" if x in self._table['p']: return self._table['p'][x] if x in self._table['q']: return 1 - self._table['q'][x] return None def l_x(x: int) -> float | None: """Helper to try compute number of lives aged x: l_x""" if x in self._table['l']: return self._table['l'][x] if x+1 in self._table['l'] and x in self._table['q']: return self._table['l'][x+1] / (1 - self._table['q'][x]) if x-1 in self._table['l'] and x-1 in self._table['q']: return self._table['l'][x-1] * (1 - self._table['q'][x-1]) if x in self._table['d'] and x in self._table['q']: return self._table['d'][x] / self._table['q'][x] return None def d_x(x: int) -> float | None: """Helper to try compute number of deaths in one year for (x): d_x""" if x in self._table['d']: return self._table['d'][x] if x+1 in self._table['l'] and x in self._table['l']: return self._table['l'][x] - self._table['l'][x+1] else: return None # Iterate a few times to impute life table values funs = {'l': l_x, 'd': d_x, 'q': q_x, 'p': p_x} updated = 0 for loop in range(2): # loop second time if radix needed prev = updated - 1 while updated != prev: # continue while changes prev = updated for col, fun in funs.items(): # loop columns for x in range(self._MINAGE, self._MAXAGE + 1): # loop ages if x not in self._table[col]: value = fun(x) if value is not None: # update value self._table[col][x] = round(value, 7) updated += 1 # increment counter of changes if self._verbose: print(f"{updated} {col}(x={x}) = {value}") if not self._table['l']: # assume starting number of lives if necc self._table['l'][self._MINAGE] = radix return self
[docs] def mu_x(self, x: int, s: int = 0, t: int = 0) -> float: """Compute mu_x from p_x in life table Args: x : age of selection s : years after selection t : death within next t years """ return -math.log(max(0.00001, self.p_x(x, s=s+t, t=1)))
[docs] def l_x(self, x: int, s: int = 0) -> float: """Lookup l_x from life table Args: x : age of selection s : years after selection """ if x+s in self._table['l']: return self._table['l'][x+s] else: return 0
[docs] def d_x(self, x: int, s: int = 0, t: int = 1) -> float: """Compute deaths as lives at x_t divided by lives at x Args: x : age of selection s : years after selection t : death within next t years """ if x+s+t <= self._MAXAGE: return self.l_x(x, s=s) - self.l_x(x, s=s+t) else: return 0.
[docs] def p_x(self, x: int, s: int = 0, t: int = 1) -> float: """t_p_x = lives beginning year x+t divided lives beginning year x Args: x : age of selection s : years after selection t : death within next t years """ denom = self.l_x(x, s=s) if denom and x+s+t <= self._MAXAGE: return self.l_x(x, s=s+t) / denom else: return 0. # in the long term, we are all dead
[docs] def q_x(self, x: int, s: int = 0, t: int = 1, u: int = 0) -> float: """Deferred mortality: u|t_q_x = (l[x+u] - l[x+u+t]) / l[x] Args: x : age of selection s : years after selection u : survive u years, then... t : death within next t years """ denom = self.l_x(x, s=s) if denom and x+s+t <= self._MAXAGE: return self.d_x(x, s=s+u, t=t) / self.l_x(x, s=s) else: return 1 # the only certainty in life
[docs] def e_x(self, x: int, s: int = 0, n: int = Reserves.WHOLE, curtate: bool = True, moment: int = 1) -> float: """Expected curtate lifetime from sum of lives in table Args: x : age of selection s : years after selection n : future lifetime limited at n years curtate : whether curtate (True) or complete (False) expectations moment : whether to compute first (1) or second (2) moment """ if moment == 1: # E[K_x] = sum([self.p(x, k+1) for k in range(n)]) n = min(self._MAXAGE - x, n) if n > 0 else self._MAXAGE # approximate complete by UDD between integer age recursion e = sum([(1 - curtate)*(self.l(x, s=s+t) - self.l(x, s=s+t+1))*0.5 + self.l(x, s=s+t+1) for t in range(n)]) # s_p_x = l_x+s/l_x return e / self.l(x, s=0) else: return super().e_x(x=x, s=s, n=n, curtate=curtate, moment=moment)
[docs] def E_x(self, x: int, s: int = 0, t: int = 1, moment: int = 1) -> float: """Pure Endowment from life table and interest rate Args: x : age of selection s : years after selection t : survives t years moment : return first (1) or second (2) moment or variance (-2) """ if t == 0: return 1. if t < 0: return 0. t = self.max_term(x+s, t) p = self.l_x(x, s=s+t) / self.l_x(x, s=s) if moment == self.VARIANCE: return self.interest.v_t(t)**moment * p * (1 - p) if moment == 1: return self.interest.v_t(t) * p # SULT shortcut: t_E_x(moment=2) = t_E_x(moment=1) * v**t return self.interest.v_t(t)**(moment-1) * self.E_x(x, s=s, t=t)
[docs] def __getitem__(self, col: str) -> Dict[int, float]: """Returns a column of the life table Args: col : name of life table column to return """ assert col[0] in self._table, f"must be one of {list(self._table.keys())}" return self._table[col[0]]
[docs] def frame(self) -> pd.DataFrame: """Return life table columns and values in a DataFrame""" return pd.DataFrame.from_dict(self._table).sort_index(axis=0)
if __name__ == "__main__": print("SOA Question 6.53: (D) 720") x = 0 life = LifeTable().set_interest(i=0.08)\ .set_table(q={x: 0.1, x+1: 0.1, x+2: 0.1}) A = life.term_insurance(x, t=3) G = life.gross_premium(a=1, A=A, benefit=2000, initial_premium=0.35) print(A, G) print(life.frame()) print() print("SOA Question 6.41: (B) 1417") x = 0 life = LifeTable().set_interest(i=0.05)\ .set_table(q={x:.01, x+1:.02}) P = 1416.93 a = 1 + life.E_x(x, t=1) * 1.01 A = (life.deferred_insurance(x, u=0, t=1) + 1.01 * life.deferred_insurance(x, u=1, t=1)) print(a, A) P = 100000 * A / a print(P) print(life.frame()) print() print("SOA Question 3.11: (B) 0.03") life = LifeTable(udd=True).set_table(q={50//2: .02, 52//2: .04}) print(life.q_r(50//2, t=2.5/2)) print(life.frame()) print() print("SOA Question 3.5: (E) 106") l = {60 + x: l * 11111 for x,l in enumerate([9, 8, 7, 6, 5, 4, 3, 2])} a, b = (LifeTable(udd=udd).set_table(l=l).q_r(60, u=3.4, t=2.5) for udd in [True, False]) print(100000 * (a - b)) print() print("SOA Question 3.14: (C) 0.345") life = LifeTable(udd=True).set_table(l={90: 1000, 93: 825}, d={97: 72}, p={96: .2}, q={95: .4, 97: 1}) print(life.q_r(90, u=93-90, t=95.5-93)) print(life.frame()) print()