Skip to content

Multi-Touch Attribution: last click lies about who earned the sale

The data in this project is synthetic — generated locally by data/make_data.py, with no external source. That is deliberate: attribution can only be scored if you know each channel’s true contribution, which no real marketing data carries. No signup, no downloads, no GPU.

Before you start

New to marketing attribution? Start here.

A customer rarely converts from a single ad. They see a display ad, later a social post, search for the brand, and finally click a direct link and buy. Attribution is how you split the credit for that sale across the channels that touched the customer — which drives how budget gets allocated.

The default almost everyone starts with is last-touch: give 100% of the credit to the final channel before conversion. It’s simple, and it’s badly wrong. Channels that start journeys (display, social) almost never get the last click, so last-touch gives them ~zero — even when they did the real work of creating demand. Channels that close (direct, branded search) get all the credit just for being last, even if the customer was already going to buy. Cut budget by last-touch and you defund the very channels that fill the funnel.

The fix is to credit channels by their contribution regardless of position — for example, a regression that asks “across all journeys, how much does having touched this channel raise the chance of conversion?”

What you’ll learn: compute last-touch attribution, see how it misranks channels, and build a model-based attribution that recovers each channel’s true contribution. Section 3 lists exactly what you’ll be able to do.

New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.

1. The Brief

You’re handed a log of customer journeys — the ordered channels each person touched, and whether they converted — and asked the question that moves budget: which channels actually earn our conversions? You’ll show why last-touch answers it wrongly, then build an attribution that reflects real contribution.

Attribution decides where millions in marketing spend go, and last-touch (or its cousin, first- touch) is still the default in far too many dashboards. The result is predictable: upper-funnel channels look worthless, get cut, and pipeline dries up a quarter later. Being able to show — with data — that the last click is not the cause is one of the highest-leverage analyses a marketing analyst can do.

2. Difficulty & time

Difficulty 5 / 10. It needs journey parsing, two attribution methods, and the insight that position is not contribution — plus a regression to recover contribution order-free. Calibrated against the ledger (G10): a peer of analytics-with-duckdb and pay-equity-gap (both 5) — one real analytical pipeline built around a conceptual trap. It sits above ab-test-readout (4), a single-statistic read-out, and below recommend-from-scratch (7), which derives an algorithm from scratch. The model is a few lines of scikit-learn; the difficulty is seeing why the obvious method is wrong.

Time is separate from difficulty: Part A is about 75 minutes; Part B is 2–3 hours of your own work.

3. What you’ll be able to do after

  • Compute last-touch attribution from raw journey paths and read its channel ranking.
  • Diagnose why it misleads — show a channel that appears in many converting journeys but is almost never the last touch.
  • Build model-based attribution — regress conversion on which channels were present, and use the coefficients as order-free contribution.
  • Compare and defend — quantify how far last-touch’s ranking is from real contribution (here it’s negatively correlated with the truth).

The finished result

By the end of Part A you’ll have attributed the same 3,000 journeys two ways and scored each ranking against each channel’s true effect — watching last-touch point the wrong way and a regression put it right:

last-touch top channels: direct, branded_search, email corr with true channel effect = -0.533
model-based top channels: display, social, paid_search corr with true channel effect = +0.957

Same journeys, opposite verdicts: last-touch credits the closers that merely got the final click, so its ranking is negatively correlated with real contribution, while the regression recovers the initiators (display, social) that did the real work. In Part B you’ll run the model-based attribution blind on 8,000 journeys and clear a 0.70 correlation floor.

4. Prereqs & time box

You can write a function and use a pandas DataFrame; you’ve seen the idea of a regression once (or will treat a coefficient as “how much this feature moves the outcome”). No marketing background needed.

Where to write and run code

Two ways to run this project — pick either:

  • In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses pandas, numpy, and scikit-learn, which run in the browser.
  • On your computer. The one-time setup — installing Python, downloading the code from the course site (no GitHub, no git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here once python -c "import pandas, sklearn" runs without error. Part B (writing your own solution) needs this local setup.

Time box

  • Part A — Guided Build: 60–90 minutes. Hard cap.
  • Part B — Your Turn: 2–4 hours. Hard cap.

5. Setup & data

The data is synthetic and generated locally — see the banner above and SOURCES.md for full provenance. One command creates both files:

Terminal window
python data/make_data.py

That writes two CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seed is fixed):

  • Part Ajourneys_a.csv (3,000 journeys) and channel_effects_a.csv (the true per-channel effect — Part A may check its work against it).
  • Part Bjourneys_b.csv (8,000 journeys). No truth file — the grader has it.

Each journey row is customer_id, path (channels joined by >, in order), converted (0/1).

6. Part A — Guided Build

You’ll work journeys_a.csv. Run the whole thing, or (better) one # %% cell at a time — see Start Here:

Terminal window
python part_a/attribute_journeys.py

Prefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.

The full, commented script is in part_a/attribute_journeys.py. The spine:

Step 1 — The journeys. Conversion rate, channel count, sample paths.

Checkpoint: 3,000 journeys, ~17% convert, 12 channels; each row is an ordered path.

Step 2 — Last-touch attribution. Credit the final channel of each conversion.

Checkpoint: closers (direct, branded_search, email) top the chart. Correlation with the true channel effect is negative (~−0.53) — last-touch points the wrong way.

Step 3 — Why. Show that display/social appear in ~1 in 5 converting journeys but are the last touch ~0% of the time.

Checkpoint: the initiators do the work but never get the last click, so last-touch gives them nothing.

Step 4 — Model-based attribution. Regress conversion on per-channel presence; the coefficient is each channel’s order-free contribution.

Checkpoint: display/social rise to the top; correlation with the true effect is ~+0.96.

Step 5 — Compare and save both attributions to part_a/attribution_a.csv.

Why this and not that

  • Why presence, not position, drives the model. Conversion is caused by which channels a customer met, not the order. Regressing on presence indicators estimates each channel’s marginal lift — exactly the contribution attribution is supposed to measure.
  • Why last-touch isn’t just noisy but wrong. It measures recency of contact, which for many channels is the opposite of causal role. That’s why its correlation with true effect is negative, not merely low — a stronger warning than “imprecise.”
  • Why correlation with the true effect, not “does the top channel look right.” Attribution is a whole ranking that sets budget; a single eyeballed channel can be right while the rest is scrambled. Correlation scores the whole vector.

Functions you’ll meet (and where to read more)

FunctionWhat it does hereDocs
s.str.split(">")Parse a path string into its channel liststr.split
s.apply(lambda t: t[-1])Take the last touch of each journeySeries.apply
s.value_counts()Count last-touch conversions per channelvalue_counts
LogisticRegression().fitEstimate order-free per-channel contributionLogisticRegression
np.corrcoef(a, b)Score an attribution against the true effectcorrcoef

If something looks off

  • Last-touch and the model rank the channels differently — which one is right? The model. That disagreement is the whole point: last-touch’s ranking is negatively correlated with the true effect (−0.53) because it credits the closers (direct, branded_search) that only got the final click, while the regression (+0.96) credits the initiators (display, social) that did the real work. If the two agreed, there’d be nothing to fix.
  • A checkpoint number doesn’t match. The data is seeded, so the numbers are exact. Re-run python data/make_data.py (you may have skipped or edited it), then re-run Part A.
  • Setup problems (Python not found, pip, FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.

7. Part B — Your Turn

Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (part_b/starter.py) that already handles the easy version; your job is to finish the # TODO it marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.

Same core skill, more journeys, and now blind and scored. Attribute the 8,000 journeys in journeys_b.csv across the 12 channels. There’s no truth file — the grader has it and scores your attribution by its correlation with each channel’s true effect. The starter (part_b/starter.py) uses last-touch and scores ~−0.54, far below the floor.

The transfer: in Part A you could check both methods against the truth. Here you can’t peek — you have to trust the model-based attribution and hand in a single answer.

Acceptance criteria (checkable)

Your solution writes into part_b/out/:

  • attribution.csv — one row per channel: channel, credit (your attributed contribution).
  • report.json — with keys channels, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (once your solution fills in the TODOs), then re-run.

You’re done when the tests pass: your attribution reaches Pearson correlation ≥ 0.70 with the true channel effects (the reference reaches ~0.95). Last-touch scores ~−0.54 and fails.

Constraints — what must be true of your solution, not how to get there

  • Credit every channel, not just the ones that appear last.
  • Attribute by contribution, order-free — the last click is a symptom of position, not cause.
  • Don’t rank channels by raw conversion counts either; a channel present in many journeys isn’t necessarily causing them (control for the others, as a regression does).

Hints

Hint 1 — reshape the paths

Turn each journey into a row of 0/1 presence flags, one column per channel: was this channel in the path? That matrix is what a model attributes on.

Hint 2 — the model

Fit LogisticRegression of converted on the presence matrix. Each channel’s coefficient is its order-free contribution — use it as the credit.

Hint 3 — sanity-check the direction (still not the code)

Initiator channels (display, social) should end up with high credit even though they’re rarely the last touch. If your top channels are direct/branded_search, you’re still doing last-touch.

8. Self-check

You don’t need the answer key. You’re done when all of these hold:

  • pytest reports correlation ≥ 0.70 (aim for ~0.95 with the regression).
  • Your top-credited channels are the initiators/high-effect ones, not the closers.
  • Re-running from python data/make_data.py reproduces the same numbers — the data is seeded.

And the tell-tale: if direct and branded_search top your ranking, you’ve reproduced last-touch’s mistake — the closers get the click, not the credit.

9. Stretch

  • Removal effect. Attribute by how much conversion drops when a channel is removed from the model (a Markov/removal-effect flavour), and compare its ranking to the coefficient version.
  • Interactions. Add a term for a specific pair of channels seen together and check whether any combination converts far better than the sum of its parts.
  • (Genuinely hard) Position-decay, honestly. Build a time-decay attribution and show that, unless it’s calibrated to real lift, it inherits last-touch’s bias — position weighting is not the same as measuring contribution.

10. Ship it

Put this in a portfolio repo and it counts. In that repo’s README:

  • State the finding in one sentence a CMO gets: “last-touch was defunding the channels that actually drive conversions — here’s the attribution that fixes it.”
  • Show the two rankings side by side (last-touch vs model) and the correlation of each with real contribution.
  • Include the presence-matrix + regression code and one paragraph on why position ≠ contribution. That paragraph is what signals you understand attribution, not just a dashboard default.

Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from zero.

11. Sources

Rendered from SOURCES.md.

FieldValue
DatasetSynthetic multi-touch marketing journeys with known channel effects (Part A 3,000, Part B 8,000 journeys)
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260718 (A) and 20260719 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot applicable — no external source

Synthetic data teaches the mechanic; it does not prove a finding about the real world. The channel effects were set on purpose — recovering them demonstrates the technique, not a claim about any real marketing mix. Real attribution needs richer methods (see SOURCES.md).

Next up

That’s the top of the Data Analytics track. Browse all courses → to pick your next build.