5. Deciding Who to Actually Contact#
A trained model gives every customer a score. Turning those scores into a campaign needs one more decision: where to draw the line. The default 0.5 is a convention, not an answer — and with asymmetric costs it is almost always wrong.
5.1. Setup#
import pandas as pd
from empulse.datasets import fetch_iranian_churn
from empulse.metrics import Cost, Metric
from empulse.models import CSBoostClassifier
from sklearn.model_selection import train_test_split
dataset = fetch_iranian_churn(backend=pd)
X, y = dataset.data, dataset.target
clv = dataset.instance_costs['clv']
X_train, X_test, y_train, y_test, clv_train, clv_test = train_test_split(
X, y, clv, test_size=0.3, random_state=42, stratify=y
)
expected_cost = Metric(dataset.cost_matrix, Cost())
model = CSBoostClassifier(loss=expected_cost).fit(X_train, y_train, clv=clv_train)
y_score = model.predict_proba(X_test)[:, 1]
5.2. How many customers should we contact?#
The maximum-profit framework answers this directly. optimal_rate
returns the fraction of the customer base to target — a number a campaign manager can act on
without knowing anything about probabilities.
from empulse.metrics import empc_score
profit = empc_score(y_test, y_score, clv=clv_test)
target_fraction = empc_score.optimal_rate(y_test, y_score, clv=clv_test)
print(f'expected profit per customer: {profit:.2f}')
print(f'contact the top {target_fraction:.1%} of customers')
Contact roughly the top 26%, for an expected profit of about 19.71 per customer.
5.3. Converting a rate into a threshold#
A fraction is operationally useful, but a classifier needs a score cut-off.
classification_threshold converts one to the other:
from empulse.metrics import classification_threshold
threshold = classification_threshold(
y_test, y_score, customer_threshold=target_fraction
)
print(f'threshold: {threshold:.4f}')
targeted = (y_score >= threshold).astype(int)
print(f'targeting {targeted.mean():.1%} of customers')
Note
The removed empc/mpc functions used to return this rate alongside the score as a tuple.
It is now .optimal_rate(...), which takes the same parameters as the metric itself.
5.4. Letting a meta-estimator do it#
Rather than thresholding by hand, CSThresholdClassifier wraps any
probabilistic estimator and overrides its predict to use the cost-optimal threshold. It can
learn the threshold at fit time, or take costs at predict time so one fitted model serves several
cost scenarios.
from empulse.models import CSThresholdClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
thresholded = CSThresholdClassifier(
estimator=Pipeline([
('scaler', StandardScaler()),
('model', LogisticRegression(max_iter=1000)),
]),
)
thresholded.fit(X_train, y_train)
predictions = thresholded.predict(X_test, fp_cost=5, fn_cost=100)
print(f'targeting {predictions.mean():.1%} of customers')
CSRateClassifier is its sibling for when you must contact a fixed
fraction — a call centre with finite capacity, say — rather than everyone above a cut-off. See
Threshold Tuning for both.
5.5. A caveat on this dataset#
Warning
132 of the 3150 customers have a Customer Value of exactly 0. For those rows every term of the cost matrix is zero, so no decision is better than any other and the cost-optimal threshold is genuinely undefined.
As a result, optimal_threshold and
optimal_rate raise a ValueError for the
Cost and Savings strategies here:
ValueError: Cannot compute the optimal threshold/rate: the cost matrix is
degenerate for at least one sample ...
This is why the section above uses empc_score. The
MaxProfit strategy derives its operating point from the ROC convex
hull across the whole population, so individual zero-value customers do not break it.
If you need a cost-based threshold on data like this, drop or impute the zero-value rows first:
import numpy as np
keep = np.asarray(clv_test) > 0
cost_threshold = expected_cost.optimal_threshold(
np.asarray(y_test)[keep], y_score[keep], clv=np.asarray(clv_test)[keep]
)
print(f'thresholds: {np.shape(cost_threshold)}, mean {np.mean(cost_threshold):.4f}')
Note that this returns one threshold per customer, not a single number. Because clv differs
per row, so does the break-even point: it is worth contacting a high-value customer at a much lower
churn probability than a low-value one. That is precisely the behaviour a single global threshold
cannot express, and one of the strongest reasons to use instance-dependent costs.
Worth knowing generally: a cost matrix that collapses to zero is a modelling signal, not just a numerical edge case. It says those customers carry no business consequence either way, so any prediction for them is equally fine.
5.6. Thresholds move with the business, not the model#
The optimal threshold depends on the cost matrix, so changing campaign economics changes who to contact — with no retraining:
cheap_campaign = empc_score.optimal_rate(
y_test, y_score, clv=clv_test, contact_cost=0.1
)
print(f'cheaper contact -> target {cheap_campaign:.1%}')
Cheaper outreach makes it worth contacting more customers. This is the practical payoff of keeping business parameters symbolic: you re-answer the question by passing a different number, not by rebuilding a model.
5.7. Next#
Validating the Whole Thing validates all of this properly with cross-validation, and tunes it.