2.1. Choosing a strategy#
A cost matrix says what the outcomes are worth. It does not say what number to report. A strategy decides that, and the same matrix paired with different strategies answers genuinely different questions: what will this model cost me, how much better is it than doing nothing, and how much could it earn at the best possible cut-off.
Empulse ships six, plus a sign-flipped sibling for three of them. This page covers what each one computes, what it needs from you, and which models can train on it.
from empulse.metrics import Cost, CostMatrix, Metric
matrix = (
CostMatrix()
.add_fp_cost('c_fp')
.add_fn_cost('c_fn')
.set_default(c_fp=1.0, c_fn=5.0)
)
y_true = [0, 1, 0, 1, 0, 1, 0, 1]
y_score = [0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 0.8, 0.9]
print(Metric(matrix, Cost())(y_true, y_score))
2.1.1. At a glance#
Strategy |
Direction |
|
Per-row costs |
Uncertain parameters |
Trainable |
|---|---|---|---|---|---|
lower is better |
calibrated probabilities |
used per row |
replaced by the mean |
yes |
|
lower is better |
calibrated probabilities |
used per row |
replaced by the mean |
yes |
|
higher is better |
calibrated probabilities |
used per row |
replaced by the mean |
yes |
|
higher is better |
any ranking score |
averaged first |
integrated over |
yes |
|
higher is better |
any ranking score |
used per row |
replaced by the mean |
tree and evolutionary models only |
|
higher is better |
any ranking score |
used per row |
replaced by the mean |
tree and evolutionary models only |
Warning
Nothing checks the “y_score must be” column at runtime. Passing an uncalibrated
predict_proba to Cost does not raise; it returns a number that is
quietly wrong. Scores, probabilities and calibration covers how much this matters and what to do about it.
2.1.2. Cost#
The cost loss answers the most direct question: what would this classifier cost if it were deployed? It sums the cost of each prediction under the cost matrix.
where \(\hat{y}_i\), \(y_i\) and \(X_i\) are the predicted class, true class and feature vector of the \(i\)-th instance.
Cost computes the expected version, weighting each outcome by the
predicted class probability rather than thresholding first:
This punishes a model for being unconfident, and it is differentiable, which is what makes it usable as a training objective. Empulse returns the mean per instance, so the number is directly readable as “cost per customer”.
Reach for it when you want an interpretable number in currency, and when your model’s probabilities mean what they say.
The hard-label variant, which thresholds scores first, is available as
cost_loss. It is a plain function rather than a strategy, because there is
nothing differentiable to train on.
2.1.3. LogCost#
LogCost is the same idea through a logarithm: it weights the cost of each
outcome by the log of the predicted probability rather than the probability itself.
The effect is a much steeper penalty for confident mistakes. Where Cost
charges a fixed amount for a wrong prediction regardless of how sure the model was,
LogCost charges more the more certain the model was about the wrong class.
It is the cost-weighted generalisation of cross-entropy, and reduces exactly to log loss when
tp_cost = tn_cost = -1 and fp_cost = fn_cost = 0.
from empulse.metrics import LogCost
print(Metric(matrix, LogCost())(y_true, y_score))
Use it when you care about the quality of the probabilities themselves, not only the decisions they lead to — for instance when the scores feed a downstream system that will threshold them differently.
2.1.4. Savings#
A cost is hard to judge in isolation: is 7.20 per customer good? Savings
answers that by expressing the cost relative to a baseline, the way \(R^2\) expresses error
relative to predicting the mean.
with \(\theta^\prime\) the parameters of the baseline. One is a perfect model, zero is no better than the baseline, and negative is worse than doing the obvious thing. Because it is a ratio, it is comparable across datasets and across cost scales in a way a raw cost is not.
The default baseline is the naive model: predict all ones or all zeros, whichever is cheaper.
2.1.4.1. Choosing a baseline#
The baseline is a parameter, not a fixed choice, and it changes what the score means:
|
The model being compared against |
|---|---|
|
All-zeros or all-ones, whichever is cheaper. The default. |
|
Always predict the negative class — “do nothing”. |
|
Always predict the positive class — “treat everyone”. |
|
Predict the class prior probability for every instance. |
an array |
Your own baseline scores, one per instance — for example a model already in production. |
'zero' and 'one' are worth reaching for when the naive baseline is not the alternative you
actually face: if the campaign either runs for everyone or not at all, compare against that.
Note
'prior' and array baselines are available on the Savings strategy
only. The standalone savings_score function accepts 'zero_one',
'zero', 'one' and arrays, but not 'prior'.
2.1.5. MaxProfit#
The three strategies above evaluate a model at the operating point implied by its scores.
MaxProfit asks a different question: if the threshold is not yet fixed,
what is the most this model could earn at the best possible cut-off?
Profit as a function of the threshold \(t\) is
where \(F_0(t)\) and \(F_1(t)\) are the cumulative distributions of the scores in each class and \(\pi_0\), \(\pi_1\) the class priors. The measure is the maximum over all thresholds:
Note
In the value-driven literature the positive class is written as class 0, so \(\pi_0\) above
is the prior of the positive class. Empulse’s tp/fp/fn/tn naming follows the
usual machine-learning convention instead; the translation is handled internally.
Because it works from the ranking rather than from calibrated probabilities,
MaxProfit is the strategy to use when your model produces scores you
trust the order of but not the values of. It is also the only strategy that yields an operating
point directly, through optimal_rate and optimal_threshold — see Threshold Tuning.
2.1.5.1. Uncertain parameters#
Its second distinguishing feature is that it integrates over uncertainty rather than collapsing it.
When a cost-matrix parameter is a sympy.stats random variable, the measure becomes an
expectation over that parameter’s distribution:
with \(w\) the joint density of the cost-benefit distribution. This is what separates the “expected maximum profit” family of measures from the plain maximum profit ones. In practice only one parameter is usually treated as uncertain.
2.1.5.2. How the integral is computed#
integration_method controls that, and the default 'auto' picks a ladder from exact to
approximate:
|
Behaviour |
|---|---|
|
One random variable: exact closed form solution. More than one: quasi-Monte Carlo, falling back to numerical quadrature (two variables) or plain Monte Carlo when a distribution cannot be sampled. |
|
Numerical quadrature via |
|
Plain Monte Carlo sampling. |
|
Low-discrepancy sampling; converges faster than plain Monte Carlo for the same budget. |
The exact path covers the Uniform, Beta, Normal, Log-Normal, Gamma, Pareto, Triangular,
Exponential, Chi-squared and Weibull distributions. It is both faster and free of sampling noise,
so leaving integration_method='auto' is almost always right; override it only to check a result
or when a distribution falls outside the supported set.
n_mc_samples_exp sets the sampling budget as \(2^{n}\) (default 16, so 65,536 samples), and
random_state makes the sampled methods reproducible.
import sympy, sympy.stats
from empulse.metrics import MaxProfit
gamma = sympy.stats.Beta('gamma', 6, 14)
clv = sympy.symbols('clv')
stochastic_matrix = CostMatrix().add_tp_benefit(gamma * clv).add_fp_cost(10)
exact = Metric(stochastic_matrix, MaxProfit())
sampled = Metric(
stochastic_matrix,
MaxProfit(integration_method='quasi-monte-carlo', n_mc_samples_exp=14, random_state=0),
)
print(exact(y_true, y_score, clv=200))
print(sampled(y_true, y_score, clv=200))
Warning
MaxProfit is a population-level measure: it is defined over the
score distributions of the two classes, not over individual rows. Array-valued cost parameters
are therefore reduced to their mean before the measure is computed. This is mathematically
equivalent to averaging the measure over instances, but it does mean an array and its mean give
the same answer. If per-row costs must genuinely drive the result, use
Cost, Savings or
EmpiricalMaxProfit.
2.1.6. EmpiricalMaxProfit#
EmpiricalMaxProfit answers the same question as
MaxProfit, but from the data rather than from a model of it. Instead of
working with the theoretical score distributions, it ranks the samples by score, walks the ROC
convex hull, and reports the profit at the best point actually achievable on this dataset.
from empulse.metrics import EmpiricalMaxProfit
print(Metric(matrix, EmpiricalMaxProfit())(y_true, y_score))
The practical difference is that it honours per-row costs. Where
MaxProfit must average a per-customer lifetime value away,
EmpiricalMaxProfit accumulates each customer’s own value as the ranking
descends. That makes it the right choice when the whole point is that customers differ — which is
why empb_score is built on it.
The cost is that it is an empirical quantity: it can be optimistic on small samples, since it picks the best cut-off on the same data it is measured on.
2.1.7. AUEPC#
Maximum profit measures report the peak of the profit curve. AUEPC
reports the area under it: not “how much can I make at the best cut-off?” but “how good is this
ranking across all the cut-offs I might end up using?”.
from empulse.metrics import AUEPC
print(Metric(matrix, AUEPC())(y_true, y_score))
With normalize=True (the default) the curve is divided by that of a perfect ranking, giving a
0–1 score in the spirit of ROC AUC but weighted by money rather than counting every swap equally.
The curve is truncated where even the oracle’s cumulative profit turns negative, since targeting
beyond that point is never worthwhile.
Use it to compare rankings when the operating point is genuinely unknown or expected to move, and a single peak would be a misleading summary.
2.1.8. Costs or profits: the same metric, either way round#
A cost matrix can be read as costs to minimise or as profits to maximise, and which one you want to read is a presentation choice. Three strategies therefore come as a pair, computing exactly the same quantity and reporting it with the opposite sign:
Minimise |
Maximise |
Computes |
|---|---|---|
Expected value per instance at the scores you pass in |
||
Value at the best cut-off, from the modelled score distributions |
||
Value at the best cut-off actually achievable on this dataset |
A sibling reports the negation of its partner, and nothing else about it changes:
from empulse.metrics import Profit
print(Metric(matrix, Cost())(y_true, y_score))
print(Metric(matrix, Profit())(y_true, y_score))
The optimal threshold and rate are identical for both members of a pair, since a best cut-off is
the same point whichever way you phrase the axis. Models are unaffected too: they optimise the
metric as a loss, which removes the sign difference, so training on
Cost and on Profit fits exactly the same model.
Pick whichever reads better in your reporting.
MinCost accepts the same arguments as
MaxProfit, and is accepted anywhere its partner is – including by the
minimax models and by ProfTreeClassifier’s fast path.
Note
Savings has no sibling. It is already a ratio against a baseline, and
every profit-phrased version of it reduces to either the same number or its negation, so there
is nothing distinct to add.
2.1.9. Which models can train on which#
A metric can always be evaluated. Training on one is a stronger requirement: the model has to be able to optimise it. Gradient-based models need an objective with usable derivatives, which the two ranking-based strategies do not provide; evolutionary and tree-based models only need a scalar fitness, so they accept anything.
Model |
||||||
|---|---|---|---|---|---|---|
✅ |
✅ |
✅ |
⚠️ |
❌ |
❌ |
|
✅ |
✅ |
✅ |
⚠️ |
❌ |
❌ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
❌ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
❌ |
|
✅ |
✅ |
✅ |
✅ |
❌ |
❌ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
|
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
|
❌ |
❌ |
❌ |
✅ |
❌ |
❌ |
|
❌ |
❌ |
❌ |
✅ |
❌ |
❌ |
⚠️ marks MaxProfit on the two gradient-based models. It works, by
approximating the piecewise-constant true/false positive rates with a smooth sigmoid, but it is
experimental and not recommended for production. The sigmoid’s temperature is the alpha
parameter, and annealing it during training is what alpha_schedule on the gradient optimizers
does — see Linear Cost-Sensitive Models.
The two minimax models are the mirror image: they optimise a worst-case expected profit and accept
MaxProfit only. Anything else raises a ValueError naming the
restriction. RobustCSClassifier and
B2BoostClassifier inherit the row of the model they wrap or subclass.
2.1.9.1. A note on training cost#
Cost and Savings produce a static objective:
the gradient and Hessian depend only on the labels and the cost matrix, so they are computed once
and reused every boosting round. MaxProfit and
LogCost produce a dynamic one, re-derived each round because the
optimal threshold (or the log weighting) moves as the model changes. Expect the dynamic strategies
to train noticeably slower.
2.1.10. Choosing between them#
If you want… |
Use |
|---|---|
A number in currency you can put in a business case |
|
To compare models across datasets or cost scales |
|
Well-calibrated probabilities, not only good decisions |
|
The best achievable profit when the threshold is not fixed |
|
The same, but with costs that genuinely differ per row |
|
To compare rankings when the operating point may move |
2.1.11. Where next#
Working with metric objects — what the resulting metric object can do.
Scores, probabilities and calibration — what your scores have to mean for these numbers to be trustworthy.
Worked cost matrices — worked cost matrices to pair with these strategies.
Customer Churn Metrics and its siblings — strategies already paired with a domain matrix.