@actuarial-ts/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +58 -18
- package/dist/mack.js.map +1 -1
- package/dist/odpBootstrap.d.ts +8 -2
- package/dist/odpBootstrap.d.ts.map +1 -1
- package/dist/odpBootstrap.js +11 -2
- package/dist/odpBootstrap.js.map +1 -1
- package/package.json +3 -1
- package/src/benktander.ts +90 -0
- package/src/berquist.ts +349 -0
- package/src/bf.ts +129 -0
- package/src/canonical.ts +122 -0
- package/src/capping.ts +295 -0
- package/src/caseOutstanding.ts +270 -0
- package/src/chainladder.ts +101 -0
- package/src/clark.ts +719 -0
- package/src/diagnostics.ts +435 -0
- package/src/discounting.ts +417 -0
- package/src/elrMethods.ts +257 -0
- package/src/factors.ts +147 -0
- package/src/fisherLange.ts +374 -0
- package/src/freqSev.ts +152 -0
- package/src/ilf.ts +567 -0
- package/src/index.ts +29 -0
- package/src/mack.ts +329 -0
- package/src/merzWuthrich.ts +147 -0
- package/src/munichChainLadder.ts +398 -0
- package/src/odpBootstrap.ts +337 -0
- package/src/onlevel.ts +155 -0
- package/src/salvageSubro.ts +205 -0
- package/src/stochastic.ts +151 -0
- package/src/tail.ts +156 -0
- package/src/trend.ts +150 -0
- package/src/triangle.ts +235 -0
- package/src/triangleAlgebra.ts +111 -0
- package/src/types.ts +357 -0
- package/src/ulae.ts +326 -0
- package/src/util.ts +68 -0
package/src/clark.ts
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import type { Triangle } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { cumulativeToIncremental } from "./triangleAlgebra.js";
|
|
4
|
+
import { isNum, lastObservedIndex } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Clark (2003), "LDF Curve-Fitting and Stochastic Reserving: A Maximum
|
|
8
|
+
* Likelihood Approach", CAS Forum Fall 2003, pp. 41-92.
|
|
9
|
+
*
|
|
10
|
+
* Expected cumulative emergence is a parametric growth curve G(x):
|
|
11
|
+
* - loglogistic: G(x) = x^omega / (x^omega + theta^omega) (thick tail)
|
|
12
|
+
* - Weibull: G(x) = 1 - exp(-(x/theta)^omega) (lighter tail)
|
|
13
|
+
* where x is measured from the AVERAGE accident date of the origin period:
|
|
14
|
+
* for a fully-earned accident year evaluated at age t months,
|
|
15
|
+
* x = max(t - 6, t / 2) (Clark Appendix B, annual cadence).
|
|
16
|
+
*
|
|
17
|
+
* Two methods for the expected incremental loss between ages x and y:
|
|
18
|
+
* - LDF: mu = ULT_i * [G(y) - G(x)] (one free ULT per origin)
|
|
19
|
+
* - Cape Cod: mu = Premium_i * ELR * [G(y) - G(x)] (one ELR for all origins)
|
|
20
|
+
*
|
|
21
|
+
* Increments are over-dispersed Poisson with Var = sigma^2 * mu; with
|
|
22
|
+
* sigma^2 treated as known the MLE maximizes the quasi-loglikelihood
|
|
23
|
+
* l = SUM [c ln(mu) - mu]. Setting dl/dULT_i = 0 (resp. dl/dELR = 0) gives
|
|
24
|
+
* ULT_i = SUM_t c_it / SUM_t dG_it (resp. ELR = SUM c / SUM P dG), so both
|
|
25
|
+
* methods reduce to a 2-parameter search over (omega, theta) with the
|
|
26
|
+
* ultimates/ELR profiled out. sigma^2 = [1/(n-p)] SUM (c - mu)^2 / mu.
|
|
27
|
+
*
|
|
28
|
+
* Variances (delta method / Rao-Cramer): process variance of a reserve R is
|
|
29
|
+
* sigma^2 * R; parameter variance is (dR)' SIGMA (dR) with
|
|
30
|
+
* SIGMA = -sigma^2 * I^{-1}, I the observed information matrix of second
|
|
31
|
+
* derivatives of l with respect to ALL parameters (ULTs/ELR included).
|
|
32
|
+
* Both I and dR are computed by central finite differences with a relative
|
|
33
|
+
* step of 1e-4 (small enough for quadrature accuracy on this smooth
|
|
34
|
+
* likelihood, large enough that the second differences sit several orders of
|
|
35
|
+
* magnitude above double-precision noise in the loglikelihood).
|
|
36
|
+
*
|
|
37
|
+
* Truncation: the loglogistic can extrapolate a very thick tail, so Clark
|
|
38
|
+
* truncates development at a chosen age T: the truncated LDF is
|
|
39
|
+
* G(avgAge(T)) / G(x), i.e. reserves emerge only up to G(avgAge(T)) rather
|
|
40
|
+
* than to 1. Untruncated runs carry a warning to that effect.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Growth curves
|
|
45
|
+
|
|
46
|
+
export type ClarkCurve = "loglogistic" | "weibull";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The growth function G(x) for x in months from the average accident date.
|
|
50
|
+
* G(x) = expected fraction of ultimate emerged by x; G(x <= 0) = 0.
|
|
51
|
+
*/
|
|
52
|
+
export function clarkGrowth(
|
|
53
|
+
curve: ClarkCurve,
|
|
54
|
+
omega: number,
|
|
55
|
+
theta: number,
|
|
56
|
+
): (xMonths: number) => number {
|
|
57
|
+
if (!isNum(omega) || omega <= 0 || !isNum(theta) || theta <= 0) {
|
|
58
|
+
throw new ReservingError(
|
|
59
|
+
"BAD_FIT",
|
|
60
|
+
"Growth-curve parameters omega and theta must be positive finite numbers",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (curve === "loglogistic") {
|
|
64
|
+
// 1 / (1 + (theta/x)^omega) is the overflow-safe form of
|
|
65
|
+
// x^omega / (x^omega + theta^omega).
|
|
66
|
+
return (x) => (x <= 0 ? 0 : 1 / (1 + Math.pow(theta / x, omega)));
|
|
67
|
+
}
|
|
68
|
+
return (x) => (x <= 0 ? 0 : 1 - Math.exp(-Math.pow(x / theta, omega)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Average-age convention for annual origins: age t months -> max(t-6, t/2). */
|
|
72
|
+
function avgAge(t: number): number {
|
|
73
|
+
return t <= 0 ? 0 : Math.max(t - 6, t / 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Result shapes
|
|
78
|
+
|
|
79
|
+
export interface ClarkOptions {
|
|
80
|
+
curve: ClarkCurve;
|
|
81
|
+
/**
|
|
82
|
+
* Development age (months) at which the growth curve is truncated: reserves
|
|
83
|
+
* emerge only up to G(avgAge(truncationAgeMonths)). Omit to extrapolate to
|
|
84
|
+
* ultimate (G -> 1), which the result then warns about.
|
|
85
|
+
*/
|
|
86
|
+
truncationAgeMonths?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ClarkRow {
|
|
90
|
+
origin: string;
|
|
91
|
+
/** Latest observed cumulative loss. */
|
|
92
|
+
latest: number;
|
|
93
|
+
/** G(x) at the origin's latest average age - expected fraction emerged. */
|
|
94
|
+
growthAtAge: number;
|
|
95
|
+
/** latest + reserve (losses at the truncation age when one is set). */
|
|
96
|
+
ultimate: number;
|
|
97
|
+
reserve: number;
|
|
98
|
+
processSd: number;
|
|
99
|
+
parameterSd: number;
|
|
100
|
+
totalSd: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ClarkResult {
|
|
104
|
+
method: "clarkLdf" | "clarkCapeCod";
|
|
105
|
+
curve: ClarkCurve;
|
|
106
|
+
omega: number;
|
|
107
|
+
theta: number;
|
|
108
|
+
/** ODP dispersion: Var(c) = sigma2 * E[c]. */
|
|
109
|
+
sigma2: number;
|
|
110
|
+
/** Degrees of freedom n - p used for sigma2. */
|
|
111
|
+
dof: number;
|
|
112
|
+
/** Cape Cod only: the profiled MLE expected loss ratio. */
|
|
113
|
+
elr?: number;
|
|
114
|
+
rows: ClarkRow[];
|
|
115
|
+
totals: { reserve: number; processSd: number; parameterSd: number; totalSd: number };
|
|
116
|
+
warnings: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Deterministic Nelder-Mead (2D)
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deterministic fixed-budget Nelder-Mead in 2D. Local copy of the private
|
|
124
|
+
* optimizer in ilf.ts (deliberately duplicated rather than exporting that
|
|
125
|
+
* module's internal helper; this is the house-standard severity-MLE search
|
|
126
|
+
* verbatim). No randomness anywhere: the caller seeds a deterministic
|
|
127
|
+
* starting simplex and the iteration budget is fixed.
|
|
128
|
+
*/
|
|
129
|
+
function nelderMead2(
|
|
130
|
+
f: (p: [number, number]) => number,
|
|
131
|
+
start: [number, number],
|
|
132
|
+
scale: [number, number],
|
|
133
|
+
iterations = 400,
|
|
134
|
+
): [number, number] {
|
|
135
|
+
type Pt = { x: [number, number]; v: number };
|
|
136
|
+
const mk = (x: [number, number]): Pt => ({ x, v: f(x) });
|
|
137
|
+
let simplex: Pt[] = [
|
|
138
|
+
mk(start),
|
|
139
|
+
mk([start[0] + scale[0], start[1]]),
|
|
140
|
+
mk([start[0], start[1] + scale[1]]),
|
|
141
|
+
];
|
|
142
|
+
for (let it = 0; it < iterations; it++) {
|
|
143
|
+
simplex.sort((p, q) => p.v - q.v);
|
|
144
|
+
const [best, mid, worst] = simplex as [Pt, Pt, Pt];
|
|
145
|
+
const centroid: [number, number] = [
|
|
146
|
+
(best.x[0] + mid.x[0]) / 2,
|
|
147
|
+
(best.x[1] + mid.x[1]) / 2,
|
|
148
|
+
];
|
|
149
|
+
const reflect = mk([
|
|
150
|
+
centroid[0] + (centroid[0] - worst.x[0]),
|
|
151
|
+
centroid[1] + (centroid[1] - worst.x[1]),
|
|
152
|
+
]);
|
|
153
|
+
if (reflect.v < best.v) {
|
|
154
|
+
const expand = mk([
|
|
155
|
+
centroid[0] + 2 * (centroid[0] - worst.x[0]),
|
|
156
|
+
centroid[1] + 2 * (centroid[1] - worst.x[1]),
|
|
157
|
+
]);
|
|
158
|
+
simplex[2] = expand.v < reflect.v ? expand : reflect;
|
|
159
|
+
} else if (reflect.v < mid.v) {
|
|
160
|
+
simplex[2] = reflect;
|
|
161
|
+
} else {
|
|
162
|
+
const contract = mk([
|
|
163
|
+
centroid[0] + 0.5 * (worst.x[0] - centroid[0]),
|
|
164
|
+
centroid[1] + 0.5 * (worst.x[1] - centroid[1]),
|
|
165
|
+
]);
|
|
166
|
+
if (contract.v < worst.v) {
|
|
167
|
+
simplex[2] = contract;
|
|
168
|
+
} else {
|
|
169
|
+
// Shrink toward the best point.
|
|
170
|
+
simplex = [
|
|
171
|
+
best,
|
|
172
|
+
mk([(best.x[0] + mid.x[0]) / 2, (best.x[1] + mid.x[1]) / 2]),
|
|
173
|
+
mk([(best.x[0] + worst.x[0]) / 2, (best.x[1] + worst.x[1]) / 2]),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
simplex.sort((p, q) => p.v - q.v);
|
|
179
|
+
return simplex[0]!.x;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Small dense linear algebra (for the delta method)
|
|
184
|
+
|
|
185
|
+
/** Gauss-Jordan inverse with partial pivoting; throws when singular. */
|
|
186
|
+
function invertMatrix(m: number[][]): number[][] {
|
|
187
|
+
const n = m.length;
|
|
188
|
+
const a = m.map((row, i) => [
|
|
189
|
+
...row,
|
|
190
|
+
...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)),
|
|
191
|
+
]);
|
|
192
|
+
for (let col = 0; col < n; col++) {
|
|
193
|
+
let pivot = col;
|
|
194
|
+
for (let r = col + 1; r < n; r++) {
|
|
195
|
+
if (Math.abs(a[r]![col]!) > Math.abs(a[pivot]![col]!)) pivot = r;
|
|
196
|
+
}
|
|
197
|
+
const pivotValue = a[pivot]![col]!;
|
|
198
|
+
if (!isNum(pivotValue) || Math.abs(pivotValue) < 1e-300) {
|
|
199
|
+
throw new ReservingError(
|
|
200
|
+
"BAD_FIT",
|
|
201
|
+
"The information matrix is singular; parameter variances are not computable for this fit",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
[a[col], a[pivot]] = [a[pivot]!, a[col]!];
|
|
205
|
+
const scale = a[col]![col]!;
|
|
206
|
+
for (let j = 0; j < 2 * n; j++) a[col]![j] = a[col]![j]! / scale;
|
|
207
|
+
for (let r = 0; r < n; r++) {
|
|
208
|
+
if (r === col) continue;
|
|
209
|
+
const factor = a[r]![col]!;
|
|
210
|
+
if (factor === 0) continue;
|
|
211
|
+
for (let j = 0; j < 2 * n; j++) a[r]![j] = a[r]![j]! - factor * a[col]![j]!;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return a.map((row) => row.slice(n));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Relative step for all finite differences (see module header). */
|
|
218
|
+
const FD_RELATIVE_STEP = 1e-4;
|
|
219
|
+
|
|
220
|
+
function fdSteps(q: number[]): number[] {
|
|
221
|
+
return q.map((v) => FD_RELATIVE_STEP * Math.max(Math.abs(v), 1));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Central-difference Hessian of f at q. */
|
|
225
|
+
function numericHessian(f: (q: number[]) => number, q: number[]): number[][] {
|
|
226
|
+
const n = q.length;
|
|
227
|
+
const h = fdSteps(q);
|
|
228
|
+
const f0 = f(q);
|
|
229
|
+
const at = (deltas: [number, number][]): number => {
|
|
230
|
+
const p = [...q];
|
|
231
|
+
for (const [idx, d] of deltas) p[idx] = p[idx]! + d;
|
|
232
|
+
return f(p);
|
|
233
|
+
};
|
|
234
|
+
const hess: number[][] = Array.from({ length: n }, () => new Array<number>(n).fill(0));
|
|
235
|
+
for (let i = 0; i < n; i++) {
|
|
236
|
+
const hi = h[i]!;
|
|
237
|
+
hess[i]![i] = (at([[i, hi]]) - 2 * f0 + at([[i, -hi]])) / (hi * hi);
|
|
238
|
+
for (let j = i + 1; j < n; j++) {
|
|
239
|
+
const hj = h[j]!;
|
|
240
|
+
const mixed =
|
|
241
|
+
(at([
|
|
242
|
+
[i, hi],
|
|
243
|
+
[j, hj],
|
|
244
|
+
]) -
|
|
245
|
+
at([
|
|
246
|
+
[i, hi],
|
|
247
|
+
[j, -hj],
|
|
248
|
+
]) -
|
|
249
|
+
at([
|
|
250
|
+
[i, -hi],
|
|
251
|
+
[j, hj],
|
|
252
|
+
]) +
|
|
253
|
+
at([
|
|
254
|
+
[i, -hi],
|
|
255
|
+
[j, -hj],
|
|
256
|
+
])) /
|
|
257
|
+
(4 * hi * hj);
|
|
258
|
+
hess[i]![j] = mixed;
|
|
259
|
+
hess[j]![i] = mixed;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return hess;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Central-difference gradient of f at q. */
|
|
266
|
+
function numericGradient(f: (q: number[]) => number, q: number[]): number[] {
|
|
267
|
+
const h = fdSteps(q);
|
|
268
|
+
return q.map((_, i) => {
|
|
269
|
+
const plus = [...q];
|
|
270
|
+
const minus = [...q];
|
|
271
|
+
plus[i] = plus[i]! + h[i]!;
|
|
272
|
+
minus[i] = minus[i]! - h[i]!;
|
|
273
|
+
return (f(plus) - f(minus)) / (2 * h[i]!);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function quadraticForm(g: number[], m: number[][]): number {
|
|
278
|
+
let total = 0;
|
|
279
|
+
for (let i = 0; i < g.length; i++) {
|
|
280
|
+
for (let j = 0; j < g.length; j++) total += g[i]! * m[i]![j]! * g[j]!;
|
|
281
|
+
}
|
|
282
|
+
return total;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Data preparation
|
|
287
|
+
|
|
288
|
+
interface ClarkCell {
|
|
289
|
+
/** Index into the INCLUDED origins (not the raw triangle row index). */
|
|
290
|
+
origin: number;
|
|
291
|
+
/** Average-age endpoints of the increment (months from avg accident date). */
|
|
292
|
+
fromX: number;
|
|
293
|
+
toX: number;
|
|
294
|
+
value: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface ClarkData {
|
|
298
|
+
origins: string[];
|
|
299
|
+
latest: number[];
|
|
300
|
+
latestAge: number[];
|
|
301
|
+
/** x = avgAge(latestAge) per included origin. */
|
|
302
|
+
latestX: number[];
|
|
303
|
+
cells: ClarkCell[];
|
|
304
|
+
warnings: string[];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function prepareAnnualIncrements(tri: Triangle): ClarkData {
|
|
308
|
+
const { ages } = tri;
|
|
309
|
+
if (tri.origins.length === 0 || ages.length === 0) {
|
|
310
|
+
throw new ReservingError("NO_DATA", "The triangle has no origins or development ages");
|
|
311
|
+
}
|
|
312
|
+
if (ages.some((a) => !Number.isInteger(a) || a <= 0 || a % 12 !== 0)) {
|
|
313
|
+
throw new ReservingError(
|
|
314
|
+
"SHAPE",
|
|
315
|
+
"Clark methods are implemented for annual cadence: development ages must be positive multiples of 12 months",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
for (let j = 1; j < ages.length; j++) {
|
|
319
|
+
if (ages[j]! <= ages[j - 1]!) {
|
|
320
|
+
throw new ReservingError("SHAPE", "Development ages must be strictly ascending");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const incremental = cumulativeToIncremental(tri);
|
|
325
|
+
const warnings: string[] = [];
|
|
326
|
+
const origins: string[] = [];
|
|
327
|
+
const latest: number[] = [];
|
|
328
|
+
const latestAge: number[] = [];
|
|
329
|
+
const latestX: number[] = [];
|
|
330
|
+
const cells: ClarkCell[] = [];
|
|
331
|
+
let negativeIncrements = 0;
|
|
332
|
+
|
|
333
|
+
for (let i = 0; i < tri.origins.length; i++) {
|
|
334
|
+
const cumRow = tri.values[i]!;
|
|
335
|
+
const lastIdx = lastObservedIndex(cumRow);
|
|
336
|
+
if (lastIdx < 0) {
|
|
337
|
+
warnings.push(`Origin ${tri.origins[i]} has no observed cells and was excluded`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// The profiled-ultimate algebra telescopes (sum of increments = latest)
|
|
341
|
+
// only when the row is observed contiguously from its first age.
|
|
342
|
+
for (let j = 0; j <= lastIdx; j++) {
|
|
343
|
+
if (!isNum(cumRow[j] ?? null)) {
|
|
344
|
+
throw new ReservingError(
|
|
345
|
+
"SHAPE",
|
|
346
|
+
`Origin ${tri.origins[i]} has an interior gap at age ${ages[j]}; Clark methods need contiguous observed development`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const originIdx = origins.length;
|
|
351
|
+
origins.push(tri.origins[i]!);
|
|
352
|
+
latest.push(cumRow[lastIdx]!);
|
|
353
|
+
latestAge.push(ages[lastIdx]!);
|
|
354
|
+
latestX.push(avgAge(ages[lastIdx]!));
|
|
355
|
+
const incRow = incremental.values[i]!;
|
|
356
|
+
for (let j = 0; j <= lastIdx; j++) {
|
|
357
|
+
const value = incRow[j]!;
|
|
358
|
+
if (value < 0) negativeIncrements++;
|
|
359
|
+
cells.push({
|
|
360
|
+
origin: originIdx,
|
|
361
|
+
fromX: avgAge(j === 0 ? 0 : ages[j - 1]!),
|
|
362
|
+
toX: avgAge(ages[j]!),
|
|
363
|
+
value,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (origins.length === 0 || cells.length === 0) {
|
|
369
|
+
throw new ReservingError("NO_DATA", "The triangle has no observed cells");
|
|
370
|
+
}
|
|
371
|
+
if (negativeIncrements > 0) {
|
|
372
|
+
warnings.push(
|
|
373
|
+
`${negativeIncrements} negative incremental cell(s); the ODP quasi-likelihood accommodates occasional negatives, but systematically negative expected development needs a different model`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return { origins, latest, latestAge, latestX, cells, warnings };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Fitting
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Fits (omega, theta) by deterministic Nelder-Mead on the profiled negative
|
|
384
|
+
* quasi-loglikelihood, searching in log space for positivity. The starting
|
|
385
|
+
* simplex is seeded from the data: theta starts at half the oldest origin's
|
|
386
|
+
* average age (the emergence midpoint of the observed history), omega at 1.5
|
|
387
|
+
* (between the two curves' typical casualty shapes). A second, tighter
|
|
388
|
+
* simplex polishes the coarse optimum; both budgets are fixed.
|
|
389
|
+
*/
|
|
390
|
+
function fitGrowthCurve(
|
|
391
|
+
negLoglik: (p: [number, number]) => number,
|
|
392
|
+
maxLatestX: number,
|
|
393
|
+
): { omega: number; theta: number } {
|
|
394
|
+
const start: [number, number] = [Math.log(1.5), Math.log(Math.max(maxLatestX / 2, 1))];
|
|
395
|
+
const coarse = nelderMead2(negLoglik, start, [0.5, 0.5], 500);
|
|
396
|
+
const polished = nelderMead2(negLoglik, coarse, [0.02, 0.02], 300);
|
|
397
|
+
if (!Number.isFinite(negLoglik(polished))) {
|
|
398
|
+
throw new ReservingError(
|
|
399
|
+
"BAD_FIT",
|
|
400
|
+
"The growth-curve fit did not converge to a finite likelihood; the data do not support this curve",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return { omega: Math.exp(polished[0]!), theta: Math.exp(polished[1]!) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Per-cell G(toX) - G(fromX); null signals an unusable parameter point. */
|
|
407
|
+
function growthDiffs(
|
|
408
|
+
curve: ClarkCurve,
|
|
409
|
+
omega: number,
|
|
410
|
+
theta: number,
|
|
411
|
+
cells: ClarkCell[],
|
|
412
|
+
): number[] | null {
|
|
413
|
+
if (!isNum(omega) || omega <= 0 || !isNum(theta) || theta <= 0) return null;
|
|
414
|
+
const g = clarkGrowth(curve, omega, theta);
|
|
415
|
+
const out = new Array<number>(cells.length);
|
|
416
|
+
for (let k = 0; k < cells.length; k++) {
|
|
417
|
+
const d = g(cells[k]!.toX) - g(cells[k]!.fromX);
|
|
418
|
+
if (!isNum(d) || d <= 0) return null;
|
|
419
|
+
out[k] = d;
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function quasiLoglik(cells: ClarkCell[], mu: number[]): number {
|
|
425
|
+
let l = 0;
|
|
426
|
+
for (let k = 0; k < cells.length; k++) {
|
|
427
|
+
const m = mu[k]!;
|
|
428
|
+
if (!(m > 0)) return Number.NaN;
|
|
429
|
+
l += cells[k]!.value * Math.log(m) - m;
|
|
430
|
+
}
|
|
431
|
+
return l;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function dispersion(
|
|
435
|
+
cells: ClarkCell[],
|
|
436
|
+
mu: number[],
|
|
437
|
+
nParams: number,
|
|
438
|
+
): { sigma2: number; dof: number } {
|
|
439
|
+
const dof = cells.length - nParams;
|
|
440
|
+
if (dof <= 0) {
|
|
441
|
+
throw new ReservingError(
|
|
442
|
+
"TOO_SMALL",
|
|
443
|
+
`${cells.length} incremental cells cannot support ${nParams} parameters; Clark's dispersion estimate needs n > p`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
let sum = 0;
|
|
447
|
+
for (let k = 0; k < cells.length; k++) {
|
|
448
|
+
sum += (cells[k]!.value - mu[k]!) ** 2 / mu[k]!;
|
|
449
|
+
}
|
|
450
|
+
return { sigma2: sum / dof, dof };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function resolveTruncation(
|
|
454
|
+
opts: ClarkOptions,
|
|
455
|
+
latestAge: number[],
|
|
456
|
+
warnings: string[],
|
|
457
|
+
): number | null {
|
|
458
|
+
const trunc = opts.truncationAgeMonths;
|
|
459
|
+
if (trunc === undefined) {
|
|
460
|
+
warnings.push(
|
|
461
|
+
`Reserves extrapolate the fitted ${opts.curve} growth curve to ultimate (G -> 1); Clark (2003) recommends truncating the tail at a finite age via truncationAgeMonths${opts.curve === "loglogistic" ? " - the loglogistic tail is thick and extrapolation can dominate the reserve" : ""}`,
|
|
462
|
+
);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
if (!isNum(trunc) || trunc <= 0) {
|
|
466
|
+
throw new ReservingError("BAD_TAIL", "truncationAgeMonths must be a positive number of months");
|
|
467
|
+
}
|
|
468
|
+
const maxAge = Math.max(...latestAge);
|
|
469
|
+
if (trunc < maxAge) {
|
|
470
|
+
throw new ReservingError(
|
|
471
|
+
"BAD_TAIL",
|
|
472
|
+
`truncationAgeMonths (${trunc}) must be at or beyond every origin's latest observed age (${maxAge})`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return trunc;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Delta-method variance assembly shared by both methods.
|
|
480
|
+
*
|
|
481
|
+
* fullLoglik takes the FULL natural-scale parameter vector q (ULTs or ELR
|
|
482
|
+
* first, then omega, theta); reserveAt(q, rowIndex) is the reserve function
|
|
483
|
+
* whose gradient enters (dR)' SIGMA (dR). The total uses the summed gradient,
|
|
484
|
+
* so cross-origin parameter covariance is included.
|
|
485
|
+
*/
|
|
486
|
+
function deltaMethodSds(
|
|
487
|
+
fullLoglik: (q: number[]) => number,
|
|
488
|
+
reserveAt: (q: number[], row: number) => number,
|
|
489
|
+
qHat: number[],
|
|
490
|
+
nRows: number,
|
|
491
|
+
sigma2: number,
|
|
492
|
+
): { perRow: number[]; total: number } {
|
|
493
|
+
const hessian = numericHessian(fullLoglik, qHat);
|
|
494
|
+
const inverse = invertMatrix(hessian);
|
|
495
|
+
// SIGMA = -sigma^2 * I^{-1} (Rao-Cramer with the observed information).
|
|
496
|
+
const covariance = inverse.map((row) => row.map((v) => -sigma2 * v));
|
|
497
|
+
const gradients = Array.from({ length: nRows }, (_, i) =>
|
|
498
|
+
numericGradient((q) => reserveAt(q, i), qHat),
|
|
499
|
+
);
|
|
500
|
+
const perRow = gradients.map((g) => Math.sqrt(Math.max(0, quadraticForm(g, covariance))));
|
|
501
|
+
const totalGradient = qHat.map((_, j) => gradients.reduce((acc, g) => acc + g[j]!, 0));
|
|
502
|
+
const total = Math.sqrt(Math.max(0, quadraticForm(totalGradient, covariance)));
|
|
503
|
+
return { perRow, total };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function assembleResult(
|
|
507
|
+
method: ClarkResult["method"],
|
|
508
|
+
opts: ClarkOptions,
|
|
509
|
+
data: ClarkData,
|
|
510
|
+
fit: { omega: number; theta: number; sigma2: number; dof: number },
|
|
511
|
+
reserves: number[],
|
|
512
|
+
parameterSds: { perRow: number[]; total: number },
|
|
513
|
+
warnings: string[],
|
|
514
|
+
elr?: number,
|
|
515
|
+
): ClarkResult {
|
|
516
|
+
const g = clarkGrowth(opts.curve, fit.omega, fit.theta);
|
|
517
|
+
const rows: ClarkRow[] = data.origins.map((origin, i) => {
|
|
518
|
+
const reserve = reserves[i]!;
|
|
519
|
+
const processSd = Math.sqrt(fit.sigma2 * Math.max(0, reserve));
|
|
520
|
+
const parameterSd = parameterSds.perRow[i]!;
|
|
521
|
+
return {
|
|
522
|
+
origin,
|
|
523
|
+
latest: data.latest[i]!,
|
|
524
|
+
growthAtAge: g(data.latestX[i]!),
|
|
525
|
+
ultimate: data.latest[i]! + reserve,
|
|
526
|
+
reserve,
|
|
527
|
+
processSd,
|
|
528
|
+
parameterSd,
|
|
529
|
+
totalSd: Math.sqrt(processSd ** 2 + parameterSd ** 2),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
const totalReserve = reserves.reduce((a, v) => a + v, 0);
|
|
533
|
+
const totalProcessSd = Math.sqrt(fit.sigma2 * Math.max(0, totalReserve));
|
|
534
|
+
return {
|
|
535
|
+
method,
|
|
536
|
+
curve: opts.curve,
|
|
537
|
+
omega: fit.omega,
|
|
538
|
+
theta: fit.theta,
|
|
539
|
+
sigma2: fit.sigma2,
|
|
540
|
+
dof: fit.dof,
|
|
541
|
+
...(elr === undefined ? {} : { elr }),
|
|
542
|
+
rows,
|
|
543
|
+
totals: {
|
|
544
|
+
reserve: totalReserve,
|
|
545
|
+
processSd: totalProcessSd,
|
|
546
|
+
parameterSd: parameterSds.total,
|
|
547
|
+
totalSd: Math.sqrt(totalProcessSd ** 2 + parameterSds.total ** 2),
|
|
548
|
+
},
|
|
549
|
+
warnings,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// Method #2: LDF (one free ultimate per origin)
|
|
555
|
+
|
|
556
|
+
export function runClarkLdf(tri: Triangle, opts: ClarkOptions): ClarkResult {
|
|
557
|
+
const data = prepareAnnualIncrements(tri);
|
|
558
|
+
const warnings = [...data.warnings];
|
|
559
|
+
const nOrigins = data.origins.length;
|
|
560
|
+
|
|
561
|
+
// Profiling requires positive observed losses per origin (ULT_i > 0).
|
|
562
|
+
const sumC = new Array<number>(nOrigins).fill(0);
|
|
563
|
+
for (const cell of data.cells) sumC[cell.origin] = sumC[cell.origin]! + cell.value;
|
|
564
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
565
|
+
if (!(sumC[i]! > 0)) {
|
|
566
|
+
throw new ReservingError(
|
|
567
|
+
"BAD_LOSSES",
|
|
568
|
+
`Origin ${data.origins[i]} has non-positive total observed losses; the LDF method's profiled ultimate is undefined`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const profiledUlts = (diffs: number[]): number[] => {
|
|
574
|
+
const sumDg = new Array<number>(nOrigins).fill(0);
|
|
575
|
+
for (let k = 0; k < data.cells.length; k++) {
|
|
576
|
+
sumDg[data.cells[k]!.origin] = sumDg[data.cells[k]!.origin]! + diffs[k]!;
|
|
577
|
+
}
|
|
578
|
+
return sumDg.map((d, i) => sumC[i]! / d);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const negLoglik = (p: [number, number]): number => {
|
|
582
|
+
const diffs = growthDiffs(opts.curve, Math.exp(p[0]), Math.exp(p[1]), data.cells);
|
|
583
|
+
if (!diffs) return Number.POSITIVE_INFINITY;
|
|
584
|
+
const ults = profiledUlts(diffs);
|
|
585
|
+
const mu = data.cells.map((cell, k) => ults[cell.origin]! * diffs[k]!);
|
|
586
|
+
const l = quasiLoglik(data.cells, mu);
|
|
587
|
+
return Number.isFinite(l) ? -l : Number.POSITIVE_INFINITY;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const { omega, theta } = fitGrowthCurve(negLoglik, Math.max(...data.latestX));
|
|
591
|
+
const diffsHat = growthDiffs(opts.curve, omega, theta, data.cells);
|
|
592
|
+
if (!diffsHat) {
|
|
593
|
+
throw new ReservingError("BAD_FIT", "The fitted growth curve is degenerate on this triangle");
|
|
594
|
+
}
|
|
595
|
+
const ultsHat = profiledUlts(diffsHat);
|
|
596
|
+
const muHat = data.cells.map((cell, k) => ultsHat[cell.origin]! * diffsHat[k]!);
|
|
597
|
+
const { sigma2, dof } = dispersion(data.cells, muHat, nOrigins + 2);
|
|
598
|
+
|
|
599
|
+
const trunc = resolveTruncation(opts, data.latestAge, warnings);
|
|
600
|
+
const truncX = trunc === null ? null : avgAge(trunc);
|
|
601
|
+
|
|
602
|
+
// Reserve as a function of the FULL vector q = [ULT_1..ULT_n, omega, theta].
|
|
603
|
+
const reserveAt = (q: number[], i: number): number => {
|
|
604
|
+
const g = clarkGrowth(opts.curve, q[nOrigins]!, q[nOrigins + 1]!);
|
|
605
|
+
const gEnd = truncX === null ? 1 : g(truncX);
|
|
606
|
+
return q[i]! * (gEnd - g(data.latestX[i]!));
|
|
607
|
+
};
|
|
608
|
+
const fullLoglik = (q: number[]): number => {
|
|
609
|
+
const diffs = growthDiffs(opts.curve, q[nOrigins]!, q[nOrigins + 1]!, data.cells);
|
|
610
|
+
if (!diffs) return Number.NaN;
|
|
611
|
+
const mus = data.cells.map((cell, k) => q[cell.origin]! * diffs[k]!);
|
|
612
|
+
return quasiLoglik(data.cells, mus);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const qHat = [...ultsHat, omega, theta];
|
|
616
|
+
const reserves = data.origins.map((_, i) => reserveAt(qHat, i));
|
|
617
|
+
const parameterSds = deltaMethodSds(fullLoglik, reserveAt, qHat, nOrigins, sigma2);
|
|
618
|
+
|
|
619
|
+
return assembleResult(
|
|
620
|
+
"clarkLdf",
|
|
621
|
+
opts,
|
|
622
|
+
data,
|
|
623
|
+
{ omega, theta, sigma2, dof },
|
|
624
|
+
reserves,
|
|
625
|
+
parameterSds,
|
|
626
|
+
warnings,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Method #1: Cape Cod (Premium x ELR x growth)
|
|
632
|
+
|
|
633
|
+
export function runClarkCapeCod(
|
|
634
|
+
tri: Triangle,
|
|
635
|
+
exposures: { origin: string; premium: number }[],
|
|
636
|
+
opts: ClarkOptions,
|
|
637
|
+
): ClarkResult {
|
|
638
|
+
const data = prepareAnnualIncrements(tri);
|
|
639
|
+
const warnings = [...data.warnings];
|
|
640
|
+
|
|
641
|
+
const premiumByOrigin = new Map(exposures.map((e) => [e.origin, e.premium]));
|
|
642
|
+
const premium = data.origins.map((origin) => {
|
|
643
|
+
const p = premiumByOrigin.get(origin) ?? null;
|
|
644
|
+
if (!isNum(p) || p <= 0) {
|
|
645
|
+
throw new ReservingError(
|
|
646
|
+
"BAD_PREMIUM",
|
|
647
|
+
`Origin ${origin} needs a positive onlevel premium for the Cape Cod method`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return p;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
let totalC = 0;
|
|
654
|
+
for (const cell of data.cells) totalC += cell.value;
|
|
655
|
+
if (!(totalC > 0)) {
|
|
656
|
+
throw new ReservingError(
|
|
657
|
+
"BAD_LOSSES",
|
|
658
|
+
"Total observed losses are non-positive; the profiled ELR is undefined",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const profiledElr = (diffs: number[]): number => {
|
|
663
|
+
let denom = 0;
|
|
664
|
+
for (let k = 0; k < data.cells.length; k++) {
|
|
665
|
+
denom += premium[data.cells[k]!.origin]! * diffs[k]!;
|
|
666
|
+
}
|
|
667
|
+
return totalC / denom;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const negLoglik = (p: [number, number]): number => {
|
|
671
|
+
const diffs = growthDiffs(opts.curve, Math.exp(p[0]), Math.exp(p[1]), data.cells);
|
|
672
|
+
if (!diffs) return Number.POSITIVE_INFINITY;
|
|
673
|
+
const elr = profiledElr(diffs);
|
|
674
|
+
const mu = data.cells.map((cell, k) => premium[cell.origin]! * elr * diffs[k]!);
|
|
675
|
+
const l = quasiLoglik(data.cells, mu);
|
|
676
|
+
return Number.isFinite(l) ? -l : Number.POSITIVE_INFINITY;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const { omega, theta } = fitGrowthCurve(negLoglik, Math.max(...data.latestX));
|
|
680
|
+
const diffsHat = growthDiffs(opts.curve, omega, theta, data.cells);
|
|
681
|
+
if (!diffsHat) {
|
|
682
|
+
throw new ReservingError("BAD_FIT", "The fitted growth curve is degenerate on this triangle");
|
|
683
|
+
}
|
|
684
|
+
const elrHat = profiledElr(diffsHat);
|
|
685
|
+
const muHat = data.cells.map((cell, k) => premium[cell.origin]! * elrHat * diffsHat[k]!);
|
|
686
|
+
const { sigma2, dof } = dispersion(data.cells, muHat, 3);
|
|
687
|
+
|
|
688
|
+
const trunc = resolveTruncation(opts, data.latestAge, warnings);
|
|
689
|
+
const truncX = trunc === null ? null : avgAge(trunc);
|
|
690
|
+
|
|
691
|
+
// Reserve as a function of the FULL vector q = [ELR, omega, theta].
|
|
692
|
+
const reserveAt = (q: number[], i: number): number => {
|
|
693
|
+
const g = clarkGrowth(opts.curve, q[1]!, q[2]!);
|
|
694
|
+
const gEnd = truncX === null ? 1 : g(truncX);
|
|
695
|
+
return premium[i]! * q[0]! * (gEnd - g(data.latestX[i]!));
|
|
696
|
+
};
|
|
697
|
+
const fullLoglik = (q: number[]): number => {
|
|
698
|
+
if (!(q[0]! > 0)) return Number.NaN;
|
|
699
|
+
const diffs = growthDiffs(opts.curve, q[1]!, q[2]!, data.cells);
|
|
700
|
+
if (!diffs) return Number.NaN;
|
|
701
|
+
const mus = data.cells.map((cell, k) => premium[cell.origin]! * q[0]! * diffs[k]!);
|
|
702
|
+
return quasiLoglik(data.cells, mus);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const qHat = [elrHat, omega, theta];
|
|
706
|
+
const reserves = data.origins.map((_, i) => reserveAt(qHat, i));
|
|
707
|
+
const parameterSds = deltaMethodSds(fullLoglik, reserveAt, qHat, data.origins.length, sigma2);
|
|
708
|
+
|
|
709
|
+
return assembleResult(
|
|
710
|
+
"clarkCapeCod",
|
|
711
|
+
opts,
|
|
712
|
+
data,
|
|
713
|
+
{ omega, theta, sigma2, dof },
|
|
714
|
+
reserves,
|
|
715
|
+
parameterSds,
|
|
716
|
+
warnings,
|
|
717
|
+
elrHat,
|
|
718
|
+
);
|
|
719
|
+
}
|