A classifier that looks too good: the call-duration column

45,211 phone contacts from a bank's deposit campaigns, 11.7% of them successful. With the call's duration a logistic model reaches an AUC of 0.91; that column is only known after the outcome. Without it, 0.76. Then the accuracy trap on an 88/12 target, and what a chronological check shows when the base rate moves from 5% to 52%.

Updated 21 Sept 202610 code blocks verifiedcase-studytarget-leakageclassificationimbalancedrare-positive-classaccuracy-trapcalibrationthresholdsdistribution-shiftlogistic-regressionsuspiciously-good-model

When a classifier scores far better than the problem seems to deserve, the first hypothesis is not that the problem was easy. It is that one column knows the answer. This study finds that column on a public marketing table, measures exactly how much it was worth, and then works through the two other traps the same table sets: an imbalanced target that makes accuracy meaningless, and a base rate that moves so much through time that the model’s probabilities stop meaning anything.

The data: direct-marketing phone contacts of a Portuguese bank between May 2008 and November 2010 (bank_marketing.csv, UCI repository): one row per contact, with the client’s age, job, marital status, education, balance and loans, how and when they were contacted, how many times, what happened in the previous campaign, and whether they subscribed to a term deposit.

The question, as it arrived: “Predict which clients will subscribe, so the next campaign can call the right people first.”

That last clause defines the prediction boundary. The prediction is used to decide whom to call; it is made before the call.

1. Understand the data

import pandas as pd, numpy as np

df = pd.read_csv("bank_marketing.csv", sep=";")
df["y"] = (df["y"] == "yes").astype(int)
print(df.shape, "| nulls:", int(df.isna().sum().sum()), "| duplicate rows:", df.duplicated().sum())
print("share subscribed: %.3f  (%d of %d)" % (df["y"].mean(), df["y"].sum(), len(df)))
print("'unknown' as a category:", {c: int((df[c] == "unknown").sum()) for c in ("job", "education", "contact", "poutcome")})
print(df[["age", "balance", "duration", "campaign", "pdays", "previous"]].describe().loc[["mean", "50%", "min", "max"]].round(1).to_string())
(45211, 17) | nulls: 0 | duplicate rows: 0
share subscribed: 0.117  (5289 of 45211)
'unknown' as a category: {'job': 288, 'education': 1857, 'contact': 13020, 'poutcome': 36959}
       age   balance  duration  campaign  pdays  previous
mean  40.9    1362.3     258.2       2.8   40.2       0.6
50%   39.0     448.0     180.0       2.0   -1.0       0.0
min   18.0   -8019.0       0.0       1.0   -1.0       0.0
max   95.0  102127.0    4918.0      63.0  871.0     275.0

One row is one contact. No nulls, but “unknown” is a level of four categorical columns, which is missingness by another name (see missing values that mean something). Positives are 11.7% of rows. pdays is −1 for the 82% of clients never contacted before, a sentinel value that a linear model would read as a very small number of days.

There is no year column, but the documentation says the rows are in date order from May 2008 to November 2010. The month column wraps from December to January twice, which recovers the year:

months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
df["m"] = df["month"].map({m: i for i, m in enumerate(months, 1)})
df["year"] = 2008 + (df["m"].diff().fillna(0) < 0).cumsum()     # the month number going backwards means a new year
print(df.groupby("year").agg(rows=("y", "size"), first_month=("month", "first"), last_month=("month", "last"), share_yes=("y", "mean")).round(3).to_string())
       rows first_month last_month  share_yes
year                                         
2008  27729         may        dec      0.051
2009  14862         jan        dec      0.171
2010   2620         jan        nov      0.516

Three years, each much smaller than the last, and the share of successful calls goes from 5% to 17% to 52%. Keep that in mind for section 5.

2. The column that knows the answer

duration is the length of the last call in seconds. Its relationship with the outcome:

print("share subscribed by quartile of call duration:")
print(df.groupby(pd.qcut(df["duration"], 4))["y"].agg(["mean", "size"]).round(3).to_string())
print("calls under a minute: %d, subscribed %.4f | calls over ten minutes: %d, subscribed %.3f"
      % ((df["duration"] < 60).sum(), df.loc[df["duration"] < 60, "y"].mean(), (df["duration"] > 600).sum(), df.loc[df["duration"] > 600, "y"].mean()))
share subscribed by quartile of call duration:
                  mean   size
duration                     
(-0.001, 103.0]  0.011  11375
(103.0, 180.0]   0.052  11285
(180.0, 319.0]   0.112  11290
(319.0, 4918.0]  0.294  11261
calls under a minute: 4659, subscribed 0.0019 | calls over ten minutes: 3790, subscribed 0.484

Calls under a minute essentially never end in a subscription; calls over ten minutes end in one half the time. This is not a discovery about clients. A call in which the client says no is short, and a call in which the client goes through the paperwork is long. The duration is a consequence of the outcome, and it does not exist at the moment the decision to call is made. The documentation shipped with a later release of this dataset says the same: the column “should only be included for benchmark purposes and should be discarded if the intention is to have a realistic predictive model”.

Nothing about the column looks wrong in a summary table. It is numeric, complete, plausibly scaled. The only way to catch it is to ask, for each column, would this be known when the prediction is made? See prediction boundaries.

3. What the leak was worth

A logistic regression on one-hot categoricals and standardised numerics, with the scaler and the dummy columns learned on the training rows only. First the split a hurried analyst would use: random 70/30.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, average_precision_score, log_loss

cat = ["job", "marital", "education", "default", "housing", "loan", "contact", "month", "poutcome"]
num = ["age", "balance", "day", "campaign", "pdays", "previous"]

def design(d, num_cols, cat_cols, columns=None):
    parts = [d[num_cols].astype(float)]
    if cat_cols:
        parts.append(pd.get_dummies(d[cat_cols], dtype=float))
    X = pd.concat(parts, axis=1)
    return X if columns is None else X.reindex(columns=columns, fill_value=0.0)      # test frame gets the training columns

def evaluate(tr, te, num_cols, cat_cols, label):
    Xtr = design(tr, num_cols, cat_cols)
    Xte = design(te, num_cols, cat_cols, Xtr.columns)
    mu, sd = Xtr.mean(), Xtr.std().replace(0, 1)                      # learned on the training rows only
    model = LogisticRegression(max_iter=2000).fit(((Xtr - mu) / sd).values, tr["y"].values)
    p = model.predict_proba(((Xte - mu) / sd).values)[:, 1]
    y = te["y"].values
    print("%-36s AUC %.3f  PR-AUC %.3f  log-loss %.3f | accuracy at 0.5: %.3f (majority class: %.3f) | mean p %.3f, actual %.3f"
          % (label, roc_auc_score(y, p), average_precision_score(y, p), log_loss(y, p), ((p > 0.5) == y).mean(), max(y.mean(), 1 - y.mean()), p.mean(), y.mean()))
    return p

rng = np.random.RandomState(0)
idx = rng.permutation(len(df))
n_tr = int(0.7 * len(df))
tr, te = df.iloc[idx[:n_tr]], df.iloc[idx[n_tr:]]
print("random 70/30 split: %d train, %d test | log-loss of the constant base rate: %.3f" % (len(tr), len(te), log_loss(te["y"], np.full(len(te), tr["y"].mean()))))
p_leak = evaluate(tr, te, num + ["duration"], cat, "logistic, with duration")
p_dur = evaluate(tr, te, ["duration"], [], "logistic, duration only")
p_honest = evaluate(tr, te, num, cat, "logistic, without duration")
random 70/30 split: 31647 train, 13564 test | log-loss of the constant base rate: 0.366
logistic, with duration              AUC 0.910  PR-AUC 0.584  log-loss 0.236 | accuracy at 0.5: 0.904 (majority class: 0.881) | mean p 0.116, actual 0.119
logistic, duration only              AUC 0.815  PR-AUC 0.427  log-loss 0.300 | accuracy at 0.5: 0.890 (majority class: 0.881) | mean p 0.116, actual 0.119
logistic, without duration           AUC 0.755  PR-AUC 0.405  log-loss 0.308 | accuracy at 0.5: 0.891 (majority class: 0.881) | mean p 0.117, actual 0.119

With duration, AUC 0.91 and a log-loss well below the base rate. duration alone scores 0.82. Without it, 0.76. One column that cannot exist at prediction time is worth more than every other column combined.

Is 0.76 just a weak model? A gradient-boosted ensemble on the same honest columns:

from sklearn.ensemble import GradientBoostingClassifier

Xtr = design(tr, num, cat)
Xte = design(te, num, cat, Xtr.columns)
gbm = GradientBoostingClassifier(n_estimators=200, max_depth=3, random_state=0).fit(Xtr.values, tr["y"].values)
p_gbm = gbm.predict_proba(Xte.values)[:, 1]
y = te["y"].values
print("boosting, without duration           AUC %.3f  PR-AUC %.3f  log-loss %.3f" % (roc_auc_score(y, p_gbm), average_precision_score(y, p_gbm), log_loss(y, p_gbm)))
boosting, without duration           AUC 0.793  PR-AUC 0.452  log-loss 0.291

A more flexible model moves the honest number from 0.76 to 0.79. The leak moved it to 0.91. When a single column is worth more than a change of model class, the column is the thing to investigate, not the model. See debugging a model that looks too good.

4. The accuracy trap

Look again at the accuracy column above. The honest model scores 0.89 at a 0.5 threshold. Always predicting “no” scores 0.88. Those two numbers are the same story: with 12% positives, a model gains almost nothing in accuracy by being right about the positives, and a model that never predicts a positive is 88% accurate. Accuracy is the wrong instrument for this target.

from sklearn.metrics import precision_score, recall_score

print("honest logistic model, random split: what each threshold does")
for t in (0.5, 0.3, 0.2, 0.15, 0.1):
    flag = p_honest > t
    print("  threshold %.2f: flags %5.1f%% of clients | precision %.3f | recall %.3f | accuracy %.3f" % (t, 100 * flag.mean(), precision_score(y, flag), recall_score(y, flag), (flag == y).mean()))
honest logistic model, random split: what each threshold does
  threshold 0.50: flags   3.3% of clients | precision 0.649 | recall 0.180 | accuracy 0.891
  threshold 0.30: flags   7.2% of clients | precision 0.544 | recall 0.330 | accuracy 0.887
  threshold 0.20: flags  11.1% of clients | precision 0.465 | recall 0.431 | accuracy 0.873
  threshold 0.15: flags  17.4% of clients | precision 0.353 | recall 0.513 | accuracy 0.830
  threshold 0.10: flags  37.7% of clients | precision 0.220 | recall 0.694 | accuracy 0.669

At 0.5 the model flags 3% of clients and finds 18% of the subscribers. At 0.1 it flags 38% and finds 69%. Which is right depends on what a call costs and what a subscription is worth, and neither number appears in the table; the threshold is a business decision, and the model’s job is to produce a ranking and honest probabilities. That is why the scores reported above are AUC (ranking), PR-AUC (ranking, weighted toward the rare class) and log-loss (probabilities), not accuracy. See classification metrics.

Whether the probabilities are honest is a separate check:

dec = pd.qcut(p_honest, 10, labels=False, duplicates="drop")
cal = pd.DataFrame({"p": p_honest, "y": y}).groupby(dec).agg(mean_predicted=("p", "mean"), share_subscribed=("y", "mean"), n=("y", "size"))
print(cal.round(3).to_string())
   mean_predicted  share_subscribed     n
0           0.018             0.032  1357
1           0.029             0.044  1356
2           0.046             0.060  1356
3           0.061             0.063  1357
4           0.074             0.066  1356
5           0.088             0.081  1356
6           0.105             0.086  1357
7           0.125             0.111  1356
8           0.168             0.162  1356
9           0.453             0.489  1357

Under the random split the probabilities are usable: each decile’s mean prediction is close to its actual subscription rate. That is a property of the split as much as of the model, as the next section shows.

5. The chronological check

The purpose of the model is to be used on the next campaign. The honest test is to train on 2008 and 2009 and score 2010.

tr, te = df[df["year"] < 2010], df[df["year"] == 2010]
print("chronological split: train 2008-2009 (%d rows, %.1f%% yes), test 2010 (%d rows, %.1f%% yes)" % (len(tr), 100 * tr["y"].mean(), len(te), 100 * te["y"].mean()))
evaluate(tr, te, num + ["duration"], cat, "logistic, with duration")
evaluate(tr, te, num, cat, "logistic, without duration")
evaluate(tr, te, num, [c for c in cat if c != "month"], "logistic, without duration or month")
chronological split: train 2008-2009 (42591 rows, 9.2% yes), test 2010 (2620 rows, 51.6% yes)
logistic, with duration              AUC 0.718  PR-AUC 0.706  log-loss 0.862 | accuracy at 0.5: 0.616 (majority class: 0.516) | mean p 0.313, actual 0.516
logistic, without duration           AUC 0.662  PR-AUC 0.663  log-loss 0.862 | accuracy at 0.5: 0.569 (majority class: 0.516) | mean p 0.288, actual 0.516
logistic, without duration or month  AUC 0.687  PR-AUC 0.691  log-loss 0.838 | accuracy at 0.5: 0.596 (majority class: 0.516) | mean p 0.260, actual 0.516

Share subscribed by decile of call duration, and the monthly base rate through the three years with the 2010 boundary marked

Show the code for this figure
import matplotlib.pyplot as plt

fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.3))
by_dur = df.groupby(pd.qcut(df["duration"], 10, labels=False, duplicates="drop"))["y"].mean()
a1.bar(by_dur.index + 1, by_dur.values, color="#b45309")
a1.set_xlabel("decile of call duration")
a1.set_ylabel("share subscribed")
a1.set_title("The leak: outcome by length of the call")
period = df["year"].astype(str) + "-" + df["m"].astype(str).str.zfill(2)
monthly = df.groupby(period)["y"].mean()
a2.plot(range(len(monthly)), monthly.values, marker="o", ms=3, color="#6d28d9")
a2.axvline(list(monthly.index).index("2010-01") - 0.5, color="#6b7280", ls="--", lw=0.9)
a2.set_xticks(range(0, len(monthly), 6))
a2.set_xticklabels(monthly.index[::6], rotation=45, fontsize=7)
a2.set_ylabel("share subscribed")
a2.set_title("Base rate by month; 2010 to the right of the line")
fig.tight_layout()
fig.savefig("bank-duration-leak-and-drift.png", dpi=120)
plt.show()

Three things changed. The ranking is weaker: AUC 0.66 without the leak, against 0.76 under the random split. The probabilities are wrong: the model expects 29% of the 2010 clients to subscribe and 52% do, because it learned a world where 9% did. And the month column now hurts: the model learned, from 2008 and 2009, that a May contact is a bad prospect (May 2008 was a huge low-yield campaign) and it applies that to a May 2010 in which most contacts succeeded. Dropping month improves the chronological AUC. A calendar column that encodes which campaign rather than which season is a leak of a subtler kind: it lets the model memorise the past rather than learn anything that transfers.

What changed in the campaigns themselves is visible in the columns:

print(pd.crosstab(df["year"], df["contact"], normalize="index").round(2).to_string())
print(pd.crosstab(df["year"], df["poutcome"], normalize="index").round(2).to_string())
contact  cellular  telephone  unknown
year                                 
2008         0.49       0.05     0.46
2009         0.91       0.09     0.00
2010         0.80       0.10     0.10
poutcome  failure  other  success  unknown
year                                      
2008         0.03   0.01     0.00     0.97
2009         0.24   0.09     0.05     0.62
2010         0.24   0.13     0.29     0.34

In 2008 nearly half the contacts had no recorded contact method and almost none had a previous-campaign outcome; by 2010 the bank was mostly calling mobiles and mostly calling people it had talked to before, nearly a third of whom had said yes last time. The 2010 rows are a different population, selected differently. The base rate did not drift on its own; the process that generates the rows changed. See distribution shift.

6. What to say

A logistic model predicts subscriptions with an AUC of 0.91 if it is given the length of the call, and 0.76 if it is not. The length of the call is determined by the outcome and is unknown when the decision to call is made, so 0.76 is the honest number under a random split, and a stronger model class only lifts it to 0.79. Accuracy is 0.89 against 0.88 for never predicting a subscription, so the model should be judged as a ranking (AUC, precision at the chosen call volume) and by its probabilities, which are well calibrated within the same period. Trained on 2008 and 2009 and applied to 2010, the ranking weakens to 0.66 and the probabilities are far too low, because the 2010 campaign targeted a different population with a success rate five times higher. Any model for the next campaign needs its base rate re-estimated, and should not use the month.

Takeaways

  1. A column that scores far above everything else is a suspect, not a find. Ask when it is recorded. Duration, a total that includes the outcome, a status field, a “date closed”: all are written after the event.
  2. The size of the jump is the tell. A better model class is worth a few points of AUC. A leak is worth fifteen.
  3. With a rare positive class, accuracy measures the base rate. Report the majority-class rate next to any accuracy, and prefer AUC, PR-AUC, log-loss and precision at a stated volume.
  4. The threshold is not a modelling choice. It comes from the cost of a call and the value of a subscription. Give the ranking and the calibration; let the decision-maker pick the point on the curve.
  5. A random split hides a moving base rate. If the model will be used later in time, test it later in time, and expect calibration to be the first casualty.
  6. Calendar columns can encode campaigns rather than seasons. If a “month” effect does not repeat across years, it is memorising, not learning.

Related: the taxi-tip column that contained the answer, leaked columns and labels that aren’t labels, and the hotel-cancellation study, where the post-event columns are less obvious and the threshold question is worked through in full.