ibnr 0.2.0__py3-none-any.whl
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.
- ibnr/__init__.py +7 -0
- ibnr/data/__init__.py +3 -0
- ibnr/data/schedule_p.py +297 -0
- ibnr/gallery/__init__.py +29 -0
- ibnr/gallery/bayesian/__init__.py +0 -0
- ibnr/gallery/bayesian/_toolchain.py +70 -0
- ibnr/gallery/bayesian/clark_growth_curve/__init__.py +3 -0
- ibnr/gallery/bayesian/clark_growth_curve/card.md +70 -0
- ibnr/gallery/bayesian/clark_growth_curve/model.py +279 -0
- ibnr/gallery/bayesian/clark_growth_curve/model.stan +69 -0
- ibnr/gallery/bayesian/compartmental/__init__.py +3 -0
- ibnr/gallery/bayesian/compartmental/card.md +169 -0
- ibnr/gallery/bayesian/compartmental/model.py +502 -0
- ibnr/gallery/bayesian/compartmental/model.stan +160 -0
- ibnr/gallery/bayesian/compartmental/model_lognormal.stan +184 -0
- ibnr/gallery/bayesian/england_verrall_odp/__init__.py +3 -0
- ibnr/gallery/bayesian/england_verrall_odp/card.md +99 -0
- ibnr/gallery/bayesian/england_verrall_odp/model.py +294 -0
- ibnr/gallery/bayesian/england_verrall_odp/model.stan +67 -0
- ibnr/gallery/bayesian/meyers_ccl/__init__.py +3 -0
- ibnr/gallery/bayesian/meyers_ccl/card.md +232 -0
- ibnr/gallery/bayesian/meyers_ccl/model.py +390 -0
- ibnr/gallery/bayesian/meyers_ccl/model.stan +76 -0
- ibnr/gallery/bayesian/meyers_ccl/model_numpyro.py +140 -0
- ibnr/gallery/bayesian/meyers_ccl/model_pymc.py +139 -0
- ibnr/gallery/bayesian/meyers_csr/__init__.py +3 -0
- ibnr/gallery/bayesian/meyers_csr/card.md +109 -0
- ibnr/gallery/bayesian/meyers_csr/model.py +247 -0
- ibnr/gallery/bayesian/meyers_csr/model.stan +76 -0
- ibnr/gallery/entry.py +56 -0
- ibnr/gallery/nn/__init__.py +2 -0
- ibnr/gallery/nn/transformer/__init__.py +4 -0
- ibnr/gallery/nn/transformer/card.md +138 -0
- ibnr/gallery/nn/transformer/config.py +50 -0
- ibnr/gallery/nn/transformer/model.py +451 -0
- ibnr/gallery/nn/transformer/network.py +212 -0
- ibnr/gallery/nn/transformer_ml/__init__.py +4 -0
- ibnr/gallery/nn/transformer_ml/card.md +73 -0
- ibnr/gallery/nn/transformer_ml/config.py +60 -0
- ibnr/gallery/nn/transformer_ml/model.py +448 -0
- ibnr/gallery/nn/transformer_ml/network.py +284 -0
- ibnr/gallery/registry.py +42 -0
- ibnr/gallery/statistical/__init__.py +4 -0
- ibnr/gallery/statistical/clark/__init__.py +3 -0
- ibnr/gallery/statistical/clark/card.md +96 -0
- ibnr/gallery/statistical/clark/model.py +291 -0
- ibnr/gallery/statistical/copula_glm/__init__.py +3 -0
- ibnr/gallery/statistical/copula_glm/card.md +77 -0
- ibnr/gallery/statistical/copula_glm/model.py +344 -0
- ibnr/gallery/statistical/sur/__init__.py +3 -0
- ibnr/gallery/statistical/sur/card.md +84 -0
- ibnr/gallery/statistical/sur/model.py +366 -0
- ibnr/kernels/__init__.py +4 -0
- ibnr/kernels/calibration.py +71 -0
- ibnr/kernels/contract.py +370 -0
- ibnr/kernels/harness.py +332 -0
- ibnr/kernels/multiline.py +351 -0
- ibnr/kernels/nn_contract.py +441 -0
- ibnr/kernels/parity.py +161 -0
- ibnr/kernels/predictive.py +105 -0
- ibnr/kernels/scores.py +39 -0
- ibnr/triangle/__init__.py +3 -0
- ibnr/triangle/core.py +239 -0
- ibnr/triangle/io.py +340 -0
- ibnr/triangle/transforms.py +189 -0
- ibnr/triangle/validate.py +69 -0
- ibnr-0.2.0.dist-info/METADATA +224 -0
- ibnr-0.2.0.dist-info/RECORD +70 -0
- ibnr-0.2.0.dist-info/WHEEL +4 -0
- ibnr-0.2.0.dist-info/licenses/LICENSE +373 -0
ibnr/__init__.py
ADDED
ibnr/data/__init__.py
ADDED
ibnr/data/schedule_p.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Adapter for the CAS Schedule P gold mart (cas-schedule-p-data-model repo).
|
|
2
|
+
|
|
3
|
+
The data pipeline lives in that repo; this package only consumes its published
|
|
4
|
+
gold mart. The mart of record is ``mart_reserving_model_training``: long-format
|
|
5
|
+
loss observations keyed by company x line_of_business x accident_year x
|
|
6
|
+
development_age x statement_year. Schema (verified 2026-06-12 rebuild):
|
|
7
|
+
|
|
8
|
+
company_code, company_name, line_of_business, statement_year, accident_year,
|
|
9
|
+
development_age (years, 1-10), calendar_age, incurred_loss, cum_paid_loss,
|
|
10
|
+
bulk_loss, earned_prem_net, earned_prem_direct, case_reserve, loss_ratio,
|
|
11
|
+
paid_to_incurred_ratio, ata_factor_into_this_age, publish_id
|
|
12
|
+
|
|
13
|
+
Note: the mart's ``incurred_loss`` is gross of bulk+IBNR and its
|
|
14
|
+
``case_reserve`` is incurred - paid (so it also contains bulk). The adapter
|
|
15
|
+
derives ``reported_loss`` = incurred_loss - bulk_loss (paid + true case),
|
|
16
|
+
which is what Meyers' monograph calls "incurred".
|
|
17
|
+
|
|
18
|
+
Two ways to point this adapter at data (either directly or via the
|
|
19
|
+
IBNR_SCHEDULE_P_WAREHOUSE environment variable; when neither is given the
|
|
20
|
+
default is ``DEFAULT_SOURCE`` — the newest GitHub release, ``@latest``
|
|
21
|
+
resolved to its concrete publish_id up front):
|
|
22
|
+
|
|
23
|
+
1. a local warehouse directory — parquet under ``warehouse/`` with the active
|
|
24
|
+
publish chosen by ``warehouse/_active_manifest.json`` (the sibling checkout
|
|
25
|
+
of cas-schedule-p-data-model);
|
|
26
|
+
2. a GitHub release spec ``github://<owner>/<repo>@<publish_id>`` — the data
|
|
27
|
+
repo publishes each gold promote as a release tagged with its publish_id,
|
|
28
|
+
carrying every gold table plus a ``manifest.json`` (asset name, sha256,
|
|
29
|
+
bytes per table). Assets are downloaded once via the ``gh`` CLI (which
|
|
30
|
+
supplies auth for the private repo) into a local cache
|
|
31
|
+
(``~/.cache/ibnr`` or IBNR_CACHE_DIR), sha256-verified, then read locally.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import subprocess
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import ibis
|
|
44
|
+
|
|
45
|
+
from ibnr.triangle.core import Triangle
|
|
46
|
+
from ibnr.triangle.io import from_long, resolve_backend
|
|
47
|
+
|
|
48
|
+
ENV_VAR = "IBNR_SCHEDULE_P_WAREHOUSE"
|
|
49
|
+
CACHE_ENV_VAR = "IBNR_CACHE_DIR"
|
|
50
|
+
GITHUB_SCHEME = "github://"
|
|
51
|
+
TRAINING_MART = "mart_reserving_model_training"
|
|
52
|
+
|
|
53
|
+
#: where the data comes from when nothing else is specified: the newest gold
|
|
54
|
+
#: publish on GitHub. ``@latest`` resolves to a concrete publish_id through gh
|
|
55
|
+
#: before anything is cached or read, so results artifacts always stamp the
|
|
56
|
+
#: exact publish even in default-configured dev.
|
|
57
|
+
DEFAULT_SOURCE = "github://EKtheSage/cas-schedule-p-data-model@latest"
|
|
58
|
+
LATEST = "latest"
|
|
59
|
+
|
|
60
|
+
#: mart column -> triangle field name. Loss fields are cumulative; premiums,
|
|
61
|
+
#: reserves and bulk are eval-date snapshots carried along as fields.
|
|
62
|
+
DEFAULT_FIELDS = {
|
|
63
|
+
"cum_paid_loss": "paid_loss",
|
|
64
|
+
"incurred_loss": "incurred_loss", # gross of bulk+IBNR
|
|
65
|
+
"bulk_loss": "bulk_loss",
|
|
66
|
+
"case_reserve": "case_reserve",
|
|
67
|
+
"earned_prem_net": "earned_premium",
|
|
68
|
+
"earned_prem_direct": "earned_premium_direct",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#: derived field: triangle field name -> ibis expression over the mart table
|
|
72
|
+
DERIVED_FIELDS = {
|
|
73
|
+
# Meyers' "incurred": net of bulk+IBNR, i.e. paid + case reserves
|
|
74
|
+
"reported_loss": lambda t: t.incurred_loss - t.bulk_loss,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
SEGMENTS = ["company_code", "company_name", "line_of_business"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def active_mart_path(warehouse: str | Path | None = None, mart: str = TRAINING_MART) -> Path:
|
|
81
|
+
"""Resolve a mart's parquet path — from a local warehouse's active-publish
|
|
82
|
+
manifest, or from a cached (downloading if needed) GitHub release when
|
|
83
|
+
``warehouse`` is a ``github://owner/repo@publish_id`` spec."""
|
|
84
|
+
source = _resolve_source(warehouse)
|
|
85
|
+
if _is_github_spec(source):
|
|
86
|
+
repo, tag = _parse_github_spec(str(source))
|
|
87
|
+
return _release_asset(repo, tag, mart)
|
|
88
|
+
warehouse = Path(source)
|
|
89
|
+
manifest = json.loads((warehouse / "_active_manifest.json").read_text())
|
|
90
|
+
rel = Path(manifest["tables"][mart])
|
|
91
|
+
# manifest paths are relative to the data-model repo root ("warehouse\...")
|
|
92
|
+
return warehouse.parent / rel
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def active_publish_id(warehouse: str | Path | None = None) -> str:
|
|
96
|
+
"""The gold publish's version stamp — stamp this into every results
|
|
97
|
+
artifact so figures trace back to an exact data publish."""
|
|
98
|
+
source = _resolve_source(warehouse)
|
|
99
|
+
if _is_github_spec(source):
|
|
100
|
+
repo, tag = _parse_github_spec(str(source))
|
|
101
|
+
manifest, _ = _release_manifest(repo, tag)
|
|
102
|
+
return str(manifest["publish_id"])
|
|
103
|
+
warehouse = Path(source)
|
|
104
|
+
manifest = json.loads((warehouse / "_active_manifest.json").read_text())
|
|
105
|
+
return str(manifest["publish_id"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_source(warehouse: str | Path | None) -> str | Path:
|
|
109
|
+
"""Explicit argument > env var > the GitHub data repo's newest publish."""
|
|
110
|
+
if warehouse is None:
|
|
111
|
+
warehouse = os.environ.get(ENV_VAR) or DEFAULT_SOURCE
|
|
112
|
+
return warehouse
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def pinned_source(warehouse: str | Path | None = None, mart: str = TRAINING_MART) -> str:
|
|
116
|
+
"""Resolve a warehouse argument to a CONCRETE, worker-safe source string.
|
|
117
|
+
|
|
118
|
+
For a GitHub spec, ``@latest`` is pinned to its publish_id and the mart
|
|
119
|
+
asset is downloaded into the cache up front. The parallel harness hands
|
|
120
|
+
THIS string to its workers, so they never re-resolve ``@latest`` (a race
|
|
121
|
+
against a release published mid-run would split the study across two data
|
|
122
|
+
versions) and never call gh concurrently — they only read the local cache.
|
|
123
|
+
Local warehouse paths pass through unchanged.
|
|
124
|
+
"""
|
|
125
|
+
source = _resolve_source(warehouse)
|
|
126
|
+
if _is_github_spec(source):
|
|
127
|
+
repo, tag = _parse_github_spec(str(source))
|
|
128
|
+
_release_asset(repo, tag, mart) # warm the cache before workers spawn
|
|
129
|
+
return f"{GITHUB_SCHEME}{repo}@{tag}"
|
|
130
|
+
return str(source)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# -- GitHub release consumption ---------------------------------------------------
|
|
134
|
+
#
|
|
135
|
+
# The data repo publishes each gold promote as an immutable release tagged with
|
|
136
|
+
# its publish_id; assets are the gold tables plus manifest.json. We download
|
|
137
|
+
# through the gh CLI (it carries auth for the private repo), cache per
|
|
138
|
+
# (repo, publish_id), and verify sha256 against the manifest.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_github_spec(source: str | Path) -> bool:
|
|
142
|
+
return isinstance(source, str) and source.startswith(GITHUB_SCHEME)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_github_spec(spec: str) -> tuple[str, str]:
|
|
146
|
+
"""``github://owner/repo@publish_id`` -> (``owner/repo``, ``publish_id``).
|
|
147
|
+
|
|
148
|
+
``@latest`` is resolved to the repo's newest release tag (one gh call,
|
|
149
|
+
cached per process) so caching and provenance always see a concrete id.
|
|
150
|
+
"""
|
|
151
|
+
body = spec[len(GITHUB_SCHEME) :]
|
|
152
|
+
repo, _, tag = body.partition("@")
|
|
153
|
+
if not tag or repo.count("/") != 1 or not all(repo.split("/")):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"bad GitHub release spec {spec!r}; expected {GITHUB_SCHEME}owner/repo@publish_id"
|
|
156
|
+
)
|
|
157
|
+
if tag == LATEST:
|
|
158
|
+
tag = _latest_tag(repo)
|
|
159
|
+
return repo, tag
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_LATEST_TAGS: dict[str, str] = {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _latest_tag(repo: str) -> str:
|
|
166
|
+
if repo not in _LATEST_TAGS:
|
|
167
|
+
if shutil.which("gh") is None:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"resolving @{LATEST} for {repo} needs the GitHub CLI (gh); "
|
|
170
|
+
"install it and run `gh auth login`, or pin a publish_id"
|
|
171
|
+
)
|
|
172
|
+
result = subprocess.run(
|
|
173
|
+
["gh", "release", "view", "--repo", repo, "--json", "tagName", "--jq", ".tagName"],
|
|
174
|
+
capture_output=True,
|
|
175
|
+
text=True,
|
|
176
|
+
)
|
|
177
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"could not resolve the latest release of {repo}: "
|
|
180
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
181
|
+
)
|
|
182
|
+
_LATEST_TAGS[repo] = result.stdout.strip()
|
|
183
|
+
return _LATEST_TAGS[repo]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cache_dir(repo: str, tag: str) -> Path:
|
|
187
|
+
root = os.environ.get(CACHE_ENV_VAR)
|
|
188
|
+
root = Path(root) if root else Path.home() / ".cache" / "ibnr"
|
|
189
|
+
return root / repo.replace("/", "__") / tag
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _gh_download(repo: str, tag: str, pattern: str, dest: Path) -> None:
|
|
193
|
+
if shutil.which("gh") is None:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
"the GitHub CLI (gh) is required to fetch data releases from the "
|
|
196
|
+
f"private {repo} repo; install it and run `gh auth login`"
|
|
197
|
+
)
|
|
198
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
[
|
|
201
|
+
"gh",
|
|
202
|
+
"release",
|
|
203
|
+
"download",
|
|
204
|
+
tag,
|
|
205
|
+
"--repo",
|
|
206
|
+
repo,
|
|
207
|
+
"--pattern",
|
|
208
|
+
pattern,
|
|
209
|
+
"--dir",
|
|
210
|
+
str(dest),
|
|
211
|
+
"--clobber",
|
|
212
|
+
],
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
)
|
|
216
|
+
if result.returncode != 0:
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"gh release download failed for {repo}@{tag} ({pattern}): "
|
|
219
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _release_manifest(repo: str, tag: str) -> tuple[dict, Path]:
|
|
224
|
+
cache = _cache_dir(repo, tag)
|
|
225
|
+
path = cache / "manifest.json"
|
|
226
|
+
if not path.exists():
|
|
227
|
+
_gh_download(repo, tag, "manifest.json", cache)
|
|
228
|
+
manifest = json.loads(path.read_text())
|
|
229
|
+
if str(manifest["publish_id"]) != tag:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"release {repo}@{tag} carries manifest for publish "
|
|
232
|
+
f"{manifest['publish_id']!r} — publishes are immutable, refusing to mix"
|
|
233
|
+
)
|
|
234
|
+
return manifest, cache
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _release_asset(repo: str, tag: str, table: str) -> Path:
|
|
238
|
+
manifest, cache = _release_manifest(repo, tag)
|
|
239
|
+
try:
|
|
240
|
+
entry = manifest["tables"][table]
|
|
241
|
+
except KeyError:
|
|
242
|
+
raise KeyError(
|
|
243
|
+
f"release {repo}@{tag} has no table {table!r}; available: {sorted(manifest['tables'])}"
|
|
244
|
+
) from None
|
|
245
|
+
path = cache / entry["asset"]
|
|
246
|
+
if not path.exists() or path.stat().st_size != int(entry["bytes"]):
|
|
247
|
+
_gh_download(repo, tag, entry["asset"], cache)
|
|
248
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
249
|
+
if digest != entry["sha256"]:
|
|
250
|
+
path.unlink(missing_ok=True)
|
|
251
|
+
raise RuntimeError(
|
|
252
|
+
f"sha256 mismatch for {entry['asset']} from {repo}@{tag}: "
|
|
253
|
+
f"got {digest}, manifest says {entry['sha256']}"
|
|
254
|
+
)
|
|
255
|
+
return path
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def load_schedule_p(
|
|
259
|
+
warehouse: str | Path | None = None,
|
|
260
|
+
*,
|
|
261
|
+
lines: list[str] | None = None,
|
|
262
|
+
companies: list[str] | None = None,
|
|
263
|
+
fields: dict[str, str] | None = None,
|
|
264
|
+
derived: dict | None = None,
|
|
265
|
+
backend: str | None = None,
|
|
266
|
+
) -> Triangle:
|
|
267
|
+
"""Load the reserving training mart as a Triangle.
|
|
268
|
+
|
|
269
|
+
Mapping: origin_period = Jan 1 of accident_year; dev_lag = development_age
|
|
270
|
+
in months; eval_date = Dec 31 of statement_year (annual statement date).
|
|
271
|
+
Values are USD thousands. ``lines``/``companies`` filter line_of_business /
|
|
272
|
+
company_code before materializing.
|
|
273
|
+
"""
|
|
274
|
+
fields = fields or DEFAULT_FIELDS
|
|
275
|
+
derived = DERIVED_FIELDS if derived is None else derived
|
|
276
|
+
con = resolve_backend(backend)
|
|
277
|
+
t = con.read_parquet(str(active_mart_path(warehouse)))
|
|
278
|
+
if lines:
|
|
279
|
+
t = t.filter(t.line_of_business.isin(lines))
|
|
280
|
+
if companies:
|
|
281
|
+
t = t.filter(t.company_code.isin(companies))
|
|
282
|
+
t = t.select(
|
|
283
|
+
*SEGMENTS,
|
|
284
|
+
origin_period=ibis.date(t.accident_year, 1, 1),
|
|
285
|
+
dev_lag=(t.development_age * 12).cast("int64"),
|
|
286
|
+
eval_date=ibis.date(t.statement_year, 12, 31),
|
|
287
|
+
**{new: t[old] for old, new in fields.items()},
|
|
288
|
+
**{name: expr(t) for name, expr in derived.items()},
|
|
289
|
+
)
|
|
290
|
+
return from_long(
|
|
291
|
+
t,
|
|
292
|
+
fields=[*fields.values(), *derived],
|
|
293
|
+
measure="cumulative",
|
|
294
|
+
origin_grain="Y",
|
|
295
|
+
dev_grain="Y",
|
|
296
|
+
units="USD thousands",
|
|
297
|
+
)
|
ibnr/gallery/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The model gallery. Public API: list(), get(), fit()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
|
|
7
|
+
from ibnr.gallery import registry as _registry
|
|
8
|
+
|
|
9
|
+
# importing the subpackages self-registers their entries
|
|
10
|
+
from ibnr.gallery import statistical as _statistical # noqa: F401
|
|
11
|
+
from ibnr.gallery.bayesian import clark_growth_curve as _clark_growth_curve # noqa: F401
|
|
12
|
+
from ibnr.gallery.bayesian import compartmental as _compartmental # noqa: F401
|
|
13
|
+
from ibnr.gallery.bayesian import england_verrall_odp as _england_verrall_odp # noqa: F401
|
|
14
|
+
from ibnr.gallery.bayesian import meyers_ccl as _meyers_ccl # noqa: F401
|
|
15
|
+
from ibnr.gallery.bayesian import meyers_csr as _meyers_csr # noqa: F401
|
|
16
|
+
from ibnr.gallery.entry import GalleryEntry
|
|
17
|
+
from ibnr.gallery.nn import transformer as _nn_transformer # noqa: F401
|
|
18
|
+
from ibnr.gallery.nn import transformer_ml as _nn_transformer_ml # noqa: F401
|
|
19
|
+
|
|
20
|
+
get = _registry.get
|
|
21
|
+
fit = _registry.fit
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list() -> builtins.list[str]: # noqa: A001 - mirrors the designed public API
|
|
25
|
+
"""Names of all registered gallery entries."""
|
|
26
|
+
return sorted(_registry.entries())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["GalleryEntry", "fit", "get", "list"]
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Windows C++ toolchain wiring shared by the Bayesian backends.
|
|
2
|
+
|
|
3
|
+
Both cmdstan (via cmdstanpy) and PyMC (via PyTensor) need a C/C++ compiler on
|
|
4
|
+
Windows. Modern RTools installs (43/44/45) ship a plain ``make`` and a g++ under
|
|
5
|
+
``x86_64-w64-mingw32.static.posix``; these helpers put them on PATH so the
|
|
6
|
+
backends can compile. No-ops on non-Windows or when a toolchain is already set.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# RTools roots newest-first; each carries a posix g++ and a usr/bin make.
|
|
17
|
+
_RTOOLS_ROOTS = ("C:/rtools45", "C:/rtools44", "C:/rtools43", "C:/rtools40")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rtools_bindirs() -> tuple[Path, Path] | None:
|
|
21
|
+
for root in _RTOOLS_ROOTS:
|
|
22
|
+
gxx = Path(root) / "x86_64-w64-mingw32.static.posix" / "bin"
|
|
23
|
+
mk = Path(root) / "usr" / "bin"
|
|
24
|
+
if gxx.exists() and mk.exists():
|
|
25
|
+
return gxx, mk
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_stan_toolchain() -> None:
|
|
30
|
+
"""Best-effort: put an RTools g++/make on PATH so cmdstan can compile.
|
|
31
|
+
|
|
32
|
+
cmdstanpy assumes mingw32-make/RTools40; modern RTools ship plain ``make``,
|
|
33
|
+
so we also set ``MAKE``.
|
|
34
|
+
"""
|
|
35
|
+
if platform.system() != "Windows":
|
|
36
|
+
return
|
|
37
|
+
if shutil.which("g++") and (shutil.which("mingw32-make") or shutil.which("make")):
|
|
38
|
+
os.environ.setdefault("MAKE", "mingw32-make" if shutil.which("mingw32-make") else "make")
|
|
39
|
+
return
|
|
40
|
+
dirs = _rtools_bindirs()
|
|
41
|
+
if dirs is not None:
|
|
42
|
+
gxx, mk = dirs
|
|
43
|
+
os.environ["PATH"] = f"{gxx};{mk};{os.environ['PATH']}"
|
|
44
|
+
os.environ.setdefault("MAKE", "make")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_pytensor_cxx() -> str | None:
|
|
48
|
+
"""Point PyTensor at an RTools g++ so it compiles C ops instead of falling
|
|
49
|
+
back to the (very slow) Python evaluation path.
|
|
50
|
+
|
|
51
|
+
Returns the g++ path configured, or None if nothing was needed/found.
|
|
52
|
+
Idempotent: a no-op once ``pytensor.config.cxx`` is already set.
|
|
53
|
+
"""
|
|
54
|
+
if platform.system() != "Windows":
|
|
55
|
+
return None
|
|
56
|
+
import pytensor
|
|
57
|
+
|
|
58
|
+
if pytensor.config.cxx:
|
|
59
|
+
return pytensor.config.cxx
|
|
60
|
+
dirs = _rtools_bindirs()
|
|
61
|
+
if dirs is None:
|
|
62
|
+
return None
|
|
63
|
+
gxx, mk = dirs
|
|
64
|
+
gxx_exe = gxx / "g++.exe"
|
|
65
|
+
if not gxx_exe.exists():
|
|
66
|
+
return None
|
|
67
|
+
if str(gxx) not in os.environ["PATH"]:
|
|
68
|
+
os.environ["PATH"] = f"{gxx};{mk};{os.environ['PATH']}"
|
|
69
|
+
pytensor.config.cxx = str(gxx_exe)
|
|
70
|
+
return str(gxx_exe)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# clark_growth_curve — Bayesian Clark (Cape Cod)
|
|
2
|
+
|
|
3
|
+
**Family:** bayesian · **Reference:** Clark, "LDF Curve-Fitting and
|
|
4
|
+
Stochastic Reserving: A Maximum Likelihood Approach", *CAS Forum* (Fall
|
|
5
|
+
2003) for the likelihood. **There is no published Stan ground truth for a
|
|
6
|
+
Bayesian Clark** — the priors below are this package's specification, and
|
|
7
|
+
the milestone-5 NumPyro/PyMC ports must hold them (and the plug-in phi)
|
|
8
|
+
constant. The statistical `clark` entry is the MLE twin sharing the exact
|
|
9
|
+
likelihood, parameterization, and age convention.
|
|
10
|
+
|
|
11
|
+
## Model
|
|
12
|
+
|
|
13
|
+
Incremental paid losses:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
X[w,d] ~ ODP: E[X] = elr * premium[w] * (G(x_hi) - G(x_lo)), Var[X] = phi * E[X]
|
|
17
|
+
G = loglogistic (default) or weibull, ages from the average accident date
|
|
18
|
+
(x = 12d - 6 for annual grains, first period starts at 0)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Cape Cod form only — one ELR against earned premium, Clark's recommendation
|
|
22
|
+
for triangle-sized data. A free-ultimates (LDF) Bayesian variant is a
|
|
23
|
+
scaffold exercise: eject this entry and swap `logelr` for a vector.
|
|
24
|
+
|
|
25
|
+
Priors:
|
|
26
|
+
|
|
27
|
+
| parameter | prior | rationale |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| logelr | normal(-0.4, sqrt(10)) | the Meyers-family variance-10 ELR prior |
|
|
30
|
+
| omega | lognormal(log 1.5, 0.5) | curve shape; mass on ~0.6–4 |
|
|
31
|
+
| theta | lognormal(log(4 * grain), 1) | curve scale; median at 4 dev periods, tracks the grain |
|
|
32
|
+
|
|
33
|
+
## Dispersion phi — plug-in from the MLE twin
|
|
34
|
+
|
|
35
|
+
`fit()` first runs the statistical `clark` entry (same curve, cape_cod) and
|
|
36
|
+
passes its Pearson scale into Stan as data — the same treatment as
|
|
37
|
+
`england_verrall_odp`'s phi. With phi fixed, the ODP quasi-likelihood is a
|
|
38
|
+
proper likelihood for `(logelr, omega, theta)` up to a constant.
|
|
39
|
+
|
|
40
|
+
## Data contract
|
|
41
|
+
|
|
42
|
+
`kernels.contract.odp_stan_data` (incremental cells, premium required,
|
|
43
|
+
negative increments rejected). The Stan data block additionally carries the
|
|
44
|
+
per-cell `age_lo`/`age_hi` and the curve code — ages are data, not model
|
|
45
|
+
logic, so ports cannot drift on the convention.
|
|
46
|
+
|
|
47
|
+
## Predictive distribution
|
|
48
|
+
|
|
49
|
+
Posterior draws of `(logelr, omega, theta)` × scaled-Poisson process draws
|
|
50
|
+
`phi * Poisson(mu/phi)` per future cell, truncated at the triangle's final
|
|
51
|
+
age (no tail), paid-to-date anchored — identical mechanics to the MLE twin
|
|
52
|
+
with the posterior replacing the delta-method MVN. Fully-developed origins
|
|
53
|
+
are constant.
|
|
54
|
+
|
|
55
|
+
## Validation
|
|
56
|
+
|
|
57
|
+
Retrospective Meyers protocol on paid: `scripts/meyers_validation.py
|
|
58
|
+
--model clark_growth_curve`. Results in
|
|
59
|
+
`analysis/results/clark_growth_curve_validation.csv`. No monograph bar
|
|
60
|
+
exists; compare against the paid panel (england_verrall_odp, meyers_csr,
|
|
61
|
+
clark MLE) on identical cohorts.
|
|
62
|
+
|
|
63
|
+
**Result (2026-07-20, loglogistic, 96/200 completed — the rest rejected for
|
|
64
|
+
negative paid increments): combined KS D = 63.4* vs crit 13.9, percentiles
|
|
65
|
+
piled at ~0 — within noise of the MLE twin's 61.3*, confirming the
|
|
66
|
+
posterior tracks the MLE.** The failure is the model, not the inference:
|
|
67
|
+
the post-1997 settlement speedup plus the loglogistic tail mass (see the
|
|
68
|
+
MLE twin's card for the weibull ablation, D = 49.6*). In the gallery this
|
|
69
|
+
entry is the growth-curve baseline for ELPD/stacking; meyers_csr (D = 4.1,
|
|
70
|
+
passes) is what calibrated paid reserving looks like in this window.
|