JuliaActuary
Practical, extensible, and open-source actuarial modeling and analysis.

Exposure Calculation with ExperienceAnalysis.jl

In this tutorial, we will walk through how to calculate exposures using the ExperienceAnalysis.jl package.

In summary, the package will help calculate the exposure periods given parameters about the kind of period and timepoints under consideration:

using ExperienceAnalysis
using Dates

issue = Date(2016, 7, 4)
termination = Date(2020, 1, 17)
basis = ExperienceAnalysis.Anniversary(Year(1))
exposure(basis, issue, termination)

This will return an array of tuples with a from and to date:

4-element Array{NamedTuple{(:from, :to),Tuple{Date,Date}},1}:
 (from = Date("2016-07-04"), to = Date("2017-07-04"))
 (from = Date("2017-07-04"), to = Date("2018-07-04"))
 (from = Date("2018-07-04"), to = Date("2019-07-04"))
 (from = Date("2019-07-04"), to = Date("2020-01-17"))

Available Exposure Basis

Where period is a Period Type from the Dates standard library.

Calculate exposures with exposures(basis,from,to,continue_exposure).

Full Example

We'll start with this as our data:

julia> df

3×4 DataFrame
│ Row │ id     │ issue      │ termination │ status  │
│     │ String │ Date       │ Date?       │ String  │
├─────┼────────┼────────────┼─────────────┼─────────┤
│ 112016-07-042020-01-17  │ Claim   │
│ 222016-01-012018-05-04  │ Lapse   │
│ 332016-01-01missing     │ Inforce │

Define the study end:

study_end = Date(2020,12,31)

Next, we do two things by iterating over and creating a new array of dates:

  1. Handle the missing case by letting the to reflect the study_end

  2. Cap the ending date at the study_end. This doesn't come into play in this example, but it's included for demonstration purposes.

to = [ismissing(d) ? study_end : min(study_end,d) for d in df.termination]

Calculate the exposure by broadcasting the exposure function over the three arrays we are passing to it:

df.exposure = exposure.(
    ExperienceAnalysis.Anniversary(Year(1)),   # The basis for our exposures
    df.issue,                             # The `from` date
    to                                    # the `to` date array we created above
    )

In our dataframe, we actually have a column that contains an array of tuples now, so to expand it so that each exposure period gets a row, we flatten the dataframe:

df = flatten(df,:exposure)

So now we have our exposures:

│ id     │ issue      │ termination │ status  │ exposure                                             │
│ String │ Date       │ Date?       │ StringNamedTuple{(:from, :to),Tuple{Date,Date}}            │
┼────────┼────────────┼─────────────┼─────────┼──────────────────────────────────────────────────────┼
│ 12016-07-042020-01-17  │ Claim   │ (from = Date("2016-07-04"), to = Date("2017-07-04")) │
│ 12016-07-042020-01-17  │ Claim   │ (from = Date("2017-07-04"), to = Date("2018-07-04")) │
│ 12016-07-042020-01-17  │ Claim   │ (from = Date("2018-07-04"), to = Date("2019-07-04")) │
│ 12016-07-042020-01-17  │ Claim   │ (from = Date("2019-07-04"), to = Date("2020-01-17")) │
│ 22016-01-012018-05-04  │ Lapse   │ (from = Date("2016-01-01"), to = Date("2017-01-01")) │
│ 22016-01-012018-05-04  │ Lapse   │ (from = Date("2017-01-01"), to = Date("2018-01-01")) │
│ 22016-01-012018-05-04  │ Lapse   │ (from = Date("2018-01-01"), to = Date("2018-05-04")) │
│ 32016-01-01missing     │ Inforce │ (from = Date("2016-01-01"), to = Date("2017-01-01")) │
│ 32016-01-01missing     │ Inforce │ (from = Date("2017-01-01"), to = Date("2018-01-01")) │
│ 32016-01-01missing     │ Inforce │ (from = Date("2018-01-01"), to = Date("2019-01-01")) │
│ 32016-01-01missing     │ Inforce │ (from = Date("2019-01-01"), to = Date("2020-01-01")) │
│ 32016-01-01missing     │ Inforce │ (from = Date("2020-01-01"), to = Date("2020-12-31")) │

Exposure Fraction

This can be extended to calculate the decimal fraction of the year under different day count conventions, such as assuming 30/360 or Actual/365, etc. using the DayCounts.jl package.

using DayCounts

df.exposure_fraction = map(e -> yearfrac(e.from,e.to,DayCounts.Actual360()),df.exposure)

So now we have:

│ exposure                                             │ exposure_fraction │
│ NamedTuple{(:from, :to),Tuple{Date,Date}}            │ Float64           │
┼──────────────────────────────────────────────────────┼───────────────────┤
│ (from = Date("2016-07-04"), to = Date("2017-07-04")) │ 1.01389           │
│ (from = Date("2017-07-04"), to = Date("2018-07-04")) │ 1.01389           │
│ (from = Date("2018-07-04"), to = Date("2019-07-04")) │ 1.01389           │
│ (from = Date("2019-07-04"), to = Date("2020-07-04")) │ 0.54722           │
│ (from = Date("2016-01-01"), to = Date("2017-01-01")) │ 1.01667           │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │ 1.01389           │
│ (from = Date("2018-01-01"), to = Date("2018-05-04")) │ 0.34167           │
│ (from = Date("2016-01-01"), to = Date("2017-01-01")) │ 1.01667           │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │ 1.01389           │
│ (from = Date("2018-01-01"), to = Date("2019-01-01")) │ 1.01389           │
│ (from = Date("2019-01-01"), to = Date("2020-01-01")) │ 1.01389           │
│ (from = Date("2020-01-01"), to = Date("2020-12-31")) │ 1.01389

Continued Exposure

To get the proper exposure for the termination type under consideration, exposure takes an optional fourth argument which will continue the exposure until the end of what would be the period notwithstanding the termination.

Extending the above analysis, we want a full exposure period for any "Claim" in this case:

continue_exposure = df.status .== "Claim"

df.exposure = exposure.(
    ExperienceAnalysis.Anniversary(Year(1)),   # The basis for our exposures
    df.issue,                             # The `from` date
    to,                                   # the `to` date array we created above
    continue_exposure                     # full exposure or not (true/false)
    )

And then the exposures look like the following. Note the difference in the fourth row:

│ exposure                                             │ exposure_fraction │
│ NamedTuple{(:from, :to),Tuple{Date,Date}}            │ Float64           │
┼──────────────────────────────────────────────────────┼───────────────────┤
│ (from = Date("2016-07-04"), to = Date("2017-07-04")) │ 1.01389           │
│ (from = Date("2017-07-04"), to = Date("2018-07-04")) │ 1.01389           │
│ (from = Date("2018-07-04"), to = Date("2019-07-04")) │ 1.01389           │
│ (from = Date("2019-07-04"), to = Date("2020-07-04")) │ 1.01667           │
│ (from = Date("2016-01-01"), to = Date("2017-01-01")) │ 1.01667           │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │ 1.01389           │
│ (from = Date("2018-01-01"), to = Date("2018-05-04")) │ 0.341667          │
│ (from = Date("2016-01-01"), to = Date("2017-01-01")) │ 1.01667           │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │ 1.01389           │
│ (from = Date("2018-01-01"), to = Date("2019-01-01")) │ 1.01389           │
│ (from = Date("2019-01-01"), to = Date("2020-01-01")) │ 1.01389           │
│ (from = Date("2020-01-01"), to = Date("2020-12-31")) │ 1.01389

Study End/Start dates

The examples above already incorporated the study end date. However, the study start date must be truncated as a post-processing step. This is because the to argument to the exposure(...) function defines the anchor point for the policy anniversary iteration. If the to was the study start date, then the anniversaries would follow that calendar date.

To truncate the exposures, you would need to drop/update exposures. Continuing the example above:

study_start = Date(2017,1,1)

# drop rows where the whole exposure is before the study_start
df_truncated = filter(row -> row.exposure.to >= study_start,df)

# update the `from` where remaining exposures start before the study_start
df_truncated.exposure = map(e -> (from = max(study_start,e.from),to = e.to), df_truncated.exposure)

And then df_truncated contains:

│ exposure                                             │
│ NamedTuple{(:from, :to),Tuple{Date,Date}}            │
┼──────────────────────────────────────────────────────┼
│ (from = Date("2017-01-01"), to = Date("2017-07-04")) │
│ (from = Date("2017-07-04"), to = Date("2018-07-04")) │
│ (from = Date("2018-07-04"), to = Date("2019-07-04")) │
│ (from = Date("2019-07-04"), to = Date("2020-07-04")) │
│ (from = Date("2017-01-01"), to = Date("2017-01-01")) │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │
│ (from = Date("2018-01-01"), to = Date("2018-05-04")) │
│ (from = Date("2017-01-01"), to = Date("2017-01-01")) │
│ (from = Date("2017-01-01"), to = Date("2018-01-01")) │
│ (from = Date("2018-01-01"), to = Date("2019-01-01")) │
│ (from = Date("2019-01-01"), to = Date("2020-01-01")) │
│ (from = Date("2020-01-01"), to = Date("2020-12-31")) │

Last item to note is that you need to recalculate the exposure_fraction as it currently reflects the pre-truncated values. When actually building a process, you could truncate before calculating the fraction to begin with.

Discussion and Questions

If you have other ideas or questions, feel free to also open an issue, or discuss on the community Zulip or Slack #actuary channel. We welcome all actuarial and related disciplines!

References

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.