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 ADDED
@@ -0,0 +1,7 @@
1
+ """Azoic: scikit-learn-compatible toolkit for non-life technical tariff modelling.
2
+
3
+ Public API surfaces are added as modules land (see PROGRESS.md). Keep this file
4
+ light so `import azoic` does not pull heavy backends.
5
+ """
6
+
7
+ __version__ = "0.5.0"
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