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

The touchpoint data model, and SQL journeys

Before any model can split a dollar, something has to turn a firehose of raw click events into an ordered journey per customer. That "something" is the part no course teaches - so we build it first. Three tables, one warehouse pattern, and a handful of window functions that quietly implement first-touch, last-touch, and position all at once. By the end you will hand every later session a clean path table on Lumen data. This is your ▶ start button for the builder track.

🟢 Start here Builders · DA / DE / DS SQL 45 min
0-3 · Setup 3-16 · The data model 16-40 · Build the journeys 40-45 · Wrap
Part 0

The gap every attribution project falls into

Every attribution tutorial you have ever read starts with a tidy little dataframe where each row is already a full customer journey. Reality never hands you that. Reality hands you a billion raw events - impressions, clicks, opens - scattered across Snowplow, Segment, a GA4 export, and a Meta CAPI feed, with no notion of "journey" anywhere. Session 1 builds the missing layer: the pipeline that stitches raw events into an ordered, per-customer path table the rest of the course can actually model on. We do it in SQL, on Lumen, with window functions carrying most of the weight.

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 runnable SQL pipeline that takes Lumen's raw touchpoints events and emits (1) an ordered touchpoint table with position_in_path and is_converting_touch, (2) one path string per customer for Markov and Shapley later, and (3) first-touch and last-touch credit in pure SQL. Every later session imports this output.
Part 1 · the schema

The three-table data model 6 min live

Lumen's whole world lives in three tables. Get these right and everything downstream - heuristics, Markov, Shapley, MMM - is just a query away. touchpoints is the event log (the grain is one interaction), conversions records the sale, and spend_weekly is the aggregate feed the mix models in B8 will need. Learn the keys that join them.

touchpoints · one interaction 🔑 touchpoint_id customer_id → event_ts (ordering key) channel (9-enum) interaction_type · device cost · geo · campaign position_in_path, is_converting → derived conversions · one sale 🔑 conversion_id customer_id → conversion_ts order_value ($92 canon) product_category new_vs_returning spend_weekly · MMM feed week · channel · geo spend · revenue impressions · reach frequency aggregate, no journeys - joined only by channel + geo customer_id touchpoints ⋈ conversions on customer_id is the whole game: it tells you which journeys ended in a $ and which did not. spend_weekly does NOT join on customer - it is person-level anonymous, which is exactly why MMM survives a cookieless world (B8). position_in_path and is_converting_touch are DERIVED - you compute them in Part 2, you never store them raw.
🔍 Click to zoom - the three Lumen tables and the keys that stitch them together
LiveWhy the grain matters3 min

The single most important word in this session is grain - the thing one row represents. In touchpoints, one row is one interaction: a single impression, click, or open. Not a session, not a customer, not a journey. Everything else is built up from that atom by grouping and ordering.

  • touchpoints - grain = one interaction. This is your raw material. Millions of rows, no order imposed yet.
  • conversions - grain = one sale. This is the label: did the journey pay off, and for how much (order_value)?
  • spend_weekly - grain = one channel-week-geo cell. No customer key at all. It exists for mix modeling, which never sees an individual.
Real world

The classic bug: someone joins touchpoints to conversions on customer_id without deduping, so a customer with 5 touches and 1 order suddenly has 5 conversion rows and $460 of revenue instead of $92. Grain discipline - knowing exactly what one row means at every step - is what stops fan-out joins from silently 5x-ing your revenue.

LiveThe 9-channel enum, fixed everywhere2 min

Lumen has exactly nine channels, and the order is fixed across every session so your outputs reconcile. Treat it as an enum, validate against it on ingest, and reject anything else.

Create demand (early)Capture demand (late)In between
display, paid_social, influencer, ctvpaid_search, organic_search, directemail, affiliate

Enum drift is a real production killer: a rogue "Paid Social " with a trailing space becomes a tenth channel, splits your credit, and nobody notices for a quarter. Lowercase, snake_case, validated on write.

Part 2 · the pipeline

From raw events to journeys with window functions 5 min live

Here is the data-stack pattern the whole industry converged on. Raw events land from your collectors, the warehouse holds them cheaply, dbt sessionizes and stitches, and out the other end comes a touchpoint fact table with an ordered path per customer. The magic in the middle is a small set of window functions.

Raw collectors Snowplow · Segment GA4 export · Meta CAPI firehose of events Warehouse BigQuery · Snowflake cheap storage, raw events land here dbt transform sessionize + stitch ROW_NUMBER, STRING_AGG FIRST/LAST_VALUE Touchpoint fact table ordered path per customer, ready for every model B2-B10 Inside "stitch": PARTITION BY customer_id ORDER BY event_ts turns an unordered pile of rows into a numbered journey. ROW_NUMBER → position · STRING_AGG → path string · FIRST_VALUE/LAST_VALUE → first & last touch, all in one pass. "Sessionize" = split a customer's events into visits by an inactivity gap (e.g. 30 min). "Stitch" = order and number them into a lifetime path. You do NOT need a Python ML library for any of this - the warehouse does it. Model libraries only enter in B3 onward.
🔍 Click to zoom - the raw-events → warehouse → dbt → path-table pipeline everyone actually runs
LiveThe four window functions that do the work3 min

Attribution heuristics are not exotic - they are window functions in disguise. Once you see the mapping, first-touch and last-touch stop being "models" and become one-liners.

  • ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY event_ts) - stamps each touch with its position_in_path. Position 1 is the first-touch, the max is the last.
  • FIRST_VALUE(channel) over that same window - the first-touch channel, directly.
  • LAST_VALUE(channel) - the last-touch channel, but only if you fix the frame (see the demo - the default frame bites everyone once).
  • STRING_AGG / ARRAY_AGG(channel ORDER BY event_ts) - collapses the journey into one path value, which is the input format Markov (B4) and Shapley (B5) expect.
Same window, many models Notice all four share one window spec: PARTITION BY customer_id ORDER BY event_ts. That partition-and-order is the single idea underneath every position-based attribution rule. Learn it once, reuse it everywhere.
Part 3 · the rules of the road

Attribution windows and the path string 4 min live

Two decisions quietly shape every number downstream: how far back you look, and how you serialize a journey. Both must be stated explicitly, because both are choices - not facts.

Self-studyLookback windows - state them or regret them2 min read

An attribution window (or lookback) is the maximum age of a touch that is allowed to earn credit for a conversion. Lumen uses a 90-day lookback for the standard model, matching its 7-21 day consideration cycle with headroom. A touch older than 90 days before the sale is dropped from the path.

  • Too short and you amputate the demand-creating channels - a display impression 40 days out vanishes, and display looks worthless (the Session A1 mistake, now in SQL).
  • Too long and you glue unrelated journeys together, crediting a channel for a sale it had nothing to do with.
  • Always write it in the query and the report. "Paid search drove 41%" is meaningless without "under a 90-day last-touch window". The window is part of the number.
Self-studyWhy the path string matters2 min read

The path string - display > paid_social > email > organic_search > paid_search - looks trivial, but it is the pivot of the entire course. Markov models (B4) build a transition matrix by counting how often one channel follows another in that string. Shapley (B5) treats the set of channels in the string as a coalition. Get the ordering or the delimiter wrong here and every learned model downstream is wrong too.

Real world

Teams often store the path with a comma delimiter, then a campaign name contains a comma, and the whole path parser shatters months later. Pick a delimiter that cannot appear in a channel name ( > is safe because channels are a fixed enum), and validate it on write.

Build-along 1 of 3

Build the ordered touchpoint table ★ 8 min · everyone

Our first query turns raw Lumen events into a numbered journey. ROW_NUMBER() gives every touch its position; a windowed MAX flags the converting touch. This is the table every later session imports.

Open your warehouse (BigQuery or SQLite both run this with tiny dialect tweaks). Point it at Lumen's raw touchpoints.

Apply the 90-day lookback in the WHERE, then number and flag with window functions:

demo1_ordered_touchpoints.sql
-- Ordered Lumen touchpoint fact table
-- One row per interaction, numbered within each customer journey
SELECT
  touchpoint_id,
  customer_id,
  event_ts,
  channel,
  interaction_type,
  cost,
  ROW_NUMBER() OVER (
    PARTITION BY customer_id
    ORDER BY event_ts
  ) AS position_in_path,
  -- the last touch in each journey = the converting touch
  CASE
    WHEN event_ts = MAX(event_ts) OVER (PARTITION BY customer_id)
    THEN TRUE ELSE FALSE
  END AS is_converting_touch
FROM touchpoints
WHERE event_ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)
ORDER BY customer_id, position_in_path;

Run it and eyeball Lumen's canonical customer: five rows, position_in_path 1 through 5, display at position 1 and paid_search at position 5 with is_converting_touch = TRUE. That matches the $92 journey exactly.

Materialize it (CREATE TABLE lumen_fct_touchpoints AS ... or a dbt model). Every session from B2 on reads this table, so name it once and never recompute the ordering ad hoc.

The default-frame trap, previewed ROW_NUMBER and MAX ... OVER are safe here, but LAST_VALUE is not - it uses a running frame by default. We fix that in Build-along 3. If your "last touch" ever looks like the current row instead of the real last one, that frame is why.
Build-along 2 of 3

Build the journey path string per customer ★ 7 min · everyone

Now collapse each ordered journey into a single path string. STRING_AGG with an ORDER BY inside it is the one-liner that feeds Markov and Shapley.

Aggregate the fact table down to one row per customer, ordering channels by time inside the aggregate:

demo2_journey_paths.sql
-- Collapse each customer's ordered touches into ONE path string
SELECT
  customer_id,
  STRING_AGG(channel, ' > ' ORDER BY event_ts) AS path,
  COUNT(*)        AS n_touches,
  MIN(event_ts)   AS first_touch_ts,
  MAX(event_ts)   AS last_touch_ts
FROM lumen_fct_touchpoints
GROUP BY customer_id;

-- Lumen's canonical customer returns:
-- display > paid_social > email > organic_search > paid_search
-- n_touches = 5

On BigQuery it is STRING_AGG; on Snowflake LISTAGG; on Postgres STRING_AGG too but the ORDER BY goes WITHIN GROUP. Same idea, watch the dialect.

Prefer ARRAY_AGG(channel ORDER BY event_ts) when the next step is Python - you get a real list, no re-parsing a delimited string.

Sanity-check: COUNT(DISTINCT path) tells you how many unique journey shapes Lumen has. That number is the state space Markov will work over in B4.

Real world

This one table - customer_id, path, n_touches - is often the single most reused artifact in a whole attribution stack. Analysts build dashboards off it, data scientists featurize from it, and the Markov/Shapley jobs read it directly. Ship it clean and you have earned your keep for the quarter.

Build-along 3 of 3

First-touch and last-touch in pure SQL ★ 7 min · everyone

Two attribution "models", zero libraries. FIRST_VALUE and LAST_VALUE read the endpoints of each journey straight out of the window - once you fix the frame.

Grab both endpoints per customer. The named WINDOW clause keeps it readable and lets both functions share one spec:

demo3_first_last_touch.sql
-- First-touch and last-touch credit, no library needed
SELECT DISTINCT
  customer_id,
  FIRST_VALUE(channel) OVER w AS first_touch_channel,
  LAST_VALUE(channel)  OVER w AS last_touch_channel
FROM lumen_fct_touchpoints
WINDOW w AS (
  PARTITION BY customer_id
  ORDER BY event_ts
  -- CRITICAL: without this frame, LAST_VALUE returns the CURRENT row,
  -- not the real last touch of the journey
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
);

-- Lumen canonical customer ->
-- first_touch_channel = display,  last_touch_channel = paid_search

Delete the ROWS BETWEEN ... line and rerun. Watch last_touch_channel turn into whatever row you are on. That is the default running frame - the bug that has cost more analysts a bad afternoon than any other in SQL attribution.

To turn these into dollars, join to conversions on customer_id and assign the full order_value to the first (or last) channel. First-touch hands display the whole $92; last-touch hands it to paid_search - exactly the mirror image from Leader Session 1.

Aggregate to channel level (GROUP BY last_touch_channel, SUM(order_value)) and you have just reproduced the last-click revenue report every dashboard ships - now you know precisely what rule it encodes.

You just built three models without saying "model" Position numbering, path strings, first/last credit - all pure SQL. B2 adds linear, time-decay, and position-based on top of this same table. You never leave the warehouse until the math genuinely needs it.
Before Session 2

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · To turn a pile of raw events into ordered per-customer journeys, the window function should...

You partition by the journey key (customer_id) and order by the time key (event_ts). That one spec underlies position_in_path, first-touch, and last-touch alike.

2 · Why build the path string with STRING_AGG / ARRAY_AGG at all?

Markov counts channel-to-channel transitions in the ordered path; Shapley treats the channel set as a coalition. No path string, no learned models downstream.

3 · A colleague reports "paid search drove 41% of revenue." What is missing before you can trust it?

The window is part of the number. "41% under a 90-day last-touch window" is a claim; "41%" alone is not. Always state the lookback in the query and the report.

Source material

What this session covers

This is the coverage gap the course owns outright: no existing attribution course teaches the SQL pipeline that produces the journey table everyone else assumes you already have. We build it from the warehouse pattern the industry actually runs.

GAP #5 (original) - SQL touchpoint/journey constructionthe 3-table model + window functions - Parts 1-2, all build-alongs
dbt / warehouse sessionize + stitch patternraw events → warehouse → dbt → fact table - Part 2
Attribution / lookback windows90-day lookback, stated explicitly - Part 3
The heuristic models on this tablelinear, time-decay, position in Builder Session 2
Production collectors (Snowplow / Segment setup)named as the source layer, not built - out of scope by design

Builder Session 1 cheat sheet · pin this

3 tablestouchpoints (grain = 1 interaction) · conversions (grain = 1 sale) · spend_weekly (channel-week-geo, no customer key).
The one window specPARTITION BY customer_id ORDER BY event_ts - underlies position, first-touch, and last-touch all at once.
Four functionsROW_NUMBER → position · FIRST_VALUE → first-touch · LAST_VALUE → last-touch (fix the frame!) · STRING_AGG → path.
LAST_VALUE trapDefault frame runs to the current row. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Lookback windowLumen = 90 days. Always stated. A number without its window is not a number.
The pipelineraw collectors → warehouse → dbt sessionize + stitch → touchpoint fact table → every model B2-B10.