learn-marketing-attribution-with-phoebe / Builder session 4 of 10
Learn Marketing Attribution with Phoebe · Builder session 4 of 10

Markov attribution, built from scratch

The heuristics guessed. Now we learn credit from the data. We model every Lumen journey as a Markov chain, build the transition matrix from real path counts, and score each channel by its removal effect - how far conversion falls when we take that channel away. You will code it in numpy, watch it reconcile to a hand-verified example, then call the industrial-strength library once you trust the math.

🟡 Core Builders · practitioner Python + numpy 45 min
0-3 · Setup 3-18 · The model 18-40 · Build it 40-45 · Recap
Part 0

Where we are: from fixed rules to learned credit

In B2 you coded the six heuristics, and in B3 you watched them fail - they assert a rule (last touch wins, recent wins) instead of asking the data what actually moved the needle. This session is the leap. Markov attribution treats every customer journey as a walk through a chain of states and scores each channel by a question no heuristic can answer: if this channel vanished, how much conversion would we lose? That is the removal effect, and it is the first genuinely data-driven credit you will build by hand.

Live - built together in session Self-study - read after class ★ Build-along - everyone runs it The data: Lumen Skincare
★ What you walk out with today A working from-scratch Markov attributor in ~40 lines of numpy - transition matrix, absorbing-chain solve, removal effect per channel, normalized to spendable credit - plus the one-line ChannelAttribution call you will actually use once your journey count runs to the millions.
Part 1 · the model

Journeys as a first-order Markov chain 7 min live

A Markov chain is a set of states and the probabilities of hopping between them, where the next hop depends only on where you are now - not how you got there. For attribution the states are: a synthetic Start, one state per channel, an absorbing Conversion, and an absorbing Null (the journey ended without buying). We estimate every transition probability by counting observed hops in Lumen's path data. That is the whole model.

▶ Start 4 journeys A · Paid social a channel state B · Email a channel state 🛒 Conversion absorbing · pays out ⛔ Null absorbing · no sale 0.75 0.25 0.50 0.25 0.25 0.667 0.333 First-order = "memoryless": the next hop depends only on the current state, never on the earlier path. Rows out of each state sum to 1.
🔍 Click to zoom - a two-channel Lumen slice as a Markov chain (A = paid social, B = email), every arrow a counted transition probability
LiveThe states, and where the probabilities come from3 min

We will teach the whole method on a tiny, hand-checkable slice of Lumen so you can verify every number, then scale it up in the demos. The slice has just two channels and four observed journeys:

Observed pathCountOutcome
Start → A → B2Conversion
Start → B → A1Conversion
Start → A1Null (no sale)

Four journeys start; three convert. So the base conversion probability is 3 / 4 = 0.75. Now read the transitions straight off the counts:

  • From Start: A is entered 3 times (2 + 1), B once. So Start→A = 0.75, Start→B = 0.25.
  • From A: A appears 4 times total - it goes to B twice, to Conversion once, to Null once. So A→B = 0.50, A→Conv = 0.25, A→Null = 0.25.
  • From B: B appears 3 times - to Conversion twice, to A once. So B→Conv = 0.667, B→A = 0.333.

Every row sums to 1, because probabilities out of a state have to go somewhere. That is your transition matrix, estimated purely by counting.

Self-studyWhy the sequence matters, not just the frequency3 min read

The heuristics only ever saw which channels appeared and where in a single path. Markov sees the full graph of how channels hand off to each other across every journey at once. A channel that always appears right before conversion, and a channel that appears early but reliably passes people onward to a closer, get scored differently - because the chain captures the sequence of transitions, not just a tally of touches.

Real world

On Lumen's real data, display and CTV rarely take the last click, so last-touch scores them near zero. But the Markov chain shows that removing them collapses the transitions that feed paid search - the closers dry up because nobody arrives to close. Sequence-awareness is exactly what surfaces those upstream engines.

Part 2 · the scoring rule

The removal effect, worked by hand 6 min live

Here is the idea that makes Markov attribution learned rather than asserted. To score a channel, we remove it - route every one of its transitions to Null, as if that channel never existed - recompute the probability of conversion from Start, and measure the fractional drop. A channel whose removal barely dents conversion earned little; a channel whose removal collapses conversion earned a lot.

A present · the real chain P(conversion | Start) 0.75 A removed · routed to Null P(conversion | Start) 0.167 remove channel A conversion collapses Removal effect of A RE(A) = 1 − 0.167 / 0.75 = 0.778 Removing A wipes out 78% of Lumen's conversion probability - it is doing far more work than any last-touch tally would ever show.
🔍 Click to zoom - the removal effect: take A away, watch conversion fall from 0.75 to 0.167, measure the fractional drop
LiveSolve the absorbing chain, remove each channel4 min

To get P(conversion | Start) we solve the chain. Let f(X) be the probability of eventually reaching Conversion starting from state X, with f(Conv) = 1 and f(Null) = 0. Then each channel state is a weighted average of where it hops:

  • f(A) = 0.50·f(B) + 0.25·1 + 0.25·0
  • f(B) = 0.667·1 + 0.333·f(A)

Substitute the second into the first and solve: f(A) = 0.70, f(B) = 0.90. Then f(Start) = 0.75·f(A) + 0.25·f(B) = 0.75·0.70 + 0.25·0.90 = 0.75 - which reconciles exactly with the base rate we counted. Good sign: the chain agrees with reality.

Now remove each channel (send all its visits to Null) and re-solve:

  • Remove A: f(A) = 0, so f(B) = 0.667, and f(Start) = 0.25·0.667 = 0.167. RE(A) = 1 − 0.167/0.75 = 0.778.
  • Remove B: f(B) = 0, so f(A) = 0.25, and f(Start) = 0.75·0.25 = 0.1875. RE(B) = 1 − 0.1875/0.75 = 0.75.
The formula, once RE(c) = 1 − P(conversion | channel c removed) / P(conversion). It is the fractional loss in conversion probability when the channel disappears. Nothing here was asserted - every number fell out of the counted data.
Part 3 · the gotcha

Why you MUST normalize, and higher-order chains 5 min live

Look at the two removal effects: RE(A) = 0.778 and RE(B) = 0.75. They sum to 1.528, not 1. This is not a bug - it is the single most misunderstood point in Markov attribution, and getting it wrong will make your credit numbers meaningless.

LiveRemoval effects overlap - that is why they don't sum to 13 min

Journeys share channels. When you remove A, some of the conversions you lose were paths that also used B - and when you remove B, you lose some of those same shared paths again. The effects overlap, so the raw removal effects double-count and overshoot 1. To turn them into a credit split that actually sums to 100% of conversions, you normalize each channel by the total:

  • credit(A) = RE(A) / ΣRE = 0.778 / 1.528 = 50.9%
  • credit(B) = RE(B) / ΣRE = 0.75 / 1.528 = 49.1%
Real world

A team once reported "our channels have 320% removal effect" as if the model were broken. It was not - nine channels sharing long journeys will happily overlap to a raw sum well above 1. They just skipped the normalization step. Normalize, and 320% becomes a clean 100% credit split you can multiply against revenue.

Self-studyFirst-order vs higher-order (k-order) Markov2 min read

We built a first-order chain: the next hop depends only on the current channel. That is the memoryless default and it is remarkably robust. But it cannot tell "email after paid social" apart from "email after display" - both collapse to "in email". A higher-order (k-order) chain fixes that by making each state the last k channels, so it remembers short sequences.

  • The cost: the state space explodes. Order-2 over 9 channels is up to 81 states; order-3 is up to 729 - and each needs enough observed paths to estimate its transitions, or the counts get too sparse to trust.
  • The tool: the ChannelAttribution package exposes order=1,2,3,... so you can raise it and watch whether credit shifts enough to justify the extra data hunger. Usually order 1 is plenty; go higher only when short sequences are clearly load-bearing.
Build-along 1 of 3

Build the transition matrix from Lumen paths ★ 8 min · everyone

Straight from the counting logic in Part 1. We take the sessionized Lumen paths (the journey table you built in B1), count every hop, and normalize each row into probabilities. Same code runs on our 4-path toy slice and on Lumen's millions of paths.

Demo 1 · count then normalize import numpy as np # Lumen journeys, sessionized into channel paths (from B1). # Each path is (list of channels, "conv" or "null"). Toy slice here; # the real Lumen frame has ~9 channels and millions of rows. paths = [ (["paid_social", "email"], "conv"), # observed x2 (["paid_social", "email"], "conv"), (["email", "paid_social"], "conv"), # observed x1 (["paid_social"], "null"), # observed x1 ] states = ["start", "paid_social", "email", "conv", "null"] idx = {s: i for i, s in enumerate(states)} counts = np.zeros((len(states), len(states))) for channels, outcome in paths: seq = ["start"] + channels + [outcome] for a, b in zip(seq[:-1], seq[1:]): counts[idx[a], idx[b]] += 1 # tally every hop row_sums = counts.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # avoid divide-by-zero T = counts / row_sums # rows now sum to 1 T[idx["conv"], idx["conv"]] = 1.0 # conversion absorbs T[idx["null"], idx["null"]] = 1.0 # null absorbs print(np.round(T[idx["start"]], 3)) # [0. 0.75 0.25 0. 0.]
Sanity check as you go Print any transient row and confirm it sums to 1. Start prints [0, 0.75, 0.25, 0, 0] - exactly the Start→A = 0.75, Start→B = 0.25 we counted by hand. If a row does not sum to 1, your path parsing dropped or duplicated a hop.
Build-along 2 of 3

Conversion probability via the absorbing chain ★ 7 min · everyone

Now we solve for P(conversion | Start) the clean linear-algebra way. Split the matrix into transient states (Start and the channels) and absorbing states (Conv, Null). The fundamental matrix N = (I − Q)⁻¹ gives expected visits; N·R gives the probability of ending in each absorbing state.

Demo 2 · absorbing-chain solve transient = ["start", "paid_social", "email"] absorbing = ["conv", "null"] ti = [idx[s] for s in transient] ai = [idx[s] for s in absorbing] Q = T[np.ix_(ti, ti)] # transient -> transient R = T[np.ix_(ti, ai)] # transient -> absorbing # Fundamental matrix N = (I - Q)^-1 ; B = N @ R = absorption probabilities N = np.linalg.inv(np.eye(len(ti)) - Q) B = N @ R p_conv = B[transient.index("start"), absorbing.index("conv")] print(round(p_conv, 3)) # 0.75 -> reconciles with the base rate
Real world

The linear solve reconciling to the observed 0.75 base rate is your green light. On real Lumen data you would confirm the model's implied conversion probability lands within a hair of the actual conversion rate before trusting any channel score - if it drifts, your paths, your absorbing states, or your Start handling are off.

Build-along 3 of 3

Removal effect per channel + the library one-liner ★ 8 min · everyone

The payoff. Wrap the solve in a function that can route a chosen channel to Null, loop over the channels, compute each removal effect, then normalize into spendable credit. Finally, the same result from ChannelAttribution once your data outgrows a from-scratch loop.

Demo 3a · from-scratch removal effect def conversion_prob(T, states, removed=None): """P(conv | start), optionally routing a removed channel to null.""" T = T.copy() if removed is not None: r = states.index(removed) T[r, :] = 0.0 T[r, states.index("null")] = 1.0 # send every visit to null ti = [states.index(s) for s in states if s not in ("conv", "null")] ai = [states.index("conv"), states.index("null")] Q, R = T[np.ix_(ti, ti)], T[np.ix_(ti, ai)] N = np.linalg.inv(np.eye(len(ti)) - Q) return (N @ R)[ti.index(states.index("start")), 0] base = conversion_prob(T, states) channels = ["paid_social", "email"] # the real 9 for Lumen removal = {c: 1 - conversion_prob(T, states, removed=c) / base for c in channels} total = sum(removal.values()) # ~1.528 here - NOT 1 credit = {c: re / total for c, re in removal.items()} # MUST normalize print({k: round(v, 3) for k, v in removal.items()}) # {'paid_social': 0.778, 'email': 0.75} print({k: round(v, 3) for k, v in credit.items()}) # {'paid_social': 0.509, 'email': 0.491}
Demo 3b · the same thing at real scale # Once you trust the math, stop hand-rolling it. ChannelAttribution has a # C++ core and scales to thousands of channels and millions of paths. from ChannelAttribution import markov_model # df has one row per journey: a "path" string + conversion / null counts result = markov_model( df, var_path="path", # e.g. "paid_social > email > paid_search" var_conv="conversions", var_null="nulls", order=1, # first-order (memoryless); try 2 or 3 later ) print(result) # channel + normalized removal-effect credit
Build it once, then buy the scale The from-scratch loop is how you understand Markov attribution and how you audit any vendor's number. ChannelAttribution (R and an official Python port) is the de-facto standard for production; DP6's marketing-attribution-models bundles heuristics, Markov and Shapley in one Python library if you want them side by side.
Before Session 5

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · The removal effect of a channel is defined as...

You remove the channel by sending all its transitions to Null, recompute conversion probability from Start, and measure how far it falls relative to the base. That fractional drop is the removal effect.

2 · Your channels' removal effects sum to 1.53, not 1. What do you do?

Shared channels mean removal effects overlap and double-count, so their raw sum runs above 1. That is expected. Divide each by the total to get a credit split that sums to 100%.

3 · What does a Markov chain capture that the heuristics cannot?

Heuristics score one path with a fixed rule. Markov learns the whole graph of channel-to-channel transitions across every journey, so it credits channels that feed the closers, not just the closers themselves.

Source material

What this session covers

This session owns Coverage Gap #1: the actual math of Markov attribution built from scratch in Python. No existing course walks the transition matrix, absorbing-chain solve and removal-effect normalization end to end - most hand you a library call and move on.

Coverage Gap #1 - Markov from scratch (original)transition matrix, removal effect, normalization - Parts 1-3, Demos 1-3
ChannelAttribution (R + official Python port)the production one-liner - Demo 3b
Udemy - Advanced Attribution using RMarkov removal-effect method - Part 2
Higher-order (k-order) Markov internalsconcept here; deeper state-space math left to the docs
Shapley value attributionthe other data-driven credit model - next session, B5

Builder Session 4 cheat sheet · pin this

The modelJourneys as a first-order Markov chain: states = Start + channels + Conversion + Null (both absorbing). Transitions counted from paths.
Removal effectRE(c) = 1 − P(conv | c removed) / P(conv). Route the channel to Null, re-solve, measure the fractional drop in conversion.
Solve the chainSplit into transient Q and absorbing R. N = (I − Q)⁻¹; N·R gives absorption probabilities. P(conv|Start) should reconcile to the base rate.
MUST normalizeRemoval effects overlap (shared channels) so they don't sum to 1. credit(c) = RE(c) / ΣRE. Never report raw removal effects as credit.
OrderFirst-order = memoryless default, robust. Higher-order (k) remembers short sequences but the state space explodes and needs far more data.
ToolsChannelAttribution (C++ core, scales to thousands of channels) is the standard. DP6 marketing-attribution-models bundles heuristics + Markov + Shapley.