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

Heuristics in code: six models, one table

Leaders learned that six heuristic rules give six different answers. Now you implement all six on Lumen - in SQL and pandas - and reconcile them on the exact $92 journey down to the cent. Linear is one division. Time-decay is one exponential. Position-based is three constants. None of them touch a training set. By the end you output one tidy table where the same journey shows six columns of credit, and you understand every number in it.

🟢 Foundational Builders SQL + pandas 45 min
0-3 · Recap 3-20 · The six rules in code 20-42 · Reconcile + aggregate 42-45 · Wrap
Part 0

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.

Live - built in session Self-study - read after class ★ Build-along - everyone codes it The data: Lumen Skincare
★ What you walk out with today A single pandas function per heuristic - first-touch, last-touch, linear, time-decay, position-based, last-non-direct - plus one reconciliation table that shows all six splitting Lumen's $92 journey, matching the canon to the cent (linear $18.40 each, U-shaped $36.80 on the ends). Then the same models rolled up to channel-level credit across every Lumen journey.
Part 1 · the easy three

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.

heuristics.py · load + single-touch + linear
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 always sums to the order value Every heuristic must conserve the dollar: within one journey the 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.
Part 2 · the exponential one

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.

~$6 ~$13 ~$19 ~$25 ~$29 display p.social email organic p.search day -14 day -7 day -3 day -1 day 0 weight = 2 ** (-days_before_conversion / 7). Normalize the 5 weights, multiply by $92 -> the credits above, summing to $92.
🔍 Click to zoom - time-decay weights climb toward the conversion (7-day half-life on Lumen's $92 journey)
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.

heuristics.py · time-decay (7-day half-life)
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)
Real world

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.

Part 3 · the shape and the filter

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.

Position-based: 40% first, 40% last, 20% split across the middle Journey total $92.00 First touch $36.80 40% of the journey 3 middle touches $18.40 ~$6.13 each, 20% total Last touch $36.80 40% of the journey The touch that opened and the touch that closed the journey both outweigh the middle three.
🔍 Click to zoom - the U-shape rewards the opener and the closer, not the middle
LiveU-shaped credit and the direct filter3 min
heuristics.py · position-based + last-non-direct
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)
Why last-non-direct exists "Direct" often is not a real channel - it is a customer typing your URL because an earlier ad already did the work, or a lost UTM. Crediting direct rewards your own missing tracking. Skipping it hands credit to the last marketing touch, which is usually what you actually meant. On Lumen's canonical path there is no direct touch, so it equals last-touch - but on real data it quietly reshuffles a lot of credit.
Build-along 1 of 3

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:

demo1_run_all.py
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:

demo1_run_all.py · the safety test
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.

Build-along 2 of 3

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:

demo2_reconcile.py
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:

channelfirstlastlineartime-decaypositionlast-non-direct
display$92.00$0$18.40~$6$36.80$0
paid_social$0$0$18.40~$13~$6.13$0
email$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.

Same $92 display touch, six credit answers First-touch $92.00 Last-touch $0 Linear $18.40 Time-decay ~$6 Position-based $36.80 Last-non-direct $0 Same $92 journey, six verdicts on the display touch - the argument was always which rule ran.
🔍 Click to zoom - the disagreement was never the data, it was which rule was running
Real world

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.

Build-along 3 of 3

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:

demo3_channel_rollup.py
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.

The heuristics' honest limit Every number you just produced came from a rule you typed, not a pattern you learned. No amount of cleaner Lumen data changes a single cell - only a better model does. That gap is precisely where Session 3 begins.
Before Session 3

This week ◐ 35 min total

Check yourself

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.

Source material

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.

Marketing Attribution and Mix Modeling (LinkedIn Learning) - ch.2first/last/linear/time-decay/position - Parts 1-3
Marketing Measurement: Attribution to Incrementality (Udemy)heuristic biases + last-non-direct - Part 3, reconciliation
Channel-level rollup across all journeysthe report each model would ship - Build-along 3
Why these rules misleadthe data-driven leap in Builder Session 3
Markov & Shapley (learned credit)built from scratch in Builder Sessions 4 and 5

Builder Session 2 cheat sheet · pin this

Linearcredit = order_value / n_touches. Lumen: $92 / 5 = $18.40 each. The simplest conserving split.
Time-decayweight = 2 ** (-days_before / half_life), normalize, scale by $. Lumen 7-day: display ~$6 → paid_search ~$29.
Position (U)0.4 first + 0.4 last, 0.2 split across the middle. Lumen: display & paid_search $36.80, middle 3 ~$6.13.
Last-non-directLast-touch, but skip 'direct'. Equals last-touch on Lumen's path (no direct touch present).
The invariantEvery model's credit sums to order_value within each journey. Assert it - it catches almost every bug.
The honest limitAll six need zero training data. Cleaner data never moves a cell - only a different rule does. Hence Session 3.