Skip to content

Cost-Sensitive Learning and Decision Thresholds

What you’ll learn

  • the derivation of the cost-optimal threshold, t=CFP/(CFP+CFN)t^* = C_{FP} / (C_{FP} + C_{FN})
  • why the default 0.50 cost EUR 12,005 where t=0.0099t^* = 0.0099 cost EUR 3,410
  • that theory matched a validation-tuned threshold to within EUR 20, with no tuning data
  • how wrong your cost estimates can be: any ratio from 21 to 535 stays within 1.4× of optimal
  • why a miscalibrated model breaks the rule — a EUR 1,970 penalty after SMOTE
  • per-row thresholds when the loss is the transaction amount: 17.8% cheaper, once you have enough frauds to measure it

Where 0.5 comes from, and why it is wrong

predict()predict() thresholds at 0.5. That is the right choice under exactly one assumption: that a false positive and a false negative cost the same. Almost no real decision satisfies it.

Continuing with the fraud data from the previous page, put two numbers on the errors:

ErrorWhat it isCost
false positivean analyst reviews a legitimate transactionCFPC_{FP} = EUR 5
false negativea fraud goes throughCFNC_{FN} = EUR 500

The split here is 50% to fit, 20% to tune thresholds, 30% to report — 44 frauds to learn from, 17 to tune on, 26 to be judged on.

The derivation

Take one row with predicted probability p=P(y=1x)p = P(y = 1 \mid x). Flagging it costs CFPC_{FP} whenever the row is not fraud; leaving it costs CFNC_{FN} whenever it is:

E[costflag]=(1p)CFP,E[costpass]=pCFN\mathbb{E}[\text{cost} \mid \text{flag}] = (1 - p)\,C_{FP}, \qquad \mathbb{E}[\text{cost} \mid \text{pass}] = p\,C_{FN}

Flag whenever flagging is cheaper:

(1p)CFP<pCFN    p>CFPCFP+CFN  =  t(1 - p)\,C_{FP} < p\,C_{FN} \;\Longleftrightarrow\; p > \frac{C_{FP}}{C_{FP} + C_{FN}} \;=\; t^*

That is the whole result. Three properties are worth naming:

  • Only the ratio matters. t=1/(1+CFN/CFP)t^* = 1/(1 + C_{FN}/C_{FP}), so doubling both costs changes nothing.
  • 0.5 is the special case CFP=CFNC_{FP} = C_{FN}.
  • It requires calibrated probabilities. The comparison is between two expected costs, and if pp is not a probability the comparison is meaningless. This is where resampling does its damage.

With EUR 5 and EUR 500:

t=55+500=0.009901t^* = \frac{5}{5 + 500} = 0.009901

What it is worth

figureTotal cost against threshold, and three ways of choosing onematplotlib
A U-shaped cost curve against a log-scale threshold, from about 20,000 EUR at 1e-5 down to a minimum near 2,700 EUR around 0.003 and back up to 13,000 EUR at 1.0. Marked points: default 0.50 at 12,005, theory t*=0.0099 at 3,410, validation-tuned 0.0097 at 3,430, test-set optimum 0.0034 at 2,675.A U-shaped cost curve against a log-scale threshold, from about 20,000 EUR at 1e-5 down to a minimum near 2,700 EUR around 0.003 and back up to 13,000 EUR at 1.0. Marked points: default 0.50 at 12,005, theory t*=0.0099 at 3,410, validation-tuned 0.0097 at 3,430, test-set optimum 0.0034 at 2,675.
The curve is flat-bottomed across more than a decade of threshold, which is why the exact value matters less than being in the right region. The default 0.50 is not in the right region: it costs EUR 12,005 against EUR 3,410. Theory and validation-tuning picked nearly the same threshold, and theory needed no data at all.
PolicyThresholdAlertsFalse alertsFrauds missedCost
review nothing0026EUR 13,000
review everything06,0005,9740EUR 29,870
default 0.500.50003124EUR 12,005
theory tt^*0.00993042824EUR 3,410
tuned on validation0.00973082864EUR 3,430
test-set optimum (oracle)0.0034EUR 2,675

The comparison to sit with is the third and fourth rows. The same model, the same predictions, and a 3.5× difference in cost. Nothing was retrained; a single number changed.

Two more readings:

Theory beat tuning. The closed form scored EUR 3,410 against the validation-tuned threshold’s EUR 3,430. Not because tuning is wrong in principle, but because tuning on 17 positives is noisy, while the formula uses information tuning cannot see — your actual costs. When you know the costs, compute the threshold; do not search for it.

The oracle is only 22% better. A threshold chosen with knowledge of the test labels reaches EUR 2,675. That gap is the irreducible cost of not knowing which specific rows are fraud, and it is small compared to the 3.5× you lose by leaving the threshold at 0.5.

figureSpending EUR 1,410 on reviews avoids EUR 11,000 of lossesmatplotlib
Stacked bars for four policies. Review nothing: 13,000 EUR all from missed fraud. Review everything: 29,870 EUR all from reviews. Threshold 0.50: 12,005 EUR almost all missed fraud. Threshold 0.0099: 3,410 EUR, split between 1,410 of reviews and 2,000 of missed fraud.Stacked bars for four policies. Review nothing: 13,000 EUR all from missed fraud. Review everything: 29,870 EUR all from reviews. Threshold 0.50: 12,005 EUR almost all missed fraud. Threshold 0.0099: 3,410 EUR, split between 1,410 of reviews and 2,000 of missed fraud.
The cost-optimal policy is the only one that spends meaningfully on both sides. It buys 282 unnecessary reviews at EUR 5 each in order to stop 20 frauds at EUR 500 each. Any policy that shows a cost concentrated entirely in one colour is at the wrong threshold.

How precise do the costs have to be?

The usual objection is that nobody knows CFNC_{FN} to two decimal places. They do not have to.

figureGuessing the cost ratio within an order of magnitude is enoughmatplotlib
Cost relative to the best possible, against the assumed cost ratio on a log axis. The curve is 3.5x at ratio 2, dips to about 1.0 near ratio 300, and rises to 3.5x at 5000. A shaded band marks ratios 21 to 535 as within 1.4x.Cost relative to the best possible, against the assumed cost ratio on a log axis. The curve is 3.5x at ratio 2, dips to about 1.0 near ratio 300, and rises to 3.5x at 5000. A shaded band marks ratios 21 to 535 as within 1.4x.
Any assumed ratio between 21 and 535 lands within 1.4× of the best achievable cost on this test set — a factor of 25 in the input mapping to 40% in the output. Being at 0.5 — an implied ratio of 1 — costs 4.5×. Order-of-magnitude honesty about your costs is enough; precision is not required.

The cost curve is flat-bottomed, so the mapping from cost estimates to money is heavily damped. The practical consequence: do not skip cost-based thresholding because the numbers are uncertain. Ask the business for a range, take the geometric midpoint, and you will land inside the flat region. The mistake that actually costs money is leaving the default in place.

The rule needs calibrated probabilities

Here is where the previous page’s warning turns into euros. Apply the same t=0.0099t^* = 0.0099 to a model trained on SMOTE-resampled data, whose probabilities are inflated roughly 13×:

figureSame rule, same costs, and on the resampled model it points at the wrong thresholdmatplotlib
Two cost curves. Left, plain model with mean predicted probability 0.0038: t* costs 3,410 against its own optimum of 2,675 at threshold 0.0034. Right, after SMOTE with mean 0.0490: t* costs 4,690 against its own optimum of 2,720 at threshold 0.0746.Two cost curves. Left, plain model with mean predicted probability 0.0038: t* costs 3,410 against its own optimum of 2,675 at threshold 0.0034. Right, after SMOTE with mean 0.0490: t* costs 4,690 against its own optimum of 2,720 at threshold 0.0746.
Both models rank about equally well, so both have a similar best-case cost — 2,675 and 2,720. But the closed-form threshold is derived from probabilities, and after SMOTE the probabilities mean something else. Following the rule costs EUR 4,690 instead of EUR 2,720: a EUR 1,970 penalty for using a valid rule on invalid inputs.
ModelMean p^\hat{p}Cost at t=0.0099t^* = 0.0099Its own best thresholdIts own best costPenalty
plain0.0038EUR 3,4100.0034EUR 2,675EUR 735
after SMOTE0.0490EUR 4,6900.0746EUR 2,720EUR 1,970

The resampled model is not worse at ranking — its achievable cost is within EUR 45 of the plain model’s. It is worse at being a probability, and the rule consumes probabilities. Its optimal threshold has moved from 0.0034 to 0.0746, a factor of 22, purely because of the training prior.

class_weight is the same idea, applied earlier

Passing class_weight={0: 1, 1: 100}class_weight={0: 1, 1: 100} multiplies the minority class’s contribution to the loss by the cost ratio. It is the training-time expression of the same asymmetry, and it moves the decision boundary rather than the threshold.

two_routes_to_the_same_place.py
# Route 1 — plain model, threshold moved to where the costs say
plain = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000))
plain.fit(X_fit, y_fit)
flag = plain.predict_proba(X_test)[:, 1] >= C_FP / (C_FP + C_FN)
 
# Route 2 — weighted training, threshold left at 0.5
weighted = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=3000, class_weight={0: 1, 1: C_FN / C_FP}))
weighted.fit(X_fit, y_fit)
flag = weighted.predict(X_test)
two_routes_to_the_same_place.py
# Route 1 — plain model, threshold moved to where the costs say
plain = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000))
plain.fit(X_fit, y_fit)
flag = plain.predict_proba(X_test)[:, 1] >= C_FP / (C_FP + C_FN)
 
# Route 2 — weighted training, threshold left at 0.5
weighted = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=3000, class_weight={0: 1, 1: C_FN / C_FP}))
weighted.fit(X_fit, y_fit)
flag = weighted.predict(X_test)

Prefer route 1, for three reasons that have nothing to do with accuracy:

  • The threshold is a deployment parameter. Costs change — a new fraud pattern, a cheaper review team, a seasonal spike — and route 1 changes a config value while route 2 retrains.
  • Route 1 keeps calibrated probabilities, so you can also report expected loss per transaction.
  • One model can serve several decisions at once: a high threshold for automatic blocking, a low one for manual review, from the same probabilities.

Use class_weightclass_weight when the minority class is so rare that the optimiser barely notices it, or when a library gives you no access to probabilities.

When the loss is not a constant

Missing a EUR 2 fraud and missing a EUR 900 fraud are not the same event, and the derivation never required CFNC_{FN} to be constant. Redo it per row with CFN(i)=amountiC_{FN}^{(i)} = \mathrm{amount}_i:

ti=CFPCFP+amountit^*_i = \frac{C_{FP}}{C_{FP} + \mathrm{amount}_i}

Cheap transactions get a high bar; expensive ones get a low one. On this data the amounts are lognormal across the whole dataset with a median of EUR 24.55, a mean of EUR 39.70 and a maximum of EUR 1,103.67. On the test split that makes the per-row thresholds span 0.0056 to 0.8662, with a median of 0.1694.

figurePer-row thresholds lose on 26 positives and win by 17.8% on 598matplotlib
Left: log-log scatter of amount against predicted probability, with an amber curve of the per-row threshold falling from 1.0 to 0.005 as amount rises; fraud dots sit mostly above the curve. Right: cost per 1,000 transactions, per-row against single tuned threshold, 142.1 vs 131.2 on the 26-fraud test set and 144.7 vs 176.1 on the 598-fraud one.Left: log-log scatter of amount against predicted probability, with an amber curve of the per-row threshold falling from 1.0 to 0.005 as amount rises; fraud dots sit mostly above the curve. Right: cost per 1,000 transactions, per-row against single tuned threshold, 142.1 vs 131.2 on the 26-fraud test set and 144.7 vs 176.1 on the 598-fraud one.
The rule is right in expectation and invisible in a small sample. On the 6,000-row test set with 26 frauds it looks 8.3% worse than a single tuned threshold; on a 120,000-row test set with 598 frauds the same rule is 17.8% cheaper. One missed EUR 250 fraud is worth 50 unnecessary reviews, so with 26 positives the comparison is decided by luck.
Test setFraudsPer-row thresholdsOne tuned thresholdDifference
6,000 rows26EUR 853EUR 7878.3% worse
120,000 rows598EUR 17,361EUR 21,12817.8% better

This is the most useful measurement on the page, and it says two things at once.

The theory is right. With enough positives, per-row thresholds save 17.8% — 1,740 alerts catching 384 of 598 frauds, against 1,594 alerts catching 400 of them. Fewer frauds caught, less money lost: the ones it lets through are the cheap ones.

Your test set probably cannot see it. At 26 positives, a 15% effect is far below the noise floor; one unlucky EUR 250 fraud moves the total by more than the entire effect. If you evaluate a per-row-threshold change on a handful of positives and it looks worse, you have learned nothing about the change.

See it move

Two costs, one threshold, and the expected cost per transaction. The curve below is computed in closed form from the fraud and legitimate score distributions, so you can watch tt^* track the ratio.

sketch The cost ratio picks the threshold p5.js
Fraud and legitimate score distributions with a base rate of 1 in 200. Dragging the cost of a missed fraud moves the expected-cost curve and its minimum, and the marked minimum always sits at C_FP/(C_FP+C_FN).

Drag the cost of a missed fraud from EUR 5 to EUR 10,000 and watch two things: the minimum slides left, monotonically, and the threshold it corresponds to is always CFP/(CFP+CFN)C_{FP}/(C_{FP} + C_{FN}). At CFN=CFPC_{FN} = C_{FP} the minimum sits exactly where predict()predict() would put it — which is the only setting in which the default is defensible.

Putting it together

diagram Diagram mermaid

Pitfalls

PitfallWhy it bitesWhat to do
Leaving the threshold at 0.5EUR 12,005 against EUR 3,410 on identical predictionsDerive it from costs, or tune it, but never inherit it
Applying tt^* to resampled probabilitiesEUR 1,970 penalty after SMOTERecalibrate, or tune empirically
Refusing to threshold because costs are uncertainAny ratio from 21 to 535 stays within 1.4×Ask for a range, take the middle, ship
Retraining when costs changeThe threshold is a config valueKeep the model, move the threshold
Using one threshold when the loss varies per row17.8% of avoidable cost at scaleti=CFP/(CFP+lossi)t^*_i = C_{FP}/(C_{FP} + \mathrm{loss}_i)
Comparing policies on a handful of positivesPer-row thresholds looked 8.3% worse on 26 fraudsBootstrap the cost difference before concluding
Optimising F1 and calling it cost-sensitiveF1 encodes a cost ratio of 1 that nobody choseState the ratio you are assuming, explicitly

Recap

  • t=CFP/(CFP+CFN)t^* = C_{FP} / (C_{FP} + C_{FN}), derived by comparing two expected costs per row.
  • At EUR 5 and EUR 500 that is 0.009901, and it cost EUR 3,410 against the default’s EUR 12,005 — a 3.5× difference from one number.
  • A threshold tuned on 17 validation positives scored EUR 3,430: the closed form did marginally better with no data.
  • The oracle threshold reached EUR 2,675, so the remaining headroom is 22%.
  • Any assumed cost ratio between 21 and 535 stays within 1.4× of optimal. Precision is not required; using 0.5 is.
  • The rule needs calibrated probabilities: after SMOTE it costs EUR 4,690 against that model’s own achievable EUR 2,720.
  • Per-row thresholds ti=CFP/(CFP+amounti)t^*_i = C_{FP}/(C_{FP} + \mathrm{amount}_i) were 17.8% cheaper on 598 frauds and 8.3% worse on 26 — the theory is right and small test sets cannot see it.
quizCheck yourself
  1. A review costs EUR 5 and a missed fraud costs EUR 500. What threshold should you flag at?

    Show answer

    B — 0.0099 — that is C_FP / (C_FP + C_FN), the point where the expected cost of flagging equals the expected cost of passing — 5 / 505 = 0.009901. On the measured test set that cost EUR 3,410 against EUR 12,005 at the default, using the very same predictions. Tuning on validation independently arrived at 0.0097 and scored EUR 3,430.

  2. You do not know C_FN precisely — somewhere between EUR 100 and EUR 1,500 per missed fraud. What now?

    Show answer

    B — Pick a value in the middle and ship it — any assumed cost ratio between 21 and 535 lands within 1.4x of the best achievable cost — The cost curve is flat-bottomed: a factor of 25 in the assumed ratio maps to 40% in cost. The default 0.5 corresponds to an assumed ratio of 1, which on this data costs 3.5x the optimum. Uncertainty is not a reason to keep the worst option.

  3. Your model was trained with SMOTE and its mean predicted probability is 0.0490 against a true rate of 0.0044. You apply t* = 0.0099. What happens?

    Show answer

    B — You over-flag: the rule assumes calibrated probabilities, and the cost comes out at EUR 4,690 against the EUR 2,720 that same model could reach at 0.0746 — Resampling shifts the prior, so every probability is inflated — here about 13-fold. The ranking is fine, but the closed form consumes probabilities, and feeding it inflated ones moves the decision to the wrong place. Recalibrate or tune empirically.

  4. Why prefer moving the threshold over training with class_weight?

    Show answer

    B — The threshold is a deployment parameter: costs change without retraining, probabilities stay calibrated, and one model can serve several decisions at once — Both express the same asymmetry and reach similar operating points. The difference is operational: a config change against a retrain, and calibrated probabilities against distorted ones. Reach for class_weight when the optimiser genuinely ignores the minority class, or when a library hides predict_proba.

  5. Per-row thresholds looked 8.3% worse than a single tuned threshold on your 26-positive test set. What is the right conclusion?

    Show answer

    B — The test set cannot resolve the effect — the same rule was 17.8% cheaper on a test set with 598 frauds, and at 26 positives one unlucky expensive fraud dominates the total — With rare, high-variance losses the noise floor is enormous. One EUR 250 fraud is worth 50 unnecessary reviews, so a 15% systematic effect is invisible on 26 positives. Bootstrap the cost difference and report 'no measurable difference' rather than 'worse'.

🧪 Try It Yourself

Exercise 1 – Derive and apply the threshold

Exercise 2 – Beat the tuner with arithmetic

Exercise 3 – Map the sensitivity to your cost guess

Exercise 4 – Price the miscalibration

Exercise 5 – Give every row its own threshold

Next

Time Series Forecasting Fundamentals — every split so far has been random. When rows are ordered in time, a random split leaks the future into the past, and the measured penalty is large.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did