azoic 0.5.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.
- azoic/__init__.py +7 -0
- azoic/cli.py +208 -0
- azoic/data.py +95 -0
- azoic/metrics.py +532 -0
- azoic/mlops.py +110 -0
- azoic/models.py +693 -0
- azoic/plots.py +615 -0
- azoic/preprocessing.py +661 -0
- azoic/profile.py +89 -0
- azoic/py.typed +0 -0
- azoic/reporting.py +200 -0
- azoic/tariff.py +573 -0
- azoic/tune.py +247 -0
- azoic/validation.py +132 -0
- azoic/workflow.py +631 -0
- azoic-0.5.0.dist-info/METADATA +108 -0
- azoic-0.5.0.dist-info/RECORD +19 -0
- azoic-0.5.0.dist-info/WHEEL +4 -0
- azoic-0.5.0.dist-info/entry_points.txt +3 -0
azoic/__init__.py
ADDED
azoic/cli.py
ADDED
|
@@ -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()
|
azoic/data.py
ADDED
|
@@ -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
|