JuliaActuary
Practical, extensible, and open-source actuarial modeling and analysis.
begin
    using MortalityTables
    using Turing
    using UUIDs
    using DataFramesMeta
    using MCMCChains, Plots, StatsPlots
    using LinearAlgebra
    using PlutoUI; TableOfContents()
    using Pipe
    using StatisticalRethinking
    using StatsFuns
end

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 for 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).

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

The example data will start with only the risk classification above

src = MortalityTables.table("2001 VBT Residual Standard Select and Ultimate - Male Nonsmoker, ANB")
MortalityTable (Insured Lives Mortality):
   Name:
       2001 VBT Residual Standard Select and Ultimate - Male Nonsmoker, ANB
   Fields: 
       (:select, :ultimate, :metadata)
   Provider:
       Society of Actuaries
   mort.SOA.org ID:
       1118
   mort.SOA.org link:
       https://mort.soa.org/ViewTable.aspx?&TableIdentity=1118
   Description:
       2001 Valuation Basic Table (VBT) Residual Standard Select and Ultimate Table -  Male Nonsmoker. Basis: Age Nearest Birthday. Minimum Select Age: 0. Maximum Select Age: 99. Minimum Ultimate Age: 25. Maximum Ultimate Age: 120
src.select[50]
71-element OffsetArray(::Vector{Float64}, 50:120) with eltype Float64 with indices 50:120:
 0.00104
 0.00139
 0.00177
 0.00218
 0.00261
 0.00315
 0.00384
 ⋮
 0.75603
 0.79988
 0.84627
 0.89536
 0.94729
 1.0
n = 10_000
10000
function generate_data_individual(tbl,issue_age=rand(50:55),inforce_years=rand(1:30),risklevel=rand(1:3))
    # risk_factors will scale the "true" parameter up or down
    # we observe the assigned risklevel, but not risk_factor
    risk_factors = [0.7,1.0,1.5]
    rf = risk_factors[risklevel]
    deaths = rand(inforce_years) .< (tbl.select[issue_age][issue_age .+ inforce_years .- 1 ] .* rf)
    
    endpoint = if sum(deaths) == 0
        last(inforce_years)
    else
        findfirst(deaths)
    end
    id= uuid1()
    map(1:endpoint) do i
        (
        issue_age=issue_age,
        risklevel = risklevel,
        att_age = issue_age + i -1,
        death = deaths[i],
        id = id,
    )
    end
    
end
generate_data_individual (generic function with 4 methods)
exposures = vcat([generate_data_individual(src) for _ in 1:n]...) |> DataFrame
issue_age risklevel att_age death id
1 52 3 52 false UUID("41c9446c-2d73-11ed-034c-a1bd79bb4903")
2 52 3 53 false UUID("41c9446c-2d73-11ed-034c-a1bd79bb4903")
3 52 3 54 false UUID("41c9446c-2d73-11ed-034c-a1bd79bb4903")
4 52 3 55 true UUID("41c9446c-2d73-11ed-034c-a1bd79bb4903")
5 55 1 55 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
6 55 1 56 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
7 55 1 57 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
8 55 1 58 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
9 55 1 59 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
10 55 1 60 false UUID("41dad9d4-2d73-11ed-2f27-a135440a2c01")
...
109187 51 1 51 false UUID("41e1e3d2-2d73-11ed-21d4-c3b2d7649aad")
data = combine(groupby(exposures,[:issue_age,:att_age])) do subdf
    (exposures = nrow(subdf),
    deaths = sum(subdf.death),
    fraction = sum(subdf.death)/ nrow(subdf))
end
    
issue_age att_age exposures deaths fraction
1 50 50 1620 32 0.0197531
2 50 51 1535 31 0.0201954
3 50 52 1455 26 0.0178694
4 50 53 1372 36 0.0262391
5 50 54 1284 28 0.0218069
6 50 55 1209 28 0.0231596
7 50 56 1128 18 0.0159574
8 50 57 1053 31 0.0294397
9 50 58 963 26 0.026999
10 50 59 866 18 0.0207852
...
180 55 84 3 0 0.0
data2 = combine(groupby(exposures,[:issue_age,:att_age,:risklevel])) do subdf
    (exposures = nrow(subdf),
    deaths = sum(subdf.death),
    fraction = sum(subdf.death)/ nrow(subdf))
end
    
issue_age att_age risklevel exposures deaths fraction
1 50 50 1 535 6 0.011215
2 50 50 2 535 13 0.0242991
3 50 50 3 550 13 0.0236364
4 50 51 1 510 6 0.0117647
5 50 51 2 503 10 0.0198807
6 50 51 3 522 15 0.0287356
7 50 52 1 488 4 0.00819672
8 50 52 2 480 6 0.0125
9 50 52 3 487 16 0.0328542
10 50 53 1 464 3 0.00646552
...
528 55 84 1 3 0 0.0

1: A single binomial parameter model

Estiamte $p$, the average mortality rate, not accounting for any variation within the population/sample:

@model function mortality(data,deaths) 
    p ~ Beta(1,1)
    for i = 1:nrow(data)
        deaths[i] ~ Binomial(data.exposures[i],p)
    end
end
mortality (generic function with 2 methods)
m1 = mortality(data,data.deaths)
DynamicPPL.Model{typeof(mortality), (:data, :deaths), (), (), Tuple{DataFrame, Vector{Int64}}, Tuple{}, DynamicPPL.DefaultContext}(Main.var"workspace#3".mortality, (data = 180×5 DataFrame
 Row │ issue_age  att_age  exposures  deaths  fraction
     │ Int64      Int64    Int64      Int64   Float64
─────┼──────────────────────────────────────────────────
   1 │        50       50       1620      32  0.0197531
   2 │        50       51       1535      31  0.0201954
   3 │        50       52       1455      26  0.0178694
   4 │        50       53       1372      36  0.0262391
   5 │        50       54       1284      28  0.0218069
  ⋮  │     ⋮         ⋮         ⋮        ⋮         ⋮
 177 │        55       81         31       3  0.0967742
 178 │        55       82         19       1  0.0526316
 179 │        55       83         11       1  0.0909091
 180 │        55       84          3       0  0.0
                                        171 rows omitted, deaths = [32, 31, 26, 36, 28, 28, 18, 31, 26, 18  …  9, 7, 4, 4, 2, 2, 3, 1, 1, 0]), NamedTuple(), DynamicPPL.DefaultContext())
num_chains = 4
4

Sampling from the posterior

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

chain = sample(m1, NUTS(), 1000)
iteration chain p lp n_steps is_accept acceptance_rate log_density ...
1 501 1 0.0303919 -532.691 3.0 1.0 0.989474 -532.691
2 502 1 0.0307197 -532.706 3.0 1.0 0.970581 -532.706
3 503 1 0.0303117 -532.748 3.0 1.0 0.876193 -532.748
4 504 1 0.0305667 -532.65 3.0 1.0 0.437099 -532.65
5 505 1 0.0307903 -532.761 1.0 1.0 0.948427 -532.761
6 506 1 0.0314313 -534.076 1.0 1.0 0.529385 -534.076
7 507 1 0.0309738 -532.987 1.0 1.0 1.0 -532.987
8 508 1 0.0311392 -533.295 3.0 1.0 0.918552 -533.295
9 509 1 0.0300322 -533.136 3.0 1.0 0.437746 -533.136
10 510 1 0.0297133 -533.942 3.0 1.0 0.760619 -533.942
...
plot(chain)

Plotting samples from the posterior

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 p 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 = data.exposures ./ sum(data.exposures)
    data_weight = .√(data_weight ./ maximum(data_weight) .* 20)
    
    p = scatter(
        data.att_age,
        data.fraction, 
        markersize = data_weight, 
        alpha = 0.5, 
        label = "Experience data point (size indicates relative exposure quantity)",
        xlabel="age",
        ylim=(0.0,0.25),
        ylabel="mortality rate", 
        title="Parametric Bayseian Mortality"
    )
    
    # show n samples from the posterior plotted on the graph
    n = 300
    ages = sort!(unique(data.att_age))
    
    for i in 1:n
        p_posterior = sample(chain,1)[:p][1]
        hline!([p_posterior],label="",alpha=0.1)
    end
    p
    
end

The posterior mean of p is of course very close to the simple proportoin of claims to exposures:

mean(chain,:p)
0.030544878779605707
sum(data.deaths) / sum(data.exposures)
0.03053477062287635

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 (ie the probablity of a death at a given age), so the value must be constrained to be in the interval between zero and one.

The prior values for a,b,c, and k are chosen to constrain the hazard (mortality) rate to be between zero and one.

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(data,deaths) 
    a ~ Exponential(0.1)
    b ~ Exponential(0.1)
    c = 0.
    k ~ truncated(Exponential(1),1,Inf)
    
    # use the variables to create a parametric mortality model
    m = MortalityTables.MakehamBeard(;a,b,c,k)

    # loop through the rows of the dataframe to let Turing observe the data 
    # and how consistent the parameters are with the data
    for i = 1:nrow(data)
        age = data.att_age[i]	
        q = MortalityTables.hazard(m,age)
        deaths[i] ~ Binomial(data.exposures[i],q)
    end
end
mortality2 (generic function with 2 methods)

We combine the model with the data:

m2 = mortality2(data,data.deaths)
DynamicPPL.Model{typeof(mortality2), (:data, :deaths), (), (), Tuple{DataFrame, Vector{Int64}}, Tuple{}, DynamicPPL.DefaultContext}(Main.var"workspace#3".mortality2, (data = 180×5 DataFrame
 Row │ issue_age  att_age  exposures  deaths  fraction
     │ Int64      Int64    Int64      Int64   Float64
─────┼──────────────────────────────────────────────────
   1 │        50       50       1620      32  0.0197531
   2 │        50       51       1535      31  0.0201954
   3 │        50       52       1455      26  0.0178694
   4 │        50       53       1372      36  0.0262391
   5 │        50       54       1284      28  0.0218069
  ⋮  │     ⋮         ⋮         ⋮        ⋮         ⋮
 177 │        55       81         31       3  0.0967742
 178 │        55       82         19       1  0.0526316
 179 │        55       83         11       1  0.0909091
 180 │        55       84          3       0  0.0
                                        171 rows omitted, deaths = [32, 31, 26, 36, 28, 28, 18, 31, 26, 18  …  9, 7, 4, 4, 2, 2, 3, 1, 1, 0]), NamedTuple(), DynamicPPL.DefaultContext())

Sampling from the posterior

We use a No-U-Turn-Sampler (NUTS) technique to sample:

chain2 = sample(m2, NUTS(), 1000)
iteration chain a b k lp n_steps is_accept ...
1 501 1 0.00472948 0.0325487 3.87839 -478.775 31.0 1.0
2 502 1 0.00536128 0.0294048 1.61793 -477.249 31.0 1.0
3 503 1 0.004643 0.0327949 2.59728 -477.211 39.0 1.0
4 504 1 0.00457191 0.0326982 2.38048 -477.013 31.0 1.0
5 505 1 0.00461624 0.03245 2.14434 -476.951 31.0 1.0
6 506 1 0.00443497 0.0333742 2.07706 -477.784 15.0 1.0
7 507 1 0.00424133 0.0346523 4.01926 -478.289 31.0 1.0
8 508 1 0.00434847 0.0332752 1.7481 -477.452 31.0 1.0
9 509 1 0.00517375 0.0311901 2.97634 -477.073 31.0 1.0
10 510 1 0.00519454 0.0306349 2.68318 -477.006 15.0 1.0
...
summarize(chain2)
parameters mean std naive_se mcse ess rhat ess_per_sec
1 :a 0.0053292 0.00089039 2.81566e-5 5.9051e-5 275.602 1.0012 5.31117
2 :b 0.0302477 0.00283911 8.97804e-5 0.000188577 257.154 1.00152 4.95565
3 :k 2.0419 0.964541 0.0305015 0.049181 402.429 1.00094 7.75528
plot(chain2)

Plotting samples from the posterior

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

let
    data_weight = data.exposures ./ sum(data.exposures)
    data_weight = .√(data_weight ./ maximum(data_weight) .* 20)
    
    p = scatter(
        data.att_age,
        data.fraction, 
        markersize = data_weight, 
        alpha = 0.5, 
        label = "Experience data point (size indicates relative exposure quantity)",
        xlabel="age",
        ylim=(0.0,0.25),
        ylabel="mortality rate", 
        title="Parametric Bayseian Mortality"
    )
    

    # show n samples from the posterior plotted on the graph
    n = 300
    ages = sort!(unique(data.att_age))
    
    for i in 1:n
        s = sample(chain2,1)
        a = only(s[:a])
        b = only(s[:b])
        k = only(s[:k])
        c = 0
        m = MortalityTables.MakehamBeard(;a,b,c,k)
        plot!(ages,age -> MortalityTables.hazard(m,age), alpha = 0.1,label="")
    end
    p
end

3. Parametric model

This model extends the prior to create a multi-level model. Each risk class (risklevel) gets its own $a$ paramater in the MakhamBeard model. The prior for $a_i$ is determined by the hyperparameter $\bar{a}$.

@model function mortality3(data,deaths) 
    risk_levels = length(levels(data.risklevel))
    b ~ Exponential(0.1)
    ā ~ Exponential(0.1)
    a ~ filldist(Exponential(ā), risk_levels)
    c = 0
    k ~ truncated(Exponential(1),1,Inf)
    
    # use the variables to create a parametric mortality model

    # loop through the rows of the dataframe to let Turing observe the data 
    # and how consistent the parameters are with the data
    for i = 1:nrow(data)
        risk = data.risklevel[i]
        
        m = MortalityTables.MakehamBeard(;a=a[risk],b,c,k)
        age = data.att_age[i]	
        q = MortalityTables.hazard(m,age)
        deaths[i] ~ Binomial(data.exposures[i],q)
    end
end
mortality3 (generic function with 2 methods)
m3 = mortality3(data2,data2.deaths)
DynamicPPL.Model{typeof(mortality3), (:data, :deaths), (), (), Tuple{DataFrame, Vector{Int64}}, Tuple{}, DynamicPPL.DefaultContext}(Main.var"workspace#3".mortality3, (data = 528×6 DataFrame
 Row │ issue_age  att_age  risklevel  exposures  deaths  fraction
     │ Int64      Int64    Int64      Int64      Int64   Float64
─────┼──────────────────────────────────────────────────────────────
   1 │        50       50          1        535       6  0.011215
   2 │        50       50          2        535      13  0.0242991
   3 │        50       50          3        550      13  0.0236364
   4 │        50       51          1        510       6  0.0117647
   5 │        50       51          2        503      10  0.0198807
  ⋮  │     ⋮         ⋮         ⋮          ⋮        ⋮         ⋮
 525 │        55       83          1          8       1  0.125
 526 │        55       83          2          1       0  0.0
 527 │        55       83          3          2       0  0.0
 528 │        55       84          1          3       0  0.0
                                                    519 rows omitted, deaths = [6, 13, 13, 6, 10, 15, 4, 6, 16, 3  …  0, 2, 1, 1, 0, 0, 1, 0, 0, 0]), NamedTuple(), DynamicPPL.DefaultContext())
chain3 = sample(m3, NUTS(), 1000)
iteration chain b ā a[1] a[2] a[3] k ...
1 501 1 0.0387773 0.00247834 0.00210139 0.00288206 0.00448758 1.51456
2 502 1 0.0317393 0.0184825 0.00320421 0.00455215 0.00716428 2.33445
3 503 1 0.0343621 0.0042054 0.00262313 0.00401055 0.00585944 2.07383
4 504 1 0.0346601 0.00507525 0.00268848 0.00391422 0.00601398 2.53488
5 505 1 0.034718 0.0317216 0.00274602 0.00401572 0.00592287 2.62754
6 506 1 0.0368171 0.00380658 0.00232701 0.0033379 0.00503976 1.67534
7 507 1 0.0333607 0.00967991 0.00270815 0.00414991 0.00621547 1.27918
8 508 1 0.0365484 0.0198086 0.00239297 0.0032678 0.00505343 1.41822
9 509 1 0.036725 0.0233713 0.00231372 0.0033162 0.00516395 1.44143
10 510 1 0.0336963 0.00324104 0.0028687 0.00417042 0.00661132 3.13087
...
summarize(chain3)
parameters mean std naive_se mcse ess rhat ess_per_sec
1 :b 0.0351672 0.00287857 9.10284e-5 0.000195092 255.768 1.00024 1.425
2 0.011527 0.017112 0.000541129 0.00101238 276.047 1.00058 1.53798
3 Symbol("a[1]") 0.00260593 0.000461786 1.4603e-5 3.04906e-5 267.566 0.999802 1.49073
4 Symbol("a[2]") 0.00383769 0.000670379 2.11992e-5 4.48842e-5 265.134 0.999746 1.47718
5 Symbol("a[3]") 0.0058196 0.000985022 3.11491e-5 6.55949e-5 260.521 1.00062 1.45148
6 :k 1.84989 0.820483 0.0259459 0.0419524 370.552 1.00177 2.0645
PRECIS(DataFrame(chain3))
┌───────┬───────────────────────────────────────────────────────┐
│ param │   mean     std    5.5%     50%   94.5%      histogram │
├───────┼───────────────────────────────────────────────────────┤
│  a[1] │ 0.0026  0.0005   0.002  0.0026  0.0034        ▂██▄▁▁▁ │
│  a[2] │ 0.0038  0.0007  0.0029  0.0037  0.0049    ▁▂▆█▅▃▁▁▁▁▁ │
│  a[3] │ 0.0058   0.001  0.0045  0.0057  0.0075  ▁▃▆██▅▄▃▁▁▁▁▁ │
│     b │ 0.0352  0.0029  0.0306  0.0352  0.0399     ▁▁▁▄▅█▇▃▂▁ │
│     k │ 1.8499  0.8205  1.0576   1.571  3.5477     █▄▃▂▁▁▁▁▁▁ │
│     ā │ 0.0115  0.0171  0.0025  0.0069  0.0324     █▁▁▁▁▁▁▁▁▁ │
└───────┴───────────────────────────────────────────────────────┘
let data = data2
    
    data_weight = data.exposures ./ sum(data.exposures)
    data_weight = .√(data_weight ./ maximum(data_weight) .* 20)
    color_i = data.risklevel
    
    p = scatter(
        data.att_age,
        data.fraction, 
        markersize = data_weight, 
        alpha = 0.5, 
        color=color_i,
        label = "Experience data point (size indicates relative exposure quantity)",
        xlabel="age",
        ylim=(0.0,0.25),
        ylabel="mortality rate", 
        title="Parametric Bayseian Mortality"
    )
    

    # show n samples from the posterior plotted on the graph
    n = 100
    
    ages = sort!(unique(data.att_age))
    for r in 1:3	
        for i in 1:n
            s = sample(chain3,1)
            a = only(s[Symbol("a[$r]")])
            b = only(s[:b])
            k = only(s[:k])
            c = 0
            m = MortalityTables.MakehamBeard(;a,b,c,k)
            if i == 1 
                plot!(ages,age -> MortalityTables.hazard(m,age),label="risk level $r", alpha = 0.2,color=r)
            else
                plot!(ages,age -> MortalityTables.hazard(m,age),label="", alpha = 0.2,color=r)
            end
        end
    end
    p
end

Handling non-unit exposures

The key is to use the Poisson distribution:

@model function mortality4(data,deaths) 
    risk_levels = length(levels(data.risklevel))
    b ~ Exponential(0.1)
    ā ~ Exponential(0.1)
    a ~ filldist(Exponential(ā), risk_levels)
    c ~ Beta(4,18)
    k ~ truncated(Exponential(1),1,Inf)
    
    # use the variables to create a parametric mortality model

    # loop through the rows of the dataframe to let Turing observe the data 
    # and how consistent the parameters are with the data
    for i = 1:nrow(data)
        risk = data.risklevel[i]
        
        m = MortalityTables.MakehamBeard(;a=a[risk],b,c,k)
        age = data.att_age[i]	
        q = MortalityTables.hazard(m,age)
        deaths[i] ~ Poisson(data.exposures[i] * q)
    end
end
mortality4 (generic function with 2 methods)
m4 = mortality4(data2,data2.deaths)
DynamicPPL.Model{typeof(mortality4), (:data, :deaths), (), (), Tuple{DataFrame, Vector{Int64}}, Tuple{}, DynamicPPL.DefaultContext}(Main.var"workspace#3".mortality4, (data = 528×6 DataFrame
 Row │ issue_age  att_age  risklevel  exposures  deaths  fraction
     │ Int64      Int64    Int64      Int64      Int64   Float64
─────┼──────────────────────────────────────────────────────────────
   1 │        50       50          1        535       6  0.011215
   2 │        50       50          2        535      13  0.0242991
   3 │        50       50          3        550      13  0.0236364
   4 │        50       51          1        510       6  0.0117647
   5 │        50       51          2        503      10  0.0198807
  ⋮  │     ⋮         ⋮         ⋮          ⋮        ⋮         ⋮
 525 │        55       83          1          8       1  0.125
 526 │        55       83          2          1       0  0.0
 527 │        55       83          3          2       0  0.0
 528 │        55       84          1          3       0  0.0
                                                    519 rows omitted, deaths = [6, 13, 13, 6, 10, 15, 4, 6, 16, 3  …  0, 2, 1, 1, 0, 0, 1, 0, 0, 0]), NamedTuple(), DynamicPPL.DefaultContext())
chain4 = sample(m4, NUTS(), 1000)
iteration chain b ā a[1] a[2] a[3] c ...
1 501 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
2 502 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
3 503 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
4 504 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
5 505 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
6 506 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
7 507 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
8 508 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
9 509 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
10 510 1 6.93745 0.552565 0.13241 0.576368 0.156364 0.180767
...
PRECIS(DataFrame(chain4))
┌───────┬─────────────────────────────────────────────────────┐
│ param │   mean  std    5.5%     50%   94.5%       histogram │
├───────┼─────────────────────────────────────────────────────┤
│  a[1] │ 0.1324  0.0  0.1324  0.1324  0.1324    ▁▂▁▄▇█▇█▆▅▂▁ │
│  a[2] │ 0.5764  0.0  0.5764  0.5764  0.5764  ▁▂▄▅▅█▄▄▃▄▅▂▁▁ │
│  a[3] │ 0.1564  0.0  0.1564  0.1564  0.1564        ▂██▇▄▃▁▁ │
│     b │ 6.9375  0.0  6.9375  6.9375  6.9375       ▁▂▂▄██▆▄▁ │
│     c │ 0.1808  0.0  0.1808  0.1808  0.1808       ▁▁▄█▇▄▃▃▁ │
│     k │ 1.3223  0.0  1.3223  1.3223  1.3223  ▁▃▁▅▄▂▃▂▂▇█▆█▁ │
│     ā │ 0.5526  0.0  0.5526  0.5526  0.5526  ▁▃▄▁▃▄▃▄▅▆▃█▅▁ │
└───────┴─────────────────────────────────────────────────────┘
risk_factors4 = [mean(chain4[Symbol("a[$f]")]) for f in 1:3]
3-element Vector{Float64}:
 0.1324101089582174
 0.5763673415251683
 0.15636399903648368
risk_factors4 ./ risk_factors4[2]
3-element Vector{Float64}:
 0.22973215069375233
 1.0
 0.27129226063141837
let data = data2
    
    data_weight = data.exposures ./ sum(data.exposures)
    data_weight = .√(data_weight ./ maximum(data_weight) .* 20)
    color_i = data.risklevel
    
    p = scatter(
        data.att_age,
        data.fraction, 
        markersize = data_weight, 
        alpha = 0.5, 
        color=color_i,
        label = "Experience data point (size indicates relative exposure quantity)",
        xlabel="age",
        ylim=(0.0,0.25),
        ylabel="mortality rate", 
        title="Parametric Bayseian Mortality"
    )
    

    # show n samples from the posterior plotted on the graph
    n = 100
    
    ages = sort!(unique(data.att_age))
    for r in 1:3	
        for i in 1:n
            s = sample(chain4,1)
            a = only(s[Symbol("a[$r]")])
            b = only(s[:b])
            k = only(s[:k])
            c = 0
            m = MortalityTables.MakehamBeard(;a,b,c,k)
            if i == 1 
                plot!(ages,age -> MortalityTables.hazard(m,age),label="risk level $r", alpha = 0.2,color=r)
            else
                plot!(ages,age -> MortalityTables.hazard(m,age),label="", alpha = 0.2,color=r)
            end
        end
    end
    p
end

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 the prediction implied by the corresponding chain sample, and the columns are the predicted value for each of the outcomes in our original dataset.

preds = predict(mortality4(data2,fill(missing,length(data2.deaths))),chain4)
iteration chain deaths[1] deaths[2] deaths[3] deaths[4] deaths[5] deaths[6] ...
1 1 1 505.0 493.0 509.0 456.0 464.0 480.0
2 2 1 523.0 498.0 506.0 461.0 476.0 520.0
3 3 1 456.0 496.0 531.0 459.0 456.0 513.0
4 4 1 537.0 536.0 517.0 498.0 463.0 423.0
5 5 1 509.0 526.0 536.0 476.0 472.0 508.0
6 6 1 467.0 484.0 486.0 476.0 419.0 460.0
7 7 1 514.0 507.0 539.0 470.0 503.0 476.0
8 8 1 491.0 463.0 520.0 477.0 453.0 465.0
9 9 1 523.0 492.0 532.0 490.0 504.0 529.0
10 10 1 478.0 502.0 515.0 465.0 485.0 482.0
...
size(preds)
(1000, 528, 1)

Built with Julia 1.8.0 and

DataFramesMeta 0.12.0
MCMCChains 5.3.1
MortalityTables 2.3.0
Pipe 1.3.0
Plots 1.32.0
PlutoUI 0.7.40
StatisticalRethinking 4.5.2
StatsFuns 1.0.1
StatsPlots 0.14.34
Turing 0.21.12

Run this Pluto Notebook

To run this page locally, download this file and open it with Pluto.jl.

The packages in JuliaActuary are open-source and liberally licensed (MIT License) to allow wide private and commercial usage of the packages, like the base Julia language and many other packages in the ecosystem.