@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
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarYearDiagnostic,
|
|
3
|
+
DiagnosticFinding,
|
|
4
|
+
DiagnosticsResult,
|
|
5
|
+
Triangle,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
import { isNum, ols, safeRatio } from "./util.js";
|
|
8
|
+
import { mackEstimators } from "./mack.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Data diagnostics an actuary uses to see when the data violates method
|
|
12
|
+
* assumptions:
|
|
13
|
+
* - paid-to-incurred ratios (case adequacy drift distorts incurred methods)
|
|
14
|
+
* - average case reserves (severity/adequacy shifts)
|
|
15
|
+
* - closure rates (settlement-rate shifts distort paid development)
|
|
16
|
+
* - calendar-year effect detection (Mack's 1994 diagonal rank test)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function ratioGrid(numTri: Triangle, denTri: Triangle): (number | null)[][] {
|
|
20
|
+
return numTri.origins.map((_, i) =>
|
|
21
|
+
numTri.ages.map((_, j) =>
|
|
22
|
+
safeRatio(numTri.values[i]?.[j] ?? null, denTri.values[i]?.[j] ?? null),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mack's calendar-year test: within each development column, factors above
|
|
29
|
+
* the column median are Large, below are Small (ties to the median drop out).
|
|
30
|
+
* Under the null of no diagonal effects, Large/Small mix randomly along each
|
|
31
|
+
* calendar diagonal; Z = sum over diagonals of min(#L, #S) has a known mean
|
|
32
|
+
* and variance. A total outside the 95% range flags calendar-year effects.
|
|
33
|
+
*/
|
|
34
|
+
export function calendarYearTest(tri: Triangle): CalendarYearDiagnostic | null {
|
|
35
|
+
const nOrigins = tri.origins.length;
|
|
36
|
+
const nCols = tri.ages.length - 1;
|
|
37
|
+
if (nCols < 1 || nOrigins < 3) return null;
|
|
38
|
+
|
|
39
|
+
// Classify each factor cell as L / S relative to its column median.
|
|
40
|
+
type Mark = "L" | "S";
|
|
41
|
+
const marks: (Mark | null)[][] = tri.origins.map(() => new Array(nCols).fill(null));
|
|
42
|
+
for (let j = 0; j < nCols; j++) {
|
|
43
|
+
const entries: { i: number; f: number }[] = [];
|
|
44
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
45
|
+
const f = safeRatio(tri.values[i]?.[j + 1] ?? null, tri.values[i]?.[j] ?? null);
|
|
46
|
+
if (isNum(f)) entries.push({ i, f });
|
|
47
|
+
}
|
|
48
|
+
if (entries.length < 2) continue;
|
|
49
|
+
const sorted = entries.map((e) => e.f).sort((a, b) => a - b);
|
|
50
|
+
const mid = sorted.length % 2 === 1
|
|
51
|
+
? sorted[(sorted.length - 1) / 2]!
|
|
52
|
+
: (sorted[sorted.length / 2 - 1]! + sorted[sorted.length / 2]!) / 2;
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (e.f > mid) marks[e.i]![j] = "L";
|
|
55
|
+
else if (e.f < mid) marks[e.i]![j] = "S";
|
|
56
|
+
// equal to the median -> eliminated
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Group by calendar diagonal: factor cell (i, j) belongs to diagonal i + j + 1
|
|
61
|
+
// (the calendar period of the numerator age).
|
|
62
|
+
const byDiagonal = new Map<number, { L: number; S: number }>();
|
|
63
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
64
|
+
for (let j = 0; j < nCols; j++) {
|
|
65
|
+
const mark = marks[i]![j];
|
|
66
|
+
if (!mark) continue;
|
|
67
|
+
const d = i + j + 1;
|
|
68
|
+
const bucket = byDiagonal.get(d) ?? { L: 0, S: 0 };
|
|
69
|
+
bucket[mark]++;
|
|
70
|
+
byDiagonal.set(d, bucket);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const binom = (n: number, k: number): number => {
|
|
75
|
+
if (k < 0 || k > n) return 0;
|
|
76
|
+
let result = 1;
|
|
77
|
+
for (let x = 1; x <= k; x++) result = (result * (n - x + 1)) / x;
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const diagonals: CalendarYearDiagnostic["diagonals"] = [];
|
|
82
|
+
let totalZ = 0;
|
|
83
|
+
let expectedTotalZ = 0;
|
|
84
|
+
let varianceTotalZ = 0;
|
|
85
|
+
const sortedDiagonals = [...byDiagonal.entries()].sort((a, b) => a[0] - b[0]);
|
|
86
|
+
for (const [d, { L, S }] of sortedDiagonals) {
|
|
87
|
+
const n = L + S;
|
|
88
|
+
if (n < 1) continue;
|
|
89
|
+
const z = Math.min(L, S);
|
|
90
|
+
const m = Math.floor((n - 1) / 2);
|
|
91
|
+
const c = binom(n - 1, m);
|
|
92
|
+
const expectedZ = n / 2 - (c * n) / 2 ** n;
|
|
93
|
+
const varianceZ =
|
|
94
|
+
(n * (n - 1)) / 4 - (c * n * (n - 1)) / 2 ** n + expectedZ - expectedZ ** 2;
|
|
95
|
+
diagonals.push({ label: `diagonal ${d}`, countLarge: L, countSmall: S, z, expectedZ, varianceZ });
|
|
96
|
+
totalZ += z;
|
|
97
|
+
expectedTotalZ += expectedZ;
|
|
98
|
+
varianceTotalZ += varianceZ;
|
|
99
|
+
}
|
|
100
|
+
if (diagonals.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const half = 1.96 * Math.sqrt(Math.max(0, varianceTotalZ));
|
|
103
|
+
const confidenceInterval: [number, number] = [expectedTotalZ - half, expectedTotalZ + half];
|
|
104
|
+
return {
|
|
105
|
+
diagonals,
|
|
106
|
+
totalZ,
|
|
107
|
+
expectedTotalZ,
|
|
108
|
+
varianceTotalZ,
|
|
109
|
+
significant: totalZ < confidenceInterval[0] || totalZ > confidenceInterval[1],
|
|
110
|
+
confidenceInterval,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Median relative shift of the most recent origin periods against the prior
|
|
116
|
+
* ones, per development column, restricted to columns that are still
|
|
117
|
+
* developing (median value below the maturity cap). This is how an actuary
|
|
118
|
+
* reads a closure-rate exhibit: a step change in the recent rows at immature
|
|
119
|
+
* ages, not a smooth trend across saturated columns.
|
|
120
|
+
*/
|
|
121
|
+
function recentVsPriorShift(
|
|
122
|
+
grid: (number | null)[][],
|
|
123
|
+
options: { recent?: number; maturityCap?: number } = {},
|
|
124
|
+
): number | null {
|
|
125
|
+
const recentN = options.recent ?? 3;
|
|
126
|
+
const maturityCap = options.maturityCap ?? Infinity;
|
|
127
|
+
if (grid.length === 0) return null;
|
|
128
|
+
const nCols = grid[0]!.length;
|
|
129
|
+
const shifts: number[] = [];
|
|
130
|
+
for (let j = 0; j < nCols; j++) {
|
|
131
|
+
const values: number[] = [];
|
|
132
|
+
for (let i = 0; i < grid.length; i++) {
|
|
133
|
+
const v = grid[i]![j] ?? null;
|
|
134
|
+
if (isNum(v)) values.push(v);
|
|
135
|
+
}
|
|
136
|
+
if (values.length < recentN + 2) continue;
|
|
137
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
138
|
+
const median = sorted[Math.floor(sorted.length / 2)]!;
|
|
139
|
+
if (median >= maturityCap) continue; // column is saturated; no signal left
|
|
140
|
+
const recent = values.slice(-recentN);
|
|
141
|
+
const prior = values.slice(0, values.length - recentN);
|
|
142
|
+
const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
143
|
+
const priorAvg = avg(prior);
|
|
144
|
+
if (priorAvg === 0) continue;
|
|
145
|
+
shifts.push(avg(recent) / priorAvg - 1);
|
|
146
|
+
}
|
|
147
|
+
if (shifts.length === 0) return null;
|
|
148
|
+
shifts.sort((a, b) => a - b);
|
|
149
|
+
const mid = Math.floor(shifts.length / 2);
|
|
150
|
+
return shifts.length % 2 === 1 ? shifts[mid]! : (shifts[mid - 1]! + shifts[mid]!) / 2;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Median relative per-period trend of a metric across development columns,
|
|
155
|
+
* fitted down each column (same maturity across origin periods).
|
|
156
|
+
*/
|
|
157
|
+
function medianColumnTrend(grid: (number | null)[][]): number | null {
|
|
158
|
+
if (grid.length === 0) return null;
|
|
159
|
+
const nCols = grid[0]!.length;
|
|
160
|
+
const trends: number[] = [];
|
|
161
|
+
for (let j = 0; j < nCols; j++) {
|
|
162
|
+
const xs: number[] = [];
|
|
163
|
+
const ys: number[] = [];
|
|
164
|
+
for (let i = 0; i < grid.length; i++) {
|
|
165
|
+
const v = grid[i]![j] ?? null;
|
|
166
|
+
if (isNum(v) && v > 0) {
|
|
167
|
+
xs.push(i);
|
|
168
|
+
ys.push(Math.log(v));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (xs.length >= 3) {
|
|
172
|
+
const fit = ols(xs, ys);
|
|
173
|
+
if (fit) trends.push(Math.exp(fit.slope) - 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (trends.length === 0) return null;
|
|
177
|
+
trends.sort((a, b) => a - b);
|
|
178
|
+
const mid = Math.floor(trends.length / 2);
|
|
179
|
+
return trends.length % 2 === 1 ? trends[mid]! : (trends[mid - 1]! + trends[mid]!) / 2;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DiagnosticsInput {
|
|
183
|
+
paid: Triangle;
|
|
184
|
+
incurred: Triangle;
|
|
185
|
+
openCounts: Triangle;
|
|
186
|
+
reportedCounts: Triangle;
|
|
187
|
+
closedCounts: Triangle;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function runDiagnostics(input: DiagnosticsInput): DiagnosticsResult {
|
|
191
|
+
const { paid, incurred, openCounts, reportedCounts, closedCounts } = input;
|
|
192
|
+
|
|
193
|
+
const paidToIncurredRatios = ratioGrid(paid, incurred);
|
|
194
|
+
const caseGrid: (number | null)[][] = paid.origins.map((_, i) =>
|
|
195
|
+
paid.ages.map((_, j) => {
|
|
196
|
+
const inc = incurred.values[i]?.[j] ?? null;
|
|
197
|
+
const pd = paid.values[i]?.[j] ?? null;
|
|
198
|
+
const open = openCounts.values[i]?.[j] ?? null;
|
|
199
|
+
const caseAmt = isNum(inc) && isNum(pd) ? inc - pd : null;
|
|
200
|
+
return safeRatio(caseAmt, open);
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
const closureRates = ratioGrid(closedCounts, reportedCounts);
|
|
204
|
+
const cyTest = calendarYearTest(paid);
|
|
205
|
+
|
|
206
|
+
const findings: DiagnosticFinding[] = [];
|
|
207
|
+
const closureShift = recentVsPriorShift(closureRates, { recent: 3, maturityCap: 0.95 });
|
|
208
|
+
if (closureShift !== null && Math.abs(closureShift) > 0.05) {
|
|
209
|
+
findings.push({
|
|
210
|
+
severity: "warning",
|
|
211
|
+
code: "SETTLEMENT_RATE_SHIFT",
|
|
212
|
+
message: `Claim settlement is ${closureShift > 0 ? "speeding up" : "slowing down"}: at still-developing maturities, closure rates for the most recent origin periods run ${(Math.abs(closureShift) * 100).toFixed(0)}% ${closureShift > 0 ? "above" : "below"} the prior periods. Paid development factors are distorted; weigh the Berquist-Sherman settlement-rate adjustment.`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const caseTrend = medianColumnTrend(caseGrid);
|
|
216
|
+
if (caseTrend !== null && Math.abs(caseTrend) > 0.08) {
|
|
217
|
+
findings.push({
|
|
218
|
+
severity: "warning",
|
|
219
|
+
code: "CASE_ADEQUACY_SHIFT",
|
|
220
|
+
message: `Average case reserves at the same maturity are trending ${caseTrend > 0 ? "up" : "down"} ${(caseTrend * 100).toFixed(1)}% per period, which suggests a change in case reserve adequacy (or severity). Incurred development is distorted; consider the Berquist-Sherman case-reserve adjustment and compare against a severity trend expectation.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const p2iTrend = medianColumnTrend(paidToIncurredRatios);
|
|
224
|
+
if (p2iTrend !== null && Math.abs(p2iTrend) > 0.03) {
|
|
225
|
+
findings.push({
|
|
226
|
+
severity: "info",
|
|
227
|
+
code: "PAID_TO_INCURRED_SHIFT",
|
|
228
|
+
message: `Paid-to-incurred ratios at the same maturity are drifting ${p2iTrend > 0 ? "up" : "down"} (median ${(p2iTrend * 100).toFixed(1)}% per period). Paid and incurred projections are likely to diverge; investigate before relying on either alone.`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (cyTest?.significant) {
|
|
232
|
+
findings.push({
|
|
233
|
+
severity: "warning",
|
|
234
|
+
code: "CALENDAR_YEAR_EFFECT",
|
|
235
|
+
message: `Mack's calendar-year test rejects the null of no diagonal effects (Z = ${cyTest.totalZ.toFixed(1)} vs expected ${cyTest.expectedTotalZ.toFixed(1)}, 95% range ${cyTest.confidenceInterval[0].toFixed(1)}-${cyTest.confidenceInterval[1].toFixed(1)}). Calendar-period influences (inflation spikes, process changes, reserve reviews) are present; development-based methods assume these away.`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (findings.length === 0) {
|
|
239
|
+
findings.push({
|
|
240
|
+
severity: "info",
|
|
241
|
+
code: "NO_MATERIAL_DISTORTIONS",
|
|
242
|
+
message:
|
|
243
|
+
"No material settlement-rate, case-adequacy, or calendar-year distortions detected. Standard development assumptions look reasonable for this data.",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
paidToIncurredRatios,
|
|
249
|
+
averageCaseReserves: caseGrid,
|
|
250
|
+
closureRates,
|
|
251
|
+
calendarYearTest: cyTest,
|
|
252
|
+
findings,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Mack (1994) Appendix G: test for correlations between subsequent
|
|
258
|
+
// development factors.
|
|
259
|
+
|
|
260
|
+
export interface FactorCorrelationColumn {
|
|
261
|
+
/** Development year k in Mack's 1-indexed notation (subsequent factors F_k vs preceding F_{k-1}). */
|
|
262
|
+
k: number;
|
|
263
|
+
/** Number of (preceding, subsequent) factor pairs used. */
|
|
264
|
+
pairs: number;
|
|
265
|
+
/** Spearman rank correlation T_k. */
|
|
266
|
+
statistic: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface FactorCorrelationResult {
|
|
270
|
+
columns: FactorCorrelationColumn[];
|
|
271
|
+
/** T: the (pairs-1)-weighted average of the T_k. E[T] = 0 under the null. */
|
|
272
|
+
statistic: number;
|
|
273
|
+
/** Var(T) = 1 / sum of weights. */
|
|
274
|
+
variance: number;
|
|
275
|
+
/** Half-width of the 50% interval: 0.67 x sqrt(Var(T)). */
|
|
276
|
+
bound50: number;
|
|
277
|
+
/** True when |T| exceeds the 50% bound (be reluctant with chain ladder). */
|
|
278
|
+
correlated: boolean;
|
|
279
|
+
warnings: string[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Average ranks (ties averaged), 1-based. */
|
|
283
|
+
function averageRanks(values: number[]): number[] {
|
|
284
|
+
const order = values
|
|
285
|
+
.map((v, idx) => ({ v, idx }))
|
|
286
|
+
.sort((a, b) => a.v - b.v);
|
|
287
|
+
const ranks = new Array<number>(values.length).fill(0);
|
|
288
|
+
let pos = 0;
|
|
289
|
+
while (pos < order.length) {
|
|
290
|
+
let end = pos;
|
|
291
|
+
while (end + 1 < order.length && order[end + 1]!.v === order[pos]!.v) end++;
|
|
292
|
+
const avg = (pos + end) / 2 + 1;
|
|
293
|
+
for (let x = pos; x <= end; x++) ranks[order[x]!.idx] = avg;
|
|
294
|
+
pos = end + 1;
|
|
295
|
+
}
|
|
296
|
+
return ranks;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Mack's Spearman test for correlation between subsequent development
|
|
301
|
+
* factors (Mack 1994, CAS Forum Spring 1994, Appendix G).
|
|
302
|
+
*
|
|
303
|
+
* For each development year k = 2..I-2: rank the subsequent factors F_k over
|
|
304
|
+
* the rows where the NEXT factor exists, rank the preceding factors F_{k-1}
|
|
305
|
+
* over those same rows (re-ranked after dropping the deeper rows), and take
|
|
306
|
+
* T_k = 1 - 6 sum(d^2) / (n(n^2-1)). Combine with weights (pairs - 1) —
|
|
307
|
+
* inverse to Var(T_k) = 1/(n_k - 1). Under the null E[T] = 0 and
|
|
308
|
+
* Var(T) = 1/sum(weights); the DELIBERATELY tight 50% interval
|
|
309
|
+
* (|T| <= 0.67 sqrt(Var)) screens for correlation in a substantial part of
|
|
310
|
+
* the triangle rather than formally testing at 5%.
|
|
311
|
+
*
|
|
312
|
+
* Returns null when no column pair has at least two factor pairs.
|
|
313
|
+
*/
|
|
314
|
+
export function factorCorrelationTest(tri: Triangle): FactorCorrelationResult | null {
|
|
315
|
+
const n = tri.origins.length;
|
|
316
|
+
const nCols = tri.ages.length - 1;
|
|
317
|
+
const warnings: string[] = [];
|
|
318
|
+
|
|
319
|
+
// F[i][c] = C[i][c+1] / C[i][c] (0-indexed column c holds Mack's F_{c+1}).
|
|
320
|
+
const F: (number | null)[][] = tri.origins.map((_, i) =>
|
|
321
|
+
Array.from({ length: nCols }, (_, c) =>
|
|
322
|
+
safeRatio(tri.values[i]?.[c + 1] ?? null, tri.values[i]?.[c] ?? null),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const columns: FactorCorrelationColumn[] = [];
|
|
327
|
+
for (let k = 2; k <= Math.min(n - 2, nCols); k++) {
|
|
328
|
+
const cs = k - 1; // 0-indexed subsequent column (F_k)
|
|
329
|
+
const cp = k - 2; // 0-indexed preceding column (F_{k-1})
|
|
330
|
+
const sub: number[] = [];
|
|
331
|
+
const pre: number[] = [];
|
|
332
|
+
for (let i = 0; i < n; i++) {
|
|
333
|
+
const a = F[i]![cs] ?? null;
|
|
334
|
+
const b = F[i]![cp] ?? null;
|
|
335
|
+
if (isNum(a) && isNum(b)) {
|
|
336
|
+
sub.push(a);
|
|
337
|
+
pre.push(b);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const m = sub.length;
|
|
341
|
+
if (m < 2) continue;
|
|
342
|
+
const hasTies =
|
|
343
|
+
new Set(sub).size !== sub.length || new Set(pre).size !== pre.length;
|
|
344
|
+
if (hasTies) {
|
|
345
|
+
warnings.push(
|
|
346
|
+
`Development year ${k}: tied factors; the Spearman statistic is approximate under ties`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const r = averageRanks(sub);
|
|
350
|
+
const s = averageRanks(pre);
|
|
351
|
+
let d2 = 0;
|
|
352
|
+
for (let x = 0; x < m; x++) d2 += (r[x]! - s[x]!) ** 2;
|
|
353
|
+
const Tk = 1 - (6 * d2) / (m * (m * m - 1));
|
|
354
|
+
columns.push({ k, pairs: m, statistic: Tk });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (columns.length === 0) return null;
|
|
358
|
+
const weightSum = columns.reduce((a, c) => a + (c.pairs - 1), 0);
|
|
359
|
+
if (weightSum <= 0) return null;
|
|
360
|
+
const statistic = columns.reduce((a, c) => a + (c.pairs - 1) * c.statistic, 0) / weightSum;
|
|
361
|
+
const variance = 1 / weightSum;
|
|
362
|
+
const bound50 = 0.67 * Math.sqrt(variance);
|
|
363
|
+
return {
|
|
364
|
+
columns,
|
|
365
|
+
statistic,
|
|
366
|
+
variance,
|
|
367
|
+
bound50,
|
|
368
|
+
correlated: Math.abs(statistic) > bound50,
|
|
369
|
+
warnings,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Mack residuals: standardized development residuals for plotting and for
|
|
375
|
+
// eyeballing origin / development / calendar structure.
|
|
376
|
+
|
|
377
|
+
export interface MackResidualCell {
|
|
378
|
+
origin: string;
|
|
379
|
+
fromAge: number;
|
|
380
|
+
toAge: number;
|
|
381
|
+
/** 1-based calendar diagonal of the factor's numerator cell. */
|
|
382
|
+
calendar: number;
|
|
383
|
+
factor: number;
|
|
384
|
+
/** (F - f_k) sqrt(C_ik) / sigma_k; null where sigma^2 is not data-estimable or zero. */
|
|
385
|
+
residual: number | null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface MackResidualsResult {
|
|
389
|
+
cells: MackResidualCell[];
|
|
390
|
+
warnings: string[];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Standardized Mack residuals r_ik = (F_ik - f_k) sqrt(C_ik) / sigma_k
|
|
395
|
+
* around the volume-weighted factors, with sigma^2_k estimated from the
|
|
396
|
+
* data (Mack 1994). Columns whose sigma^2 cannot be estimated from at least
|
|
397
|
+
* two factors — or where the factors show no dispersion at all — yield null
|
|
398
|
+
* residuals with a warning; extrapolated sigma^2 is deliberately NOT used
|
|
399
|
+
* here (a residual against an extrapolated scale is not a diagnostic).
|
|
400
|
+
*
|
|
401
|
+
* Structural property: within each column, sum_i residual_ik x sqrt(C_ik)
|
|
402
|
+
* = 0 exactly, because f_k is the volume-weighted average.
|
|
403
|
+
*/
|
|
404
|
+
export function mackResiduals(tri: Triangle): MackResidualsResult {
|
|
405
|
+
const warnings: string[] = [];
|
|
406
|
+
const { f, sigma2 } = mackEstimators(tri);
|
|
407
|
+
const cells: MackResidualCell[] = [];
|
|
408
|
+
const flaggedColumns = new Set<number>();
|
|
409
|
+
|
|
410
|
+
for (let c = 0; c < tri.ages.length - 1; c++) {
|
|
411
|
+
const s2 = sigma2[c] ?? null;
|
|
412
|
+
const usable = isNum(s2) && s2 > 0;
|
|
413
|
+
if (!usable && !flaggedColumns.has(c)) {
|
|
414
|
+
flaggedColumns.add(c);
|
|
415
|
+
warnings.push(
|
|
416
|
+
`Column ${tri.ages[c]}-${tri.ages[c + 1]}: sigma^2 is ${s2 === null ? "not estimable from the data" : "zero (no dispersion)"}; residuals are null there`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
for (let i = 0; i < tri.origins.length; i++) {
|
|
420
|
+
const c0 = tri.values[i]?.[c] ?? null;
|
|
421
|
+
const c1 = tri.values[i]?.[c + 1] ?? null;
|
|
422
|
+
if (!isNum(c0) || !isNum(c1) || c0 <= 0) continue;
|
|
423
|
+
const factor = c1 / c0;
|
|
424
|
+
cells.push({
|
|
425
|
+
origin: tri.origins[i]!,
|
|
426
|
+
fromAge: tri.ages[c]!,
|
|
427
|
+
toAge: tri.ages[c + 1]!,
|
|
428
|
+
calendar: i + c + 1,
|
|
429
|
+
factor,
|
|
430
|
+
residual: usable ? ((factor - f[c]!) * Math.sqrt(c0)) / Math.sqrt(s2!) : null,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return { cells, warnings };
|
|
435
|
+
}
|