Source code for actuarialmath.selectlife

"""Select and Ultimate Life Table -- Loads and calculates select life tables

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

[docs]class SelectLife(LifeTable): """Calculate select life table, and iteratively fill in missing values Args: periods : number of select period years verbose : whether to echo update steps Notes: 6 types of columns can be loaded and calculated in the select table: - 'q' : probability [x]+s dies in one year - 'l' : number of lives aged [x]+s - 'd' : number of deaths of age [x]+s - 'A' : whole life insurance - 'a' : whole life annuity - 'e' : expected future curtate lifetime of [x]+s """ def __init__(self, periods: int = 0, udd: bool = True, verbose: bool = False, **kwargs): super().__init__(udd=udd, **kwargs) self._select = {'l':{}, 'd':{}, 'q':{}, 'e':{}, 'A':{}, 'a':{}} self.periods_ = periods self._verbose = verbose
[docs] def __getitem__(self, table: str) -> Dict[int, float]: """Returns values from a select and ultimate table Args: table : may be {'l', 'q', 'e', 'd', 'a', 'A'} """ return self._select[table[0]]
[docs] def set_table(self, l: Dict[int, List[float]] | None = None, d: Dict[int, List[float]] | None = None, q: Dict[int, List[float]] | None = None, A: Dict[int, List[float]] | None = None, a: Dict[int, List[float]] | None = None, e: Dict[int, List[float]] | None = None, fill: bool = True, radix: int = LifeTable._RADIX) -> "SelectLife": """Update from table, every age has row for all select durations Args: q : probability [x]+s dies in one year l : number of lives aged [x]+s d : number of deaths of [x]+s A : whole life insurance, or a : whole life annuity, or e : expected future lifetime of [x]+s radix : initial number of lives fill : whether to fill missing values using recursion formulas Examples: >>> life = SelectLife().set_table(l={55: [10000, 9493, 8533, 7664], >>> 56: [8547, 8028, 6889, 5630], >>> 57: [7011, 6443, 5395, 3904], >>> 58: [5853, 4846, 3548, 2210]}, >>> e={57: [None, None, None, 1]}) >>> print(life.e_r(58, s=2)) """ periods = self.periods_ # infer number of select years, and age range minage = self._MINAGE maxage = self._MAXAGE for lbl, table in [('A',A), ('a',a), ('q',q), ('d',d), ('l',l), ('e',e)]: self._select[lbl] = {} if table: # update given table cells for age, row in table.items(): periods = max(len(row) - 1, periods) minage = age if minage < 0 else min(minage, age) maxage = age if maxage < 0 else max(maxage, age) self._select[lbl][age] = {k:v for k,v in enumerate(row)} self.periods_ = periods # update number of select years, and age range self._MINAGE = minage self._MAXAGE = maxage if fill: # iteratively fill missing table values self.fill_table(radix=radix) return self
[docs] def set_select(self, s: int, age_selected: bool, radix: int = LifeTable._RADIX, fill: bool = False, l: Dict[int, float] | None = None, d: Dict[int, float] | None = None, q: Dict[int, float] | None = None, A: Dict[int, float] | None = None, a: Dict[int, float] | None = None, e: Dict[int, float] | None = None) -> "SelectLife": """Update a table column, for a particular duration s in the select period Args: s : column to populate - n is ultimate, 0..n-1 is year after select age_selected : is indexed by age selected or actual (False, default) radix : initial number of lives fill : whether to fill missing values using recursion formulas q : probabilities [x]+s dies in next year, by age l : number of lives aged [x]+s, by age d : number of deaths of [x]+s, by age A : whole life insurance of [x]+s, by age a : whole life annuity of [x]+s, by age e : expected future lifetime of [x]+s, by age """ def ifelse(x, y): return y if x is None else x s = self.periods_ if s < 0 else s tables = { 'l': ifelse(l, {}), 'q': ifelse(q, {}), 'd': ifelse(e, {}), 'e': ifelse(e, {}), 'a': ifelse(a, {}), 'A': ifelse(A, {}) } for table, item in tables.items(): if item: if table not in self._select: self._select[table] = {} for age in item.keys(): x = age - s * (1 - age_selected) # get true age index if x not in self._select[table]: self._select[table][x] = {} self._select[table][x][s] = item[age] if self._MINAGE < 0 or x < self._MINAGE: # infer min age self._MINAGE = x if age > self._MAXAGE: # infer max age self._MAXAGE = x if fill: self.fill_table(radix=radix) return self
def _get_sel(self, x: int, s: int, table: str) -> float | None: """Helper to read right across, and down if neccesary (when s > n)""" if s > self.periods_: x += (s - self.periods_) s = self.periods_ if (x in self._select[table] and s in self._select[table][x] and self._select[table][x][s] is not None): return self._select[table][x][s] # in select def _isin_sel(self, x: int, s: int, table: str) -> bool: """Helper to check if value not missing""" return self._get_sel(x, s, table) is not None
[docs] def fill_table(self, radix: int = LifeTable._RADIX) -> "SelectLife": """Fills in missing table values (does not check for consistency) Args: radix : initial number of lives """ def A_x(x: int, s: int) -> float | None: """Helper to apply backward and forward recursion for insurance A_x""" if self._isin_sel(x, s, 'A'): return self._get_sel(x, s, 'A') _x, _s = (x, s+1) if s < self.periods_ else (x+1, s) # right or down if self._isin_sel(x, s, 'q') and self._isin_sel(_x, _s, 'A'): q = self._get_sel(x, s, 'q') # A_x = (v q) + (v p A_x+1) return self.interest.v * (q + (1-q)*self._get_sel(_x, _s, 'A')) backwards = [(x, s-1)] # to move backwards along row if s >= self.periods_: # if in ultimate, can also move up column backwards += [(x-1, s)] for _x, _s in backwards: # A_x+1 = (A_x - qv) / (p v) if self._isin_sel(_x, _s, 'q') and self._isin_sel(_x, _s, 'A'): q = self._get_sel(_x, _s, 'q') return ((self._get_sel(_x, _s, 'A') - q * self.interest.v) / (self.interest.v * (1 - q))) def a_x(x: int, s: int) -> float | None: """Helper to apply backward and forward recursion for annuity a_x""" if self._isin_sel(x, s, 'a'): return self._get_sel(x, s, 'a') _x, _s = (x, s+1) if s < self.periods_ else (x+1, s) # right or down if self._isin_sel(x, s, 'q') and self._isin_sel(_x, _s, 'a'): p = 1 - self._get_sel(x, s, 'q') # a_x = 1 + (v p a_x+1) return 1 + self.interest.v * p * self._get_sel(_x, _s, 'a') backwards = [(x, s-1)] # to move backwards along row if s >= self.periods_: # if in ultimate, can also move up column backwards += [(x-1, s)] for _x, _s in backwards: # a_x+1 = (a_x - 1) / (p v) if self._isin_sel(_x, _s, 'q') and self._isin_sel(_x, _s, 'a'): p = 1 - self._get_sel(_x, _s, 'q') return (self._get_sel(_x, _s, 'a') - 1)/(p*self.interest.v) def l_x(x: int, s: int) -> float | None: """Helper to solve for number of lives aged [x]+s: l_[x]+s""" if self._isin_sel(x, s, 'l'): return self._get_sel(x, s, 'l') if self._isin_sel(x, s-1, 'l') and self._isin_sel(x, s-1, 'q'): return self._get_sel(x, s-1, 'l')*(1 - self._get_sel(x, s-1, 'q')) if self._isin_sel(x, s+1, 'l') and self._isin_sel(x, s, 'q'): return self._get_sel(x, s+1, 'l') / (1 - self._get_sel(x, s, 'q')) if self._isin_sel(x, s, 'd') and self._isin_sel(x, s, 'q'): return self._get_sel(x, s, 'd') / self._get_sel(x, s, 'q') backwards = [(x, s-1)] # to move backwards along row if s >= self.periods_: # if in ultimate, can also move up column backwards += [(x-1, s)] for _x, _s in backwards: # l_x+1 = l_x * p_x if self._isin_sel(_x, _s, 'l') and self._isin_sel(_x, _s, 'q'): p = 1 - self._get_sel(_x, _s, 'q') return self._get_sel(_x, _s, 'l') * p def d_x(x: int, s: int) -> float | None: """Helper to solve for deaths at [x]+s: l_[x]+s - l_[x]+s+1""" if self._isin_sel(x, s, 'd'): return self._get_sel(x, s, 'd') if self._isin_sel(x, s+1, 'l') and self._isin_sel(x, s, 'l'): return self._get_sel(x, s, 'l') - self._get_sel(x, s+1, 'l') def q_x(x: int, s: int) -> float | None: """Helper to solve for one-year mortality [x]+s: q_[x]+s""" if self._isin_sel(x, s, 'q'): return self._get_sel(x, s, 'q') if self._isin_sel(x, s, 'd') and self._isin_sel(x, s, 'l'): return self._get_sel(x, s, 'd') / self._get_sel(x, s, 'l') if self._isin_sel(x, s, 'e') and self._isin_sel(x, s+1, 'e'): return 1 - (self._get_sel(x, s, 'e') / (1 + self._get_sel(x, s+1, 'e'))) def e_x(x: int, s: int) -> float | None: """Helper to solve for expected kurtate lifetime: e_[x]+s""" if self._isin_sel(x, s, 'e'): return self._get_sel(x, s, 'e') _x, _s = (x, s+1) if s < self.periods_ else (x+1, s) # right or down if self._isin_sel(x, s, 'q') and self._isin_sel(_x, _s, 'e'): return ((1 - self._get_sel(x, s, 'q')) * (1 + self._get_sel(_x, _s, 'e'))) # e_x = p(1 + e_x+1) backwards = [(x, s-1)] # to move backwards along row if s >= self.periods_: # if in ultimate, can also move up column backwards += [(x-1, s)] for _x, _s in backwards: # e_x+1 = e_x/p_x - 1 if self._isin_sel(_x, _s, 'e') and self._isin_sel(_x, _s, 'q'): return (self._get_sel(_x, _s, 'e') / (1 - self._get_sel(_x, _s, 'q'))) - 1 # Iterate a few times to impute select table values funs = {'l': l_x, 'A': A_x, 'a': a_x, 'q': q_x, 'd': d_x, 'e': e_x} curr = 0 # number of updates filled in for loop in range(2): # loop second time if need to assume radix prev = curr - 1 while curr != prev: # continue while changes made prev = curr for tab, fun in funs.items(): # for each table if tab not in self._select: self._select[tab] = {} for x in range(self._MINAGE, self._MAXAGE + 1): # for each age if x not in self._select[tab]: self._select[tab][x] = {} for s in range(self.periods_+1): # each year after select if not self._isin_sel(x, s, tab): val = fun(x, s) if val is not None: self._select[tab][x][s] = val curr += 1 if self._verbose: print(f"{curr} {tab}(x={x}, s={s}) = {val}") if 0 not in self._select['l'][self._MINAGE]: # arbitrary initial lives self._select['l'][self._MINAGE][0] = radix return self
[docs] def A_x(self, x: int, s: int = 0, moment: int = 1, discrete: bool = True, **kwargs) -> float: """Returns insurance value computed from select table Args: x : age of selection s : years after selection """ assert moment == 1 and discrete, "Must be discrete insurance" if self._isin_sel(x, s, 'A'): return self._get_sel(x, s, 'A') else: return super().A_x(x=x, s=s, moment=1, discrete=True, **kwargs)
[docs] def a_x(self, x: int, s: int = 0, moment: int = 1, discrete: bool = True, **kwargs) -> float: """Returns annuity value computed from select table Args: x : age of selection s : years after selection """ assert moment == 1 and discrete, "Must be discrete annuity" if self._isin_sel(x, s, 'a'): return self._get_sel(x, s, 'a') else: return super().A_x(x=x, s=s, moment=1, discrete=True, **kwargs)
[docs] def l_x(self, x: int, s: int = 0) -> float: """Returns number of lives aged [x]+s computed from select table Args: x : age of selection s : years after selection """ return self._get_sel(x, s, 'l')
[docs] def p_x(self, x: int, s: int = 0, t: int = 1) -> float: """t_p_[x]+s by chain rule: prod(1_p_[x]+s+y) for y in range(t) Args: x : age of selection s : years after selection t : survives t years """ return np.prod([1 - self._get_sel(x, s+y, 'q') for y in range(t)])
[docs] def q_x(self, x: int, s: int = 0, t: int = 1, u: int = 0) -> float: """t|u_q_[x]+s = [x]+s survives u years, does not survive next t Args: x : age of selection s : years after selection u : survives u years, then t : dies within next t years """ return (1. - self.p_x(x, s=s + u, t=t)) * self.p_x(x, s=s, t=u)
[docs] def e_x(self, x: int, s: int = 0, t: int = LifeTable.WHOLE, curtate: int = True) -> float: """Returns expected life time computed from select table Args: x : age of selection s : years after selection t : limit of expected future lifetime """ assert curtate, "Must be curtate lifetimes" if (self._isin_sel(x, s, 'e')): return self._get_sel(x, s, 'e') e = sum([self.p_x(x, s=s, t=k+1) for k in range(max(1, t))]) return e
[docs] def frame(self, table: str = 'l') -> pd.DataFrame: """Returns select and ultimate table values as a DataFrame Args: table : table to return, one of ['A', 'a', 'q', 'd', 'e', 'l'] Examples: >>> table={21: [.00120, .00150, .00170, .00180], >>> 22: [.00125, .00155, .00175, .00185], >>> 23: [.00130, .00160, .00180, .00195]} >>> life = SelectLife(verbose=True).set_table(q=table) >>> print(life.frame('l').round(1)) >>> print(life.frame('q').round(6)) """ return pd.DataFrame.from_dict(self._select[table[0]], orient='index')\ .sort_index(axis=0)\ .sort_index(axis=1)\ .rename_axis('Age')\ .rename_axis(table[0] + '_[x]+s:', axis=1)
if __name__ == "__main__": print("SOA Question 3.2: (D) 14.7") e_curtate = SelectLife.e_approximate(e_complete=15) life = SelectLife(udd=True).set_table(l={65: [1000, None,], 66: [955, None]}, e={65: [e_curtate, None]}, d={65: [40, None,], 66: [45, None]}) print(life.e_r(66)) print(life.frame('e')) print() print("SOA Question 4.16: (D) .1116") q = [.045, .050, .055, .060] q_ = {50+x: [0.7 * q[x] if x < 4 else None, 0.8 * q[x+1] if x+1 < 4 else None, q[x+2] if x+2 < 4 else None] for x in range(4)} life = SelectLife(verbose=True).set_table(q=q_).set_interest(i=.04) print(life.term_insurance(50, t=3)) print() print("SOA Question 4.13: (C) 350 ") life = SelectLife().set_interest(i=0.04)\ .set_table(q={65: [.08, .10, .12, .14], 66: [.09, .11, .13, .15], 67: [.10, .12, .14, .16], 68: [.11, .13, .15, .17], 69: [.12, .14, .16, .18]}) print(life.deferred_insurance(65, t=2, u=2, b=2000)) print() print("SOA Question 3.13: (B) 1.6") life = SelectLife().set_table(l={55: [10000, 9493, 8533, 7664], 56: [8547, 8028, 6889, 5630], 57: [7011, 6443, 5395, 3904], 58: [5853, 4846, 3548, 2210]}, e={57: [None, None, None, 1]}) print(life.e_r(58, s=2)) print() print("SOA Question 3.12: (C) 0.055 ") life = SelectLife(udd=False).set_table(l={60: [10000, 9600, 8640, 7771], 61: [8654, 8135, 6996, 5737], 62: [7119, 6549, 5501, 4016], 63: [5760, 4954, 3765, 2410]}) print(life.q_r(60, s=1, t=3.5) - life.q_r(61, s=0, t=3.5)) print() print("SOA Question 3.7: (b) 16.4") life = SelectLife().set_table(q={50: [.0050, .0063, .0080], 51: [.0060, .0073, .0090], 52: [.0070, .0083, .0100], 53: [.0080, .0093, .0110]}) print(1000*life.q_r(50, s=0, r=0.4, t=2.5)) print() print("SOA Question 3.6: (D) 5.85") life = SelectLife().set_table(q={60: [.09, .11, .13, .15], 61: [.1, .12, .14, .16], 62: [.11, .13, .15, .17], 63: [.12, .14, .16, .18], 64: [.13, .15, .17, .19]}, e={61: [None, None, None, 5.1]}) print(life.e_x(61)) print() print("SOA Question 3.3: (E) 1074") life = SelectLife().set_table(l={50: [99, 96, 93], 51: [97, 93, 89], 52: [93, 88, 83], 53: [90, 84, 78]}) print(10000*life.q_r(51, s=0, r=0.5, t=2.2)) print() print("SOA Question 3.1: (B) 117") life = SelectLife().set_table(l={60: [80000, 79000, 77000, 74000], 61: [78000, 76000, 73000, 70000], 62: [75000, 72000, 69000, 67000], 63: [71000, 68000, 66000, 65000]}) print(1000*life.q_r(60, s=0, r=0.75, t=3, u=2)) print() print("Other usage examples") table={21: [.00120, .00150, .00170, .00180], 22: [.00125, .00155, .00175, .00185], 23: [.00130, .00160, .00180, .00195]} life = SelectLife(verbose=True).set_table(q=table) print(life.frame('l').round(1)) print('--------------') print(life.frame('q').round(6)) print('==============') print(life.p_x(21, 1, 4)) #0.99317