azoic 0.5.0__tar.gz
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.
- azoic-0.5.0/PKG-INFO +108 -0
- azoic-0.5.0/README.md +76 -0
- azoic-0.5.0/pyproject.toml +92 -0
- azoic-0.5.0/pyproject.toml.orig +76 -0
- azoic-0.5.0/src/azoic/__init__.py +7 -0
- azoic-0.5.0/src/azoic/cli.py +208 -0
- azoic-0.5.0/src/azoic/data.py +95 -0
- azoic-0.5.0/src/azoic/metrics.py +532 -0
- azoic-0.5.0/src/azoic/mlops.py +110 -0
- azoic-0.5.0/src/azoic/models.py +693 -0
- azoic-0.5.0/src/azoic/plots.py +615 -0
- azoic-0.5.0/src/azoic/preprocessing.py +661 -0
- azoic-0.5.0/src/azoic/profile.py +89 -0
- azoic-0.5.0/src/azoic/py.typed +0 -0
- azoic-0.5.0/src/azoic/reporting.py +200 -0
- azoic-0.5.0/src/azoic/tariff.py +573 -0
- azoic-0.5.0/src/azoic/tune.py +247 -0
- azoic-0.5.0/src/azoic/validation.py +132 -0
- azoic-0.5.0/src/azoic/workflow.py +631 -0
azoic-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: azoic
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: scikit-learn-compatible toolkit for non-life technical tariff (pure premium) modelling
|
|
5
|
+
Author: thomas-bury
|
|
6
|
+
Author-email: thomas-bury <thomas.bury@yuzzu.be>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: numpy>=2.0
|
|
9
|
+
Requires-Dist: pandas>=3.0
|
|
10
|
+
Requires-Dist: scikit-learn>=1.6
|
|
11
|
+
Requires-Dist: glum>=3.0
|
|
12
|
+
Requires-Dist: lightgbm>=4.3
|
|
13
|
+
Requires-Dist: pyarrow>=15.0
|
|
14
|
+
Requires-Dist: pydantic>=2.7
|
|
15
|
+
Requires-Dist: pyyaml>=6.0
|
|
16
|
+
Requires-Dist: typer>=0.12
|
|
17
|
+
Requires-Dist: matplotlib>=3.8
|
|
18
|
+
Requires-Dist: openpyxl>=3.1
|
|
19
|
+
Requires-Dist: s3fs>=2024.6 ; extra == 'aws'
|
|
20
|
+
Requires-Dist: mlflow>=2.14 ; extra == 'mlops'
|
|
21
|
+
Requires-Dist: plotly>=6.9.0 ; extra == 'plot'
|
|
22
|
+
Requires-Dist: optuna>=3.6 ; extra == 'tune'
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Project-URL: Repository, https://github.com/ThomasBury/azoic
|
|
25
|
+
Project-URL: Issues, https://github.com/ThomasBury/azoic/issues
|
|
26
|
+
Project-URL: Documentation, https://thomasbury.github.io/azoic/
|
|
27
|
+
Provides-Extra: aws
|
|
28
|
+
Provides-Extra: mlops
|
|
29
|
+
Provides-Extra: plot
|
|
30
|
+
Provides-Extra: tune
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Azoic
|
|
34
|
+
|
|
35
|
+
A scikit-learn-compatible Python toolkit for non-life technical tariff (pure
|
|
36
|
+
premium) modelling: actuarial preprocessing, GLM (glum) + GBM (LightGBM),
|
|
37
|
+
frequency-severity decomposition, actuarial diagnostics, reproducible runs,
|
|
38
|
+
GBM-to-GLM distillation, and multiplicative tariff export.
|
|
39
|
+
|
|
40
|
+
- Documentation: <https://thomasbury.github.io/azoic/>
|
|
41
|
+
- Hosted tutorial series: <https://thomasbury.github.io/azoic/tutorial/index.html>
|
|
42
|
+
- Full spec and conventions: [`PRD.md`](PRD.md)
|
|
43
|
+
- Agent quick rules: [`AGENTS.md`](AGENTS.md)
|
|
44
|
+
- Status: [`PROGRESS.md`](PROGRESS.md)
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
Requires Python 3.12 or newer. In your own uv project:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add azoic==0.5.0
|
|
52
|
+
uv run azoic --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For development or the executable tutorial, clone this repository and run:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv sync --no-dev # runtime only
|
|
59
|
+
uv sync # default development environment
|
|
60
|
+
uv sync --all-extras --all-groups # everything
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Correctness conventions
|
|
64
|
+
|
|
65
|
+
Frequency models use `claim_count / exposure` with `sample_weight=exposure`.
|
|
66
|
+
`deviance_test` is exposure-weighted mean Tweedie deviance with fixed
|
|
67
|
+
`power=1.5`. Gini is a concentration Gini for ranking; equal prediction scores
|
|
68
|
+
are evaluated as tied blocks.
|
|
69
|
+
|
|
70
|
+
Temporal holdouts never split equal timestamps. Tuning selects parameters on an
|
|
71
|
+
inner split of the outer training partition, then refits on outer training data
|
|
72
|
+
and reports the untouched outer test. Exported tariffs reject unknown categorical
|
|
73
|
+
levels and non-finite numeric inputs.
|
|
74
|
+
|
|
75
|
+
Positive-objective GBMs can be exported through `azoic export-tariff
|
|
76
|
+
--distill`; the workbook reproduces the distilled GLM student, not the teacher.
|
|
77
|
+
|
|
78
|
+
## Executable tutorial series
|
|
79
|
+
|
|
80
|
+
The numbered Quarto book under [`examples/`](examples/) runs the complete
|
|
81
|
+
technical-tariff workflow on pinned OpenML data and then covers tuning,
|
|
82
|
+
temporal stability, protected-group audit, and manual tariff recipes. Install
|
|
83
|
+
the Jupyter kernel plus the MLflow, Plotly, and tuning extras; install Quarto
|
|
84
|
+
separately and ensure its `quarto` executable is on `PATH`.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync --group demo --extra mlops --extra plot --extra tune
|
|
88
|
+
just demo
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The render produces the ignored book under `examples/_book/`; fetched data,
|
|
92
|
+
the tariff workbook, reports, and local MLflow files stay under the ignored
|
|
93
|
+
`examples/_artifacts/`. To inspect the recorded run afterward:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv run mlflow ui \
|
|
97
|
+
--backend-store-uri sqlite:///examples/_artifacts/fremtpl2/mlflow.db
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Dev
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
just check # ruff + ty + pytest
|
|
104
|
+
just test # pytest -x
|
|
105
|
+
just lint
|
|
106
|
+
just format
|
|
107
|
+
uv run ty check # production source types
|
|
108
|
+
```
|
azoic-0.5.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Azoic
|
|
2
|
+
|
|
3
|
+
A scikit-learn-compatible Python toolkit for non-life technical tariff (pure
|
|
4
|
+
premium) modelling: actuarial preprocessing, GLM (glum) + GBM (LightGBM),
|
|
5
|
+
frequency-severity decomposition, actuarial diagnostics, reproducible runs,
|
|
6
|
+
GBM-to-GLM distillation, and multiplicative tariff export.
|
|
7
|
+
|
|
8
|
+
- Documentation: <https://thomasbury.github.io/azoic/>
|
|
9
|
+
- Hosted tutorial series: <https://thomasbury.github.io/azoic/tutorial/index.html>
|
|
10
|
+
- Full spec and conventions: [`PRD.md`](PRD.md)
|
|
11
|
+
- Agent quick rules: [`AGENTS.md`](AGENTS.md)
|
|
12
|
+
- Status: [`PROGRESS.md`](PROGRESS.md)
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Requires Python 3.12 or newer. In your own uv project:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add azoic==0.5.0
|
|
20
|
+
uv run azoic --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For development or the executable tutorial, clone this repository and run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv sync --no-dev # runtime only
|
|
27
|
+
uv sync # default development environment
|
|
28
|
+
uv sync --all-extras --all-groups # everything
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Correctness conventions
|
|
32
|
+
|
|
33
|
+
Frequency models use `claim_count / exposure` with `sample_weight=exposure`.
|
|
34
|
+
`deviance_test` is exposure-weighted mean Tweedie deviance with fixed
|
|
35
|
+
`power=1.5`. Gini is a concentration Gini for ranking; equal prediction scores
|
|
36
|
+
are evaluated as tied blocks.
|
|
37
|
+
|
|
38
|
+
Temporal holdouts never split equal timestamps. Tuning selects parameters on an
|
|
39
|
+
inner split of the outer training partition, then refits on outer training data
|
|
40
|
+
and reports the untouched outer test. Exported tariffs reject unknown categorical
|
|
41
|
+
levels and non-finite numeric inputs.
|
|
42
|
+
|
|
43
|
+
Positive-objective GBMs can be exported through `azoic export-tariff
|
|
44
|
+
--distill`; the workbook reproduces the distilled GLM student, not the teacher.
|
|
45
|
+
|
|
46
|
+
## Executable tutorial series
|
|
47
|
+
|
|
48
|
+
The numbered Quarto book under [`examples/`](examples/) runs the complete
|
|
49
|
+
technical-tariff workflow on pinned OpenML data and then covers tuning,
|
|
50
|
+
temporal stability, protected-group audit, and manual tariff recipes. Install
|
|
51
|
+
the Jupyter kernel plus the MLflow, Plotly, and tuning extras; install Quarto
|
|
52
|
+
separately and ensure its `quarto` executable is on `PATH`.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv sync --group demo --extra mlops --extra plot --extra tune
|
|
56
|
+
just demo
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The render produces the ignored book under `examples/_book/`; fetched data,
|
|
60
|
+
the tariff workbook, reports, and local MLflow files stay under the ignored
|
|
61
|
+
`examples/_artifacts/`. To inspect the recorded run afterward:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv run mlflow ui \
|
|
65
|
+
--backend-store-uri sqlite:///examples/_artifacts/fremtpl2/mlflow.db
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Dev
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
just check # ruff + ty + pytest
|
|
72
|
+
just test # pytest -x
|
|
73
|
+
just lint
|
|
74
|
+
just format
|
|
75
|
+
uv run ty check # production source types
|
|
76
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "azoic"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "scikit-learn-compatible toolkit for non-life technical tariff (pure premium) modelling"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"numpy>=2.0",
|
|
9
|
+
"pandas>=3.0",
|
|
10
|
+
"scikit-learn>=1.6",
|
|
11
|
+
"glum>=3.0",
|
|
12
|
+
"lightgbm>=4.3",
|
|
13
|
+
"pyarrow>=15.0",
|
|
14
|
+
"pydantic>=2.7",
|
|
15
|
+
"pyyaml>=6.0",
|
|
16
|
+
"typer>=0.12",
|
|
17
|
+
"matplotlib>=3.8",
|
|
18
|
+
"openpyxl>=3.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[project.authors]]
|
|
22
|
+
name = "thomas-bury"
|
|
23
|
+
email = "thomas.bury@yuzzu.be"
|
|
24
|
+
|
|
25
|
+
[project.license]
|
|
26
|
+
text = "MIT"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/ThomasBury/azoic"
|
|
30
|
+
Issues = "https://github.com/ThomasBury/azoic/issues"
|
|
31
|
+
Documentation = "https://thomasbury.github.io/azoic/"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
aws = ["s3fs>=2024.6"]
|
|
35
|
+
mlops = ["mlflow>=2.14"]
|
|
36
|
+
tune = ["optuna>=3.6"]
|
|
37
|
+
plot = ["plotly>=6.9.0"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
azoic = "azoic.cli:app"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.11.19,<0.12.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
demo = ["jupyter>=1.1"]
|
|
48
|
+
dev = [
|
|
49
|
+
"pytest>=8.0",
|
|
50
|
+
"ruff>=0.5",
|
|
51
|
+
"pre-commit>=3.7",
|
|
52
|
+
"ty>=0.0.71",
|
|
53
|
+
]
|
|
54
|
+
docs = [
|
|
55
|
+
"mkdocstrings-python>=2.0.5",
|
|
56
|
+
"zensical>=0.0.54",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
src = [
|
|
63
|
+
"src",
|
|
64
|
+
"tests",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = [
|
|
69
|
+
"E",
|
|
70
|
+
"F",
|
|
71
|
+
"W",
|
|
72
|
+
"I",
|
|
73
|
+
"B",
|
|
74
|
+
"UP",
|
|
75
|
+
"C4",
|
|
76
|
+
"SIM",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.per-file-ignores]
|
|
80
|
+
"src/azoic/cli.py" = ["B008"]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.format]
|
|
83
|
+
quote-style = "double"
|
|
84
|
+
|
|
85
|
+
[tool.ty.src]
|
|
86
|
+
include = ["src"]
|
|
87
|
+
|
|
88
|
+
[tool.pytest.ini_options]
|
|
89
|
+
minversion = "8.0"
|
|
90
|
+
testpaths = ["tests"]
|
|
91
|
+
addopts = "-ra --strict-markers"
|
|
92
|
+
filterwarnings = ["ignore:Passing a non-Collection iterable to parametrize is deprecated"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "azoic"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "scikit-learn-compatible toolkit for non-life technical tariff (pure premium) modelling"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "thomas-bury", email = "thomas.bury@yuzzu.be" }]
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
urls = { Repository = "https://github.com/ThomasBury/azoic", Issues = "https://github.com/ThomasBury/azoic/issues", Documentation = "https://thomasbury.github.io/azoic/" }
|
|
10
|
+
dependencies = [
|
|
11
|
+
"numpy>=2.0",
|
|
12
|
+
"pandas>=3.0",
|
|
13
|
+
"scikit-learn>=1.6",
|
|
14
|
+
"glum>=3.0",
|
|
15
|
+
"lightgbm>=4.3",
|
|
16
|
+
"pyarrow>=15.0",
|
|
17
|
+
"pydantic>=2.7",
|
|
18
|
+
"pyyaml>=6.0",
|
|
19
|
+
"typer>=0.12",
|
|
20
|
+
"matplotlib>=3.8",
|
|
21
|
+
"openpyxl>=3.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
aws = ["s3fs>=2024.6"]
|
|
26
|
+
mlops = ["mlflow>=2.14"]
|
|
27
|
+
tune = ["optuna>=3.6"]
|
|
28
|
+
plot = ["plotly>=6.9.0"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
azoic = "azoic.cli:app"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.11.19,<0.12.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
demo = ["jupyter>=1.1"]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0",
|
|
41
|
+
"ruff>=0.5",
|
|
42
|
+
"pre-commit>=3.7",
|
|
43
|
+
"ty>=0.0.71",
|
|
44
|
+
]
|
|
45
|
+
docs = [
|
|
46
|
+
"mkdocstrings-python>=2.0.5",
|
|
47
|
+
"zensical>=0.0.54",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py312"
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "W", "I", "B", "UP", "C4", "SIM"]
|
|
57
|
+
# Typer's documented pattern uses `typer.Option(...)` / `typer.Argument(...)`
|
|
58
|
+
# as parameter defaults; ruff's B008 misfires on it.
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"src/azoic/cli.py" = ["B008"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.format]
|
|
63
|
+
quote-style = "double"
|
|
64
|
+
|
|
65
|
+
[tool.ty.src]
|
|
66
|
+
include = ["src"]
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
minversion = "8.0"
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
addopts = "-ra --strict-markers"
|
|
72
|
+
filterwarnings = [
|
|
73
|
+
# sklearn.utils.estimator_checks.parametrize_with_checks still passes a
|
|
74
|
+
# generator to pytest.parametrize (upstream, not our control).
|
|
75
|
+
"ignore:Passing a non-Collection iterable to parametrize is deprecated",
|
|
76
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Azoic CLI (Typer): ``profile`` / ``fit`` / ``compare`` / ``export-tariff`` /
|
|
2
|
+
``tune``.
|
|
3
|
+
|
|
4
|
+
Wraps the M5 + M6 + M7 pieces end-to-end:
|
|
5
|
+
* ``azoic profile`` -- runs ``profile_features`` + ``screen_features`` and
|
|
6
|
+
prints (or writes) the resulting screening table.
|
|
7
|
+
* ``azoic fit`` -- loads an ``ExperimentConfig`` YAML, ``run_experiment``s
|
|
8
|
+
it, prints a model card to stdout (and/or writes it to disk).
|
|
9
|
+
* ``azoic compare`` -- runs one or more configs and prints a side-by-side
|
|
10
|
+
per-model metrics table (CSV to disk if ``--out``).
|
|
11
|
+
* ``azoic export-tariff`` -- runs a config, exports a named GLM or, with
|
|
12
|
+
``--distill``, an exportable GLM student of a positive-objective GBM.
|
|
13
|
+
* ``azoic tune`` -- optuna hyperparameter search per model
|
|
14
|
+
(``tune`` extra, M7 / v0.2 part 1) then a model card of the best fit.
|
|
15
|
+
|
|
16
|
+
ponytail: the CLI is a thin shell over ``workflow`` + ``reporting`` +
|
|
17
|
+
``tariff`` + ``tune``; no business logic lives here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from azoic.data import DatasetSpec, load_data
|
|
27
|
+
from azoic.profile import profile_features, screen_features
|
|
28
|
+
from azoic.reporting import comparison_table, model_card
|
|
29
|
+
from azoic.tariff import distill_gbm as _distill_gbm
|
|
30
|
+
from azoic.tariff import export_tariff as _export_tariff
|
|
31
|
+
from azoic.workflow import ExperimentConfig, _data_fingerprint, run_experiment
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
help="Azoic: non-life technical tariff (pure premium) modelling.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def profile(
|
|
42
|
+
data: str = typer.Option(..., "--data", help="Parquet file (or s3:// path) to profile."),
|
|
43
|
+
target: str = typer.Option(..., "--target", help="Target column name (e.g. claim_amount)."),
|
|
44
|
+
exposure: str = typer.Option(..., "--exposure", help="Exposure column name."),
|
|
45
|
+
claim_count: str | None = typer.Option(None, "--claim-count", help="Claim-count column."),
|
|
46
|
+
time_col: str | None = typer.Option(None, "--time-col", help="Time column."),
|
|
47
|
+
out: Path | None = typer.Option(
|
|
48
|
+
None, "--out", help="Write the screening table to CSV (default: print to stdout)."
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Profile features in a portfolio and screen them for keep / bin / group / drop."""
|
|
52
|
+
spec = DatasetSpec(
|
|
53
|
+
target=target,
|
|
54
|
+
exposure=exposure,
|
|
55
|
+
claim_count=claim_count,
|
|
56
|
+
time_col=time_col,
|
|
57
|
+
)
|
|
58
|
+
df = load_data(data, spec=spec)
|
|
59
|
+
prof = profile_features(df)
|
|
60
|
+
screened = screen_features(prof)
|
|
61
|
+
|
|
62
|
+
if out is not None:
|
|
63
|
+
screened.to_csv(out, index=False)
|
|
64
|
+
typer.echo(f"Wrote {len(screened)} rows to {out}")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
typer.echo(screened.to_string(index=False))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def fit(
|
|
72
|
+
config: Path = typer.Option(..., "--config", help="ExperimentConfig YAML."),
|
|
73
|
+
out: Path | None = typer.Option(None, "--out", help="Write the model card markdown here."),
|
|
74
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Do not print the card to stdout."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Run all models in an ``ExperimentConfig`` YAML; produce a model card."""
|
|
77
|
+
cfg = ExperimentConfig.from_yaml(config)
|
|
78
|
+
run = run_experiment(cfg)
|
|
79
|
+
md = model_card(run)
|
|
80
|
+
|
|
81
|
+
if out is not None:
|
|
82
|
+
out.write_text(md, encoding="utf-8")
|
|
83
|
+
typer.echo(f"Wrote markdown card to {out}")
|
|
84
|
+
if not quiet:
|
|
85
|
+
typer.echo(md)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def compare(
|
|
90
|
+
configs: list[Path] = typer.Argument(
|
|
91
|
+
..., help="One or more ExperimentConfig YAML files to run and compare."
|
|
92
|
+
),
|
|
93
|
+
out: Path | None = typer.Option(
|
|
94
|
+
None, "--out", help="Write the comparison table to CSV (default: print to stdout)."
|
|
95
|
+
),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Run multiple configs and print a side-by-side per-model metric table."""
|
|
98
|
+
runs = [run_experiment(ExperimentConfig.from_yaml(cfg_path)) for cfg_path in configs]
|
|
99
|
+
rows = comparison_table(runs)
|
|
100
|
+
|
|
101
|
+
if out is not None:
|
|
102
|
+
rows.to_csv(out, index=False)
|
|
103
|
+
typer.echo(f"Wrote {len(rows)} rows to {out}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
typer.echo(f"Azoic compare\n{rows.to_string(index=False)}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("export-tariff")
|
|
110
|
+
def export_tariff(
|
|
111
|
+
config: Path = typer.Option(..., "--config", help="ExperimentConfig YAML."),
|
|
112
|
+
model: str = typer.Option(..., "--model", help="Name of the model to export."),
|
|
113
|
+
out: Path = typer.Option(..., "--out", help="Output xlsx path."),
|
|
114
|
+
distill: bool = typer.Option(
|
|
115
|
+
False,
|
|
116
|
+
"--distill",
|
|
117
|
+
help="Distill a positive-objective GBM to an exportable GLM on the existing split.",
|
|
118
|
+
),
|
|
119
|
+
recalibrate: bool = typer.Option(
|
|
120
|
+
True,
|
|
121
|
+
"--recalibrate/--no-recalibrate",
|
|
122
|
+
help="Shift the base so the tariff reproduces the observed training "
|
|
123
|
+
"total claim amount (default: on).",
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Run a config and export a GLM or an explicitly distilled GBM tariff."""
|
|
127
|
+
from azoic.models import RiskGBM, RiskGLM
|
|
128
|
+
|
|
129
|
+
cfg = ExperimentConfig.from_yaml(config)
|
|
130
|
+
run, ests = run_experiment(cfg, return_estimators=True)
|
|
131
|
+
if model not in ests:
|
|
132
|
+
raise typer.BadParameter(
|
|
133
|
+
f"model {model!r} not in config models {list(ests)}; check the YAML `models:` keys."
|
|
134
|
+
)
|
|
135
|
+
est = ests[model]
|
|
136
|
+
final_est = est.steps[-1][1] if hasattr(est, "steps") else est
|
|
137
|
+
if isinstance(final_est, RiskGBM) and not distill:
|
|
138
|
+
raise typer.BadParameter(
|
|
139
|
+
f"model {model!r} ends in RiskGBM; pass --distill to export a GLM student."
|
|
140
|
+
)
|
|
141
|
+
if isinstance(final_est, RiskGLM) and distill:
|
|
142
|
+
raise typer.BadParameter("--distill is only valid for a RiskGBM model")
|
|
143
|
+
if not isinstance(final_est, (RiskGLM, RiskGBM)):
|
|
144
|
+
raise typer.BadParameter(
|
|
145
|
+
f"model {model!r} ends in {type(final_est).__name__}; export-tariff requires a RiskGLM."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
df = load_data(cfg.data_path, spec=cfg.spec)
|
|
149
|
+
if _data_fingerprint(df) != run.data_fingerprint:
|
|
150
|
+
raise typer.BadParameter("dataset changed since fitting; rerun with an unchanged input")
|
|
151
|
+
X = df[list(est.feature_names_in_)]
|
|
152
|
+
y = df[cfg.spec.target]
|
|
153
|
+
train_indices = list(run.train_indices)
|
|
154
|
+
X_train = X.iloc[train_indices]
|
|
155
|
+
if isinstance(final_est, RiskGBM):
|
|
156
|
+
est = _distill_gbm(est, X_train, X.iloc[list(run.test_indices)])
|
|
157
|
+
metrics = vars(est)["distillation_metrics_"]
|
|
158
|
+
typer.echo(
|
|
159
|
+
"Distillation fidelity: "
|
|
160
|
+
f"teacher_student_deviance={metrics['teacher_student_deviance']:.6g}, "
|
|
161
|
+
f"student_teacher_total_ratio={metrics['student_teacher_total_ratio']:.6g}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
_export_tariff(
|
|
165
|
+
est,
|
|
166
|
+
out,
|
|
167
|
+
X=X_train,
|
|
168
|
+
y=y.iloc[train_indices],
|
|
169
|
+
exposure_col=cfg.spec.exposure,
|
|
170
|
+
recalibrate=recalibrate,
|
|
171
|
+
)
|
|
172
|
+
typer.echo(f"Wrote tariff for {model} to {out}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def tune(
|
|
177
|
+
config: Path = typer.Option(..., "--config", help="ExperimentConfig YAML."),
|
|
178
|
+
trials: int | None = typer.Option(
|
|
179
|
+
None,
|
|
180
|
+
"--trials",
|
|
181
|
+
help="Optuna trials per model; overrides YAML (default: YAML or 20).",
|
|
182
|
+
),
|
|
183
|
+
calibration_penalty: float | None = typer.Option(
|
|
184
|
+
None,
|
|
185
|
+
"--calibration-penalty",
|
|
186
|
+
help="O/P penalty weight; overrides YAML (default: YAML or 1.0).",
|
|
187
|
+
),
|
|
188
|
+
out: Path | None = typer.Option(None, "--out", help="Write the model card markdown here."),
|
|
189
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Do not print the card to stdout."),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Tune model hyperparameters with optuna, then write a model card of the best fit."""
|
|
192
|
+
from azoic.tune import tune_experiment
|
|
193
|
+
|
|
194
|
+
cfg = ExperimentConfig.from_yaml(config)
|
|
195
|
+
result = tune_experiment(cfg, n_trials=trials, calibration_penalty=calibration_penalty)
|
|
196
|
+
md = model_card(result.run)
|
|
197
|
+
|
|
198
|
+
for name, params in result.best_params.items():
|
|
199
|
+
typer.echo(f"tuned {name}: " + ", ".join(f"{k}={v}" for k, v in params.items()))
|
|
200
|
+
if out is not None:
|
|
201
|
+
out.write_text(md, encoding="utf-8")
|
|
202
|
+
typer.echo(f"Wrote markdown card to {out}")
|
|
203
|
+
if not quiet:
|
|
204
|
+
typer.echo(md)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
app()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Dataset specifications and loaders.
|
|
2
|
+
|
|
3
|
+
pandas / pyarrow at boundaries. S3 paths work out of the box via fsspec when
|
|
4
|
+
the optional ``aws`` extra is installed (`uv sync --extra aws`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
MIN_EXPOSURE = 1.0 / 366.0
|
|
16
|
+
"""Minimum per-row exposure: one day in year fractions (leap-year denominator).
|
|
17
|
+
|
|
18
|
+
Sub-day rows are data errors for pure-premium modelling: a tiny exposure
|
|
19
|
+
inflates ``claim_amount / exposure`` rates and destabilizes GLM fits.
|
|
20
|
+
"""
|
|
21
|
+
EXPOSURE_FLOOR = MIN_EXPOSURE * (1.0 - 1e-3)
|
|
22
|
+
"""Validation threshold: one day minus a 0.1% relative tolerance for day-count
|
|
23
|
+
representations (freMTPL2 stores one-day policies ~163 ulps below 1/366)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatasetSpec(BaseModel):
|
|
27
|
+
"""Names of the special (non-feature) columns in a pricing dataset.
|
|
28
|
+
|
|
29
|
+
Feature columns are everything else. `.target` and `.exposure` are required;
|
|
30
|
+
the rest are optional. Unknown fields are rejected.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
target: str
|
|
36
|
+
exposure: str
|
|
37
|
+
claim_count: str | None = None
|
|
38
|
+
time_col: str | None = None
|
|
39
|
+
protected_cols: list[str] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@field_validator("target", "exposure", "claim_count", "time_col")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _non_empty(cls, v: str | None) -> str | None:
|
|
44
|
+
if v is not None and not v.strip():
|
|
45
|
+
raise ValueError("column name must be non-empty")
|
|
46
|
+
return v
|
|
47
|
+
|
|
48
|
+
@field_validator("protected_cols")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _valid_protected_cols(cls, values: list[str]) -> list[str]:
|
|
51
|
+
if any(not name or not name.strip() for name in values):
|
|
52
|
+
raise ValueError("protected column names must be non-empty")
|
|
53
|
+
duplicates = sorted({name for name in values if values.count(name) > 1})
|
|
54
|
+
if duplicates:
|
|
55
|
+
raise ValueError(f"protected_cols must be unique; duplicates: {duplicates}")
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def _protected_cols_are_not_outcomes(self):
|
|
60
|
+
special = {self.target, self.exposure, self.claim_count, self.time_col}
|
|
61
|
+
overlap = sorted(set(self.protected_cols) & special)
|
|
62
|
+
if overlap:
|
|
63
|
+
raise ValueError(f"protected_cols overlaps other special columns: {overlap}")
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def _special_cols_are_distinct(self):
|
|
68
|
+
names = [self.target, self.exposure, self.claim_count, self.time_col]
|
|
69
|
+
present = [name for name in names if name is not None]
|
|
70
|
+
duplicates = sorted({name for name in present if present.count(name) > 1})
|
|
71
|
+
if duplicates:
|
|
72
|
+
raise ValueError(f"special column names must be distinct; duplicates: {duplicates}")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def required_columns(self) -> list[str]:
|
|
76
|
+
cols = [self.target, self.exposure]
|
|
77
|
+
for opt in (self.claim_count, self.time_col):
|
|
78
|
+
if opt:
|
|
79
|
+
cols.append(opt)
|
|
80
|
+
cols.extend(self.protected_cols)
|
|
81
|
+
return cols
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_data(path: str | Path, spec: DatasetSpec | None = None, **kwargs: Any) -> pd.DataFrame:
|
|
85
|
+
"""Read a Parquet dataset (local or s3://) into a pandas DataFrame.
|
|
86
|
+
|
|
87
|
+
When ``spec`` is given, required columns must be present or ValueError.
|
|
88
|
+
Extra kwargs forward to ``pandas.read_parquet``.
|
|
89
|
+
"""
|
|
90
|
+
df = pd.read_parquet(path, **kwargs)
|
|
91
|
+
if spec is not None:
|
|
92
|
+
missing = [c for c in spec.required_columns() if c not in df.columns]
|
|
93
|
+
if missing:
|
|
94
|
+
raise ValueError(f"loaded dataset is missing required columns: {missing}")
|
|
95
|
+
return df
|