Sales wants a lead score so they can work the best leads first. Build it, then work out how far down the list anyone should go. Do it yourself before reading the walkthrough.
The situation
Vantith is a B2B SaaS business. 34,000 inbound leads over 52 weeks, 6.4% of which convert at a mean deal of 12,941 pounds. Reps currently work whatever arrived most recently.
The data
| Column | Meaning |
|---|---|
| lead_id, week_index | One row per inbound lead |
| source, company_size, job_role | Where it came from and who they are |
| pages_viewed, emails_opened | Engagement before the form |
| work_email, phone_given, demo_requested | What they filled in |
| first_touch_hours | Hours between the lead arriving and anyone contacting it |
| converted, deal_value | The outcome |
One of those columns will improve your model and must not be in it. Working out which, and why, is part of the exercise.
The economics
| Quantity | Value |
|---|---|
| Cost to work a lead through to a close attempt | 450 pounds |
| Contribution margin on a won deal | 78% |
What the room believes
- Sales should work the highest scoring leads first.
- Every inbound lead is worth working, because the deals are large.
- A better model is the biggest improvement available here.
- How quickly a lead is contacted is an operational detail, not a modelling concern.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- A scoring model that could actually be run at the moment a lead arrives.
- A stated rule, in pounds, for how far down the ranked list to work, and the arithmetic behind it.
- A comparison of your rule against working everything, measured in contribution.
- A recommendation ranked by size, so the business knows which change to make first.
Four questions worth asking before you fit anything
Which of these columns would exist at the moment you need a score? What does it cost to work a lead, and what does a win earn, and what does the ratio of those two tell you? Is the biggest available improvement necessarily a model? And is anything in this file something the team did rather than something about the lead?
If you want to go further
- Fit the model twice, once with every column and once with only what is knowable on arrival, and compare.
- Plot contribution against how much of the list you work, and find where it peaks.
- Quantify the biggest non-model lever you can find in the data, and compare it to what your model is worth.
- Say which of your findings would survive being called causal, and which needs a test.
When you are done, read the walkthrough. Three of the four beliefs are wrong, and the largest finding in it is not the thing sales asked for. Compare your recommendation to its recommendation, and check whether you ranked yours by size.
Sales wanted a lead score so they could work the best leads first. The score is worth 1.82 times the base conversion rate. Answering the phone sooner is worth 5.36 times. The model is the smaller half of the answer.
The situation. Vantith is a B2B SaaS business with 34,000 inbound leads across 52 weeks. 6.4% convert, at a mean deal of 12,941 pounds. Sales works the newest lead first and wants a score so they can work the best one first instead.
What the business is left with. A score, a threshold in pounds saying how far down the list to work, and a finding about response time that is worth more than either.
Attempt it first. The brief has the file and the economics with none of the answers.
Contents
Section 1Problem Definition
No code yet. There are two questions hiding in a lead scoring request and only one of them is a modelling question.
| Question | Kind of question | Answered in |
|---|---|---|
| Which leads are most likely to convert | A model | Sections 5 to 7c |
| How far down that list should anyone work | Arithmetic on the unit economics | Section 7d |
Sales asked for the first. The second is where the money is, and it needs numbers the model cannot supply.
The economics, agreed before modelling
| Quantity | Value | Source |
|---|---|---|
| Cost to work a lead | 450 pounds | Loaded SDR and AE time through to a close attempt. Not one phone call |
| Mean deal value | 12,941 pounds | Observed on won deals |
| Contribution margin | 78% | Finance |
| Contribution per win | 10,094 pounds | The two above |
The number the whole project produces
Working a lead is worth it when the chance it converts, times 10,094 pounds of contribution, beats the 450 pounds it costs to work.
That break-even probability is 4.46%. Overall conversion is 6.4%, so a large part of the list is already below the line. The model’s job is to say which part.
Hypotheses
- H1. Sales should work the highest scoring leads first.
- H2. Every inbound lead is worth working, since the deals are large and the cost of a call is not.
- H3. A better model is the biggest available improvement.
- H4. How quickly a lead is contacted is an operational detail, not a modelling concern.
Section 2Data Collection
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss
SEED, COST_TO_WORK, GROSS_MARGIN = 20260822, 450.0, 0.78
d = pd.read_csv('data/vantith-leads.csv')
print('leads :', len(d))
print('conversion :', round(d['converted'].mean(), 4))
print('mean deal :', round(d.loc[d['converted'] == 1, 'deal_value'].mean()))
leads : 34,000
conversion : 0.0641
mean deal : 12,941
| Column | Meaning | Known when the lead arrives |
|---|---|---|
| source, company_size, job_role | Where they came from and who they are | Yes |
| pages_viewed, emails_opened | Engagement before the form | Yes |
| work_email, phone_given, demo_requested | What they filled in | Yes |
| first_touch_hours | How long before anyone contacted them | No |
| converted, deal_value | The outcome | No |
One column is a decision, not an attribute
first_touch_hours records something the sales team did, after the lead arrived. It is not a property of the lead and it does not exist at the moment a score is needed. Section 5a shows what happens if you forget that, and section 7d shows why the column is still the most valuable thing in the file.
Section 3Data Preprocessing
3aDuplicates and schema checks
print('duplicate rows :', d.duplicated().sum())
print('duplicate ids :', d['lead_id'].duplicated().sum())
print('missing cells :', d.isna().sum().sum())
duplicate rows : 0
duplicate ids : 0
missing cells : 0
3bHandling categorical mess
print(d.groupby('source')[['converted']].agg(['size', 'mean']).round(4).to_string())
size mean
source
Partner referral 3,385 0.1383
Webinar 4,391 0.0720
Organic 7,088 0.0628
Paid search 8,176 0.0544
Cold outbound reply 3,441 0.0477
Content download 7,519 0.0456
Partner referral converts at 13.8% and Content download at 4.6%. Source, company size and role are all one-hot encoded in 5c.
3cDealing with outliers
Deal values have the long right tail every B2B book has. They do not enter the model, which predicts whether a lead converts rather than for how much, and the economics in 7d use the mean deal as a single figure. Section 7d also varies it, because a mean over a skewed distribution is exactly the assumption worth testing.
3dHandling missing values
missing cells : 0
None. Worth one line.
3eHandling skewed data
Response time is right-skewed: a median of 35.7 hours against a mean of 45.3. That gap is the operational story, and section 4b measures what it costs.
3fData types and normalisation
LEAD = ['pages_viewed', 'emails_opened', 'work_email', 'phone_given', 'demo_requested']
CAT = ['source', 'company_size', 'job_role']
X_lead = pd.get_dummies(d[LEAD + CAT], columns=CAT, drop_first=True)
# the same matrix with the thing that has not happened yet added, for section 5a
X_all = X_lead.copy()
X_all['first_touch_hours'] = d['first_touch_hours'].values
y = d['converted'].values
print('usable features :', X_lead.shape[1])
print('with the leak :', X_all.shape[1])
usable features : 15
with the leak : 16
Section 4Exploratory Data Analysis
4aTarget variable analysis
y = d['converted'].values
mean_margin = d.loc[d['converted'] == 1, 'deal_value'].mean() * GROSS_MARGIN
break_even = COST_TO_WORK / mean_margin
print('conversion :', round(d['converted'].mean(), 4))
print('contribution per win :', round(mean_margin))
print('break-even probability:', round(break_even, 5))
print('leads below break-even at the base rate:',
'the average lead clears it' if d['converted'].mean() > break_even else 'most do not')
conversion : 0.0641
contribution per win : 10,094
break-even probability: 0.04458
leads below break-even at the base rate: the average lead clears it
6.4% of leads convert. Against a break-even of 4.46% that means the average lead is worth working and a good share of them are not, which is the entire threshold question in one sentence.
4bNumerical variables
One numerical column dominates everything else in this dataset, and it is the one that is not allowed in the model.
for lo, hi in [(0, 6), (6, 24), (24, 72), (72, 168), (168, 400)]:
g = d[(d['first_touch_hours'] >= lo) & (d['first_touch_hours'] < hi)]
if len(g) < 30:
continue
print(f'{lo:3d} to {hi:3d} hours n {len(g):6,d} conversion {g["converted"].mean():.4f}')
0 to 6 hours n 2,045 conversion 0.1051
6 to 24 hours n 9,491 conversion 0.0828
24 to 72 hours n 15,928 conversion 0.0599
72 to 168 hours n 6,179 conversion 0.0354
168 to 400 hours n 357 conversion 0.0196
A lead contacted inside six hours converts at 10.5%. The same kind of lead contacted after a week converts at 2.0%.
Speed is worth more than the model
Contacting inside six hours rather than after a week is worth a factor of 5.36 on conversion. The trend against log wait time has a p-value below the smallest number floating point can represent.
Section 7d builds a model that lifts conversion by a factor of 1.82 on its top tier. Speed is roughly three times the size of the prize, and it needs no model at all.
H4 is wrong. Response time is not an operational detail sitting next to the modelling problem, it is the larger part of the same problem.
4cCategorical variables
| Source | Leads | Conversion | Mean wait | Mean deal |
|---|---|---|---|---|
| Partner referral | 3,385 | 13.8% | 45.1h | 11,105 |
| Webinar | 4,391 | 7.2% | 44.9h | 11,710 |
| Organic | 7,088 | 6.3% | 45.4h | 14,162 |
| Paid search | 8,176 | 5.4% | 44.9h | 12,815 |
| Cold outbound reply | 3,441 | 4.8% | 45.4h | 13,798 |
| Content download | 7,519 | 4.6% | 45.8h | 14,748 |
The waits are broadly similar across sources, which is itself a finding: the queue is worked in the order things arrive, not in the order they matter.
4dRelationships between variables
The relationship that matters is between response time and conversion, and it is already established. The second is between company size and deal value, which is why section 7d checks whether the threshold should differ by segment rather than being one number for the whole list.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. Work the highest scoring leads first | Right, and incomplete | The top tier converts at 1.82 times the base rate. Section 7d shows ordering is worth less than timing |
| H2. Every lead is worth working | Wrong | Break-even is 4.46% and working the whole list returns 2,011,314 against 2,681,751 for the best cut |
| H3. A better model is the biggest win available | Wrong | Selection is worth 1.82 times, speed 5.36 times |
| H4. Response time is an operational detail | Wrong | It is the single strongest thing in the file and it is entirely within the team’s control |
4fSubgroups
Large companies convert at similar rates to small ones and are worth several times as much when they do. That makes the break-even probability different for them, and section 7d returns to it: one threshold for the whole list is a simplification, and a defensible one only while the segments are close.
Section 5Feature Engineering
5aThe leakage trap
This dataset contains a column that improves the model and destroys it. Fit both and look at the difference.
tr_idx, te_idx = train_test_split(d.index, test_size=0.3, random_state=SEED, stratify=y)
def boost():
return HistGradientBoostingClassifier(max_depth=4, learning_rate=0.06,
max_iter=300, random_state=SEED)
for name, Xuse in [('lead attributes only', X_lead), ('including response time', X_all)]:
m = boost().fit(Xuse.loc[tr_idx], y[tr_idx])
p = m.predict_proba(Xuse.loc[te_idx])[:, 1]
print(f'{name:26s} AUC {roc_auc_score(y[te_idx], p):.4f} '
f'AP {average_precision_score(y[te_idx], p):.4f}')
lead attributes only AUC 0.6844 AP 0.1330
including response time AUC 0.7032 AP 0.1557
Why the better model is the useless one
Adding response time lifts AUC by 0.0188. It is a real improvement and the model cannot be deployed, because at the moment a lead needs a score nobody has contacted it yet and the column is empty.
The gain is small enough to be tempting, which is what makes this trap dangerous. A column that doubled the AUC would be obviously wrong. A column that adds 0.0188 looks like a fair feature until someone tries to run it.
The tell is not statistical, it is a question about time: was this knowable when the prediction is needed? Ask it of every column and this one answers no.
5bNew features
None. Everything in the file that is legitimately available is already in a usable form, and the temptation worth resisting is engineering something out of first_touch_hours that smuggles it back in.
5cEncoding
Source, company size and role are one-hot encoded. Company size is ordered and could have been encoded as an integer, which would tell a linear model something true and tell a tree nothing it cannot find on its own. It is left as dummies so the two model families in 7b are compared on the same matrix.
5dFeature selection
Fifteen columns, all kept. The selection decision that matters in this project is which single column to exclude, and 5a makes it.
Section 6Model Selection
| Question | Choice | Why not the obvious alternative |
|---|---|---|
| Which model | Gradient boosting, against logistic regression | Both are fitted. If logistic were close, it would ship, because a sales team will ask why a lead scored what it did |
| Which metric for the model | Average precision | Conversion is 6.4%, so AUC is dominated by the leads nobody argues about |
| Which metric for the decision | Contribution in pounds | A threshold cannot be chosen from a classification metric. It needs the cost of working a lead and the value of a win |
| How to set the threshold | Expected value per lead, not a fixed top N | A percentage cut is arbitrary. The break-even probability is a property of the economics and it moves when they move |
Calibration is load bearing again
The threshold rule compares a predicted probability against 4.46%. If the probabilities are systematically high the cut lands in the wrong place, so section 7c measures calibration before 7d uses it.
Section 7Model Training
7aBaselines
| Baseline | What it is | Result |
|---|---|---|
| Work every lead | What the team does now | 2,011,314 pounds from 10,200 leads |
| Logistic regression on the same features | The simpler model | AUC 0.6915 |
| Work the newest first | The current ordering | Not a ranking on quality at all. Section 4b shows why it still partly works |
7bComparing candidates
Boosting reaches an AUC of 0.6844 against 0.6915 for logistic regression on the same columns. The gap is small. Boosting is taken forward on average precision, and the honest note is that a logistic model would have been defensible and easier to explain to a sales floor.
7cHyperparameter tuning
Left at sensible defaults. What is checked instead is whether the probabilities can be compared against a break-even figure.
model = boost().fit(X_lead.loc[tr_idx], y[tr_idx])
prob = model.predict_proba(X_lead.loc[te_idx])[:, 1]
print('Brier :', round(brier_score_loss(y[te_idx], prob), 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 {y[te_idx][bins.values == b].mean():.4f}')
Brier : 0.05843
decile 1 predicted 0.0182 actual 0.0206
decile 2 predicted 0.0275 actual 0.0225
decile 3 predicted 0.0361 actual 0.0255
decile 4 predicted 0.0428 actual 0.0520
decile 5 predicted 0.0484 actual 0.0559
decile 6 predicted 0.0551 actual 0.0480
decile 7 predicted 0.0634 actual 0.0667
decile 8 predicted 0.0750 actual 0.0814
decile 9 predicted 0.0998 actual 0.1157
decile 10 predicted 0.1830 actual 0.1529
Largest gap between predicted and actual in any decile is 0.0301, which is close enough to compare against a break-even of 4.46%.
7dFinal evaluation
How far down the list to work
mean_margin = d.loc[d['converted'] == 1, 'deal_value'].mean() * GROSS_MARGIN
break_even = COST_TO_WORK / mean_margin
print('contribution per win :', round(mean_margin))
print('break-even probability:', round(break_even, 5))
te = d.loc[te_idx].copy()
te['prob'] = prob
for q in [0.0, 0.2, 0.3, 0.4, 0.5, 0.6]:
sub = te[te['prob'] >= te['prob'].quantile(q)]
profit = sub['converted'].sum() * mean_margin - len(sub) * COST_TO_WORK
print(f'work the top {1 - q:4.0%} leads {len(sub):6,d} '
f'wins {int(sub["converted"].sum()):4d} profit {profit:12,.0f}')
contribution per win : 10,094
break-even probability: 0.04458
work the top 100% leads 10,200 wins 654 profit 2,011,314
work the top 80% leads 8,160 wins 610 profit 2,485,189
work the top 70% leads 7,140 wins 584 profit 2,681,751
work the top 60% leads 6,120 wins 531 profit 2,605,782
work the top 50% leads 5,100 wins 474 profit 2,489,439
work the top 40% leads 4,085 wins 426 profit 2,461,688
Profit peaks at 70.0% of the list and falls away on both sides. Working everything leaves 670,437 pounds on the table.
Work fewer leads and make more money
Working the whole list returns 2,011,314 pounds across the holdout.
Applying the expected value rule from section 1, working a lead only when its predicted probability times 10,094 beats 450, cuts the list to 6,327 leads, or 62.0%, and returns 2,613,570.
The best fixed cut is 70.0% at 2,681,751. Both beat working everything by roughly 670,437 pounds, and H2 is dead.
# the rule from section 1, applied per lead rather than as a percentage cut
te['expected_profit'] = te['prob'] * mean_margin - COST_TO_WORK
worked = te[te['expected_profit'] > 0]
profit = worked['converted'].sum() * mean_margin - len(worked) * COST_TO_WORK
print(f'rule works {len(worked):,} of {len(te):,} leads '
f'({len(worked) / len(te):.1%}) profit {profit:,.0f}')
rule works 6,327 of 10,200 leads (62.0%) profit 2,613,570
Selection against speed
The model multiplies conversion by 1.82 on its top tier. Answering sooner multiplies it by 5.36.
If every lead were contacted inside six hours, conversion across the whole book would move from 6.4% to roughly 10.5%. That is about 1,392 extra deals a year and 14,054,541 pounds of contribution, from process rather than from prediction.
The honest caveat on that number
Leads contacted quickly may be quicker to contact for a reason: an inbound demo request from a work email gets picked up first. Some of the 5.36 times gap is therefore selection rather than speed, and the scenario above is an upper bound rather than a forecast.
The fix is the same either way, and it is testable: route half the incoming leads to a same-hour queue for a month and measure it. That is a cheaper experiment than the model was.
Section 8Documentation and Handoff
Two changes, and the smaller one is the model
Work fewer leads. Break-even is 4.46%. Working the whole list returns 2,011,314 pounds across the holdout; applying the rule returns 2,613,570 from 62.0% of the leads. Worth about 670,437 pounds.
Answer sooner. Conversion inside six hours is 10.5% against 2.0% after a week, a factor of 5.36. The model’s top tier manages a factor of 1.82. Speed is the bigger lever and it needs no model.
What to do, and who owns it
| Action | Detail | Owner |
|---|---|---|
| Score every lead on arrival, on lead attributes only | Fifteen columns, none of them recording anything the team did afterwards | Analytics |
| Apply the expected value rule, not a top N | Work while probability times 10,094 beats 450. The cut moves when the economics move | Sales operations |
| Route high scores to a same-hour queue | The two findings compound: the leads worth working are also the ones where speed pays most | Sales operations |
| Run the speed test before believing the speed number | Half of incoming leads to a same-hour queue for a month. Section 7d explains why the 5.36 times figure is an upper bound | Sales operations |
| Recheck the break-even quarterly | It is a ratio of two numbers finance owns, and it moves with deal size and headcount cost | Finance, Analytics |
What not to do
- Do not put response time in the model. It improves AUC by 0.0188 and cannot be known when a score is needed.
- Do not set the threshold as a top percentage. 70.0% is the right cut for these economics and for no others.
- Do not work every lead because the deals are large. That is H2, and it costs about 670,437 pounds on this holdout.
- Do not treat the speed finding as settled. Part of it is selection. The test that separates them takes a month.
- Do not use one threshold for every segment once deal sizes diverge further. Large accounts have a lower break-even because a win is worth more.
Reproducibility
| Item | Value |
|---|---|
| File | vantith-leads.csv, 34,000 leads across 52 weeks |
| Split | 23,800 train, 10,200 holdout, stratified on the outcome |
| Features | Lead attributes only. first_touch_hours excluded by design |
| Model | Gradient boosting, AUC 0.6844, Brier 0.05843 |
| Economics | Cost to work 450, contribution per win 10,094, break-even 4.46% |
| Libraries | pandas, numpy, scipy.stats, scikit-learn |
What to take from this
- A lead scoring request contains two questions. Which leads, and how many. Only the first is a model and only the second was worth 670,437 pounds here.
- Ask of every column whether it exists at prediction time. Response time passes every statistical check and fails that one.
- A small AUC gain from a leaky feature is more dangerous than a large one. 0.0188 looks like a fair feature.
- Set thresholds from economics, not from percentiles. Break-even here is 4.46%, and it is a fact about the business rather than about the data.
- Check whether the biggest lever is a model at all. Here it was not, and the analysis that found that took less time than the model did.
- Say which of your findings is causal and which is not. The threshold is arithmetic. The speed number needs a test.
Sales asked for a score so they could work the best leads first. They should work fewer leads, sooner. The model is a real improvement and it is the smaller half of the answer, which is worth saying out loud in the handoff rather than letting the model be the headline because it was the thing that was asked for.
Size the speed test before you run it
Sample Size Calculator
The recommendation that matters here is a routing experiment, not a model. Put the current conversion rate of 6.4% and the lift you would need to see in, and it tells you how many weeks of leads the test needs before the answer means anything.
Free, no signup. Pairs with the Power and MDE Calculator.
Before the model, the funnel
B2B Lead Generation Dashboard
Leads, quotes and closes in one view, so marketing and sales are reading the same numbers. It is the reporting layer that makes a conversation about thresholds possible at all.
Free Excel template, LAD branded. No signup.
Companion projects. Late Payment Prediction is the other project here where a model meets a constraint, and where the ranking rather than the accuracy decides the value. Channel Reallocation asks the same question one step earlier, about where the leads come from.