The short answer
Revenue Operations (RevOps) keeps one agreed set of definitions, data and reports across marketing, sales and customer success. Attribution decides which marketing activity gets credit for revenue. A GTM engineer owns the pipes and the SQL behind it: touch data in, one credit table out, one dashboard on top. You will produce a defensible estimate and a range, because an exact answer does not exist.
Why this matters
The roadmap says: "If you cannot mathematically prove ROI, the CMO will cut your budget." ROI is return on investment, and a CMO is the Chief Marketing Officer who owns the marketing budget. I found no study showing that budgets get cut because ROI was not "mathematically proven", so treat that claim as folklore.
"Mathematically" is also the wrong standard. In 15 large Facebook ad experiments, observational methods often failed to reproduce the randomized results, even with extensive demographic and behavioral controls (Gordon, Zettelmeyer, Bhargava and Chapsky, Marketing Science 2019). Observational means comparing people who happened to see ads with people who did not, not randomly chosen groups. That is consumer advertising, but a model fitted to click data is likewise not proof of cause.
B2B is harder. Dreamdata's 2026 benchmark, built from more than 66 million sessions across more than 3.5 million customer journeys, reports an average journey of 272 days, 88 touchpoints and 10 stakeholders. That is vendor data from one platform, so read it as a rough picture: many people, many months, many channels.
So the real job is a number leaders can act on, the range across methods, and the next test. The practice goal, "which combination sourced your closed-won deals, exactly", has two traps: "sourced" is a naming rule, not a cause, and "exactly" is not available.
Core concepts
What RevOps owns
RevOps runs the revenue system: the CRM (customer relationship management system, the database of accounts, contacts and deals, such as HubSpot or Salesforce), funnel definitions, lead routing, forecasting and reporting. Marketing, sales and customer success each keep their own numbers, and the numbers disagree. A GTM engineer writes the SQL and enforces data rules so "pipeline" means one thing in every meeting.
Example, with made-up numbers: marketing counts form fills and reports 400 leads, sales counts first calls and reports 150. RevOps writes one rule.
Funnel stages and definitions
A funnel is an ordered list of stages a record passes through. HubSpot's default lifecycle stages are a common template, defined in its knowledge base:
| Stage | HubSpot's definition |
|---|---|
| Lead | Converted on your site or another interaction beyond a subscription sign-up |
| Marketing Qualified Lead (MQL) | Marketing has qualified it as ready for sales |
| Sales Qualified Lead (SQL) | Sales has qualified it as a potential customer |
| Opportunity | Associated with a deal |
| Customer | At least one closed deal |
HubSpot also has Subscriber, Evangelist and Other stages. In this table only, "SQL" means Sales Qualified Lead. Elsewhere in this module it means the query language.
The definitions matter more than the tool. Write down the rule that moves a record between stages. A deal is a possible sale tracked in the CRM, pipeline is the value of deals still open, and closed-won means signed. Also separate sourced pipeline, which credits the activity that created the opportunity, from influenced pipeline, which credits every activity that touched the account, so channel totals overlap and must never be summed. Example: a $50,000 deal touched by a webinar and an ad is $50,000 influenced for each, so two channels claim $100,000.
Funnel leakage and conversion math
Leakage is the share that drops out between stages. Step conversion is reached next stage / reached this stage. Cumulative conversion multiplies the steps. These numbers come from the project below:
| Stage | Accounts | Step conversion | Leaked |
|---|---|---|---|
| Known account | 4,000 | ||
| Engaged | 3,279 | 82.0% | 18.0% |
| Opportunity | 1,837 | 56.0% | 44.0% |
| Closed-won | 330 | 18.0% | 82.0% |
Cumulative conversion is 330 / 4,000 = 8.25%. The biggest absolute leak is opportunity to won (1,507 accounts), but the cheapest fix may be earlier, so weigh volume, not only the lowest rate. Measure a stage rate on the cohort that entered it, not on whoever is in the stage this month.
Cohort analysis
A cohort groups records by when they entered (for example, the month an account was first seen) and follows each group over time, so you can ask "do newer cohorts convert faster?" without mixing old and new. The key rule is right-censoring: a cohort from last month has not had six months to convert, so drop cells that have not happened yet. The project's query does this and uses a running sum, so each cell is "percent of the cohort with an opportunity by month k".
Example, with made-up numbers: 200 accounts are first seen in January. By month 0, 30 have an opportunity (15%), by month 1 a total of 40 (20%), by month 2 a total of 64 (32%). If today is in March, February's cohort has only months 0 and 1, so its month 2 cell stays blank.
Single-touch and rule-based models
A touch is one recorded interaction: an ad click, a webinar attendance, a reply to a cold email. First-touch gives 100% of credit to the earliest touch, last-touch to the latest. First-touch overpays what opens journeys (ads), last-touch what closes them (email, direct). Use them as the two ends of a range.
Multi-touch models split credit by a fixed rule. Take one $100,000 deal with four touches: LinkedIn ad (day 0), webinar (day 30), LinkedIn ad (day 45), cold email (day 58), and an opportunity created on day 60.
| Model | Rule | Webinar | Cold email | |
|---|---|---|---|---|
| First-touch | 100% to touch 1 | $100,000 | $0 | $0 |
| Last-touch | 100% to touch 4 | $0 | $0 | $100,000 |
| Linear | 25% each | $50,000 | $25,000 | $25,000 |
| U-shaped | 40% first, 40% last, 20% across the middle | $50,000 | $10,000 | $40,000 |
| Time decay | weight 0.5 to the power of (days before / 7) | about $20,800 | about $4,700 | about $74,500 |
W-shaped adds a third peak, often 30% each to the first, lead-creation and opportunity-creation touches and 10% to the rest. Weights are a choice, and vendors differ. Adobe Marketo Measure gives its U-shaped model 50% to the first touch and 50% to the lead-creation touch, with nothing for the middle. Google Analytics 4 removed its first-click, linear, time-decay and position-based models in November 2023. HubSpot replaced its U, W, J and inverse-J models with an Empirical model. Two tools that both say "U-shaped" may not match.
Account-based attribution and buying groups
A B2B deal is bought by a group, so the unit is the account, not the contact. You can gather touches two ways: every touch by anyone at the account in a lookback window before the opportunity (180 days in the project), or only touches by people attached to the deal as opportunity contact roles, such as champion. In Salesforce the object is OpportunityContactRole (ContactId, OpportunityId, Role, IsPrimary). In HubSpot, association labels do the same job.
Example, with made-up numbers: an account has 12 touches in the 180 days before its opportunity, but the 2 people with contact roles made only 3 of them. The account method spreads credit over 12 touches. The role method uses 3, so a webinar the other people attended gets nothing.
Roles are only as good as the habit of filling them in. Salesforce Campaign Influence creates an influence record only when a campaign member also holds a contact role on an open opportunity, and stops creating records once the opportunity closes. If reps skip roles, campaigns get no credit.
Data-driven attribution: Markov chains
Rule-based models guess the weights. Data-driven models learn them from journeys that converted and that did not. In a Markov chain the next step depends only on the current step. Anderl, Becker, von Wangenheim and Schumann (2016) apply Markov graphs to channel attribution. The usual way to turn the chain into credit is the removal effect:
- Treat each journey as a path: start, the channels in order, then "converted" or "null".
- Turn transition counts between states into probabilities.
- Compute the probability that a journey from start ends in "converted".
- Remove one channel (send every move into it to "null") and recompute.
- Removal effect is
1 - P(without) / P(with). A channel's credit is its removal effect divided by the sum of all removal effects.
In the project data the base probability is 8.66%. Without LinkedIn ads it falls to 2.99%, a removal effect of 65.5%. The four removal effects (LinkedIn ads 65.5%, cold email 44.5%, organic visits 35.5%, webinars 29.8%) sum to 175.4%, so LinkedIn's credit is 65.5 / 175.4, about 37.4%. Dividing by the sum makes the credits add to 100%.
Data-driven attribution: Shapley values
The Shapley value, from cooperative game theory, gives each channel its average marginal contribution across every order in which channels could join. For k channels it sums, over every subset S of the other channels, |S|! (k-|S|-1)! / k! times v(S + channel) - v(S). You must define v(S), the value a set of channels earns on its own. The project uses "conversions from journeys that used only channels in S", a modelling choice that another definition would change.
Example, with made-up numbers: A alone earns 10 conversions, B alone earns 6, together they earn 20. A joins first in half the orders (adds 10) and second in the other half (adds 20 - 6 = 14), so A gets 0.5 x 10 + 0.5 x 14 = 12. B gets 0.5 x 6 + 0.5 x (20 - 10) = 8. The two add to 20.
The work doubles with every channel added, so researchers look for faster formulations (Zhao, Mahboobi and Bagheri, 2018). Berman (2018) reports that last-touch credit can lead to lower advertiser profits and that Shapley credit can raise them.
UTM discipline
Every model fails if the channel label is wrong. UTM parameters are link tags that say where a visit came from. Google says to always set utm_source, utm_medium and utm_campaign, and warns that values are case sensitive, so utm_source=google and utm_source=Google are different rows. Example: ?utm_source=linkedin&utm_medium=paid_social&utm_campaign=q4_webinar. Fix a lowercase convention with a fixed list of mediums, generate links from one script and check for new values weekly. Tags miss word of mouth, podcasts and private chats.
Self-reported attribution
Add a free-text field, "How did you first hear about us?", to the demo form. Refine Labs compared it with software attribution in a 12-month test in 2023 on a "Book a Strategy Call" form (620 conversions). Software said 78% of conversions came from web searches, while buyers named web search 12% of the time and "dark social" (social media, podcasts, word of mouth and community, which tracking tags cannot see) for 85%. That is one consultancy's test, so treat it as a hint. Dreamdata's own test (100 form fills, 49 useful answers) found answers vague, such as "Google". Use answers to find channels tracking cannot see, then test them.
Incrementality and holdout tests
Incrementality asks how many conversions happened because of an activity, beyond those that would have happened anyway. Only an experiment answers it cleanly: randomly hold out a share of accounts, compare conversion with the exposed group, and the difference is the lift. The weakness is power: small lifts need big samples. In holdout.py, a webinar effect shows up with 4,000 accounts, but a 10% odds boost from cold email is noise until 100,000. B2B volumes are often too small, and a holdout costs revenue. Marketing mix modelling (MMM) is the other route: a statistical model that relates total spend per channel to total outcomes over time, so it needs no individual tracking. One example is Google's open-source Meridian (Apache-2.0).
The honest limits
Tracking is incomplete, journeys are observed rather than randomized, and the model choice moves the answer. In the project, webinars log fewer touches than ads and keen accounts do everything, so even data-driven models follow touch volume and intent more than cause. Show a range, name the assumptions, and pair the model with one experiment.
Key terms
The tools
| Tool | What it does | Pricing model | Notes |
|---|---|---|---|
| HubSpot attribution reporting | Contact and deal attribution in the CRM, with five models (first touch, last touch, linear, time decay, empirical) | Marketing Hub Professional from $800 per month (3 core seats), Enterprise from $3,600 per month (5), plus onboarding fees | Contact attribution needs Professional. Deal create and deal revenue attribution need Enterprise. |
| Salesforce Campaign Influence | Splits opportunity revenue across campaigns using contact roles | Listed for Professional, Enterprise, Performance, Unlimited and Developer editions | The default model gives 100% to one campaign. First, last and even-distribution models are for Account Engagement users only. |
| Adobe Marketo Measure | Formerly Bizible. Touchpoint tracking with U, W, full-path and custom models | Quote-based. I found no public list price | Adobe's docs list it as a current product. |
| Dreamdata | B2B revenue attribution and account journeys on CRM and ad data | Free plan at $0 per month (2 months of history, 5 seats), custom pricing above | The free plan is the easiest real-data sandbox. |
| DuckDB | In-process analytical SQL database used in the project | Free, MIT license | Runs on a laptop with no server. |
| Metabase | Dashboards over any SQL database | Open source is free to self-host. Cloud Starter lists $90 per month on annual billing | docker run -d -p 3000:3000 --name metabase metabase/metabase starts it. |
Build the project
You will build a sourced-revenue dashboard on a synthetic (made-up) company, then check it against a truth you control. A generator creates 4,000 accounts with buying groups, touches from LinkedIn ads, webinars, cold email and organic visits, and opportunities. Because you write the generator, you know what each channel truly adds, which real attribution never tells you. You need no budget, CRM or ad account.
Each account gets a hidden intent score from 0 to 1 for how keen it is to buy. Keen accounts open and win more and attend more webinars. When a hidden factor drives both exposure and winning, it is called confounding, and it flatters the channel. The generator builds it in.
- Make a folder named
revops, create a virtual environment and install the packages (shell block below). - Save
gen_data.pyin that folder and run it. It prints the true incremental wins and creates four tables. - Save
models.sql,funnel.sqlandcohort.sqlin the same folder. - Save
dashboard.pythere and run it. It prints the credit table and writesdashboard.html. - Check that each model column sums to $14,505,700, the total won revenue, give or take a dollar of rounding.
- Open
dashboard.htmland find the funnel step with the highest leak. - Save
data_driven.py, run it, and compare its shares with the rule-based shares. - Save
holdout.py, run it, and note which channel effects are clear at 4,000 accounts. - Write a half-page memo: sourced revenue per channel under three models, the range, and the one experiment you would run next.
- Optional: set
HUBSPOT_TOKEN, savehubspot_pull.pyand run it to print five closed-won deals.
mkdir revops && cd revops
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install duckdb numpy pandas requestsgen_data.py creates the world. Each channel has a hidden effect on win odds, and exposure depends on how keen the account is, which is the confounding that real attribution faces:
"""Synthetic B2B funnel with a known causal truth."""
import datetime as dt
import duckdb
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
N = 4000
START = dt.datetime(2025, 1, 1)
# channel -> (odds multiplier on win, base exposure, rise with intent)
CH = {
"linkedin_ads": (1.15, 0.55, 0.0), # broad reach, weak effect
"webinar": (2.00, 0.08, 0.35), # strong effect, attended by keen accounts
"cold_email": (1.10, 0.20, 0.55), # aimed at hot accounts, tiny effect
"organic": (1.00, 0.10, 0.60), # a symptom of intent, no effect
}
sig = lambda x: 1 / (1 + np.exp(-x))
days = lambda lo, hi: dt.timedelta(days=float(rng.uniform(lo, hi)))
contacts, touches, opps, roles = [], [], [], []
truth = dict.fromkeys(CH, 0.0)
cid = oid = 0
for acct in range(N):
intent = rng.beta(2, 5)
members = []
for _ in range(int(rng.integers(1, 7))): # a buying group of 1 to 6 people
cid += 1
members.append(cid)
contacts.append((cid, acct, START + days(0, 330)))
t_first = min(c[2] for c in contacts[-len(members):])
logit = -4.0 + 4.0 * intent + 0.8 # win odds given an opportunity
last_touch = t_first
hit = [k for k, (m, p0, s) in CH.items() if rng.random() < min(p0 + s * intent, 0.95)]
logit += sum(np.log(CH[k][0]) for k in hit)
p_opp = 0.35 + 0.4 * intent
for k in hit:
for _ in range(int(rng.integers(1, 5 if k == "linkedin_ads" else 3))):
ts = t_first + days(0, 150)
last_touch = max(last_touch, ts)
touches.append((acct, int(rng.choice(members)), k, ts))
# expected wins this channel adds: P(opp) * (P(win) - P(win without it))
truth[k] += p_opp * (sig(logit) - sig(logit - np.log(CH[k][0])))
if rng.random() < p_opp:
oid += 1
created = last_touch + days(1, 30)
opps.append((oid, acct, created, created + days(30, 120),
"won" if rng.random() < sig(logit) else "lost",
round(float(rng.lognormal(10.6, 0.5)), -2)))
for c in rng.choice(members, size=max(1, len(members) // 2), replace=False):
roles.append((oid, int(c), "champion" if c == members[0] else "influencer"))
con = duckdb.connect("revops.duckdb")
tables = {
"contacts": (contacts, ["contact_id", "account_id", "created_at"]),
"touches": ([(i + 1, *t) for i, t in enumerate(touches)],
["touch_id", "account_id", "contact_id", "channel", "touch_ts"]),
"opportunities": (opps, ["opp_id", "account_id", "created_at", "closed_at", "stage", "amount"]),
"opportunity_contact_roles": (roles, ["opp_id", "contact_id", "role"]),
}
for name, (rows, cols) in tables.items():
df = pd.DataFrame(rows, columns=cols)
con.execute(f"create or replace table {name} as select * from df")
print({k: round(float(v), 1) for k, v in truth.items()}, "true incremental wins")
print(len(contacts), len(touches), len(opps), sum(o[4] == "won" for o in opps), "won")models.sql builds one row per won deal and touch, then applies each model's weights. The left join keeps deals with no touch, so every model ties out to total revenue:
-- models.sql. Every touch at the account in the 180 days before the opportunity. The left join keeps untouched deals.
create or replace view paths as
select o.opp_id, o.amount, coalesce(t.channel, 'no_touch') as channel, t.touch_ts,
row_number() over w as pos,
count(*) over (partition by o.opp_id) as n,
coalesce(date_diff('day', t.touch_ts, o.created_at), 0) as days_before
from opportunities o
left join touches t
on t.account_id = o.account_id
and t.touch_ts <= o.created_at
and t.touch_ts > o.created_at - interval 180 day
where o.stage = 'won'
window w as (partition by o.opp_id order by t.touch_ts, t.touch_id);
-- Credit weights. Each model sums to 1 per opportunity.
create or replace view credit as
select *,
(pos = 1)::int as w_first,
(pos = n)::int as w_last,
1.0 / n as w_linear,
case when n = 1 then 1.0
when n = 2 then 0.5
when pos in (1, n) then 0.4
else 0.2 / (n - 2) end as w_u_shaped,
power(0.5, days_before / 7.0)
/ sum(power(0.5, days_before / 7.0)) over (partition by opp_id) as w_time_decay
from paths;
select channel,
round(sum(amount * w_first)) as first_touch,
round(sum(amount * w_last)) as last_touch,
round(sum(amount * w_linear)) as linear,
round(sum(amount * w_u_shaped)) as u_shaped,
round(sum(amount * w_time_decay)) as time_decay
from credit
group by channel
order by channel;funnel.sql gives the leakage table, and cohort.sql reads its view, so run the funnel first:
-- funnel.sql. One row per account with a flag per stage reached.
create or replace view account_funnel as
select a.account_id,
date_trunc('month', a.first_seen) as cohort,
exists (select 1 from touches t where t.account_id = a.account_id) as engaged,
o.opp_id is not null as has_opp,
coalesce(o.stage = 'won', false) as won
from (select account_id, min(created_at) as first_seen from contacts group by 1) a
left join opportunities o on o.account_id = a.account_id;
with s as (
select count(*) as known,
count(*) filter (where engaged) as engaged,
count(*) filter (where has_opp) as opp,
count(*) filter (where won) as won
from account_funnel)
select stage, accounts,
round(100.0 * accounts / lag(accounts) over (order by ord), 1) as step_conv_pct,
round(100.0 - 100.0 * accounts / lag(accounts) over (order by ord), 1) as leaked_pct
from (select 1 as ord, 'known account' as stage, known as accounts from s
union all select 2, 'engaged', engaged from s
union all select 3, 'opportunity', opp from s
union all select 4, 'closed-won', won from s)
order by ord;-- cohort.sql. Share of each first-seen month that reached an opportunity within N months.
with as_of as (select date_trunc('month', max(created_at)) as m from opportunities),
size as (select cohort, count(*) as n from account_funnel group by 1),
hits as (
select f.cohort, date_diff('month', f.cohort, date_trunc('month', o.created_at)) as k, count(*) as c
from account_funnel f join opportunities o using (account_id)
group by 1, 2),
grid as (
select s.cohort, s.n, k
from size s, range(0, 7) as r(k), as_of
where s.cohort + to_months(k) <= as_of.m) /* drop cells that have not happened yet */
select g.cohort::date as cohort, g.n, g.k as months_since,
round(100.0 * sum(coalesce(h.c, 0)) over (partition by g.cohort order by g.k) / g.n, 1) as pct_with_opp
from grid g left join hits h on h.cohort = g.cohort and h.k = g.k
order by 1, 3;dashboard.py runs all three files and writes one static page:
"""Build dashboard.html from the SQL files."""
import duckdb
con = duckdb.connect("revops.duckdb")
def run(path):
stmts = [s for s in open(path).read().split(";") if s.strip()]
for s in stmts[:-1]:
con.execute(s)
return con.execute(stmts[-1]).fetchdf()
models = run("models.sql").set_index("channel")
print(models.round().to_string())
touched = models.drop("no_touch")
share = (touched / touched.sum() * 100).round(1) # percent of touched revenue per model
funnel = run("funnel.sql")
cohort = run("cohort.sql").pivot(index="cohort", columns="months_since", values="pct_with_opp")
parts = [
"<h2>Sourced won revenue by channel (USD)</h2>", models.to_html(float_format="{:,.0f}".format),
"<h2>Share of sourced revenue by model (%)</h2>", share.to_html(),
"<h2>Funnel leakage</h2>", funnel.to_html(index=False, na_rep=""),
"<h2>Cohort: % of accounts with an opportunity, by months since first seen</h2>",
cohort.to_html(na_rep=""),
]
html = ("<!doctype html><meta charset=utf-8><title>Revenue attribution</title>"
"<style>body{font:15px system-ui;margin:2rem}table{border-collapse:collapse}"
"td,th{border:1px solid #ccc;padding:4px 10px;text-align:right}</style>" + "".join(parts))
open("dashboard.html", "w", encoding="utf-8").write(html)
print("wrote dashboard.html")data_driven.py computes the Markov removal effect and Shapley values from account journeys, including accounts that never converted:
"""Markov removal effect and Shapley values on account journeys. Run: python data_driven.py"""
from collections import Counter
from itertools import combinations
from math import factorial
import duckdb
import numpy as np
con = duckdb.connect("revops.duckdb", read_only=True)
rows = con.execute("""
select list(t.channel order by t.touch_ts, t.touch_id) as path,
coalesce(bool_or(o.stage = 'won'), false) as converted
from touches t
left join opportunities o on o.account_id = t.account_id
where o.created_at is null or t.touch_ts <= o.created_at
group by t.account_id
""").fetchall()
journeys = [(list(p), bool(c)) for p, c in rows]
def p_conv(journeys, drop=None):
"""P(start -> conv) in a Markov chain. Dropping a channel sends its inflow to null."""
n = Counter()
for path, conv in journeys:
seq = ["start", *path, "conv" if conv else "null"]
for a, b in zip(seq, seq[1:]):
if a != drop:
n[(a, "null" if b == drop else b)] += 1
out = Counter()
for (a, _), c in n.items():
out[a] += c
ix = {s: i for i, s in enumerate(sorted(out))}
Q, r = np.zeros((len(ix), len(ix))), np.zeros(len(ix))
for (a, b), c in n.items():
if b == "conv":
r[ix[a]] += c / out[a]
elif b in ix:
Q[ix[a], ix[b]] += c / out[a]
return np.linalg.solve(np.eye(len(ix)) - Q, r)[ix["start"]]
channels = sorted({c for p, _ in journeys for c in p})
base = p_conv(journeys)
removal = {c: 1 - p_conv(journeys, drop=c) / base for c in channels}
markov = {c: v / sum(removal.values()) for c, v in removal.items()}
def shapley(journeys, channels):
sets = Counter(frozenset(p) for p, conv in journeys if conv) # converting journeys by channel set
v = lambda S: sum(n for s, n in sets.items() if s <= S) # conversions reachable with only S
k, out = len(channels), {}
for c in channels:
rest = [x for x in channels if x != c]
out[c] = sum(
factorial(len(S)) * factorial(k - len(S) - 1) / factorial(k)
* (v(frozenset(S) | {c}) - v(frozenset(S)))
for m in range(k) for S in combinations(rest, m))
return {c: x / sum(out.values()) for c, x in out.items()}
shap = shapley(journeys, channels)
print(f"{'channel':14}{'markov':>8}{'shapley':>9}")
for c in channels:
print(f"{c:14}{markov[c]:8.1%}{shap[c]:9.1%}")holdout.py randomizes exposure and measures lift with a 95% interval:
"""Randomized holdout in the same world as gen_data.py."""
import numpy as np
rng = np.random.default_rng(7)
def holdout(odds_multiplier, n):
intent = rng.beta(2, 5, n)
treated = rng.random(n) < 0.5 # the randomization
logit = -3.2 + 4 * intent + np.where(treated, np.log(odds_multiplier), 0)
opp = rng.random(n) < 0.35 + 0.4 * intent
won = opp & (rng.random(n) < 1 / (1 + np.exp(-logit)))
p1, p0 = won[treated].mean(), won[~treated].mean()
se = np.sqrt(p1 * (1 - p1) / treated.sum() + p0 * (1 - p0) / (~treated).sum())
return p1 - p0, 1.96 * se
for name, mult in [("webinar", 2.0), ("linkedin_ads", 1.15), ("cold_email", 1.10)]:
for n in (4_000, 100_000):
lift, ci = holdout(mult, n)
print(f"{name:13} n={n:>7,} lift {lift:+.3f} +/- {ci:.3f} {'clear' if abs(lift) > ci else 'noise'}")For a real HubSpot account, hubspot_pull.py uses the date-based 2026-09 API. HubSpot says v1 to v3 endpoints are unsupported after September 2027, and v4 support ends on March 30, 2027, so new code uses dated paths. The deal search endpoint returns at most 200 records per page, and HubSpot's usage guidelines say search has stricter limits. Confirm that the automatic "Is Closed Won" property is named hs_is_closed_won under Settings, Properties, and use a private app token with read access to deals. On Salesforce, the same job is a SOQL query on OpportunityContactRole.
"""Closed-won deals from HubSpot. Needs HUBSPOT_TOKEN."""
import os
import time
import requests
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['HUBSPOT_TOKEN']}"}
def closed_won_deals():
"""Yield closed-won deals. 0-3 is HubSpot's object type ID for deals."""
body = {
"filterGroups": [{"filters": [
{"propertyName": "hs_is_closed_won", "operator": "EQ", "value": "true"}]}],
"properties": ["dealname", "amount", "createdate", "closedate"],
"limit": 200,
}
while True:
r = requests.post(f"{BASE}/crm/objects/2026-09/0-3/search",
headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
page = r.json()
yield from page["results"]
after = page.get("paging", {}).get("next", {}).get("after")
if not after:
return
body["after"] = after
time.sleep(0.5) # pause between pages to stay under the search rate limit
if __name__ == "__main__":
for _, deal in zip(range(5), closed_won_deals()):
print(deal["id"], deal["properties"].get("dealname"), deal["properties"].get("amount"))What the run shows. All five model columns tie to $14,505,700, of which $1,917,300 had no touch at all. Among deals with touches, LinkedIn ads get 39% to 44% of credit under every rule and webinars 16% to 21%. Markov and Shapley give LinkedIn 37% to 38% and webinars 17% to 20%. The generator's truth is different: of the wins the channels cause, webinars cause 58.7%, LinkedIn ads 27.6%, cold email 13.7% and organic visits 0%. Every model still gives organic 14% to 20% and underpays webinars by roughly two thirds. Only the holdout sees the webinar effect (lift +0.061, plus or minus 0.018, at 4,000 accounts). That gap is the lesson.
What done looks like. The pipeline runs from an empty folder, the dashboard shows five models side by side, and you can explain in two sentences why the data-driven models missed the truth.
Stretch goal. Make webinar exposure independent of intent in the generator, rerun everything and watch the models move toward the truth. Then add a W-shaped model.
Common mistakes
- Treating one model as the truth. Show first-touch, last-touch and one data-driven model, with the range.
- Adding up influenced revenue across channels. It usually exceeds 100%, so only credit-weighted numbers are additive.
- Letting UTM values drift (
LinkedIn,linkedin,li), which splits one channel into three rows. - Ignoring right-censoring, so recent cohorts look worse than old ones.
- Reading a data-driven model as causal. It reshuffles observed credit. Only a holdout measures lift.
- Skipping contact roles. When reps leave them blank, account credit quietly disappears.
Check yourself
1. A deal has four touches: ad, webinar, ad, email. Which model gives the email the most credit?
Why: Last-touch gives the final touch 100%. U-shaped gives it 40% and linear 25%.
2. A funnel converts 56% from engaged to opportunity and 18% from opportunity to won. What is the cumulative conversion across both steps?
Why: Step rates multiply, so 0.56 times 0.18 is about 0.101.
3. A report gives each deal's full amount to every channel that touched it, sums per channel and gets 240% of closed revenue. What does that tell you?
Why: Influenced revenue gives each touching channel full credit, so the sum can exceed 100%.
4. A Markov model drops conversion probability from 8% to 4% when you remove email. What is email's removal effect?
Why: Removal effect is 1 minus P(without) over P(with), so 1 minus 4 over 8.
5. In the project, a Markov model credits organic visits with 20% of revenue, yet the generator gives organic no effect on win odds. What is the best explanation?
Why: Models reshuffle credit among observed touches, so a channel keen accounts use anyway earns credit without causing the sale.
How to learn this faster
Learn in this order: funnel math, then SQL window functions (row_number, count over, running sums), then the rule-based models, then one data-driven model. Skip a Shapley engine for 15 channels and skip chasing the "best" model.
The drill: write one deal's credit under all five rules on paper, then confirm your SQL matches. Do three deals, including one with a single touch.
- Day 1: Write funnel rules for a company you know. Run
gen_data.py. - Day 2: Write first-touch, last-touch and linear queries. Check they tie to total revenue.
- Day 3: Add U-shaped and time decay. Do the paper drill on three deals.
- Day 4: Run the Markov and Shapley code. Change one channel's effect and watch the shares.
- Day 5: Run the holdout, build the dashboard and write the memo.
Resources
- HubSpot: create attribution reportsDocsFree
Current models and the tier each report needs.
- Salesforce Campaign Influence implementation guide (PDF)DocsFree
Contact-role auto-association and the standard models.
- Adobe Marketo Measure attribution modelsDocsFree
Exact weights for U, W and full-path models.
- Google Analytics: attribution models in GA4DocsFree
The models left after the November 2023 removals.
- Gordon et al.: comparison of advertising measurement approachesResearchFree
Observational attribution can be badly wrong.
- Anderl et al.: mapping the customer journeyResearchFree
Markov graphs for channel attribution.
- Berman: beyond the last touchResearchFree
The case for Shapley over last-touch.
- Refine Labs: the attribution mirageArticleFree
Software versus self-reported attribution.
- Google MeridianRepoFree
Open-source marketing mix modelling.
- DuckDB documentation: window functionsDocsFree
The features the credit queries use.
Next module
You can now say where revenue came from. Stage 7, AI Sales Enablement and Coaching, turns to the people who close it: how call recordings and models help reps improve.
Built the project and passed the quiz? Mark the stage done. Your progress stays in this browser only.
Talk to Claude instead of typing to it.
Ducktate turns what you say into clean text, on your Mac, and pastes it anywhere.
Get early access