Two people can chase 120 invoices a week. Decide which 120. Work it yourself before reading the walkthrough.
The situation
Halloway and Finch is an accountancy practice. Its own sales ledger holds 25,287 invoices across 620 clients and 24 months, and 26.9% of them are paid late. Credit control currently works down the list from the largest invoice.
The partners want a weekly chase list. The constraint is real: 120 calls a week is what two people can do.
The data
| Column | Meaning |
|---|---|
| invoice_id, client_id, month_index | One row per invoice and the month it was raised |
| sector, client_size | Client context |
| payment_terms_days, on_direct_debit | The terms it went out on |
| invoice_amount | Value excluding VAT |
| prior_invoices, prior_late_rate, months_since_last_late | The client’s history as at the day of issue |
| disputed_line | Whether any line was queried |
| paid_late, days_late | The outcome |
One of those columns cannot be used. Working out which, and why, is part of the exercise.
What the partners believe
- Large invoices are more likely to be paid late.
- A model that predicts lateness accurately will improve collections.
- The client’s own history is the strongest signal available.
- The numbers a classifier outputs can be treated as probabilities.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- A model, validated in a way that reflects how it would be used in practice.
- A ranked chase list of 120 invoices, and a stated reason for the ordering you chose.
- A comparison of your ordering against at least two alternatives, measured in money rather than in classification metrics.
- A clear statement of any assumption you had to make that the data does not support.
Four questions worth asking before you fit anything
Which columns would actually be known on the morning an invoice is raised? What is the business optimising, and is that the same thing your metric is optimising? Does invoice size tell you anything about whether it will be paid late? And if you wanted to combine a model’s output with a pound amount, what would have to be true of that output first?
If you want to go further
- Split the data two ways, once at random and once on time, and compare what each reports.
- Build the simplest possible baseline from a single column before you build anything else, and make your model beat it.
- Work out how the answer changes as the capacity changes from 40 calls a week to 960.
- Decide what you would need to log from now on to stop one of your assumptions being an assumption.
When you are done, read the walkthrough. Two of the four beliefs are wrong, and the second one is wrong in a way that produces the best looking model and the worst result. Compare your chase list to its chase list in pounds, not in precision.
Credit control can chase 120 invoices a week and chases the biggest ones. A model that predicts late payment well makes this worse, because the invoices most likely to go late are the small ones. The fix is one multiplication.
The situation. Halloway and Finch is an accountancy practice with 25,287 invoices across 620 clients and 24 months. 26.9% go past their due date, carrying 18,732,638 pounds. Two people can chase about 120 invoices a week between them.
What the business is left with. A weekly chase list, ranked so that the limited capacity is pointed at the money rather than at the count.
Attempt it first. The brief has the ledger and the constraint with none of the answers.
Contents
Section 1Problem Definition
No code yet. This project has a capacity constraint in it, and a constraint changes what the model is for. Without one you want good predictions. With one you want a good ordering, and those are not the same objective.
Business objective
Rank this week’s open invoices so the 120 that get chased are the 120 worth chasing. Success is cash brought forward, not invoices correctly labelled.
Success metrics, written before the data is opened
| Role | Metric | Why |
|---|---|---|
| Primary | Value at risk in the chased list | The whole point of the exercise is cash. An invoice correctly identified as late and worth 90 pounds has cost a phone call and saved almost nothing |
| Supporting | Average precision | Whether the ranking is any good at finding late invoices at all, independent of size |
| Diagnostic | Calibration | A probability is only multipliable by an amount if it means what it says. Section 7c tests it rather than assuming it |
The metric that will mislead you
Precision. A list with high precision is full of invoices that really did go late, which feels like success and is compatible with recovering almost nothing. Section 7d produces a list with the best precision of the three and the worst result.
Hypotheses
- H1. Large invoices are more likely to be paid late.
- H2. A model that predicts lateness accurately will improve collections.
- H3. The client’s own history is the strongest signal available.
- H4. Predicted probabilities can be treated as probabilities.
The assumption that turns risk into cash
A chase brings forward roughly 35% of the value it is aimed at. That number comes from the practice rather than from the data, it is varied in section 7c, and it scales every result without changing any ranking.
Section 2Data Collection
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss
SEED, CAPACITY, RECOVERY = 20260822, 120, 0.35
d = pd.read_csv('data/halloway-finch-ledger.csv')
print('invoices :', len(d))
print('clients :', d['client_id'].nunique())
print('late rate :', round(d['paid_late'].mean(), 4))
print('value late:', round(d.loc[d['paid_late'] == 1, 'invoice_amount'].sum()))
invoices : 25,287
clients : 620
late rate : 0.2688
value late: 18,732,638
| Column | Meaning |
|---|---|
| invoice_id, client_id, month_index | One row per invoice, with the month it was raised |
| sector, client_size | Client context |
| payment_terms_days, on_direct_debit | The terms this invoice went out on |
| invoice_amount | Value, excluding VAT |
| prior_invoices, prior_late_rate | The client’s history as at the day of issue |
| months_since_last_late | How long since this client last paid late |
| disputed_line | Whether any line was queried |
| paid_late, days_late | The outcome |
Everything here was knowable on the day of issue
prior_late_rate counts the client’s earlier invoices only, not this one and not later ones. That is what makes the model usable: it scores an invoice the morning it is raised, which is the only moment a chase list can be built.
Section 3Data Preprocessing
3aDuplicates and schema checks
print('duplicate rows :', d.duplicated().sum())
print('duplicate invoices :', d['invoice_id'].duplicated().sum())
print('missing cells :', d.isna().sum().sum())
print('months :', d['month_index'].nunique())
duplicate rows : 0
duplicate invoices : 0
missing cells : 0
months : 24
3bHandling categorical mess
print(d.groupby('sector')['paid_late'].agg(['size', 'mean']).round(4)
.sort_values('mean', ascending=False).to_string())
size mean
sector
Hospitality 3,220 0.3177
Construction 4,819 0.3086
Professional services 5,956 0.2530
Manufacturing 3,930 0.2511
Technology 3,097 0.2486
Retail 4,265 0.2401
Sector matters: Hospitality runs at 31.8% against Retail at 24.0%. Both are one-hot encoded in 5c rather than ordinal encoded, because there is no order to them.
3cDealing with outliers
print('mean :', round(d['invoice_amount'].mean(), 2))
print('median :', round(d['invoice_amount'].median(), 2))
print('99th :', round(d['invoice_amount'].quantile(0.99), 2))
print('max :', round(d['invoice_amount'].max(), 2))
print('skew :', round(d['invoice_amount'].skew(), 2))
mean : 2,796.81
median : 1,491.89
99th : 18,428.57
max : 43,189.69
skew : 3.15
Nothing is trimmed. The large invoices are the entire commercial point of the exercise, and winsorising them would remove exactly the cases the ranking exists to find. This is the opposite of the decision the profit leak audit made about order values, for a different reason: there the tail distorted an average, here the tail is the objective.
3dHandling missing values
missing cells : 0
None. prior_late_rate has no natural value for a client’s first invoice and the ledger carries the practice’s default rather than a null, which is stated in the dictionary and worth knowing: for those rows the feature is an assumption, not a measurement.
3eHandling skewed data
Invoice amount is heavily right-skewed at 3.15. No transform is applied. Gradient boosting splits on order, so the shape is irrelevant to it, and the amount is used unlogged in section 7d because pounds are the unit of the decision. Logging it there would optimise something nobody wants.
3fData types and normalisation
The split is the decision that belongs here, and it is a modelling decision disguised as a preprocessing one.
CUT = 18
tr, te = d[d['month_index'] < CUT], d[d['month_index'] >= CUT]
print(f'train {len(tr):,} invoices, months 0 to {CUT - 1}, late {tr["paid_late"].mean():.4f}')
print(f'test {len(te):,} invoices, months {CUT} on, late {te["paid_late"].mean():.4f}')
train 19,044 invoices, months 0 to 17, late 0.271
test 6,243 invoices, months 18 on, late 0.2622
Split on time, then check whether it mattered
A random split would let the model learn from a client’s March invoices to predict their February ones, which it will never be able to do in production. Splitting on time removes that.
Section 7b measures what the random split would have claimed. On this ledger the difference is 0.0053, which is nothing. That is worth reporting rather than hiding: the right choice did not pay off here, and it would have on a book with drifting client behaviour.
Section 4Exploratory Data Analysis
4aTarget variable analysis
26.9% of invoices are paid late, carrying 18,732,638 pounds, or 26.5% of billed value. Imbalanced enough that accuracy is useless and balanced enough that nothing exotic is needed.
4bNumerical variables
One relationship decides the shape of this project.
r, p = stats.pointbiserialr(d['paid_late'], d['invoice_amount'])
print(f'correlation between amount and lateness: {r:+.4f} p {p:.4f}')
print('mean amount, paid late :', round(d.loc[d['paid_late'] == 1, 'invoice_amount'].mean(), 2))
print('mean amount, paid on time :', round(d.loc[d['paid_late'] == 0, 'invoice_amount'].mean(), 2))
correlation between amount and lateness: -0.0068 p 0.2776
mean amount, paid late : 2,755.61
mean amount, paid on time : 2,811.96
Late invoices are worth 2,756 pounds on average and on-time invoices 2,812. Size tells you nothing about whether an invoice will be paid on time.
H1 is wrong, and it is the belief the current process runs on
Amount and lateness correlate at -0.0068 with a p-value of 0.28. They are independent.
Credit control chases the biggest invoices because it feels like chasing the money. It is chasing a column that carries no information about who pays late.
That independence is also what makes the rest of this project work. Because size and risk are unrelated, knowing the risk adds something size cannot, and multiplying the two is not double counting.
4cCategorical variables
Sector spans 24.0% to 31.8%, and direct debit is the single largest protective factor in the ledger. Both are available at issue.
4dRelationships between variables
The client’s own history is the obvious candidate for the strongest signal, and 7b tests it as a baseline in its own right rather than assuming it.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. Large invoices go late more often | Wrong | Correlation -0.0068, p 0.28 |
| H2. Accurate prediction improves collections | Wrong on its own | Ranking by probability gives the best precision of the three strategies and the worst recovery. Section 7d |
| H3. Client history is the strongest signal | Right | Ranking on prior late rate alone reaches an AUC of 0.6445, against 0.6414 for the full model |
| H4. Predicted probabilities are probabilities | Only after checking | Section 7c measures it. Here they were already close, and the check is what licenses the multiplication in 7d |
4fSubgroups
The subgroup that decides the money is invoices in the top decile by amount. They are 10% of the ledger and carry a disproportionate share of the value at risk, and they are no more likely to be late than anything else. Every strategy in 7d is really a statement about how many of them make the list.
Section 5Feature Engineering
5aThe leakage trap
The one that would sink this
days_late is in the ledger. It is the outcome measured in days and it is only known once the invoice is settled. Include it and the model scores near perfectly and cannot be used, because on the morning an invoice is raised it does not exist.
The subtler one is any client-level aggregate computed across the whole file. A client’s overall late rate, calculated once over all 24 months, tells an invoice in month three about invoices in month twenty. Every history feature here is built from earlier invoices only.
The time split in 3f is the second line of defence. If a leak of that kind survived into the feature table, the gap between random and time-split performance in 7b is where it would show.
5bNew features
Three are constructed at the row level as the ledger is written, and each is a running figure rather than a total: prior invoice count, prior late rate and months since the client last paid late. Nothing is added at modelling time.
5cEncoding
NUM = ['payment_terms_days', 'on_direct_debit', 'invoice_amount', 'prior_invoices',
'prior_late_rate', 'months_since_last_late', 'disputed_line']
CAT = ['sector', 'client_size']
X = pd.get_dummies(d[NUM + CAT], columns=CAT, drop_first=True)
Xtr, Xte = X.loc[tr.index], X.loc[te.index]
ytr, yte = tr['paid_late'].values, te['paid_late'].values
print('feature matrix :', X.shape)
feature matrix : (25,287, 14)
5dFeature selection
All fourteen columns are kept. With 19,044 training rows there is no pressure to cut, and permutation importance in 7d is used to describe the model rather than to prune it.
Section 6Model Selection
| Question | Choice | Why not the obvious alternative |
|---|---|---|
| Which model | Gradient boosting, with logistic regression as the honest comparison | Boosting handles the skewed amount and the interactions between sector and terms without being told. Logistic is fitted anyway, because if it were within a whisker the simpler model should ship |
| Which metric | Average precision, then value at risk | AUC is dominated by the large majority of on-time invoices. Average precision follows the positive class, and value is what the business is actually optimising |
| How to validate | Split on time at month 18 | A random split leaks the future into the past on any dataset with a time index. It also produces a number the model will never reproduce in use |
| Whether to rebalance | No | 26.9% positives is not rare. Resampling would distort the probabilities, and section 7d multiplies those probabilities by money |
Why rebalancing is ruled out here specifically
Class weights and oversampling both improve ranking metrics and both destroy calibration. On most classification projects that trade is fine because only the order is used. Here the probability is multiplied by an invoice amount, so a probability that is systematically too high produces a chase list that is systematically wrong about which invoices carry the most expected risk.
Section 7Model Training
7aBaselines
| Baseline | AUC | What it is |
|---|---|---|
| Assume every invoice is late | 0.5 | captures everything and chases nothing usefully |
| Rank by the client’s prior late rate | 0.6445 | one column, no model |
| Rank by invoice amount | 0.5101 | what credit control does today |
Ranking on the client’s prior late rate alone reaches 0.6445. Ranking on invoice amount, which is what the practice does today, reaches 0.5101, which is chance. The bar a model has to clear is the history column, not zero.
7bComparing candidates
def boost():
return HistGradientBoostingClassifier(max_depth=4, learning_rate=0.06,
max_iter=300, random_state=SEED)
def logit():
return make_pipeline(StandardScaler(),
LogisticRegression(max_iter=3000, random_state=SEED))
for name, make in (('logistic', logit), ('boosting', boost)):
m = make().fit(Xtr, ytr)
p = m.predict_proba(Xte)[:, 1]
print(f'{name:10s} AUC {roc_auc_score(yte, p):.4f} '
f'AP {average_precision_score(yte, p):.4f} '
f'Brier {brier_score_loss(yte, p):.5f}')
logistic AUC 0.6361 AP 0.3678 Brier 0.18553
boosting AUC 0.6414 AP 0.3802 Brier 0.18411
Boosting wins on average precision and goes forward. Neither model is impressive in absolute terms, and that is the honest position: late payment is substantially driven by things no ledger records, such as whether the client’s own customer paid them this month.
# what a random split would have claimed for the same model
from sklearn.model_selection import train_test_split
Xr_tr, Xr_te, yr_tr, yr_te = train_test_split(X, d['paid_late'].values, test_size=len(te),
random_state=SEED,
stratify=d['paid_late'].values)
m_rand = boost().fit(Xr_tr, yr_tr)
print('random split AUC :', round(roc_auc_score(yr_te, m_rand.predict_proba(Xr_te)[:, 1]), 4))
print('time split AUC :', 0.6414)
random split AUC : 0.6361
time split AUC : 0.6414
A difference of 0.0053. On this ledger the random split would not have flattered the model at all, because client behaviour is stable across the two years. Reporting that is more useful than claiming a benefit that did not arrive: the time split is still the right default, and here it cost nothing to be careful.
7cHyperparameter tuning
Almost none, and the reason matters. What is tuned instead is calibration, because section 7d multiplies these probabilities by pounds.
from sklearn.calibration import CalibratedClassifierCV
model = boost().fit(Xtr, ytr)
prob = model.predict_proba(Xte)[:, 1]
cal = CalibratedClassifierCV(boost(), method='isotonic', cv=3).fit(Xtr, ytr)
prob_cal = cal.predict_proba(Xte)[:, 1]
print('Brier, raw :', round(brier_score_loss(yte, prob), 5))
print('Brier, calibrated :', round(brier_score_loss(yte, prob_cal), 5))
bins = pd.qcut(pd.Series(prob).rank(method='first'), 10, labels=False)
for b in sorted(bins.unique()):
print(f'decile {b + 1:2d} predicted {prob[bins == b].mean():.4f} '
f'actual {yte[bins.values == b].mean():.4f}')
Brier, raw : 0.18411
Brier, calibrated : 0.18503
decile 1 predicted 0.0586 actual 0.0880
decile 2 predicted 0.1210 actual 0.1667
decile 3 predicted 0.1792 actual 0.1699
decile 4 predicted 0.2352 actual 0.2612
decile 5 predicted 0.2535 actual 0.2528
decile 6 predicted 0.2674 actual 0.2628
decile 7 predicted 0.2927 actual 0.2917
decile 8 predicted 0.3131 actual 0.3237
decile 9 predicted 0.3432 actual 0.3590
decile 10 predicted 0.4067 actual 0.4464
Predicted against actual late rate in each decile. The largest gap is 0.0457, which is close enough that the probabilities can be multiplied by money.
Calibration did not need fixing, and that is the finding
Isotonic recalibration moved the Brier score from 0.18411 to 0.18503, which is slightly worse. The raw probabilities are used.
Boosting on a reasonably balanced target with no resampling usually is close to calibrated. The point of the check is not that it always finds a problem, it is that multiplying an uncalibrated probability by an amount produces a confident ordering with no basis, and you cannot know which case you are in without measuring.
7dFinal evaluation
The model is now scored against the thing it is actually for: a list of 480 invoices, which is about a month of chasing at 120 a week.
te2 = te.copy()
te2['prob'] = prob
te2['expected_value'] = te2['prob'] * te2['invoice_amount']
te2['at_risk'] = te2['paid_late'] * te2['invoice_amount']
K = CAPACITY * 4
def chase_by(col):
picked = te2.nlargest(K, col)
return (picked['paid_late'].mean(),
picked['at_risk'].sum(),
picked['at_risk'].sum() / te2['at_risk'].sum())
for label, col in [('Biggest invoices first', 'invoice_amount'),
('Highest probability first', 'prob'),
('Highest expected value first', 'expected_value')]:
prec, val, share = chase_by(col)
print(f'{label:30s} precision {prec:.3f} at risk {val:12,.0f} '
f'{share:.1%} of all late value')
Biggest invoices first precision 0.246 at risk 1,560,786 33.6% of all late value
Highest probability first precision 0.463 at risk 658,854 14.2% of all late value
Highest expected value first precision 0.329 at risk 1,781,545 38.4% of all late value
The strategy with the best precision finds the least money. Ranking by probability is right 46.2% of the time and recovers 14.2% of the value at risk.
The best precision is the worst list
Ranking by probability finds the most late invoices: precision 0.4625 against 0.3292 for expected value. It is the better model by every classification metric.
It captures 658,854 pounds of value at risk. Expected value captures 1,781,545, which is 1,122,691 pounds more from the same 480 phone calls.
At a 35% recovery rate that is 392,942 pounds of cash, bought with one multiplication and a worse precision score.
At every capacity the expected value ranking finds more money. Ranking by probability barely beats chasing at random on this measure, which is the whole argument in one line.
What the model is using
The client’s own history dominates, which confirms H3. Invoice amount contributes almost nothing to predicting lateness, and everything to deciding what to do about it.
Section 8Documentation and Handoff
Chase by expected value, not by size and not by risk
The practice chases the biggest invoices. Amount and lateness are independent at -0.0068, so that ranking carries no information about who will pay late, and it still finds 33.6% of the value at risk purely by being big.
A model that ranks by probability is better at predicting and worse at collecting: 14.2% of the value, at the best precision of the three.
Multiply the two. Expected value finds 38.4% of the value at risk from the same 480 calls, worth 392,942 pounds of recovered cash a month over ranking by probability alone.
The weekly chase list
| Step | Detail | Owner |
|---|---|---|
| Score every invoice the morning it is raised | All fourteen features are known at issue. Nothing waits for the due date | Practice systems |
| Rank by probability times amount | Not by either one alone. This is the whole change | Practice systems |
| Take the top 120 a week | Sized to what two people can actually do, not to a probability threshold | Credit control |
| Record the outcome of every chase | Chased or not, and paid or not. Without it the recovery rate stays an assumption and the model can never be retrained on its own effect | Credit control |
| Refit quarterly on a time split | Client behaviour drifts. Section 7b found no drift over two years, which is a reason to check rather than to stop checking | Analytics |
What not to do
- Do not report precision as the success measure. The list with the best precision here recovers the least money.
- Do not chase the biggest invoices. It is the current process and the column it ranks on is uncorrelated with lateness.
- Do not rebalance the classes. It would improve the ranking metrics and break the calibration the expected value calculation depends on.
- Do not use days_late as a feature. It is the outcome, and it does not exist when the list is built.
- Do not treat 35% recovery as measured. It is the practice’s estimate. It scales every figure here and changes no ranking, and logging chase outcomes would replace it with a number.
Reproducibility
| Item | Value |
|---|---|
| File | halloway-finch-ledger.csv, 25,287 invoices |
| Split | Time based at month 18, 19,044 train and 6,243 test |
| Model | boosting, chosen on average precision |
| Calibration | Checked with isotonic, not applied. Largest decile gap 0.0457 |
| Ranking | predicted probability multiplied by invoice amount |
| Capacity | 120 invoices a week, evaluated over 480 calls |
| Recovery assumption | 35% of value at risk, from the practice |
| Libraries | pandas, numpy, scipy.stats, scikit-learn |
What to take from this
- A capacity constraint changes the objective from prediction to ranking. Once you can only act on the top N, the question is what N should contain, not how accurate the scores are.
- Rank by what you are optimising. Cash means probability times amount. Probability alone optimises a count.
- Check calibration before you multiply a probability by anything. An uncalibrated score can order a list perfectly well and still be nonsense once it meets a pound sign.
- Split on time when the data has a time index. Here it happened to cost nothing, and you can only know that by doing it.
- Precision and recovery can point in opposite directions. This ledger shows the best precision producing the worst outcome, which is worth remembering the next time a model is signed off on a classification report.
The practice asked for a model that predicts late payment. The model is mediocre, at an AUC of 0.6414, and it still moves 392,942 pounds a month, because the value was never in the prediction. It was in what the prediction gets multiplied by.
Generate the evaluation, then argue about the threshold
Model Evaluation Code Generator
Precision, recall and average precision on an imbalanced target, with the threshold chosen by what a mistake costs rather than by 0.5. That is the framework this project uses, and it is the step most classification write-ups skip.
Free, no signup. Pairs with the Model Selection Generator.
Where the ledger comes from
Ecommerce Dashboard: A Free Excel Template
Before any model, someone has to be able to see the ageing and the value at risk in one place. This template does that from an export, and it is the reporting layer a chase list sits on top of.
Free Excel template, LAD branded. No signup.
Companion projects. The Profit Leak Audit is the other project here where a decision turns on cost rather than on a rate. A Complete Churn Analysis runs the same modelling spine end to end against a capacity constrained outreach list.