Nested Projection Mechanics

JuliaActuary is an ecosystem of packages that makes Julia the easiest language to get started for actuarial workflows.
modeling
benchmark
actuaryutilities
tutorial

A simple example of how one could define a nested projection system. Includes the following examples: - Outer loop policy projections only - Outer + Inner loop policy projections with padded cashflows determining reserves and capital - Outer + Inner loop with a stochastic interest rate for the reserves

In this notebook, we define a term life policy, implement the mechanics for “outer” projected values, as well as “inner” projections so that we can determine a projection-based reserve. This is done with both a deterministic and stochastic “inner” loop.

using ActuaryUtilities
using DataFrames
using Setfield

Policy Data & Methods

We will use a simple Term policy as an example for the mechanics. We don’t need to, but to illustrate that we could easily implement different product types, we first define an abstract type for Policys, and then the specific Term implementation.

The type annotations aren’t strictly necessary, but knowing the types in advance helps Julia specialize the code for it.

abstract type Policy end

struct Term <: Policy
    inforce::Float64 # count of inforce
    term::Int        # length of benefit period (in months)
    premium::Float64
    face::Float64
end

For consistency across different calculated amounts, we will keep the function signature consistent, even if not all of the arguments are used:

(policy,assumptions,time) -> result

premiums(pol::Term, assumptions, time) = pol.inforce * pol.premium / 12

qx(pol::Term, assumptions, time) = assumptions.q / 12

deaths(pol::Term, assumptions, time) = pol.inforce * qx(pol, assumptions, time)

claims(pol::Term, assumptions, time) = deaths(pol, assumptions, time) * pol.face
claims (generic function with 1 method)

Projection Assumptions

We define some global assumptions that get passed around. It’s good practice (and performant) to pass variables into functions instead of just referring to global variable values.

assumptions = (
    q=0.012,
    int_reserve=0.02,
    capital_factor=0.1, # rate * reserves
)
(q = 0.012, int_reserve = 0.02, capital_factor = 0.1)

Inner-loop assumption

In this example, we’re assuming just a PADed mortality rate for the inner loop. We take the assumption set and use Setfield.@set to return a new immutable named tuple with just that value modified:

innerloop_assumption(outer_assump) = @set outer_assump.q *= 1.2
innerloop_assumption (generic function with 1 method)

Projection Logic

The architecture takes inspiration from web server architecture where data is passed through multiple processing steps before being returned. The logic is contained within a function called project, which:

  1. Project a single timestep and create a tuple of values: (policy, assumptions, result)
  2. Apply the function additional_processing which takes as an argument (policy, assumptions, result).
  3. additional_processing can then define an “inner” loop, which could just be to apply the project with a modified set of assumptions. In this way, one or more “inner” loops can be defined.
  4. The final additional_processing function should return whatever you want to return as a result.

By default, the additional_processing will simply return the last argument, result and therefore will not have any inner loops.

"""
    project(policy,assumptions;start_time=1,additional_processing=res)

The kwarg `additional_processing` defines an intermediate processing step where one can take the current model state and perform additional work, including nested projections. If left out of the arguments, the default for `additional_processing` is `res`, where `res` is (pol,assumptions, result)->result (ie will just return the model point's results with no additional work being done).

"""
function project(
    pol::Term,
    assumptions;
    start_time=1,
    additional_processing=(pol, assumptions, result) -> result
)

    # alias the assumptions to A for brevity
    A = assumptions


    # iterate over the policy from the start time to the end of the policy's term
    map(start_time:pol.term) do t
        # calculate components of the projection
        timestep = t
        premium = premiums(pol, A, t)
        q = qx(pol, A, t)
        death = deaths(pol, A, t)
        claim = claims(pol, A, t)
        net_cf = premium - claim
        inforce = pol.inforce - death
        pol = @set pol.inforce = inforce

        # return a vector of name tuples with the results
        result = (;
            timestep,
            premium,
            death,
            claim,
            net_cf,
            inforce,
            q,
        )

        # apply additional processing function 
        additional_processing(pol, A, result)
    end
end
project (generic function with 1 method)
# Function signature: (policy, assumptions, result) -> updated result
function run_inner(policy, assumptions, result)
    additional_results = if result.timestep + 1 <= policy.term
        A = innerloop_assumption(assumptions)
        p = project(policy, A; start_time=result.timestep + 1)

        # calculate the reserves as the present value of the
        # cashflows within the inner loop projections
        # discounted at the reserve interest rate
        # (an annual rate, converted to match the monthly timesteps)
        i_monthly = (1 + A.int_reserve)^(1 / 12) - 1
        reserves = -pv(i_monthly, [modelpoint.net_cf for modelpoint in p])
        capital = reserves * A.capital_factor
        (; reserves, capital)
    else
        reserves = 0.0
        capital = 0.0
        (; reserves, capital)

    end

    return merge(result, additional_results)

end
run_inner (generic function with 1 method)

And a stochastic version:

# Function signature: (policy, assumptions, result) -> updated result
function run_inner_stochastic(policy, assumptions, result)
    additional_results = if result.timestep + 1 <= policy.term
        A = innerloop_assumption(assumptions)
        p = project(policy, A; start_time=result.timestep + 1)

        # simple stochastic interest rate 
        n = 100

        reserves = let
            cfs = [modelpoint.net_cf for modelpoint in p]
            -sum(1:n) do _
                # shock the annual rate each scenario, then convert to monthly
                i = (1 + A.int_reserve + 0.005 * randn())^(1 / 12) - 1
                pv(i, cfs)
            end / n
        end
        capital = reserves * A.capital_factor
        (; reserves, capital)
    else
        reserves = 0.0
        capital = 0.0
        (; reserves, capital)
    end
    return merge(result, additional_results)

end
run_inner_stochastic (generic function with 1 method)

Projections

First, define a sample policy:

p = Term(1.0, 120, 1300.0, 100_000.0)
Term(1.0, 120, 1300.0, 100000.0)

A projection without any additional processing:

project(p, assumptions) |> DataFrame
120×7 DataFrame
95 rows omitted
Row timestep premium death claim net_cf inforce q
Int64 Float64 Float64 Float64 Float64 Float64 Float64
1 1 108.333 0.001 100.0 8.33333 0.999 0.001
2 2 108.225 0.000999 99.9 8.325 0.998001 0.001
3 3 108.117 0.000998001 99.8001 8.31667 0.997003 0.001
4 4 108.009 0.000997003 99.7003 8.30836 0.996006 0.001
5 5 107.901 0.000996006 99.6006 8.30005 0.99501 0.001
6 6 107.793 0.00099501 99.501 8.29175 0.994015 0.001
7 7 107.685 0.000994015 99.4015 8.28346 0.993021 0.001
8 8 107.577 0.000993021 99.3021 8.27517 0.992028 0.001
9 9 107.47 0.000992028 99.2028 8.2669 0.991036 0.001
10 10 107.362 0.000991036 99.1036 8.25863 0.990045 0.001
11 11 107.255 0.000990045 99.0045 8.25037 0.989055 0.001
12 12 107.148 0.000989055 98.9055 8.24212 0.988066 0.001
13 13 107.04 0.000988066 98.8066 8.23388 0.987078 0.001
⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮
109 109 97.2377 0.000897579 89.7579 7.47983 0.896682 0.001
110 110 97.1405 0.000896682 89.6682 7.47235 0.895785 0.001
111 111 97.0434 0.000895785 89.5785 7.46487 0.894889 0.001
112 112 96.9463 0.000894889 89.4889 7.45741 0.893994 0.001
113 113 96.8494 0.000893994 89.3994 7.44995 0.8931 0.001
114 114 96.7525 0.0008931 89.31 7.4425 0.892207 0.001
115 115 96.6558 0.000892207 89.2207 7.43506 0.891315 0.001
116 116 96.5591 0.000891315 89.1315 7.42762 0.890424 0.001
117 117 96.4626 0.000890424 89.0424 7.4202 0.889533 0.001
118 118 96.3661 0.000889533 88.9533 7.41278 0.888644 0.001
119 119 96.2697 0.000888644 88.8644 7.40536 0.887755 0.001
120 120 96.1735 0.000887755 88.7755 7.39796 0.886867 0.001

And an example which uses a PADed inner loop to determine the resserves and capital:

project(p, assumptions; additional_processing=run_inner) |> DataFrame
120×9 DataFrame
95 rows omitted
Row timestep premium death claim net_cf inforce q reserves capital
Int64 Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
1 1 108.333 0.001 100.0 8.33333 0.999 0.001 1175.91 117.591
2 2 108.225 0.000999 99.9 8.325 0.998001 0.001 1166.43 116.643
3 3 108.117 0.000998001 99.8001 8.31667 0.997003 0.001 1156.94 115.694
4 4 108.009 0.000997003 99.7003 8.30836 0.996006 0.001 1147.45 114.745
5 5 107.901 0.000996006 99.6006 8.30005 0.99501 0.001 1137.95 113.795
6 6 107.793 0.00099501 99.501 8.29175 0.994015 0.001 1128.45 112.845
7 7 107.685 0.000994015 99.4015 8.28346 0.993021 0.001 1118.94 111.894
8 8 107.577 0.000993021 99.3021 8.27517 0.992028 0.001 1109.43 110.943
9 9 107.47 0.000992028 99.2028 8.2669 0.991036 0.001 1099.9 109.99
10 10 107.362 0.000991036 99.1036 8.25863 0.990045 0.001 1090.38 109.038
11 11 107.255 0.000990045 99.0045 8.25037 0.989055 0.001 1080.84 108.084
12 12 107.148 0.000989055 98.9055 8.24212 0.988066 0.001 1071.3 107.13
13 13 107.04 0.000988066 98.8066 8.23388 0.987078 0.001 1061.76 106.176
⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮
109 109 97.2377 0.000897579 89.7579 7.47983 0.896682 0.001 113.263 11.3263
110 110 97.1405 0.000896682 89.6682 7.47235 0.895785 0.001 103.009 10.3009
111 111 97.0434 0.000895785 89.5785 7.46487 0.894889 0.001 92.7472 9.27472
112 112 96.9463 0.000894889 89.4889 7.45741 0.893994 0.001 82.4766 8.24766
113 113 96.8494 0.000893994 89.3994 7.44995 0.8931 0.001 72.1973 7.21973
114 114 96.7525 0.0008931 89.31 7.4425 0.892207 0.001 61.9094 6.19094
115 115 96.6558 0.000892207 89.2207 7.43506 0.891315 0.001 51.6129 5.16129
116 116 96.5591 0.000891315 89.1315 7.42762 0.890424 0.001 41.3078 4.13078
117 117 96.4626 0.000890424 89.0424 7.4202 0.889533 0.001 30.9939 3.09939
118 118 96.3661 0.000889533 88.9533 7.41278 0.888644 0.001 20.6714 2.06714
119 119 96.2697 0.000888644 88.8644 7.40536 0.887755 0.001 10.3401 1.03401
120 120 96.1735 0.000887755 88.7755 7.39796 0.886867 0.001 0.0 0.0

And a stochastic example:

project(p, assumptions; additional_processing=run_inner_stochastic) |> DataFrame
120×9 DataFrame
95 rows omitted
Row timestep premium death claim net_cf inforce q reserves capital
Int64 Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
1 1 108.333 0.001 100.0 8.33333 0.999 0.001 1176.38 117.638
2 2 108.225 0.000999 99.9 8.325 0.998001 0.001 1169.29 116.929
3 3 108.117 0.000998001 99.8001 8.31667 0.997003 0.001 1160.1 116.01
4 4 108.009 0.000997003 99.7003 8.30836 0.996006 0.001 1149.18 114.918
5 5 107.901 0.000996006 99.6006 8.30005 0.99501 0.001 1140.68 114.068
6 6 107.793 0.00099501 99.501 8.29175 0.994015 0.001 1130.31 113.031
7 7 107.685 0.000994015 99.4015 8.28346 0.993021 0.001 1118.52 111.852
8 8 107.577 0.000993021 99.3021 8.27517 0.992028 0.001 1109.71 110.971
9 9 107.47 0.000992028 99.2028 8.2669 0.991036 0.001 1097.51 109.751
10 10 107.362 0.000991036 99.1036 8.25863 0.990045 0.001 1090.7 109.07
11 11 107.255 0.000990045 99.0045 8.25037 0.989055 0.001 1081.11 108.111
12 12 107.148 0.000989055 98.9055 8.24212 0.988066 0.001 1074.57 107.457
13 13 107.04 0.000988066 98.8066 8.23388 0.987078 0.001 1063.01 106.301
⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮
109 109 97.2377 0.000897579 89.7579 7.47983 0.896682 0.001 113.286 11.3286
110 110 97.1405 0.000896682 89.6682 7.47235 0.895785 0.001 103.041 10.3041
111 111 97.0434 0.000895785 89.5785 7.46487 0.894889 0.001 92.7658 9.27658
112 112 96.9463 0.000894889 89.4889 7.45741 0.893994 0.001 82.4517 8.24517
113 113 96.8494 0.000893994 89.3994 7.44995 0.8931 0.001 72.1992 7.21992
114 114 96.7525 0.0008931 89.31 7.4425 0.892207 0.001 61.9001 6.19001
115 115 96.6558 0.000892207 89.2207 7.43506 0.891315 0.001 51.6143 5.16143
116 116 96.5591 0.000891315 89.1315 7.42762 0.890424 0.001 41.302 4.1302
117 117 96.4626 0.000890424 89.0424 7.4202 0.889533 0.001 30.9985 3.09985
118 118 96.3661 0.000889533 88.9533 7.41278 0.888644 0.001 20.6697 2.06697
119 119 96.2697 0.000888644 88.8644 7.40536 0.887755 0.001 10.3402 1.03402
120 120 96.1735 0.000887755 88.7755 7.39796 0.886867 0.001 0.0 0.0

Endnotes

Further Work

This example is simple, but could be greatly optimized to reduce intermediate variable allocations, refine the timing of cashflows, add additional decrements, handle different types of Policys, abstract some of the projection mechanics into an Iterable object, etc.

Disclaimer

Created as a proof of concept and not indended to be interpreted as a meaningful projection.