7  Frequency–severity alternatives

Separate models help investigate whether a pricing discrepancy comes from how often claims occur (frequency) or how much each claim costs (severity). For example, a segment may have more claims than predicted but smaller claims than predicted; the product can conceal these opposing errors. Use the component tables to choose what to investigate, then assess the final pure premium against losses as well.

These two alternatives use the same stored partition as the direct Tweedie models. Frequency fits claim count / exposure with exposure weights. Severity fits claim amount / claim count with claim-count weights on positive-claim rows only; that filter remains inside FrequencySeverityModel.fit. Their product predicts pure-premium rates. The GLMs use the tariff structure; the GBMs use raw features. Good calibration of each component in its own groups does not guarantee calibration of their product on all policies: the weights and populations differ, and component errors can reinforce or offset each other.

7.1 Fit the two alternatives

The configurations keep the earlier GLM preprocessing and GBM settings. Severity’s positive-claim training filter remains inside the composite estimator; no rows are removed from the frequency training population.

fs_glm_config = glm_config.model_copy(update={
    "name": "fremtpl2-frequency-severity-glm",
    "models": {
        "frequency-severity-glm": ModelSpec(
            kind="frequency_severity",
            frequency=ModelSpec(
                kind="glm",
                params={"family": "poisson", "link": "log", "alpha": 0.001},
            ),
            severity=ModelSpec(
                kind="glm",
                params={"family": "gamma", "link": "log", "alpha": 0.001},
            ),
        ),
    },
})
fs_gbm_config = gbm_config.model_copy(update={
    "name": "fremtpl2-frequency-severity-gbm",
    "models": {
        "frequency-severity-gbm": ModelSpec(
            kind="frequency_severity",
            frequency=ModelSpec(
                kind="gbm",
                params={
                    "objective": "poisson",
                    "n_estimators": 100,
                    "num_leaves": 31,
                    "learning_rate": 0.05,
                    "random_state": RANDOM_STATE,
                },
            ),
            severity=ModelSpec(
                kind="gbm",
                params={
                    "objective": "gamma",
                    "n_estimators": 100,
                    "num_leaves": 31,
                    "learning_rate": 0.05,
                    "random_state": RANDOM_STATE,
                },
            ),
        ),
    },
})

7.2 Read the components for the same policies

Prediction needs features and exposure, with no claim outcomes. For the GLM, reuse the training-fitted preprocessing before accessing freq_ and sev_ on the final estimator. Pipeline slicing retains those fitted steps; calling transform does not refit their bins or groups. Both components then receive only the transformed features, just as during fitting. The GBM components receive the raw features.

from sklearn.pipeline import Pipeline

component_predictions = {}
component_inputs = test_frame[raw_predict_columns]
for name, estimator in fs_estimators.items():
    if isinstance(estimator, Pipeline):
        component_frame = estimator[:-1].transform(component_inputs)[FEATURES]
        composite = estimator[-1]
    else:
        component_frame = component_inputs[FEATURES]
        composite = estimator
    frequency = np.asarray(composite.freq_.predict(component_frame))
    severity = np.asarray(composite.sev_.predict(component_frame))
    product = frequency * severity
    assert np.isfinite(frequency).all() and (frequency > 0).all()
    assert np.isfinite(severity).all() and (severity > 0).all()
    np.testing.assert_allclose(product, estimator.predict(component_inputs))
    component_predictions[name] = pd.DataFrame({
        "policy_id": test_frame["policy_id"].to_numpy(),
        "frequency": frequency,
        "severity": severity,
        "raw_product": product,
    })
    print(name)
    display(component_predictions[name].head(5).rename(columns={
        "policy_id": "Policy ID", "frequency": "Claims / insured year",
        "severity": "Cost / claim", "raw_product": "Raw cost / insured year",
    }))
frequency-severity-glm
Policy ID Claims / insured year Cost / claim Raw cost / insured year
0 2280481.0 0.058318 1849.874295 107.880143
1 176225.0 0.055767 1401.345722 78.148615
2 1049171.0 0.066731 1690.319063 112.795904
3 2143167.0 0.076694 1611.204271 123.569990
4 144041.0 0.056041 1467.972805 82.267338
frequency-severity-gbm
Policy ID Claims / insured year Cost / claim Raw cost / insured year
0 2280481.0 0.080167 1735.699987 139.145773
1 176225.0 0.056543 1504.566133 85.072437
2 1049171.0 0.067787 1851.040518 125.476814
3 2143167.0 0.069854 1158.050118 80.894522
4 144041.0 0.045365 1192.184750 54.083549

Multiplying claims per insured year by cost per claim gives cost per insured year. These are raw component predictions. Later, a training O/P multiplier adjusts the product once. Applying it to both components would multiply the product by the square of that factor and would give the components an unsupported interpretation.

7.3 Check each component on held-out observations

calibration_table sums observed totals and divides by the supplied weights. For frequency, pass counts, predicted frequency, and exposure on all test policies. For severity, pass amounts, predicted cost per claim, and claim counts on positive-claim test policies. Zero-claim policies have no observed claim size; they still receive severity predictions above and remain in product evaluation.

Each table uses five weighted prediction groups (fewer if predictions tie). Frequency groups have roughly equal exposure; severity groups have roughly equal claim counts. Group numbers run from lower to higher predictions within that component/model, so the same number across tables need not identify the same policies. The renamed columns below show the component units instead of the function’s default loss labels. O/P above one means observed totals exceed predicted totals; below one means the reverse.

claim_counts = test_frame["claim_count"].to_numpy()
positive_claim = claim_counts > 0
component_tables = {}
for name, predictions in component_predictions.items():
    frequency_table = calibration_table(
        claim_counts, predictions["frequency"], w_test, n_bins=5,
    ).rename(columns={
        "group": "Group", "exposure": "Exposure (insured years)",
        "claim_amount": "Observed claims", "predicted_claim_amount": "Predicted claims",
        "observed_pure_premium": "Observed claims / year",
        "predicted_pure_premium": "Predicted claims / year", "o_p_ratio": "O/P",
    })
    severity_table = calibration_table(
        y_test[positive_claim], predictions["severity"].to_numpy()[positive_claim],
        claim_counts[positive_claim], n_bins=5,
    ).rename(columns={
        "group": "Group", "exposure": "Claim count",
        "claim_amount": "Observed amount", "predicted_claim_amount": "Predicted amount",
        "observed_pure_premium": "Observed cost / claim",
        "predicted_pure_premium": "Predicted cost / claim", "o_p_ratio": "O/P",
    })
    component_tables[name] = {"frequency": frequency_table, "severity": severity_table}
    print(f"{name}: raw frequency, all held-out policies")
    display(frequency_table.round(3))
    print(f"{name}: raw severity, positive-claim held-out policies")
    display(severity_table.round(3))
frequency-severity-glm: raw frequency, all held-out policies
Group Exposure (insured years) Observed claims Predicted claims Observed claims / year Predicted claims / year O/P
0 0 14098.743 541.0 585.735 0.038 0.042 0.924
1 1 14105.726 687.0 725.867 0.049 0.051 0.946
2 2 14101.951 843.0 852.553 0.060 0.060 0.989
3 3 14102.409 1106.0 1116.954 0.078 0.079 0.990
4 4 14103.044 2060.0 2006.665 0.146 0.142 1.027
frequency-severity-glm: raw severity, positive-claim held-out policies
Group Claim count Observed amount Predicted amount Observed cost / claim Predicted cost / claim O/P
0 0 1046.0 1730291.27 1505127.472 1654.198 1438.936 1.150
1 1 1048.0 2100826.83 1701828.632 2004.606 1623.882 1.234
2 2 1047.0 1726905.93 1847205.501 1649.385 1764.284 0.935
3 3 1048.0 2104560.95 2044673.421 2008.169 1951.024 1.029
4 4 1048.0 2371264.51 2577517.952 2262.657 2459.464 0.920
frequency-severity-gbm: raw frequency, all held-out policies
Group Exposure (insured years) Observed claims Predicted claims Observed claims / year Predicted claims / year O/P
0 0 14101.710 475.0 575.742 0.034 0.041 0.825
1 1 14102.129 666.0 717.940 0.047 0.051 0.928
2 2 14102.385 823.0 854.006 0.058 0.061 0.964
3 3 14103.046 1039.0 1072.051 0.074 0.076 0.969
4 4 14102.603 2234.0 2067.040 0.158 0.147 1.081
frequency-severity-gbm: raw severity, positive-claim held-out policies
Group Claim count Observed amount Predicted amount Observed cost / claim Predicted cost / claim O/P
0 0 1047.0 1858246.83 1326052.589 1774.830 1266.526 1.401
1 1 1047.0 2194617.70 1505019.735 2096.101 1437.459 1.458
2 2 1048.0 1759285.11 1634660.487 1678.707 1559.791 1.076
3 3 1047.0 2044890.18 1795883.837 1953.095 1715.266 1.139
4 4 1048.0 2176809.67 2229448.075 2077.108 2127.336 0.976

In the default full-portfolio render (133,535 held-out policies), GLM severity group 1 contains 1,048 claims: observed cost per claim is about 2,005 versus 1,624 predicted, giving O/P 1.234. Observed claim size is 23.4% above the prediction in that group, even though the GLM’s frequency-group ratios are fairly close to one. This points to claim-size calibration as a useful next investigation.

For the GBM, severity group 1 has 1,047 claims and observed cost per claim about 2,096 versus 1,437 predicted (O/P 1.458). Its lowest-frequency group instead has 475 observed claims versus about 576 predicted over 14,102 insured years (O/P 0.825). That is an overprediction of claim counts alongside an underprediction of claim size in a different grouping. The group memberships differ, so these two ratios cannot be multiplied to explain the product’s error. Inspect which policies and features drive each discrepancy before deciding what to change. These numbers describe the default run; use the tables above if you change the sample.

Small claim counts and a few large losses can make a severity discrepancy unstable. Inspect the support columns before deciding to change a feature or model. These held-out tables diagnose the fitted alternatives; do not use their O/P ratios to recalibrate predictions on this same holdout.

7.4 Compare the product with direct Tweedie

The raw product is checked against each saved run below. Each alternative also gets its own multiplier from training observed losses divided by training predicted losses. This balances training totals; it need not improve held-out calibration or deviance. Gini measures ranking and is unchanged by a positive multiplier.

appendix_rows = []
for run in appendix_runs:
    for name in run.models:
        estimator = fs_estimators[name]
        train_prediction = np.asarray(estimator.predict(train_frame[raw_predict_columns]))
        prediction = np.asarray(estimator.predict(test_frame[raw_predict_columns]))
        assert np.isfinite(train_prediction).all() and (train_prediction > 0).all()
        assert np.isfinite(prediction).all() and (prediction > 0).all()
        raw_metrics = holdout_diagnostics(y_test, w_test, prediction)
        np.testing.assert_allclose(
            list(raw_metrics.values()), [run.models[name].metrics[k] for k in raw_metrics]
        )
        factor = op_ratio(y_train, train_prediction, w_train)
        np.testing.assert_allclose(op_ratio(y_train, factor * train_prediction, w_train), 1)
        adjusted = holdout_diagnostics(y_test, w_test, factor * prediction)
        np.testing.assert_allclose(adjusted["gini_test"], raw_metrics["gini_test"])
        for scale, values in [("raw", raw_metrics), ("training O/P adjusted", adjusted)]:
            appendix_rows.append({"model": name, "prediction_scale": scale,
                                  "training_factor": factor, **values})
for scale, table in pd.DataFrame(appendix_rows).groupby("prediction_scale", sort=False):
    print(scale)
    display(table.drop(columns="prediction_scale").set_index("model").rename(columns={
        "training_factor": "Factor", "gini_test": "Gini", "op_ratio_test": "O/P",
        "deviance_test": "Deviance", "d2_test": "D²",
    }))
print("All candidates on the raw scale")
display(comparison_table([*runs, *appendix_runs]).set_index("model")
        [["gini_test", "op_ratio_test", "deviance_test", "d2_test"]]
        .rename(columns={"gini_test": "Gini", "op_ratio_test": "O/P",
                         "deviance_test": "Deviance", "d2_test": "D²"}))
raw
Factor Gini O/P Deviance D²
model
frequency-severity-glm 1.007749 0.313569 1.032296 75.237864 0.050222
frequency-severity-gbm 1.144758 0.338867 1.174354 74.560943 0.058768
training O/P adjusted
Factor Gini O/P Deviance D²
model
frequency-severity-glm 1.007749 0.313569 1.024358 75.233317 0.050280
frequency-severity-gbm 1.144758 0.338867 1.025853 74.327163 0.061719
All candidates on the raw scale
Gini O/P Deviance D²
model
direct-tweedie-glm 0.309850 1.022067 75.338331 0.048954
tweedie-lightgbm 0.341982 1.178104 74.721321 0.056743
frequency-severity-glm 0.313569 1.032296 75.237864 0.050222
frequency-severity-gbm 0.338867 1.174354 74.560943 0.058768

These are descriptive comparisons on one split, not evidence of statistical superiority. The final table uses raw predictions for every candidate on the same holdout; Tweedie deviance uses exposure weights and fixed power 1.5 (lower is better), while O/P measures overall level and Gini measures concentration ranking. The adjusted product rows above are a separate comparison scale.

In that same default render, the frequency–severity GLM has slightly lower raw deviance than the direct Tweedie GLM (75.238 versus 75.338) and higher Gini (0.314 versus 0.310), but its overall O/P is farther from one (1.032 versus 1.022). For raw-feature GBMs, the product has lower deviance (74.561 versus 74.721), but slightly lower Gini (0.339 versus 0.342). Its raw O/P is still 1.174: observed losses are 17.4% above predicted losses. Applying its frozen training factor once brings held-out O/P to about 1.026; this does not establish segment calibration. The component view helps explain what to investigate, while the product metrics show the tradeoffs in the final price. Neither alternative improves every metric, and this one split does not establish which model will perform best on future data.

To save model cards, dashboards, or MLflow runs, continue with the optional reporting and operations recipe. This chapter creates only its data cache and in-page results; it does not set up a tracking store.