Actuarial¶
actuarial
¶
Actuarial layer: compound distributions, aggregate losses and reinsurance.
An insurance portfolio produces a random number of claims of random size, and the quantity that matters is their total::
S = X_1 + X_2 + ... + X_N
with N the claim count and X_i the individual losses. This module builds
the distribution of S from a frequency model and a severity distribution,
and prices the reinsurance structures written on top of it.
Two routes to the aggregate distribution, with different failure modes:
Panjer recursion (:func:panjer_recursion) is exact given a discretised
severity, and gives the whole distribution rather than a sample. It is the
right tool when the severity is not too heavy and the expected claim count is
moderate. It has two limitations that this module reports rather than hides:
the severity grid is finite, so mass past its end is lost and counted; and the
recursion starts from P_N(f_0), which underflows to zero for a large
expected count, taking the whole distribution with it.
Simulation (:func:simulate_aggregate_loss) has neither limitation and
converges slowly, which is the trade. For a genuinely heavy-tailed severity it
is usually the honest choice, and :class:EmpiricalAggregate plugs the sample
into the same interface so the two routes are interchangeable.
A warning that applies to both, and to every actuarial text that recommends a
normal or translated-gamma approximation to the aggregate: for a severity
with tail index alpha <= 2 the variance of S does not exist, and for
alpha <= 1 neither does its mean. :func:compound_moments reports inf
in those cases. An approximation matching two moments cannot match moments
that are not there.
AggregateLoss
dataclass
¶
The distribution of the aggregate loss on an arithmetic grid.
Produced by :func:panjer_recursion. Probabilities sit at 0, h, 2h, ...
and truncated_mass is what fell off the end -- read it before trusting
anything in the far tail, since a quantile above 1 - truncated_mass
cannot be resolved at all and is reported as inf.
Attributes:
| Name | Type | Description |
|---|---|---|
h |
float
|
Grid span. |
probabilities |
list[float]
|
Probability at each grid point. |
truncated_mass |
float
|
Probability beyond the last grid point. |
severity_tail_mass |
float
|
Severity mass lost in discretisation, propagated here because it is the usual cause of the above. |
cdf
¶
Probability the aggregate is at most x.
expected_shortfall
¶
Mean aggregate loss given it exceeds the value at risk.
Returns inf when the grid has been truncated inside the region being
averaged, since the missing mass is exactly the part that matters most.
Source code in heavytails/actuarial.py
mean
¶
Mean of the gridded distribution.
Below the true mean by whatever the truncation removed. Compare against
:func:compound_moments, which is exact.
Source code in heavytails/actuarial.py
ppf
¶
Quantile of the aggregate.
Returns inf above 1 - truncated_mass, because the grid holds no
information there. Returning the last grid point instead would look like
an answer.
Source code in heavytails/actuarial.py
sf
¶
stop_loss_premium
¶
The stop-loss premium E[(S - retention)+].
The expected cost of aggregate excess-of-loss cover attaching at
retention: the reinsurer pays whatever the total exceeds it.
The truncated mass lies entirely above the retention, so the value
computed from the grid is a lower bound: the part that fell off the
end contributes at least truncated_mass * (grid_end - retention)
more. When that shortfall is a material fraction of the answer this
returns inf rather than a number known to be too small. A heavy
tail always truncates something, so demanding none would make this
useless on exactly the distributions it is for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
retention
|
float
|
Aggregate attachment point, non-negative. |
required |
tolerance
|
float
|
Largest relative shortfall to accept, 1% by default. Tighten it when the premium feeds a reserve rather than a comparison. |
0.01
|
Returns:
| Type | Description |
|---|---|
float
|
The expected excess, or |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in heavytails/actuarial.py
value_at_risk
¶
variance
¶
Variance of the gridded distribution, subject to the same caveat.
Binomial
dataclass
¶
Binomial claim count: m risks each producing a claim with probability
p.
Under-dispersed, with variance below the mean, so it fits a closed group of
policies where at most one claim per policy is possible. Included because it
completes the (a,b,0) class the Panjer recursion is defined on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Number of risks, a positive integer. |
required |
p
|
float
|
Claim probability per risk, in (0, 1). |
required |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
Examples:
EmpiricalAggregate
dataclass
¶
A simulated aggregate sample, wrapped so it behaves like a distribution.
Gives simulation output the same cdf/ppf/sf interface the
gridded result has, so the two routes are interchangeable and the functions
in :mod:heavytails.risk accept either.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
samples
|
Sequence[float]
|
Simulated aggregate losses. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
cdf
¶
expected_shortfall
¶
Mean of the sample above its level quantile.
Source code in heavytails/actuarial.py
mean
¶
ppf
¶
Empirical quantile, by the order statistic at ceil(u*n).
Source code in heavytails/actuarial.py
sf
¶
stop_loss_premium
¶
Empirical E[(S - retention)+].
Source code in heavytails/actuarial.py
LayeredSeverity
dataclass
¶
LayeredSeverity(
severity, terms=PolicyTerms(), basis="per-loss"
)
A severity distribution with policy terms applied.
Two bases, and choosing the wrong one is the classic error in this calculation:
"per-loss"
The payment on every loss, including the zero paid on losses below the
deductible. Has an atom of size F(deductible) at zero, and pairs
with the original claim frequency.
"per-payment"
The payment conditional on there being one. No atom at zero, a larger
mean, and pairs with a frequency thinned by the probability of
exceeding the deductible.
Both pairings describe the same aggregate loss, and the test suite asserts
they agree. Mixing them -- per-payment severity with unthinned frequency --
overstates the expected aggregate by a factor of 1/S(deductible), which
for a high deductible is very large and looks plausible.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
severity
|
Any
|
Ground-up loss distribution, with |
required |
terms
|
PolicyTerms
|
The policy structure. |
PolicyTerms()
|
basis
|
str
|
|
'per-loss'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from heavytails import Pareto
>>> layer = LayeredSeverity(Pareto(alpha=2.0, xm=1.0),
... PolicyTerms(deductible=2.0, limit=8.0))
>>> round(layer.mean(), 6)
0.4
exceedance_probability
property
¶
Probability a ground-up loss exceeds the deductible.
This is the thinning factor: the frequency of payments is the frequency of losses multiplied by this.
cdf
¶
Distribution function of the payment, for one value or many.
Source code in heavytails/actuarial.py
lev
¶
The layer's own limited expected value E[min(Y, t)].
Censoring the payment at t is the same as censoring the loss at
d + t/c, so this is a difference of the severity's limited expected
values and stays exact::
E[Y ^ t] = c * (E[X ^ (d + t/c)] - E[X ^ d])
:func:limited_expected_value picks this up automatically. It matters:
computing it numerically instead means integrating a quantile function
with a large atom at zero, and the mean-preserving discretisation takes
a second difference of the result, which amplifies any error in it by
orders of magnitude.
Source code in heavytails/actuarial.py
mean
¶
ppf
¶
Quantile function of the payment, for one probability or many.
Source code in heavytails/actuarial.py
rvs
¶
Draw n independent payments.
Source code in heavytails/actuarial.py
NegativeBinomial
dataclass
¶
Negative binomial claim count, in the actuarial (r, beta) parameters.
Mean r*beta and variance r*beta*(1 + beta), so the variance always
exceeds the mean. That over-dispersion is the reason to prefer it to
:class:Poisson: it arises exactly when the rate itself is uncertain, since
a gamma-mixed Poisson is negative binomial.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
r
|
float
|
Positive shape. Need not be an integer. |
required |
beta
|
float
|
Positive scale. |
required |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If either parameter is not positive. |
Examples:
draw
¶
mean
¶
panjer_ab
¶
pgf
¶
Probability generating function (1 - beta(s - 1))^(-r).
Source code in heavytails/actuarial.py
pmf
¶
Probability of exactly k claims.
Source code in heavytails/actuarial.py
rvs
¶
Draw n independent claim counts, via the gamma-Poisson mixture.
thin
¶
Keep each claim independently with probability.
Scales beta, which follows from the gamma mixture: thinning the
Poisson layer scales the mixing gamma's scale by the same factor.
Source code in heavytails/actuarial.py
Poisson
dataclass
¶
Poisson claim count with rate lam.
The default frequency model, and the one where variance equals mean. Real
portfolios are usually over-dispersed, which is what
:class:NegativeBinomial is for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lam
|
float
|
Expected number of claims per period, positive. |
required |
Raises:
| Type | Description |
|---|---|
ParameterError
|
If |
Examples:
draw
¶
mean
¶
panjer_ab
¶
pgf
¶
pmf
¶
rvs
¶
thin
¶
Keep each claim independently with probability.
Thinning a Poisson gives a Poisson, which is what lets a per-payment
severity be paired with a reduced claim count. See
:class:LayeredSeverity for why that matters.
Source code in heavytails/actuarial.py
PolicyTerms
dataclass
¶
Deductible, limit and coinsurance applied to a single loss.
The payment on a loss x is coinsurance * min(max(x - deductible, 0),
limit). Note that limit caps the excess over the deductible, not the
loss: a policy described as "1M excess of 100k" has deductible=100_000
and limit=1_000_000, and pays at most 1M. Where a limit is quoted as a
maximum covered loss u instead, pass limit=u - deductible.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
deductible
|
float
|
Loss retained by the insured, non-negative. |
0.0
|
limit
|
float | None
|
Largest excess covered, or |
None
|
coinsurance
|
float
|
Share of the covered excess paid, in (0, 1]. |
1.0
|
Raises:
| Type | Description |
|---|---|
ParameterError
|
If any parameter is outside its range. |
Examples:
>>> terms = PolicyTerms(deductible=100.0, limit=500.0)
>>> terms.payment(50.0), terms.payment(300.0), terms.payment(9999.0)
(0.0, 200.0, 500.0)
payment
¶
The amount paid on a loss of size loss, for one loss or many.
Mirrors its input, so pricing a whole simulated book is one call rather than one per claim.
Source code in heavytails/actuarial.py
compound_moments
¶
Exact mean and variance of the aggregate loss.
E[S] = E[N] E[X] and Var[S] = E[N] Var[X] + Var[N] E[X]^2, which
hold whatever the frequency and severity are, and need no grid. Use them to
check a Panjer or simulation result.
Both are inf for a heavy enough severity, and that is the point of
reporting them: a Pareto severity has no variance for alpha <= 2 and no
mean for alpha <= 1. Any approximation to the aggregate that matches two
moments -- the normal and translated-gamma approximations of the actuarial
literature -- is inapplicable there, and this says so before it is used.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency
|
Any
|
Frequency model with |
required |
severity
|
Any
|
Severity distribution. |
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float]
|
|
Examples:
>>> from heavytails import Pareto
>>> compound_moments(Poisson(lam=2.0), Pareto(alpha=1.5, xm=1.0))
(6.0, inf)
Source code in heavytails/actuarial.py
discretise_severity
¶
Put a continuous severity onto the arithmetic grid the recursion needs.
Panjer recursion works on a severity supported on 0, h, 2h, ..., so a
continuous one must be discretised first. The choice of h is the whole
accuracy story: too coarse and the answer is wrong, too fine and the
recursion is quadratic in the number of points.
Two methods:
"mass"
Assign each grid point the probability of the interval around it. Simple
and fast, and biased: the discrete distribution has slightly the wrong
mean.
"mean-preserving"
Local moment matching, which makes the discretised mean equal the
severity's own mean exactly (up to the grid's end). Costs two limited
expected values per point. Prefer it for pricing, where an error in the
mean is an error in the premium.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
severity
|
Any
|
Distribution with |
required |
h
|
float
|
Grid span, positive. |
required |
n
|
int
|
Number of grid points, so the grid runs to |
required |
method
|
str
|
|
'mass'
|
Returns:
| Type | Description |
|---|---|
list[float]
|
The probabilities on the grid, and the mass beyond its end. **That |
float
|
second number is not decoration**: for a heavy tail it can be |
tuple[list[float], float]
|
substantial, and every quantity derived from the grid is wrong by |
tuple[list[float], float]
|
roughly that much. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from heavytails import Pareto
>>> probs, lost = discretise_severity(Pareto(alpha=2.0, xm=1.0), 0.5, 200)
>>> round(sum(probs), 6), round(lost, 6)
(0.999899, 0.000101)
Source code in heavytails/actuarial.py
762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 | |
excess_of_loss_premium
¶
Expected annual cost of per-risk excess-of-loss reinsurance.
The reinsurer pays min(max(X - retention, 0), limit) on each individual
claim, so the expected cost per period is::
E[N] * (E[X ^ (retention + limit)] - E[X ^ retention])
exactly, with no grid and no simulation. That closed form is why
:func:limited_expected_value exists.
An unlimited layer on a severity with no mean has infinite expected cost,
and this returns inf rather than a large number. A limited layer on
the same severity is finite, which is precisely why reinsurance of
catastrophe risk is always written with a limit.
This is the expected loss cost, not a quotable premium: it carries no loading for expenses, risk margin or the reinsurer's cost of capital, and for a heavy-tailed layer the risk margin is the larger part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency
|
Any
|
Frequency model with a |
required |
severity
|
Any
|
Ground-up severity. |
required |
retention
|
float
|
Attachment point per claim, non-negative. |
required |
limit
|
float | None
|
Width of the layer, or |
None
|
Returns:
| Type | Description |
|---|---|
float
|
The expected cost per period. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from heavytails import Pareto
>>> round(excess_of_loss_premium(Poisson(lam=10.0),
... Pareto(alpha=2.0, xm=1.0),
... retention=5.0, limit=15.0), 6)
1.5
Source code in heavytails/actuarial.py
limited_expected_value
¶
The limited expected value E[min(X, d)].
The workhorse of layer pricing: the expected cost of the layer from a to
b is E[X ^ b] - E[X ^ a], so every deductible, limit and excess-of-
loss premium reduces to a difference of these.
It is finite even when E[X] is not, which is the reason to compute the
layer this way rather than by integrating the severity: a Pareto with
alpha <= 1 has no mean, but every bounded layer on it still has a
perfectly finite price.
Closed forms are used for Pareto, LogNormal, Weibull and the generalized
Pareto. Anything else is integrated as int_0^F(d) ppf(u) du + d*S(d),
which is a bounded smooth integrand on a finite interval even when the
density is not.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dist
|
Any
|
Severity distribution with |
required |
d
|
float
|
Censoring point, non-negative. May be |
required |
nodes
|
int
|
Quadrature nodes for the numeric fallback. |
512
|
Returns:
| Type | Description |
|---|---|
float
|
|
float
|
does not exist. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from heavytails import Pareto
>>> round(limited_expected_value(Pareto(alpha=2.0, xm=1.0), 10.0), 6)
1.9
>>> limited_expected_value(Pareto(alpha=2.0, xm=1.0), float("inf"))
2.0
Source code in heavytails/actuarial.py
panjer_recursion
¶
The aggregate loss distribution by Panjer's recursion.
For a frequency in the (a,b,0) class, where p_k/p_{k-1} = a + b/k,
the aggregate probabilities satisfy::
g_0 = P_N(f_0)
g_k = [sum_j (a + b*j/k) f_j g_{k-j}] / (1 - a*f_0)
which is exact for the discretised severity, and quadratic in n. It
replaces the n-fold convolution that a direct calculation would need.
Two things go wrong, and both are detected rather than silently returned:
The recursion can start at zero. g_0 is the probability of no loss,
exp(lam(f_0 - 1)) for a Poisson, and for a large expected count that
underflows to exactly zero -- after which every g_k is zero too and the
output is not a distribution at all. This raises instead.
The grid can be too short. A heavy-tailed severity puts real mass beyond
any finite grid, and the result carries both the severity mass lost in
discretisation and the aggregate mass lost past the end. Use
:func:simulate_aggregate_loss when they are large.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency
|
Any
|
A frequency model exposing |
required |
severity
|
Any
|
Continuous severity distribution. |
required |
h
|
float
|
Grid span. |
required |
n
|
int
|
Number of aggregate grid points. |
required |
method
|
str
|
Discretisation method, see :func: |
'mass'
|
Returns:
| Type | Description |
|---|---|
AggregateLoss
|
The aggregate distribution. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the grid is invalid. |
ArithmeticError
|
If |
Examples:
>>> from heavytails import Pareto
>>> agg = panjer_recursion(Poisson(lam=2.0), Pareto(alpha=3.0, xm=1.0),
... h=0.25, n=400)
>>> round(agg.mean(), 3)
2.985
Source code in heavytails/actuarial.py
1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 | |
simulate_aggregate_loss
¶
Simulate aggregate losses directly.
Draw a claim count, draw that many severities, add them up, repeat. Slower
to converge than :func:panjer_recursion and subject to neither of its
failure modes: no grid to truncate and no g_0 to underflow. For a
genuinely heavy-tailed severity this is usually the right route.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency
|
Any
|
Frequency model with a |
required |
severity
|
Any
|
Severity with a |
required |
n_sims
|
int
|
Number of periods to simulate. |
required |
seed
|
int | None
|
Seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
list[float]
|
One aggregate loss per simulated period. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from heavytails import Pareto
>>> losses = simulate_aggregate_loss(Poisson(lam=2.0),
... Pareto(alpha=3.0, xm=1.0),
... n_sims=5, seed=1)
>>> len(losses)
5