Heuristics are just arithmetic on the path table
You built lumen_fct_touchpoints in Session 1 - ordered, numbered, path-string ready. Every heuristic in this session is a small function over that table. The point of coding all six is not that any one is hard; it is that you will feel, in the output, how much the answer swings purely on the rule. That felt swing is the motivation for the data-driven leap in Session 3. First, master the rules you assert.
Single-touch and linear in pandas 6 min live
First-touch, last-touch, and linear need no weighting logic at all. First and last are a single-row pick; linear is one division. We load Lumen's journeys into a dataframe and write each as a function that returns a credit-per-touch series.
LiveLoad the journeys, then split them4 min▶
Read the fact table you built in B1, join order values, and you have a tidy per-touch dataframe. Every heuristic below returns a credit column that sums to order_value within each journey.
import pandas as pd
# One row per touch: customer_id, position_in_path, channel,
# event_ts, order_value (order_value repeated per journey)
tp = pd.read_parquet("lumen_fct_touchpoints.parquet")
def first_touch(g):
c = g.sort_values("event_ts").copy()
c["credit"] = 0.0
c.iloc[0, c.columns.get_loc("credit")] = c["order_value"].iloc[0]
return c
def last_touch(g):
c = g.sort_values("event_ts").copy()
c["credit"] = 0.0
c.iloc[-1, c.columns.get_loc("credit")] = c["order_value"].iloc[0]
return c
def linear(g):
c = g.sort_values("event_ts").copy()
c["credit"] = c["order_value"].iloc[0] / len(c) # value / n_touches
return c
# On Lumen's $92, 5-touch journey:
# first_touch -> display $92 · last_touch -> paid_search $92
# linear -> $18.40 to each of the 5 touches
credit column sums to order_value. If it does not, you have a bug. Linear makes this obvious - $18.40 × 5 = $92 - and it is the invariant to assert in a test for every model.
Time-decay: the half-life weight 5 min live
Time-decay says a touch closer to the conversion earned more. The weight is a classic exponential decay keyed to a half-life - Lumen uses 7 days, so a touch 7 days before the sale gets half the weight of one at the moment of purchase. Compute raw weights, then normalize so they still sum to the order value.
LiveThe decay formula, in code3 min▶
The half-life form 2 ** (-days / half_life) is the intuitive one: every half_life days, the weight halves. Compute per touch, normalize across the journey, scale by the order value.
import numpy as np
def time_decay(g, half_life=7.0):
c = g.sort_values("event_ts").copy()
conv_ts = c["event_ts"].max() # the conversion moment
days_before = (conv_ts - c["event_ts"]).dt.total_seconds() / 86400
w = np.power(2.0, -days_before / half_life) # half every 7 days
c["credit"] = c["order_value"].iloc[0] * w / w.sum() # normalize to $
return c
# Lumen $92 journey, half_life=7:
# display ~$6 · paid_social ~$13 · email ~$19 · organic ~$25 · paid_search ~$29
# (sums to $92)
The half-life is a lever, not a law. Set it to 1 day and time-decay collapses toward last-touch; set it to 90 days and it flattens toward linear. Teams that "use time-decay" without stating the half-life are hiding the most important knob in the model - always report it, the way you always report the lookback window.
Position-based and last-non-direct 5 min live
Position-based (U-shaped) tells a story: the touch that started the journey and the touch that closed it matter most. Lumen uses 40% to first, 40% to last, and the remaining 20% split evenly across the middle. Last-non-direct is a tiny variant of last-touch that skips self-navigation.
LiveU-shaped credit and the direct filter3 min▶
def position_based(g, first=0.4, last=0.4):
c = g.sort_values("event_ts").copy()
n, ov = len(c), c["order_value"].iloc[0]
w = np.zeros(n)
if n == 1:
w[0] = 1.0
else:
w[0] = first
w[-1] = last
middle = (1.0 - first - last) / (n - 2) if n > 2 else 0.0
if n > 2:
w[1:-1] = middle
c["credit"] = ov * w
return c
def last_non_direct(g):
c = g.sort_values("event_ts").copy()
non_direct = c[c["channel"] != "direct"]
winner = (non_direct if len(non_direct) else c).index[-1]
c["credit"] = 0.0
c.loc[winner, "credit"] = c["order_value"].iloc[0]
return c
# Lumen $92, 5 touches, position_based:
# display $36.80 · paid_search $36.80 · middle 3 (p.social/email/organic)
# split $18.40 -> ~$6.13 each
# last_non_direct: paid_search $92 (no 'direct' in this path, so = last-touch)
Run all six heuristics, one table out ★ 8 min · everyone
Wire the six functions into a registry and apply each per journey. The output is a long dataframe: every touch, its channel, and six credit columns.
Register the models by name so you can loop over them:
MODELS = {
"first_touch": first_touch,
"last_touch": last_touch,
"linear": linear,
"time_decay": time_decay,
"position_based": position_based,
"last_non_direct": last_non_direct,
}
def run_all(df):
out = df.copy()
for name, fn in MODELS.items():
credited = (df.groupby("customer_id", group_keys=False)
.apply(fn)[["credit"]]
.rename(columns={"credit": name}))
out = out.join(credited)
return out
per_touch = run_all(tp) # every touch now has 6 credit columns
Assert the invariant across all six models at once - each must conserve the order value within every journey:
for name in MODELS:
sums = per_touch.groupby("customer_id")[name].sum()
ov = tp.groupby("customer_id")["order_value"].first()
assert np.allclose(sums, ov), f"{name} does not conserve $!"
Run it on Lumen. If the assert passes, every model splits every journey exactly - no dollars invented, none lost.
The reconciliation table: same journey, six answers ★ 7 min · everyone
Now the payoff. Filter to Lumen's canonical customer and pivot the six models side by side. This is the builder-track version of Leader Session 1's headline chart - except you computed every cell.
Slice the one journey and reshape to channel-by-model:
one = per_touch[per_touch.customer_id == "LUMEN_CANON"]
table = (one.set_index("channel")[list(MODELS)]
.round(2))
print(table)
The result reconciles to canon exactly:
| channel | first | last | linear | time-decay | position | last-non-direct |
|---|---|---|---|---|---|---|
| display | $92.00 | $0 | $18.40 | ~$6 | $36.80 | $0 |
| paid_social | $0 | $0 | $18.40 | ~$13 | ~$6.13 | $0 |
| $0 | $0 | $18.40 | ~$19 | ~$6.13 | $0 | |
| organic_search | $0 | $0 | $18.40 | ~$25 | ~$6.13 | $0 |
| paid_search | $0 | $92.00 | $18.40 | ~$29 | $36.80 | $92.00 |
Read down the display row: $92, then $0, then $18.40, then ~$6, then $36.80, then $0. Same customer, same $92, six verdicts. You just reproduced the credit problem in code - and every column conserves to $92.
Ship this reconciliation table as an internal tool and it ends more attribution arguments than any slide. When the paid-search owner insists their channel "drives" 41%, you show the row: under linear it is $18.40, under first-touch it is $0. The disagreement was never about data - it was about which rule was quietly running.
Aggregate to channel-level credit across all Lumen journeys ★ 7 min · everyone
One journey is a teaching example; a budget needs every journey. Roll the six models up to channel totals across all of Lumen and you have produced the exact report each attribution setting would ship.
Group the per-touch credits by channel for each model:
channel_credit = (per_touch
.groupby("channel")[list(MODELS)]
.sum()
.round(0)
.sort_values("last_touch", ascending=False))
# Share of total credit under each model:
channel_share = (channel_credit / channel_credit.sum() * 100).round(1)
print(channel_share) # e.g. paid_search 41% last-touch, far less under first-touch
Compare the last_touch column to first_touch. On Lumen the demand-creating channels (display, paid_social) climb hard when you stop crowning the closer - the create-vs-capture split from Leader Session 1, now quantified.
Save channel_credit - Session 3 compares these heuristic totals head-to-head against a data-driven model to show exactly where the assumed rules mislead.
This week ◐ 35 min total
- Run all three build-alongs on Lumen and confirm the reconciliation table matches canon to the cent.
- Re-implement linear and time-decay in pure SQL on
lumen_fct_touchpoints(windowCOUNTfor linear; a windowed exponential for decay). Prove the SQL and pandas outputs agree. - Sweep the time-decay half-life across 1, 7, 30 days and watch the channel rollup slide from near-last-touch toward near-linear. Plot it.
- Optional: add a
W-shapedmodel (first, last, and the lead-conversion touch each get 30%) and slot it into the registry. Notice how easily one more asserted rule joins the pile - and how none of them learn.
Three questions before you go 🎯 ◐ 90 seconds
1 · Under the linear model, the credit each touch receives is...
Linear is one division: $92 / 5 = $18.40 to every touch. It conserves the dollar and asserts that every touch is worth exactly the same.
2 · In time-decay, the 7-day half-life controls...
Every 7 days back from the conversion, a touch's weight halves. Shrink the half-life and time-decay approaches last-touch; grow it and it flattens toward linear.
3 · What do all six heuristics have in common that a data-driven model does not?
Heuristics are fixed rules. Cleaner Lumen data never changes a single credit cell - only a different rule does. Learning credit from the data is the Session 3 leap.
What this session covers
This session implements, in runnable code, the heuristic model family that the leading attribution courses cover conceptually - and reconciles them on one shared journey so leader and builder tracks agree to the cent.