Arron Bennett, Founder of Bennett Financials

Most small B2B teams don't need a machine learning platform to score leads. They need to know which leads to call first on Monday morning.

If you can export your CRM to a CSV, you can build a lead scoring model in about 60 lines of plain Python. No libraries beyond the standard library. It's simple enough that a sales manager can read the output and agree with it, which matters more than a slightly better accuracy number.

Here's the approach, the code, and how to check it isn't fooling you.

Key takeaways

  • A useful lead score can come from smoothed win rates per field, such as source, company size and job title.
  • Smoothing stops a value with three leads and two wins from looking like a 67% winner.
  • Always test on a holdout set of later leads, not the leads you trained on.
  • If the top third of scored leads doesn't win far more often than the bottom third, don't use the score.

What is lead scoring?

Lead scoring ranks incoming leads by how likely they are to become customers, using what you already know about them, such as where they came from, company size and job title. A simple model learns from past leads with known outcomes. Sales then calls the highest-scoring leads first, so the most promising prospects get the fastest response.

Where does the data come from?

The example uses a hypothetical company. A 14-person B2B appointment-setting agency, $1.7 million in revenue, exports 600 leads from its CRM covering a year. The data is a synthetic sample made for this post, but the columns match a typical export:

lead_id,created,source,company_size,title,won 1,2026-01-15,webinar,11-50,sales_lead,1 2,2026-01-15,cold_list,1-10,sales_lead,0 

won is 1 if the lead became a paying client and 0 if not. If your CRM tracks stages, collapse them to won or not won. Make sure every lead is old enough to have had a fair chance to close.

A note on the target. In this short clip I explain how to calculate close rate properly: new customers divided by sales calls that actually happened, over a long enough window, with at least 20 calls. Lead scoring has the same problem. Small samples lie.

How do you get a clean export?

Most CRMs let you export deals or contacts to CSV from a list view. Before you run anything, clean three things. First, make the field values consistent: "Referral", "referral" and "Referred by partner" should be one value. Second, drop leads created in the last month or two, since many of them simply haven't had time to close yet. Third, decide what counts as won, such as a signed contract or a first paid invoice, and apply it to every row the same way.

Ten minutes of cleaning here does more for the score than any change to the model.

The code

import csv from collections import defaultdict FEATURES = ["source", "company_size", "title"] PRIOR_WEIGHT = 10 # pretend every value starts with 10 "average" leads def load(path): with open(path, newline="") as f: rows = list(csv.DictReader(f)) for r in rows: r["won"] = int(r["won"]) return sorted(rows, key=lambda r: r["created"]) def fit(rows): base = sum(r["won"] for r in rows) / len(rows) lift = {} for feat in FEATURES: wins, total = defaultdict(int), defaultdict(int) for r in rows: wins[r[feat]] += r["won"] total[r[feat]] += 1 for value in total: # smoothed win rate, pulled toward the overall rate when data is thin rate = (wins[value] + PRIOR_WEIGHT * base) / (total[value] + PRIOR_WEIGHT) lift[(feat, value)] = rate / base return base, lift def score(row, base, lift): s = base for feat in FEATURES: s *= lift.get((feat, row[feat]), 1.0) return min(s, 0.99) def report(rows, base, lift): scored = sorted(rows, key=lambda r: score(r, base, lift), reverse=True) third = len(scored) // 3 bands = {"top": scored[:third], "middle": scored[third:2 * third], "bottom": scored[2 * third:]} for name, band in bands.items(): won = sum(r["won"] for r in band) print(f"{name:>6}: {len(band):3d} leads, {won:3d} won, {won / len(band):5.1%} win rate") if __name__ == "__main__": rows = load("leads.csv") train, test = rows[:400], rows[400:] base, lift = fit(train) print(f"Base win rate (train): {base:.1%}\n") for (feat, value), l in sorted(lift.items(), key=lambda kv: -kv[1]): print(f"{feat:>12} = {value:<12} lift {l:4.2f}") print("\nHoldout (last 200 leads by date):") report(test, base, lift) 

How does the model work?

For each field, the code calculates a win rate for every value, such as referral or cold list, then divides it by the overall win rate to get a lift. A lift of 2 means leads with that value win twice as often as average. A lead's score is the overall win rate multiplied by the lifts of its values.

The PRIOR_WEIGHT is the important line. Without it, a job title that appears four times with two wins gets a 50% win rate and a huge lift. With it, every value behaves as if it started with 10 average leads, so rare values stay close to 1.0 until there's enough data to move them. Change it to 0 and rerun to see how jumpy the scores get.

Multiplying lifts assumes the fields are roughly independent. They rarely are, which is why this is a ranking tool, not a probability you should quote.

Testing it on a holdout set

The script sorts leads by date, trains on the first 400 and tests on the last 200. That mimics real use: you build the score from past leads and apply it to new ones. Here's the output:

Base win rate (train): 10.0% source = referral lift 2.09 title = sales_lead lift 1.60 company_size = 51-200 lift 1.45 source = webinar lift 1.28 company_size = 11-50 lift 1.20 company_size = 200+ lift 0.99 title = owner lift 0.89 source = paid_search lift 0.88 title = marketing lift 0.83 title = other lift 0.67 company_size = 1-10 lift 0.36 source = cold_list lift 0.10 Holdout (last 200 leads by date): top: 66 leads, 18 won, 27.3% win rate middle: 66 leads, 4 won, 6.1% win rate bottom: 68 leads, 1 won, 1.5% win rate 

The top third of holdout leads won 27% of the time. The bottom third won 1.5%. That's a score worth using. If your bands come out close together, the fields you're using don't predict much, and you should try others, such as how the lead was first contacted or how fast they replied.

Notice that owner scores below average here, even though in this sample owners are actually good leads. With only a few hundred leads, some lifts will be wrong. That's why you show sales the lift table and let them question it.

How should a sales team use the score?

Use the score to decide the order of follow-up, not whether to follow up at all. Call top-band leads first and fastest. Put bottom-band leads into a lighter process, such as an email sequence. Retrain every quarter as new outcomes come in, and check the holdout bands each time.

A few practical points:

  • Keep the fields few. Three or four fields that sales already records beats twenty half-filled ones.
  • Don't score on things you can't know at the start, such as "had a second meeting." That leaks the answer into the score.
  • Watch the bottom band. If it starts winning more, something changed in your market or your messaging.

What does this mean for the business?

For the agency in the example, the main win is time. If reps call the top third first, most of the deals come from the first third of calls. Measure it the plain way: new clients per sales hour, and close rate over at least 20 calls, before and after you start using the score.

FAQ

Do I need machine learning for lead scoring?

Not to start. Smoothed win rates per field are often good enough for small teams and much easier to explain.

How many leads do I need?

A few hundred with known outcomes is a reasonable start. Fewer than that, and most lifts will be noise.

How often should I retrain the model?

Quarterly is usually enough, or after a big change in marketing or pricing.

For more on measuring the funnel this score feeds, see Bennett Financials' funnel check on SQL rate benchmarks.