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 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,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"
@@ -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