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
# 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
        reserves = -pv(A.int_reserve, [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
            i = A.int_reserve
            f = pv(i + 0.005 * randn(), [modelpoint.net_cf for modelpoint in p])

            -sum(f for _ in 1:n) / 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 504.61 50.461
2 2 108.225 0.000999 99.9 8.325 0.998001 0.001 503.148 50.3148
3 3 108.117 0.000998001 99.8001 8.31667 0.997003 0.001 501.668 50.1668
4 4 108.009 0.000997003 99.7003 8.30836 0.996006 0.001 500.169 50.0169
5 5 107.901 0.000996006 99.6006 8.30005 0.99501 0.001 498.652 49.8652
6 6 107.793 0.00099501 99.501 8.29175 0.994015 0.001 497.117 49.7117
7 7 107.685 0.000994015 99.4015 8.28346 0.993021 0.001 495.561 49.5561
8 8 107.577 0.000993021 99.3021 8.27517 0.992028 0.001 493.986 49.3986
9 9 107.47 0.000992028 99.2028 8.2669 0.991036 0.001 492.391 49.2391
10 10 107.362 0.000991036 99.1036 8.25863 0.990045 0.001 490.775 49.0775
11 11 107.255 0.000990045 99.0045 8.25037 0.989055 0.001 489.138 48.9138
12 12 107.148 0.000989055 98.9055 8.24212 0.988066 0.001 487.479 48.7479
13 13 107.04 0.000988066 98.8066 8.23388 0.987078 0.001 485.799 48.5799
109 109 97.2377 0.000897579 89.7579 7.47983 0.896682 0.001 101.795 10.1795
110 110 97.1405 0.000896682 89.6682 7.47235 0.895785 0.001 93.3884 9.33884
111 111 97.0434 0.000895785 89.5785 7.46487 0.894889 0.001 84.8223 8.48223
112 112 96.9463 0.000894889 89.4889 7.45741 0.893994 0.001 76.0936 7.60936
113 113 96.8494 0.000893994 89.3994 7.44995 0.8931 0.001 67.199 6.7199
114 114 96.7525 0.0008931 89.31 7.4425 0.892207 0.001 58.1351 5.81351
115 115 96.6558 0.000892207 89.2207 7.43506 0.891315 0.001 48.8986 4.88986
116 116 96.5591 0.000891315 89.1315 7.42762 0.890424 0.001 39.4858 3.94858
117 117 96.4626 0.000890424 89.0424 7.4202 0.889533 0.001 29.8932 2.98932
118 118 96.3661 0.000889533 88.9533 7.41278 0.888644 0.001 20.1172 2.01172
119 119 96.2697 0.000888644 88.8644 7.40536 0.887755 0.001 10.1541 1.01541
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 495.008 49.5008
2 2 108.225 0.000999 99.9 8.325 0.998001 0.001 551.777 55.1777
3 3 108.117 0.000998001 99.8001 8.31667 0.997003 0.001 456.478 45.6478
4 4 108.009 0.000997003 99.7003 8.30836 0.996006 0.001 437.01 43.701
5 5 107.901 0.000996006 99.6006 8.30005 0.99501 0.001 681.328 68.1328
6 6 107.793 0.00099501 99.501 8.29175 0.994015 0.001 512.651 51.2651
7 7 107.685 0.000994015 99.4015 8.28346 0.993021 0.001 538.304 53.8304
8 8 107.577 0.000993021 99.3021 8.27517 0.992028 0.001 848.208 84.8208
9 9 107.47 0.000992028 99.2028 8.2669 0.991036 0.001 523.956 52.3956
10 10 107.362 0.000991036 99.1036 8.25863 0.990045 0.001 404.1 40.41
11 11 107.255 0.000990045 99.0045 8.25037 0.989055 0.001 508.197 50.8197
12 12 107.148 0.000989055 98.9055 8.24212 0.988066 0.001 468.548 46.8548
13 13 107.04 0.000988066 98.8066 8.23388 0.987078 0.001 456.656 45.6656
109 109 97.2377 0.000897579 89.7579 7.47983 0.896682 0.001 104.005 10.4005
110 110 97.1405 0.000896682 89.6682 7.47235 0.895785 0.001 97.0335 9.70335
111 111 97.0434 0.000895785 89.5785 7.46487 0.894889 0.001 83.7453 8.37453
112 112 96.9463 0.000894889 89.4889 7.45741 0.893994 0.001 75.1751 7.51751
113 113 96.8494 0.000893994 89.3994 7.44995 0.8931 0.001 65.4564 6.54564
114 114 96.7525 0.0008931 89.31 7.4425 0.892207 0.001 59.3372 5.93372
115 115 96.6558 0.000892207 89.2207 7.43506 0.891315 0.001 49.5258 4.95258
116 116 96.5591 0.000891315 89.1315 7.42762 0.890424 0.001 39.476 3.9476
117 117 96.4626 0.000890424 89.0424 7.4202 0.889533 0.001 29.9494 2.99494
118 118 96.3661 0.000889533 88.9533 7.41278 0.888644 0.001 20.2168 2.02168
119 119 96.2697 0.000888644 88.8644 7.40536 0.887755 0.001 10.1431 1.01431
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.