Skip to content

Models and evaluation API

The estimators share the scikit-learn protocol and keep special columns inside X. Metrics calculate actuarial evidence; plot functions render it. Start with model choice and fitting, then use the diagnostics and visualization guide for complete table and chart recipes.

Targets and explicit weights must be finite one-dimensional vectors with one value per row. Model predictors must keep their fitted names and order; predict and score reject renamed, reordered, missing, or extra predictors. Prediction can omit exposure and frequency–severity outcome columns. Scoring uses exposure by default; explicit sample_weight also works without exposure. Weights must be non-negative with a positive total, and default exposure weights must meet the one-day floor.

Module Public API Purpose
azoic.models RiskGLM, RiskGBM, FrequencySeverityModel scikit-learn-compatible pure-premium estimators with specials inside X
azoic.metrics gini, lorenz, calibration_table, stability_table, one_way_table, double_lift_table, op_ratio, mean_tweedie_deviance, mean_poisson_deviance, mean_gamma_deviance Actuarial diagnostics as values and tables
azoic.plots plot_lorenz, plot_lift, plot_calibration, plot_one_way, plot_double_lift, plot_actual_vs_predicted, model_colors, azoic_style Matplotlib rendering of the metric results

Pure-premium model estimators: GLM (glum), GBM (LightGBM), freq x severity.

Special columns travel inside X per AGENTS.md rule 8 so pipelines, GridSearchCV and cross_val_score keep working: pass exposure_col (and, for the FrequencySeverityModel, claim_count_col / claim_amount_col) to the estimator and it pops those columns from X inside fit / predict.

The exposure_col on a sub-estimator is really a weight column -- popped from X and routed to the backend as sample_weight. The pure-premium convention (PRD section 5 rule 1): y = claim_amount / exposure and sample_weight = exposure.

FrequencySeverityModel.fit applies the actuarial severity filter (claim_count

0, since Gamma needs y > 0) -- never in user code (rule 3).

FrequencySeverityModel

Bases: RegressorMixin, BaseEstimator

Frequency x severity pure-premium meta-estimator.

Fits freq on per-row claim frequency (claim_count / exposure, sample_weight exposure) and sev on per-claim severity (claim_amount / claim_count, sample_weight claim_count) using only rows where claim_count > 0 (Gamma needs y > 0). The filter lives here per AGENTS.md rule 3, never in user code. Before deriving rates or fitting either component, exposure must be positive and finite, counts and amounts non-negative and finite, and count and amount zero or positive together. Invalid outcomes raise ValueError even when y is omitted.

fit(X, y) accepts the same pure-premium rate target as RiskGLM and RiskGBM and validates it against claim_amount / exposure. y may be omitted because the outcome columns already travel inside X. predict returns rates and score compares rate targets.

Sub-estimators are duck-typed: anything exposing fit(X, y, sample_weight=...) and predict(X) works -- RiskGLM, RiskGBM, sklearn's own GLM estimators, etc.

Parameters

freq, sev : estimator Sub-estimators for frequency and severity. Required. exposure_col, claim_count_col, claim_amount_col : str Names of the special columns inside X.

Attributes

freq_, sev_ : fitted sub-estimators (clones of freq / sev) n_features_in_, feature_names_in_

Warns

UserWarning If a categorical level observed in the full training data has no positive-claim row available to fit severity.

score(X, y, sample_weight=None)

D^2 (Tweedie, p=1.5) on pure-premium rates, weighted by exposure.

y is the pure-premium rate target. Exposure from X is the default weight; an explicit sample_weight overrides it and works without an exposure column. Weights must be finite, non-negative, one-dimensional, and not all zero; popped exposure must be at least one day. Prediction may omit all special columns, but predictors must retain their fitted names and order.

RiskGBM

Bases: RegressorMixin, BaseEstimator

Gradient boosted tree model on top of lightgbm.LGBMRegressor.

fit and score take per-exposure rate targets; predict returns rates. Targets must be finite one-dimensional vectors with one value per row. Prediction and scoring require the fitted predictor names and order. When exposure_col is set, exposure supplies the default sample weight and is excluded from the backend features; prediction may omit it.

Parameters

objective : str LightGBM objective: "tweedie" (pure-premium default), "poisson", "gamma", "regression", "rmse", ... exposure_col : str | None Column in X popped and forwarded to LightGBM as sample_weight. tweedie_variance_power : float Tweedie power; validated in __init__ to lie in [1.0, 2.0) (PRD rule 4) when objective == "tweedie". monotone_constraints : dict[str, int] | sequence[int] | None Per-feature monotonicity for LightGBM's tree splits. dict keys are post-exposure feature names (missing keys default to 0); sequence must be one-dimensional with exactly that post-exposure feature count. Values must be in {-1, 0, 1} (1 = increasing, -1 = decreasing, 0 = unconstrained). Nonzero constraints on categorical columns are rejected before LightGBM is invoked. None = no constraints. num_leaves, max_depth, learning_rate, n_estimators, min_child_samples, subsample, subsample_freq, colsample_bytree, reg_alpha, reg_lambda, random_state, n_jobs, verbose Forwarded explicitly to LightGBM (no **kwargs; LightGBM kwargs break get_params per AGENTS.md).

Attributes

backend_ : LGBMRegressor The fitted LightGBM estimator. feature_names_in_, n_features_in_

score(X, y, sample_weight=None)

D^2 of the deviance matching objective (Tweedie power map).

Only objectives in _GBM_OBJECTIVE_POWER (poisson, gamma, the L2 family) plus tweedie are scorable this way; anything else raises -- pass an explicit scoring= to GridSearchCV / cross_val_score.

RiskGLM

Bases: RegressorMixin, BaseEstimator

Generalized linear model on top of glum.GeneralizedLinearRegressor.

fit and score take per-exposure rate targets; predict returns rates. Targets must be finite one-dimensional vectors with one value per row. Prediction and scoring require the fitted predictor names and order. When exposure_col is set, exposure supplies the default sample weight and is excluded from the backend features; prediction may omit it.

Parameters

family : str Glum family name: "normal", "poisson", "gamma", "tweedie", "binomial", "inverse.gaussian", "negative.binomial". "tweedie"` defaults to power 1.5 unlesstweedie_poweris given. link : str Glum link:"auto","log","identity","sqrt", ... exposure_col : str | None Column in X to pop and forward to glum assample_weight(the exposure-weight pure-premium convention). Other special columns (e.g.claim_count/claim_amount) are NOT auto-stripped -- drop them up-front (or wrap in aColumnTransformer) so they don't leak as features. tweedie_power : float | None Tweedie variance power (used only whenfamily == "tweedie"). alpha, l1_ratio : float Regularization strength and Elastic-Net mixing. Default0.001adds a vanishing ridge so glum stays well-conditioned on wide designs; passalpha=0.0`` for an unpenalized MLE. fit_intercept : bool max_iter, gradient_tol : int | float | None Glum solver controls. random_state : int | None

Attributes

backend_ : GeneralizedLinearRegressor The fitted glum estimator. coef_, intercept_ : np.ndarray | float Re-exposed from the backend for tarification (M6). feature_names_in_, n_features_in_

Actuarial diagnostics: ranking, calibration, stability, one-way, and double-lift.

Pure-premium convention (see PRD.md section 5): y_true = claim_amount (aggregate) y_pred = predicted pure premium (rate per exposure unit) sample_weight = exposure

Deviances are re-exported from sklearn (never reimplemented) -- they already accept sample_weight.

Concentration curves order policies by ascending predicted rate. Gini is twice the signed area from the curve to the diagonal: positive when claims tend to concentrate at higher predictions, negative for the reverse. Tied-score blocks are integrated, equivalently the Frees-Meyers-Cummings midrank formula. This is a prediction-ordered measure. Observed-rate ordering recovers the corresponding exposure-weighted pairwise inequality Gini.

Lorenz = namedtuple('Lorenz', ['exposure_pct', 'claims_pct', 'gini']) module-attribute

Exposure-vs-claims concentration curve (actuarial ascending y_pred).

calibration_table(y_true, y_pred, sample_weight=None, *, groups=None, n_bins=10, claim_count=None)

Per-segment observed vs predicted pure premium, exposure, and O/P ratio.

y_true = claim_amount, y_pred = predicted pure premium, sample_weight = exposure. When groups is None, segments are deciles of predicted risk, exposure-weighted when sample_weight is supplied. Otherwise groups is an array-like of one segment label per row; NaN labels form their own segment (dropna=False). Columns: group, exposure, claim_amount, predicted_claim_amount, observed_pure_premium, predicted_pure_premium, o_p_ratio; plus claim_count when provided. Constant predictions form one real group, including without weights.

double_lift_table(y_true, pred_a, pred_b, sample_weight=None, *, n_bins=10, label_a='model A', label_b='model B')

Per-decile observed pure premium ordered by the pred_a / pred_b ratio.

Policies are bucketed into exposure-weighted deciles of the ratio (high ratio = A predicts a higher rate relative to B), and per decile the table reports mean ratio, exposure, observed pure premium, and predicted pure premium for both A and B. Compare each predicted rate with observations within each ratio group; rising observations alone cannot select a model. Extreme ratio groups contain the largest relative disagreements, whose credibility depends on exposure and claims. Rates are absolute, not normalized by portfolio means. Use azoic.plots.plot_double_lift to render.

Both predictions are floored at _RATIO_EPS to avoid division-by-zero when a GBM objective returns exact zeros (Poisson on never-claimed rows). Generated model columns must be distinct and must not match reserved output columns (for example, label_a="observed" is invalid). Each segment must sum to at least one day of exposure, as in calibration and one-way tables.

gini(y_true, y_pred, sample_weight=None)

Exposure-weighted concentration Gini (actuarial low-to-high convention).

y_true is aggregate claim amount, y_pred is predicted rate, and sample_weight is exposure (unit exposure when omitted). Order by ascending prediction and integrate cumulative claim share C(u) against cumulative exposure share u: G = 1 - 2 * integral(C(u), u=0..1). This is twice the signed area from the curve to the diagonal, within [-1, 1] for non-negative claims and positive exposure. Negative values indicate claims concentrated toward lower predictions; zero need not imply random predictions. Observed-rate ordering gives a hindsight upper bound, generally below one, equal to exposure-weighted inequality Gini.

Pairwise absolute-difference inequality Gini measures observed-rate dispersion independently of predictions. This implementation instead integrates tied-score blocks, equivalently the Frees-Meyers-Cummings midrank formula. Ties are independent of row order; positive scaling preserves Gini. Returns 0 when total claims or total exposure is 0.

lorenz(y_true, y_pred, sample_weight=None)

Return the ascending concentration curve plus the Gini.

exposure_pct and claims_pct both start at 0.0 and end at 1.0, suitable for direct plotting. Policies are ordered by predicted rate, with equal scores aggregated before integration. The curve may cross the diagonal; Gini is twice its signed area to that diagonal. A lower curve dominates another only when it is nowhere above it.

one_way_table(X, feature, y_true, y_pred, sample_weight=None, *, n_bins=10)

Per-level observed vs predicted pure premium for one feature.

Buckets rows by feature. Categorical / low-cardinality features are kept at their actual level (grouped levels from AutoGrouper are preserved as-is, including declared ordered-category order). Numeric features with n_bins=None get one level per unique raw value (natural-value one-way on test rows: driver age has as many points as distinct values); an integer n_bins exposure- weight quantiles numeric features into n_bins bins for near- continuous features like density.

y_true = claim_amount, y_pred = predicted pure premium (rate), sample_weight = exposure. Output columns:

level, level_label, level_center, exposure, claim_amount, predicted_claim_amount, observed_pure_premium, predicted_pure_premium, o_p_ratio.

level is the grouping key (integer code for numeric binned, raw value for numeric unique-value mode and categorical). level_label is a human-readable label (bin interval [a, b] for quantiled numeric, the actual level otherwise). level_center is the exposure-weighted mean of the raw feature within the bin (quantiled numeric only); the raw value itself in unique-value mode; NaN for categorical or missing values. Numeric missing rows retain their totals as Missing with an undefined centre, and their exposure does not affect bin boundaries.

op_ratio(y_true, y_pred, sample_weight=None)

Portfolio observed/predicted pure-premium ratio (1.0 = perfect level).

observed = sum(claim_amount) / sum(exposure) predicted = sum(pure_premium * exposure) / sum(exposure)

stability_table(y_true, y_pred, sample_weight, *, periods)

Return exposure-weighted diagnostics for each naturally sorted period.

y_true is aggregate claim amount, y_pred is predicted pure premium, and sample_weight is exposure. The fixed-power Tweedie deviance and period-relative :math:D^2 use power=1.5. D^2 is NaN when the period's exposure-weighted observed-mean prediction has zero deviance.

All inputs must be non-empty, one-dimensional, equal-length arrays without missing periods. Claim amounts must be non-negative and finite; predictions and exposures must be positive and finite.

Actuarial diagnostic plots: Lorenz, lift, calibration, one-way, double-lift.

All chart functions accept ax= to embed in a caller figure and path= to save a PNG. ax=None triggers a guided layout built with matplotlib.pyplot.subplot_mosaic (bottom exposure panel where useful).

Style is fivethirtyeight layered with :data:AZOIC_STYLE (CVD-safe Okabe-Ito palette, white background, subtle grid); every function applies it to its own figure, and :func:azoic_style lets callers style embedded grids identically. Observed quantities are always the same grey (:data:OBSERVED); each model keeps one color across all charts via :func:model_colors.

azoic_style()

Apply the azoic chart style (fivethirtyeight base + CVD-safe overrides).

model_colors(names)

Stable Okabe-Ito color per model name, consistent across all charts.

plot_actual_vs_predicted(y_true, y_pred, sample_weight=None, *, bins='log', gridsize=50, cmap=None, ax_lim=None, logx=False, logy=False, ax=None, path=None, title='Actual vs predicted')

Hexbin density of observed vs predicted rates with a residual panel.

y_true is aggregate claim amount, y_pred is predicted pure-premium rate, and sample_weight is exposure. Observed amounts are divided by exposure before plotting; omitted exposure means unit exposure. Left panel: observed rate on x, predicted rate on y, y=x dashed reference. Right panel: rate residuals (observed − predicted) vs predicted rate with a zero reference. Density colouring is exposure-weighted (Σ exposure per hex) when supplied, otherwise log counts; the two panels get independent colorbars. cividis by default (CVD-safe). Inputs must be finite, one-dimensional, and equal-length; claims must be non-negative and exposure strictly positive for rate division.

Interpret credible exposure-weighted grouped mean residuals, not the densest band in zero-heavy outcomes. A constant non-zero mean residual indicates an additive discrepancy. If observed rate equals c * y_pred, residuals equal (c - 1) * y_pred: a slope can be purely multiplicative and removable by one factor. Review remaining curvature only after level.

logy=True uses a log y axis for positive predictions and a symmetric log (symlog) y axis for signed residuals, with Matplotlib's default linear threshold of 2 around zero. logy=False keeps linear y axes. Callers can adjust the axes after plotting. bins="log" controls density colour independently of the residual axis scale.

plot_calibration(table, *, ax=None, path=None, title='Calibration', logx=False, logy=False)

Scatter of observed vs predicted pure premium per segment.

table is the output of azoic.metrics.calibration_table. Point size is proportional to segment exposure; the dashed y=x line is perfect calibration. The decile-level observed-vs-paired view lives in :func:plot_lift.

plot_double_lift(table, *, ax=None, path=None, label_a='model A', label_b='model B', color_a=None, color_b=None, title='Double-lift chart', exposure='panel', logy=False)

Per-decile observed pure premium, ordered by the pred_a / pred_b ratio.

table is the output of azoic.metrics.double_lift_table. Three lines: observed (grey o-), predicted A (s--), predicted B (^:), one color per model, per ratio decile (mean ratio on tick labels). Compare each prediction with observations within each ratio group; observed direction alone cannot select a model. Extreme groups capture the largest relative disagreements; check their exposure and claims credibility. Curves show absolute rates, without normalization by portfolio means. Stand-alone draws a bottom exposure panel; embedded grids get background bars.

plot_lift(table, *, color=None, label='predicted', ax=None, path=None, title='Lift chart', exposure='background', logy=False)

Observed vs predicted pure premium per exposure-balanced decile.

table is the output of azoic.metrics.calibration_table (deciles of predicted risk by default). Observed is the grey o- line, predicted the model-colored s-- line: the two should track in level (calibration) while observed rises with the decile (ranking); crossings expose deciles where the model over- or under-prices. Translucent background bars show the exposure share per decile (sklearn-example style).

plot_lorenz(y_true, y_pred, sample_weight=None, *, ax=None, path=None, title='Lorenz curve', label='model', color=None, show_oracle=False, show_shade=False)

Plot exposure-vs-claims concentration curves, one color per model.

y_pred is a single prediction array or a mapping {model_name: predictions}. The diagonal (random ranking) is drawn exactly once; with show_oracle=True the oracle ranks by observed rate (aggregate claims divided by exposure; omitted weights mean unit exposure). Aggregate claims and exposure still determine the cumulative shares. This hindsight ranking bound is not a deployable model and need not have Gini one. It is overlaid once as a grey dash-dot reference. Curves are sorted from safest to riskiest (ascending prediction). Gini, shown in the legend, is twice the signed area from each curve to the diagonal; even a positive-Gini curve can cross the diagonal. Shading shows the area, not twice its value.

plot_one_way(table, *, color=None, label='predicted', ax=None, path=None, title='One-way observed vs predicted', xlabel='Feature value', exposure='panel', logy=False)

Per-level observed vs predicted pure premium with the exposure layout.

Use the output of azoic.metrics.one_way_table. With n_bins=None the table has one level per unique value (natural numeric axis: driver age has as many points as distinct test values). If any centre is missing, all groups use labelled positions so the missing segment remains visible. Observed is the grey o- line, predicted the model-colored s-- line. Stand-alone (ax=None, default exposure="panel") a bottom exposure-share panel is drawn via subplot_mosaic; pass exposure="background" when embedding in a caller grid to underlay translucent exposure bars instead.