31  Bayesian Mortality Modeling

Author

Alec Loudenback

“After a year of intense mental struggle, however, [Arthur Bailey] realized to his consternation that actuarial sledgehammering worked. He even preferred [the Bayesian underpinnings of credibility theory] to the elegance of frequentism. He positively liked formulae that described ‘actual data. . . . I realized that the hard-shelled underwriters were recognizing certain facts of life neglected by the statistical theorists.’ He wanted to give more weight to a large volume of data than to the frequentists’ small sample; doing so felt surprisingly ‘logical and reasonable.’ He concluded that only a ‘suicidal’ actuary would use Fisher’s method of maximum likelihood, which assigned a zero probability to nonevents.” - Sharon Bertsch McGrayne, Excerpt From The Theory That Would Not Die

31.1 Chapter Overview

An example of using a Bayesian MCMC approach with Turing.jl to fit a mortality curve to sample data, with multi-level models and censored data.

31.2 Generating fake data

The problem of interest is to look at mortality rates, which are given in terms of exposures (whether or not a life experienced a death in a given year).

We’ll grab some example rates from an insurance table, which has a “selection” component: When someone enters observation, say at age 50, their mortality is path dependent (so someone who started being observed at 50 will have a different risk/mortality rate at age 55 than someone who started being observed at 45). For simplicity, though, the simulation below uses the table’s ultimate rates only, so the generated data will not exhibit this selection effect.

Additionally, there may be additional groups of interest, such as:

  • high/medium/low risk classification
  • sex
  • group (e.g. company, data source, etc.)
  • type of insurance product offered

The example data will start with only the risk classification above.

using MortalityTables
using Turing
using DataFramesMeta
using LinearAlgebra
using CairoMakie
using StatsBase
n = 10_000
inforce = [(issue_age=rand(30:70), risk_level=rand(1:3)) for _ in 1:n]
10000-element Vector{@NamedTuple{issue_age::Int64, risk_level::Int64}}:
 (issue_age = 43, risk_level = 1)
 (issue_age = 67, risk_level = 3)
 (issue_age = 30, risk_level = 1)
 (issue_age = 68, risk_level = 3)
 (issue_age = 35, risk_level = 1)
 (issue_age = 63, risk_level = 1)
 (issue_age = 46, risk_level = 3)
 (issue_age = 51, risk_level = 2)
 (issue_age = 52, risk_level = 3)
 (issue_age = 66, risk_level = 2)
 (issue_age = 58, risk_level = 1)
 (issue_age = 63, risk_level = 3)
 (issue_age = 43, risk_level = 1)
 ⋮
 (issue_age = 69, risk_level = 1)
 (issue_age = 38, risk_level = 1)
 (issue_age = 30, risk_level = 2)
 (issue_age = 48, risk_level = 3)
 (issue_age = 57, risk_level = 1)
 (issue_age = 60, risk_level = 1)
 (issue_age = 38, risk_level = 1)
 (issue_age = 68, risk_level = 3)
 (issue_age = 59, risk_level = 3)
 (issue_age = 32, risk_level = 3)
 (issue_age = 37, risk_level = 1)
 (issue_age = 32, risk_level = 2)
tbl_name = "2001 VBT Residual Standard Select and Ultimate - Male Nonsmoker, ANB"
base_table = MortalityTables.table(tbl_name)

# Risk level multipliers: 1 = preferred (0.7x), 2 = standard (1.0x), 3 = substandard (1.5x)
const RISK_MULTIPLIERS = (0.7, 1.0, 1.5)

function tabular_mortality(params, issue_age, att_age, risk_level)
    params.ultimate[att_age] * RISK_MULTIPLIERS[risk_level]
end
tabular_mortality (generic function with 1 method)
"""
Simulate mortality outcomes for a portfolio of policies.
Returns a DataFrame with one row per policy-year exposure.
"""
function model_outcomes(inforce, assumption, assumption_params; n_years=5)
    # Pre-allocate result vectors
    result_issue_age = Int[]
    result_risk_level = Int[]
    result_att_age = Int[]
    result_death = Int[]

    sizehint!(result_issue_age, length(inforce) * n_years)
    sizehint!(result_risk_level, length(inforce) * n_years)
    sizehint!(result_att_age, length(inforce) * n_years)
    sizehint!(result_death, length(inforce) * n_years)

    for pol in inforce
        for t in 1:n_years
            att_age = pol.issue_age + t - 1
            q = assumption(assumption_params, pol.issue_age, att_age, pol.risk_level)
            died = rand() < q

            push!(result_issue_age, pol.issue_age)
            push!(result_risk_level, pol.risk_level)
            push!(result_att_age, att_age)
            push!(result_death, died ? 1 : 0)

            # If died, no more exposures for this policy
            died && break
        end
    end

    DataFrame(
        issue_age=result_issue_age,
        risk_level=result_risk_level,
        att_age=result_att_age,
        death=result_death,
        exposures=ones(Int, length(result_death))  # each row is one exposure
    )
end

exposures = model_outcomes(inforce, tabular_mortality, base_table)

# Aggregate by issue_age and att_age
data = @chain exposures begin
    groupby([:issue_age, :att_age])
    @combine(:exposures = length(:death),
        :deaths = sum(:death),
        :fraction = sum(:death) / length(:death))
end

# Aggregate including risk_level for multi-level modeling
data2 = @chain exposures begin
    groupby([:issue_age, :att_age, :risk_level])
    @combine(:exposures = length(:death),
        :deaths = sum(:death),
        :fraction = sum(:death) / length(:death))
end
615×6 DataFrame
590 rows omitted
Row issue_age att_age risk_level exposures deaths fraction
Int64 Int64 Int64 Int64 Int64 Float64
1 30 30 1 77 0 0.0
2 30 30 2 74 0 0.0
3 30 30 3 80 0 0.0
4 30 31 1 77 0 0.0
5 30 31 2 74 0 0.0
6 30 31 3 80 0 0.0
7 30 32 1 77 0 0.0
8 30 32 2 74 0 0.0
9 30 32 3 80 0 0.0
10 30 33 1 77 0 0.0
11 30 33 2 74 0 0.0
12 30 33 3 80 0 0.0
13 30 34 1 77 0 0.0
604 70 71 1 88 1 0.0113636
605 70 71 2 76 3 0.0394737
606 70 71 3 81 2 0.0246914
607 70 72 1 87 4 0.045977
608 70 72 2 73 3 0.0410959
609 70 72 3 79 2 0.0253165
610 70 73 1 83 6 0.0722892
611 70 73 2 70 2 0.0285714
612 70 73 3 77 5 0.0649351
613 70 74 1 77 3 0.038961
614 70 74 2 68 1 0.0147059
615 70 74 3 72 2 0.0277778

31.3 1. A single binomial parameter model

Estimate \(q\), the average mortality rate, not accounting for any variation within the population/sample. Our model is defined as a Beta prior on \(q\) with a Binomial likelihood:

\[ \begin{aligned} q &\sim \text{Beta}(1,1) \\ \text{deaths} &\sim \text{Binomial}(n, q) \end{aligned} \]

@model function mortality(exposures, deaths)
    q ~ Beta(1, 1)
    # Observe the death count for each age group
    for i in eachindex(deaths)
        deaths[i] ~ Binomial(exposures[i], q)
    end
end

m1 = mortality(data.exposures, data.deaths)
DynamicPPL.Model{typeof(mortality), (:exposures, :deaths), (), (), Tuple{Vector{Int64}, Vector{Int64}}, Tuple{}, DynamicPPL.DefaultContext, false}(Main.mortality, (exposures = [231, 231, 231, 231, 231, 256, 256, 255, 255, 254  …  246, 241, 231, 223, 219, 253, 245, 239, 230, 217], deaths = [0, 0, 0, 0, 0, 0, 1, 0, 1, 0  …  5, 10, 8, 4, 8, 8, 6, 9, 13, 6]), NamedTuple(), DynamicPPL.DefaultContext())

31.3.1 Sampling from the posterior

We use a No-U-Turn-Sampler (NUTS) technique to sample multiple chains at once:

num_chains = 4
chain = sample(m1, NUTS(), MCMCThreads(), 400, num_chains)
╭─FlexiChain (400 iterations, 4 chains) ───────────────────────────────────────
 ↓ iter  = 201:600                                                            
 → chain = 1:4                                                                
                                                                              
 Parameters (1) ── AbstractPPL.VarName                                        
  Float64  q                                                                  
                                                                              
 Extras (14)                                                                  
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯

Here, we have asked for the outcomes to be modeled via a single parameter for the population. We see that the posterior distribution of \(q\) is very close to the overall population mortality rate:

# Posterior mean of q should be close to the pooled fraction
(posterior_mean_q=mean(chain[:q]), pooled_fraction=sum(data.deaths) / sum(data.exposures))
(posterior_mean_q = 0.007951668571004317, pooled_fraction = 0.007932321674916823)

However, we can see that the sampling of possible posterior parameters doesn’t really fit the data very well since our model was so simplified. The lines represent the posterior binomial probability.

This is saying that for the observed data, if there really is just a single probability q that governs the true process that came up with the data, there’s a pretty narrow range of values it could possibly be:

let
    data_weight = sqrt.(data.exposures) / 2
    f = Figure()
    ax = Axis(f[1, 1],
        xlabel="age",
        ylabel="mortality rate",
        limits=(nothing, nothing, -0.01, 0.10),
        title="Single-parameter Bayesian Mortality"
    )
    scatter!(ax,
        data.att_age,
        data.fraction,
        markersize=data_weight,
        color=(:blue, 0.5),
        label="Experience data (size ~ exposure)")

    # Subsample posterior draws of q
    n_samples = 300
    q_posterior = sample(vec(chain[:q]), n_samples)

    # Draw horizontal lines for each posterior sample
    for q in q_posterior
        hlines!(ax, [q], color=(:grey, 0.1))
    end

    f
end

31.4 2. Parametric model

In this example, we utilize a MakehamBeard parameterization because it’s already very similar in form to a logistic function. This is important because our desired output is a probability (i.e., the probability of a death at a given age), so the value must be constrained to be in the interval between zero and one.

The prior distributions for a, b, and k are chosen to constrain the hazard (mortality) rate to be between zero and one (c is fixed at zero in this model; it becomes a sampled parameter in the final model below).

This isn’t an ideal parameterization (e.g. we aren’t including information about the select underwriting period), but is an example of utilizing Bayesian techniques on life experience data.

@model function mortality2(ages, exposures, deaths)
    a ~ Exponential(0.1)
    b ~ Exponential(0.1)
    c = 0.0
    k ~ truncated(Exponential(1), 1, Inf)

    # Create parametric mortality model once
    m = MortalityTables.MakehamBeard(; a, b, c, k)

    # Observe deaths for each age/exposure combination
    for i in eachindex(deaths)
        q = MortalityTables.hazard(m, ages[i])
        if !isfinite(q) || q < 0 || q > 1
            Turing.@addlogprob! -Inf
            return
        end
        deaths[i] ~ Binomial(exposures[i], q)
    end
end
mortality2 (generic function with 2 methods)
TipGuarding against invalid probabilities

During sampling, NUTS uses gradient-based proposals that can explore extreme parameter values. For example, a large b may cause exp(b * age) to overflow, producing a NaN hazard rate. Since Binomial(n, p) requires 0 ≤ p ≤ 1, passing an invalid value would throw a DomainError. The guard clause handles this by calling Turing.@addlogprob! -Inf, which sets the log-density to negative infinity and tells the sampler to reject that proposal. This is the idiomatic Turing.jl pattern for enforcing domain constraints that arise from complex likelihood computations.

We combine the model with the data and sample from the posterior using a similar call as before:

m2 = mortality2(data.att_age, data.exposures, data.deaths)

chain2 = sample(m2, NUTS(), MCMCThreads(), 400, num_chains)
╭─FlexiChain (400 iterations, 4 chains) ───────────────────────────────────────
 ↓ iter  = 201:600                                                            
 → chain = 1:4                                                                
                                                                              
 Parameters (3) ── AbstractPPL.VarName                                        
  Float64  a, b, k                                                            
                                                                              
 Extras (14)                                                                  
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯

31.4.1 Plotting samples from the posterior

We can see that the sampling of possible posterior parameters fits the data well:

let
    data_weight = sqrt.(data.exposures) / 2
    f = Figure()
    ax = Axis(f[1, 1],
        xlabel="age",
        ylabel="mortality rate",
        limits=(nothing, nothing, -0.01, 0.10),
        title="Parametric Bayesian Mortality"
    )
    scatter!(ax,
        data.att_age,
        data.fraction,
        markersize=data_weight,
        color=(:blue, 0.5),
        label="Experience data (size ~ exposure)")

    # Subsample joint posterior draws (a, b, and k taken from the same iteration)
    n_samples = 300
    idx = sample(1:length(vec(chain2[:a])), n_samples)
    a_samples = vec(chain2[:a])[idx]
    b_samples = vec(chain2[:b])[idx]
    k_samples = vec(chain2[:k])[idx]

    ages = sort!(unique(data.att_age))

    for i in 1:n_samples
        m = MortalityTables.MakehamBeard(; a=a_samples[i], b=b_samples[i], c=0.0, k=k_samples[i])
        qs = MortalityTables.hazard.(m, ages)
        lines!(ax, ages, qs, color=(:grey, 0.1))
    end
    f
end

Recall that the lines are not plotting the possible outcomes of the claim rates, but the mean claim rate for the given age.

31.5 3. Multi-level model

This model extends the prior to create a multi-level model. Each risk class (risk_level) gets its own \(a\) parameter in the MakehamBeard model. The prior for \(a_i\) is determined by the hyper-parameter \(\bar{a}\).

@model function mortality3(ages, exposures, risk_levels, deaths, n_risk_levels)
    b ~ Exponential(0.1)
    ā ~ Exponential(0.1)
    a ~ filldist(Exponential(ā), n_risk_levels)
    c = 0.0
    k ~ truncated(Exponential(1), 1, Inf)

    # Observe deaths for each row
    for i in eachindex(deaths)
        m = MortalityTables.MakehamBeard(; a=a[risk_levels[i]], b, c, k)
        q = MortalityTables.hazard(m, ages[i])
        if !isfinite(q) || q < 0 || q > 1
            Turing.@addlogprob! -Inf
            return
        end
        deaths[i] ~ Binomial(exposures[i], q)
    end
end

n_risk_levels = length(unique(data2.risk_level))
m3 = mortality3(data2.att_age, data2.exposures, data2.risk_level, data2.deaths, n_risk_levels)

chain3 = sample(m3, NUTS(), 1000)

summarystats(chain3)
╭─FlexiSummary (9 statistics) ─────────────────────────────────────────────────
   iter    collapsed                                                          
   chain   collapsed                                                          
 ↓ stat  = [mean, std, mcse, ess_bulk, ess_tail, rhat, q5, q50, q95]          
                                                                              
 Parameters (6) ── AbstractPPL.VarName                                        
  Float64  b, ā, a[1], a[2], a[3], k                                          
                                                                              
 Extras (14)                                                                  
  Float64  n_steps, is_accept, acceptance_rate, log_density,                  
           hamiltonian_energy, hamiltonian_energy_error,                      
           max_hamiltonian_energy_error, tree_depth, numerical_error,         
           step_size, nom_step_size, logprior, loglikelihood, logjoint        
                                                                              
 Summary                                                                      
   param    mean     std    mcse  ess_bulk  ess_tail    rhat      q5       
       b  4.8239  0.0002  0.0001    3.7195   21.1839  1.6339  4.8236       
       ā  0.7550  0.0000  0.0000   10.5756   20.3236  1.0798  0.7550       
    a[1]  1.5925  0.0001  0.0000    2.5889   22.1047  2.0531  1.5924       
    a[2]  2.4170  0.0001  0.0001    2.4599   13.3781  2.0539  2.4169       
    a[3]  0.6748  0.0000  0.0000    2.6350   26.7112  1.9214  0.6748       
       k  1.1493  0.0000  0.0000    6.4504   16.8189  1.0637  1.1493       
╰──────────────────────────────────────────────────────────────────────────────╯
let
    colors = Makie.wong_colors()
    data_weight = sqrt.(data2.exposures)

    p, ax, _ = scatter(
        data2.att_age,
        data2.fraction,
        markersize=data_weight,
        alpha=0.5,
        color=[(colors[c], 0.7) for c in data2.risk_level],
        label="Experience data point",
        axis=(
            xlabel="age",
            limits=(nothing, nothing, -0.01, 0.10),
            ylabel="mortality rate",
            title="Multi-level Bayesian Mortality"
        )
    )

    # Subsample joint posterior draws (a, b, and k taken from the same iteration)
    n_samples = 100
    idx = sample(1:length(vec(chain3[:b])), n_samples)
    b_samples = vec(chain3[:b])[idx]
    k_samples = vec(chain3[:k])[idx]

    ages = sort!(unique(data2.att_age))

    for r in 1:3
        a_samples = vec(chain3[@varname(a[r])])[idx]
        for i in 1:n_samples
            m = MortalityTables.MakehamBeard(; a=a_samples[i], b=b_samples[i], c=0.0, k=k_samples[i])
            qs = MortalityTables.hazard.(m, ages)
            lines!(ages, qs, label="risk level $r", alpha=0.2, color=(colors[r], 0.2))
        end
    end
    axislegend(ax, merge=true)
    p
end

Again, the lines are not plotting the possible outcomes of the claim rates, but the mean claim rate for the given age and risk class.

31.6 Handling non-unit exposures

The key is to use the Poisson distribution, which is a limiting approximation to the Binomial distribution:

@model function mortality4(ages, exposures, risk_levels, deaths, n_risk_levels)
    b ~ Exponential(0.1)
    ā ~ Exponential(0.1)
    a ~ filldist(Exponential(ā), n_risk_levels)
    c ~ Beta(4, 18)
    k ~ truncated(Exponential(1), 1, Inf)

    # Observe deaths for each row using Poisson likelihood
    for i in eachindex(deaths)
        m = MortalityTables.MakehamBeard(; a=a[risk_levels[i]], b, c, k)
        q = MortalityTables.hazard(m, ages[i])
        if !isfinite(q) || q < 0 || q > 1
            Turing.@addlogprob! -Inf
            return
        end
        deaths[i] ~ Poisson(exposures[i] * q)
    end
end

m4 = mortality4(data2.att_age, data2.exposures, data2.risk_level, data2.deaths, n_risk_levels)

chain4 = sample(m4, NUTS(), 1000)
╭─FlexiChain (1000 iterations, 1 chain) ───────────────────────────────────────
 ↓ iter  = 501:1500                                                           
 → chain = 1:1                                                                
                                                                              
 Parameters (5) ── AbstractPPL.VarName                                        
  Float64          b, ā, c, k                                                 
  Vector{Float64}  a (3,)                                                     
                                                                              
 Extras (14)                                                                  
  Int64    n_steps, tree_depth                                                
  Bool     is_accept, numerical_error                                         
  Float64  acceptance_rate, log_density, hamiltonian_energy,                  
           hamiltonian_energy_error, max_hamiltonian_energy_error, step_size, 
           nom_step_size, logprior, loglikelihood, logjoint                   
╰──────────────────────────────────────────────────────────────────────────────╯
# Extract posterior means for risk factors and compute relative factors
risk_factors4 = [mean(chain4[@varname(a[f])]) for f in 1:3]
println("Risk factors relative to standard (level 2): ", risk_factors4 ./ risk_factors4[2])

let
    colors = Makie.wong_colors()
    data_weight = sqrt.(data2.exposures) / 2

    p, ax, _ = scatter(
        data2.att_age,
        data2.fraction,
        markersize=data_weight,
        alpha=0.5,
        color=data2.risk_level,
        label="Experience data point",
        axis=(
            xlabel="age",
            limits=(nothing, nothing, -0.01, 0.10),
            ylabel="mortality rate",
            title="Poisson Multi-level Bayesian Mortality"
        )
    )

    # Subsample joint posterior draws (a, b, c, and k taken from the same iteration)
    n_samples = 100
    idx = sample(1:length(vec(chain4[:b])), n_samples)
    b_samples = vec(chain4[:b])[idx]
    c_samples = vec(chain4[:c])[idx]
    k_samples = vec(chain4[:k])[idx]

    ages = sort!(unique(data2.att_age))

    for r in 1:3
        a_samples = vec(chain4[@varname(a[r])])[idx]
        for i in 1:n_samples
            m = MortalityTables.MakehamBeard(; a=a_samples[i], b=b_samples[i], c=c_samples[i], k=k_samples[i])
            qs = MortalityTables.hazard.(m, ages)
            lines!(ages, qs, label="risk level $r", alpha=0.2, color=(colors[r], 0.2))
        end
    end
    axislegend(ax, merge=true)
    p
end
Risk factors relative to standard (level 2): [0.8663552552791414, 1.0, 1.9008056839792784]

31.7 Model Predictions

We can generate predictive estimates by passing a vector of missing in place of the outcome variables and then calling predict.

We get a table of values where each row is the prediction implied by the corresponding chain sample, and the columns are the predicted value for each of the outcomes in our original dataset.

# Create model with missing deaths to generate predictions
pred_model = mortality4(
    data2.att_age,
    data2.exposures,
    data2.risk_level,
    fill(missing, length(data2.deaths)),
    n_risk_levels
)
preds = predict(pred_model, chain4; include_all=false);

Averaging the predictions across the chain samples gives the posterior-mean predicted deaths for each row of the data, which we can compare directly with the observed counts:

predicted_deaths = [mean(preds[@varname(deaths[i])]) for i in eachindex(data2.deaths)]
@transform(select(data2, :att_age, :risk_level, :exposures, :deaths),
    :predicted_deaths = round.(predicted_deaths; digits=1))
615×5 DataFrame
590 rows omitted
Row att_age risk_level exposures deaths predicted_deaths
Int64 Int64 Int64 Int64 Float64
1 30 1 77 0 0.1
2 30 2 74 0 0.1
3 30 3 80 0 0.1
4 31 1 77 0 0.1
5 31 2 74 0 0.1
6 31 3 80 0 0.1
7 32 1 77 0 0.1
8 32 2 74 0 0.1
9 32 3 80 0 0.1
10 33 1 77 0 0.1
11 33 2 74 0 0.1
12 33 3 80 0 0.1
13 34 1 77 0 0.1
604 71 1 88 1 1.7
605 71 2 76 3 1.7
606 71 3 81 2 3.1
607 72 1 87 4 1.8
608 72 2 73 3 1.7
609 72 3 79 2 3.4
610 73 1 83 6 1.9
611 73 2 70 2 1.9
612 73 3 77 5 3.6
613 74 1 77 3 1.9
614 74 2 68 1 1.9
615 74 3 72 2 3.7

The posterior-mean predictions track the observed deaths closely across ages and risk levels — a compact posterior predictive check that the final multi-level model has captured the structure of the data.