Learn how to build a complete marketing attribution engine in pandas that processes raw event-level touchpoint data, correctly handles multi-conversion customers, and applies first-touch, last-touch, and linear attribution models with full revenue conservation validation. This is the attribution infrastructure your marketing team doesn't know to ask for.

Marketing teams have been arguing about attribution for decades, and for good reason: it's genuinely hard. A customer sees a Facebook ad on Monday, clicks a Google search ad on Wednesday, opens an email on Friday, and converts on Saturday through a direct visit. Which channel deserves credit? Give it all to Facebook and you'll overfund top-of-funnel. Give it all to the last click and you'll starve awareness campaigns that started the journey. Spread it evenly and someone will ask why you chose that over anything else.
The truth is that no attribution model is perfectly correct. Each one is a deliberate simplification that encodes assumptions about how your marketing works. The real skill isn't picking the "right" model — it's building infrastructure that lets you run multiple models simultaneously, compare the results, and make defensible decisions about budget allocation. That's exactly what you'll build in this lesson.
By the end of this article you'll have a complete, production-ready pandas attribution engine that processes raw event-level touchpoint data, applies first-touch, last-touch, and linear attribution rules, and produces a comparison report you can hand to a marketing team or drop into an automated pipeline. You'll also understand the edge cases that break naive implementations and how to handle them gracefully.
What you'll learn:
You should be comfortable with pandas groupby, merge, and sorting operations. If you haven't worked through Grouping and Aggregating in pandas: groupby as the PivotTable Replacement and Joining DataFrames with pandas merge: SQL Joins and VLOOKUP in Python, do those first — you'll use both heavily here. Familiarity with sort_values, boolean indexing, and apply will also help. If you need to review environment setup, see Setting Up Python for Data Analysis: Python, VS Code, Jupyter, and Virtual Environments.
Before writing a single line of code, you need to be clear on what data attribution analysis actually requires. There are two separate datasets that have to be joined:
In real systems these often live in separate tables, sometimes in different databases. For our pipeline, we'll represent them as two DataFrames that get merged.
Here's the schema we'll work with:
Touchpoints table:
customer_id — unique identifier for the customertouchpoint_id — unique identifier for this specific interactionchannel — the marketing channel (e.g., "paid_search", "email", "organic_social")touchpoint_date — when the interaction happenedConversions table:
customer_id — links back to the touchpoint tableconversion_id — unique identifier for this conversion eventconversion_date — when the purchase happenedrevenue — the dollar amount of this conversionA customer can have multiple conversions over time, and you need to be careful to only attribute touchpoints to the correct conversion window. That's a subtlety we'll address shortly.
Let's build a realistic synthetic dataset:
import pandas as pd
import numpy as np
np.random.seed(42)
# --- Touchpoints ---
touchpoints_raw = [
# Customer 1001: typical multi-touch journey
("1001", "tp_001", "paid_search", "2024-01-03"),
("1001", "tp_002", "organic_social", "2024-01-07"),
("1001", "tp_003", "email", "2024-01-10"),
("1001", "tp_004", "direct", "2024-01-12"),
# Customer 1002: single touchpoint, easy case
("1002", "tp_005", "paid_search", "2024-01-05"),
# Customer 1003: same channel twice — how do models handle this?
("1003", "tp_006", "display_ads", "2024-01-02"),
("1003", "tp_007", "display_ads", "2024-01-08"),
("1003", "tp_008", "email", "2024-01-11"),
# Customer 1004: long journey with many channels
("1004", "tp_009", "organic_search", "2024-01-01"),
("1004", "tp_010", "paid_search", "2024-01-04"),
("1004", "tp_011", "display_ads", "2024-01-06"),
("1004", "tp_012", "email", "2024-01-09"),
("1004", "tp_013", "organic_social", "2024-01-11"),
("1004", "tp_014", "direct", "2024-01-13"),
# Customer 1005: two separate conversions (requires windowing)
("1005", "tp_015", "organic_search", "2024-01-02"),
("1005", "tp_016", "email", "2024-01-05"),
# Gap — second purchase journey starts here
("1005", "tp_017", "paid_search", "2024-01-20"),
("1005", "tp_018", "direct", "2024-01-24"),
# Customer 1006: touchpoints on same date
("1006", "tp_019", "email", "2024-01-06"),
("1006", "tp_020", "paid_search", "2024-01-06"),
("1006", "tp_021", "organic_social", "2024-01-09"),
]
touchpoints = pd.DataFrame(
touchpoints_raw,
columns=["customer_id", "touchpoint_id", "channel", "touchpoint_date"]
)
touchpoints["touchpoint_date"] = pd.to_datetime(touchpoints["touchpoint_date"])
# --- Conversions ---
conversions_raw = [
("1001", "conv_001", "2024-01-13", 250.00),
("1002", "conv_002", "2024-01-06", 89.99),
("1003", "conv_003", "2024-01-12", 175.50),
("1004", "conv_004", "2024-01-14", 420.00),
("1005", "conv_005", "2024-01-07", 130.00), # First conversion
("1005", "conv_006", "2024-01-26", 210.00), # Second conversion
("1006", "conv_007", "2024-01-10", 95.00),
]
conversions = pd.DataFrame(
conversions_raw,
columns=["customer_id", "conversion_id", "conversion_date", "revenue"]
)
conversions["conversion_date"] = pd.to_datetime(conversions["conversion_date"])
print(f"Touchpoints: {len(touchpoints)} rows")
print(f"Conversions: {len(conversions)} rows")
print(f"Unique customers: {touchpoints['customer_id'].nunique()}")
Note
Customer 1005 has two conversions on separate dates and touchpoints split between those journeys. This is the hardest case in attribution — you need to ensure that tp_015 and tp_016 only get attributed to conv_005, and tp_017/tp_018 only to conv_006. Naively joining on customer_id alone will double-attribute touchpoints.
The core of any attribution system is the join logic that assigns touchpoints to the correct conversion event. The rule is: a touchpoint belongs to a conversion if it occurred before that conversion date and after the previous conversion date (if one exists) for the same customer.
This is not a simple pd.merge() call. You need a cross-join filtered by date conditions.
def assign_touchpoints_to_conversions(touchpoints_df, conversions_df):
"""
Join touchpoints to conversions using an attribution window.
Each touchpoint is assigned to the earliest conversion that:
1. Occurred after the touchpoint date
2. Belongs to the same customer
Returns a DataFrame with one row per touchpoint, annotated with
its conversion_id and revenue.
"""
# Cross-join on customer_id, then filter by date logic
# We use a merge on customer_id first (much faster than a true cross-join)
joined = touchpoints_df.merge(
conversions_df[["customer_id", "conversion_id", "conversion_date", "revenue"]],
on="customer_id",
how="inner"
)
# Keep only touchpoints that occurred BEFORE or ON the conversion date
joined = joined[joined["touchpoint_date"] <= joined["conversion_date"]]
# For each touchpoint, find the EARLIEST eligible conversion
# (this correctly handles customers with multiple conversions)
joined = joined.sort_values(["touchpoint_id", "conversion_date"])
joined = joined.drop_duplicates(subset=["touchpoint_id"], keep="first")
return joined.reset_index(drop=True)
attributed = assign_touchpoints_to_conversions(touchpoints, conversions)
print(f"\nAttributed touchpoints: {len(attributed)}")
print(attributed[["customer_id", "touchpoint_id", "channel", "touchpoint_date", "conversion_id", "conversion_date", "revenue"]].to_string(index=False))
Let's verify customer 1005 is handled correctly:
check_1005 = attributed[attributed["customer_id"] == "1005"][
["touchpoint_id", "channel", "touchpoint_date", "conversion_id", "revenue"]
]
print(check_1005.to_string(index=False))
# Expected:
# tp_015 organic_search 2024-01-02 conv_005 130.0
# tp_016 email 2024-01-05 conv_005 130.0
# tp_017 paid_search 2024-01-20 conv_006 210.0
# tp_018 direct 2024-01-24 conv_006 210.0
Warning
If you skip the date filtering and just join on customer_id, every touchpoint gets paired with every conversion for that customer. For a customer with 4 touchpoints and 2 conversions, you'd get 8 rows instead of 4. This inflates attributed revenue grotesquely and is one of the most common bugs in homegrown attribution code. Always validate your join with a row-count check: the output should have exactly as many rows as there are touchpoints (assuming every touchpoint has at least one eligible conversion).
Before building any models, validate the join. Garbage in, garbage out — and in attribution analysis, incorrect joins are exceptionally easy to miss because the final numbers can look plausible even when they're wrong.
def validate_attribution_join(original_touchpoints, attributed_df):
"""Run sanity checks on the attribution join result."""
issues = []
# 1. Check for touchpoints that got duplicated (joined to multiple conversions)
duplicate_touchpoints = attributed_df["touchpoint_id"].duplicated().sum()
if duplicate_touchpoints > 0:
issues.append(f"FAIL: {duplicate_touchpoints} touchpoints matched to multiple conversions")
else:
print("PASS: No duplicate touchpoints")
# 2. Check for orphaned touchpoints (in original but missing from attributed)
missing_tps = set(original_touchpoints["touchpoint_id"]) - set(attributed_df["touchpoint_id"])
if missing_tps:
issues.append(f"INFO: {len(missing_tps)} touchpoints had no eligible conversion: {missing_tps}")
else:
print("PASS: All touchpoints matched to at least one conversion")
# 3. Check that touchpoint_date is always before conversion_date
date_violations = attributed_df[
attributed_df["touchpoint_date"] > attributed_df["conversion_date"]
]
if len(date_violations) > 0:
issues.append(f"FAIL: {len(date_violations)} touchpoints dated AFTER their conversion")
else:
print("PASS: All touchpoints precede their conversion dates")
# 4. Revenue sanity — total attributed revenue per conversion_id should equal original revenue
rev_check = attributed_df.groupby("conversion_id")["revenue"].nunique()
if (rev_check > 1).any():
issues.append("FAIL: Multiple revenue values found for the same conversion_id")
else:
print("PASS: Revenue values are consistent per conversion")
if issues:
print("\nIssues found:")
for issue in issues:
print(f" - {issue}")
else:
print("\nAll validation checks passed.")
return len(issues) == 0
validate_attribution_join(touchpoints, attributed)
This validation pattern — check for duplicates, check for orphans, check date logic, check revenue consistency — is worth running every time this pipeline runs in production, not just during development. If you're building this into a scheduled report, have it raise an exception when critical checks fail. See Building and Automating Recurring Reports with pandas: Scheduling Scripts to Run Without You for how to wire that into a monitoring layer.
Now for the interesting part. We have a clean attributed DataFrame where each row is one touchpoint assigned to one conversion. Our three models all work on this same dataset — they just differ in how they allocate the conversion's revenue across the touchpoints.
First-touch gives 100% of the credit to the first touchpoint in the customer journey. The logic: identify the touchpoint with the earliest date, assign it the full revenue, give everyone else zero.
def apply_first_touch(attributed_df):
"""
First-touch attribution: 100% credit to the earliest touchpoint per conversion.
Returns the attributed DataFrame with a 'first_touch_credit' column.
"""
df = attributed_df.copy()
# Sort by conversion, then by touchpoint date ascending
df = df.sort_values(["conversion_id", "touchpoint_date", "touchpoint_id"])
# Rank touchpoints within each conversion window
df["touch_rank"] = df.groupby("conversion_id")["touchpoint_date"].rank(
method="first", # "first" breaks ties by order of appearance
ascending=True
)
# First touch gets full revenue; everyone else gets 0
df["first_touch_credit"] = df.apply(
lambda row: row["revenue"] if row["touch_rank"] == 1 else 0.0,
axis=1
)
return df
first_touch_df = apply_first_touch(attributed)
Tip
The method="first" argument in .rank() is critical when multiple touchpoints share the same date. Without it, pandas uses "average" ranking, which means two same-day touchpoints both get rank 1.5 — and neither would receive first-touch credit. Customer 1006 has exactly this situation: two touchpoints on 2024-01-06. The method="first" approach resolves ties by row order, which means your sort_values() call determines the winner. Make this tie-breaking rule explicit and document it.
Let's verify customer 1006:
print(first_touch_df[first_touch_df["customer_id"] == "1006"][
["touchpoint_id", "channel", "touchpoint_date", "touch_rank", "first_touch_credit"]
].to_string(index=False))
# tp_019 email 2024-01-06 1.0 95.0
# tp_020 paid_search 2024-01-06 2.0 0.0
# tp_021 organic_social 2024-01-09 3.0 0.0
Last-touch is the mirror image — all credit goes to the final touchpoint before conversion. This is the default in most analytics platforms including Google Analytics (Universal), which is one reason marketing teams are often over-indexed on "direct" and branded search traffic.
def apply_last_touch(attributed_df):
"""
Last-touch attribution: 100% credit to the most recent touchpoint per conversion.
Returns the attributed DataFrame with a 'last_touch_credit' column.
"""
df = attributed_df.copy()
df = df.sort_values(["conversion_id", "touchpoint_date", "touchpoint_id"])
# Rank descending so the latest touchpoint gets rank 1
df["touch_rank_desc"] = df.groupby("conversion_id")["touchpoint_date"].rank(
method="last", # "last" breaks ties in descending order
ascending=False
)
df["last_touch_credit"] = df.apply(
lambda row: row["revenue"] if row["touch_rank_desc"] == 1 else 0.0,
axis=1
)
return df
last_touch_df = apply_last_touch(attributed)
Linear attribution distributes revenue equally across all touchpoints in the journey. If a conversion had 4 touchpoints, each gets 25% of the revenue. This is the most "fair" model in the sense that it doesn't privilege any position in the funnel.
def apply_linear(attributed_df):
"""
Linear attribution: revenue split equally across all touchpoints per conversion.
Returns the attributed DataFrame with a 'linear_credit' column.
"""
df = attributed_df.copy()
# Count touchpoints per conversion
touchpoint_counts = df.groupby("conversion_id")["touchpoint_id"].transform("count")
# Each touchpoint gets an equal share
df["linear_credit"] = df["revenue"] / touchpoint_counts
return df
linear_df = apply_linear(attributed)
Linear attribution is elegant in its simplicity and is the easiest to implement — but notice what happens with repeated channels. Customer 1003 hit display_ads twice. In linear attribution, that channel gets credit for two touchpoints, so it receives 2/3 of the revenue while email gets 1/3. Whether that's the right behavior depends on your business logic. Some teams deduplicate channels before applying linear attribution; others count each interaction. Be explicit about this decision.
Key insight
The reason most companies default to last-touch isn't because it's most accurate — it's because it was the only thing their analytics tools could measure in the pre-cookie era. The goal of building your own pandas attribution engine is to escape these defaults and make deliberate, auditable choices.
Having three separate functions is good for modularity, but what you actually want is a single function that runs all models and returns a unified result. This makes downstream aggregation much cleaner.
def run_attribution_pipeline(touchpoints_df, conversions_df):
"""
Master attribution pipeline.
Steps:
1. Join touchpoints to conversions
2. Validate the join
3. Apply all three attribution models
4. Return a merged DataFrame with all credit columns
"""
# Step 1: Join
attributed = assign_touchpoints_to_conversions(touchpoints_df, conversions_df)
# Step 2: Validate
is_valid = validate_attribution_join(touchpoints_df, attributed)
if not is_valid:
raise ValueError("Attribution join failed validation. Check your input data.")
# Step 3: Apply models
ft = apply_first_touch(attributed)[["touchpoint_id", "first_touch_credit"]]
lt = apply_last_touch(attributed)[["touchpoint_id", "last_touch_credit"]]
lin = apply_linear(attributed)[["touchpoint_id", "linear_credit"]]
# Step 4: Merge all credits back onto the attributed base
result = attributed.merge(ft, on="touchpoint_id", how="left")
result = result.merge(lt, on="touchpoint_id", how="left")
result = result.merge(lin, on="touchpoint_id", how="left")
return result
full_result = run_attribution_pipeline(touchpoints, conversions)
print("\nFull attribution result:")
display_cols = [
"customer_id", "touchpoint_id", "channel",
"touchpoint_date", "conversion_id", "revenue",
"first_touch_credit", "last_touch_credit", "linear_credit"
]
print(full_result[display_cols].to_string(index=False))
The touchpoint-level result is valuable for debugging, but what leadership actually wants to see is: which channels are driving revenue, and how does that story change depending on the attribution model?
def build_channel_comparison(full_result_df):
"""
Aggregate attributed revenue by channel for all three models.
Returns a wide comparison DataFrame sorted by linear attribution.
"""
channel_summary = full_result_df.groupby("channel").agg(
touchpoints=("touchpoint_id", "count"),
first_touch_revenue=("first_touch_credit", "sum"),
last_touch_revenue=("last_touch_credit", "sum"),
linear_revenue=("linear_credit", "sum"),
).reset_index()
# Sort by linear revenue descending (neutral baseline for ordering)
channel_summary = channel_summary.sort_values(
"linear_revenue", ascending=False
).reset_index(drop=True)
# Add percentage columns so we can see share of wallet per model
for col in ["first_touch_revenue", "last_touch_revenue", "linear_revenue"]:
pct_col = col.replace("revenue", "pct")
total = channel_summary[col].sum()
channel_summary[pct_col] = (channel_summary[col] / total * 100).round(1)
return channel_summary
channel_comparison = build_channel_comparison(full_result_df=full_result)
print("\n--- Revenue Attribution by Channel ---")
print(channel_comparison.to_string(index=False))
The output will look something like this (numbers will vary):
channel touchpoints first_touch_revenue first_touch_pct last_touch_revenue last_touch_pct linear_revenue linear_pct
paid_search 4 339.99 29.6 89.99 7.8 196.32 17.1
organic_search 2 420.00 36.6 0.00 0.0 192.50 16.8
email 4 0.00 0.0 175.50 15.3 182.75 15.9
direct 3 0.00 0.0 545.00 47.5 177.50 15.5
organic_social 3 0.00 0.0 95.00 8.3 148.33 12.9
display_ads 3 175.50 15.3 0.00 0.0 87.33 7.6
direct 1 89.99 7.8 0.00 0.0 44.99 3.9
Key insight
Look at direct traffic under last-touch vs. first-touch. Under last-touch, direct gets nearly half of all revenue because customers often type the URL directly when they're ready to buy — they were persuaded earlier by other channels. First-touch correctly shows direct as a minor driver because it almost never initiates discovery. Neither model is "wrong"; they're answering different questions.
Since we've built the infrastructure cleanly, adding a fourth model is straightforward. Time-decay attribution weights touchpoints so that more recent interactions receive more credit. The weighting function typically uses a half-life (e.g., 7 days), meaning a touchpoint that happened 7 days before conversion gets half the weight of one that happened on the day of conversion.
def apply_time_decay(attributed_df, half_life_days=7):
"""
Time-decay attribution: credit weighted by recency.
Touchpoints closer to the conversion date receive more credit.
half_life_days: the number of days before conversion at which a touchpoint
receives half the weight of a same-day touchpoint.
"""
df = attributed_df.copy()
# Calculate days before conversion for each touchpoint
df["days_before_conversion"] = (
df["conversion_date"] - df["touchpoint_date"]
).dt.days
# Exponential decay weight: 2^(-days / half_life)
# A touchpoint on the conversion day gets weight 1.0
# A touchpoint 7 days before (with half_life=7) gets weight 0.5
# A touchpoint 14 days before gets weight 0.25, etc.
df["decay_weight"] = np.power(2, -df["days_before_conversion"] / half_life_days)
# Normalize weights within each conversion so they sum to 1
total_weight_per_conversion = df.groupby("conversion_id")["decay_weight"].transform("sum")
df["time_decay_credit"] = df["revenue"] * (df["decay_weight"] / total_weight_per_conversion)
return df
time_decay_df = apply_time_decay(attributed)
# Verify: credits per conversion should sum to revenue
credit_check = time_decay_df.groupby("conversion_id").agg(
total_credit=("time_decay_credit", "sum"),
revenue=("revenue", "first")
)
credit_check["match"] = np.isclose(credit_check["total_credit"], credit_check["revenue"])
print(credit_check)
This check using np.isclose() instead of exact equality is important — floating-point division will introduce tiny rounding errors that would cause exact equality to fail spuriously. This is a pattern worth internalizing for any numeric validation involving division.
Here's a property that every attribution model must satisfy: the sum of attributed credits across all touchpoints for a given conversion must exactly equal the conversion's revenue. This is the conservation law of attribution. If your model violates it, you've made an error somewhere.
def verify_revenue_conservation(full_result_df, conversions_df):
"""
Verify that each model correctly conserves revenue per conversion.
Total credited revenue should equal total actual revenue.
"""
models = {
"first_touch": "first_touch_credit",
"last_touch": "last_touch_credit",
"linear": "linear_credit",
}
actual_total = conversions_df["revenue"].sum()
print(f"Actual total revenue across all conversions: ${actual_total:,.2f}\n")
for model_name, credit_col in models.items():
model_total = full_result_df[credit_col].sum()
match = np.isclose(model_total, actual_total, rtol=1e-4)
status = "✓ PASS" if match else "✗ FAIL"
print(f"{model_name:20s}: ${model_total:,.2f} [{status}]")
# Also verify per-conversion
print("\nPer-conversion check (first 5 conversions):")
per_conv = full_result_df.groupby("conversion_id").agg(
actual_revenue=("revenue", "first"),
ft_sum=("first_touch_credit", "sum"),
lt_sum=("last_touch_credit", "sum"),
lin_sum=("linear_credit", "sum"),
).head()
print(per_conv.to_string())
verify_revenue_conservation(full_result, conversions)
Warning
If you're running attribution on data where the same conversion appears in the touchpoints table (e.g., an e-commerce platform that logs the "checkout confirmation" page as a touchpoint), you'll double-count. The conversion event itself should not appear in the touchpoints table. Audit your data sources carefully for this pattern — it's extremely common when touchpoints and conversions come from the same event stream.
The wide format with three credit columns is great for debugging, but a long format is better for charts and for feeding into pivot_table. Let's reshape using melt.
def build_long_format_summary(channel_comparison_df):
"""
Reshape the channel summary from wide to long for easier visualization.
"""
revenue_cols = ["first_touch_revenue", "last_touch_revenue", "linear_revenue"]
long_df = channel_comparison_df.melt(
id_vars=["channel", "touchpoints"],
value_vars=revenue_cols,
var_name="model",
value_name="attributed_revenue"
)
# Clean up model names for readability
long_df["model"] = long_df["model"].str.replace("_revenue", "").str.replace("_", " ").str.title()
return long_df.sort_values(["channel", "model"])
long_summary = build_long_format_summary(channel_comparison)
print(long_summary.head(15).to_string(index=False))
Now plotting the comparison is straightforward using seaborn:
import matplotlib.pyplot as plt
import seaborn as sns
fig, ax = plt.subplots(figsize=(12, 6))
sns.barplot(
data=long_summary,
x="channel",
y="attributed_revenue",
hue="model",
palette=["#2196F3", "#FF5722", "#4CAF50"],
ax=ax
)
ax.set_title("Revenue Attribution by Channel and Model", fontsize=14, fontweight="bold")
ax.set_xlabel("Marketing Channel", fontsize=11)
ax.set_ylabel("Attributed Revenue ($)", fontsize=11)
ax.legend(title="Attribution Model", loc="upper right")
ax.tick_params(axis='x', rotation=30)
plt.tight_layout()
plt.savefig("attribution_comparison.png", dpi=150)
plt.show()
For more on building effective comparison charts for analysis like this, see Visualizing Data with matplotlib and seaborn: Charts That Explain Your Analysis.
A comparison table like this is typically destined for a stakeholder report. The cleanest deliverable is a formatted Excel workbook with multiple sheets: one for the channel summary, one for the touchpoint-level detail, one for validation results.
def export_attribution_report(full_result_df, channel_comparison_df, filename="attribution_report.xlsx"):
"""
Export a multi-sheet attribution report to Excel.
"""
with pd.ExcelWriter(filename, engine="openpyxl") as writer:
# Sheet 1: Channel summary
channel_comparison_df.to_excel(
writer,
sheet_name="Channel Summary",
index=False
)
# Sheet 2: Touchpoint-level detail
detail_cols = [
"customer_id", "conversion_id", "channel",
"touchpoint_date", "conversion_date", "revenue",
"first_touch_credit", "last_touch_credit", "linear_credit"
]
full_result_df[detail_cols].sort_values(
["customer_id", "conversion_id", "touchpoint_date"]
).to_excel(
writer,
sheet_name="Touchpoint Detail",
index=False
)
# Sheet 3: Model totals for quick executive summary
model_totals = pd.DataFrame({
"Model": ["First Touch", "Last Touch", "Linear"],
"Total Attributed Revenue": [
full_result_df["first_touch_credit"].sum(),
full_result_df["last_touch_credit"].sum(),
full_result_df["linear_credit"].sum(),
]
})
model_totals.to_excel(
writer,
sheet_name="Model Totals",
index=False
)
print(f"Report exported to {filename}")
export_attribution_report(full_result, channel_comparison)
For more control over formatting — column widths, number formats, conditional highlighting — see Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work.
The pipeline above works cleanly for thousands of customers. At millions of rows, the cross-join-then-filter approach in assign_touchpoints_to_conversions() can become expensive. Here's how to think about scaling it:
The bottleneck is the merge. Joining N touchpoints against M conversions on customer_id alone creates up to N × (M/U) rows before filtering (where U is unique customers). For a customer with 50 touchpoints and 10 conversions, that's 500 rows that immediately get filtered down to 50. Acceptable at small scale; brutal at large scale.
Solutions at scale:
Pre-filter conversions before merging. If you have a lookback window (e.g., 90 days), filter touchpoints to that window before joining.
Process in customer-level chunks. Group by customer_id, apply the full logic per customer, then concatenate. This keeps memory usage bounded.
Use sorted merge with tolerance. If you sort both DataFrames by date, you can use pd.merge_asof() for a window join without an explicit cross-product.
# Efficient approach for large datasets: merge_asof for nearest-date matching
# This works when you have one touchpoint per window (not our multi-touch case)
# For full multi-touch, chunked processing is more practical
def run_attribution_chunked(touchpoints_df, conversions_df, chunk_size=10_000):
"""
Process attribution in customer-level chunks.
Useful when the intermediate cross-join creates too many rows.
"""
customer_ids = touchpoints_df["customer_id"].unique()
results = []
for i in range(0, len(customer_ids), chunk_size):
chunk_customers = customer_ids[i:i + chunk_size]
tp_chunk = touchpoints_df[touchpoints_df["customer_id"].isin(chunk_customers)]
conv_chunk = conversions_df[conversions_df["customer_id"].isin(chunk_customers)]
if len(conv_chunk) == 0:
continue
attributed_chunk = assign_touchpoints_to_conversions(tp_chunk, conv_chunk)
results.append(attributed_chunk)
return pd.concat(results, ignore_index=True)
For even larger datasets — tens of millions of events — consider reading in chunks and using efficient data types from the start, as covered in Handling Large Datasets in Python: Chunked Reading, Efficient dtypes, and When to Use Polars.
Tip
One of the most effective performance optimizations for attribution pipelines is to filter out touchpoints for customers who never converted before doing any joins. Even in a high-converting campaign, the majority of users who see an ad don't buy. Computing attribution for non-converters is pure waste.
Now it's your turn. Download or create the dataset from this lesson and extend the pipeline with the following challenges:
Challenge 1 — Position-based (U-shaped) attribution
Implement a U-shaped attribution model that gives 40% credit to the first touch, 40% to the last touch, and distributes the remaining 20% equally across any middle touchpoints. For a journey with only one touchpoint, that touchpoint should receive 100%. For a two-touchpoint journey, each gets 50%.
def apply_u_shaped(attributed_df):
"""
U-shaped (position-based) attribution.
40% first, 40% last, 20% distributed linearly across middle touches.
Your implementation here.
"""
df = attributed_df.copy()
# Hint: use groupby + transform to count touchpoints per conversion
# Rank touchpoints to identify first (rank=1) and last (rank=max)
# Handle the edge cases: 1 touchpoint, 2 touchpoints, 3+ touchpoints
raise NotImplementedError("Implement this model")
return df
Challenge 2 — Channel deduplication for linear
Modify apply_linear() to deduplicate channels before distributing credit. If a customer hit display_ads twice, it should count once — meaning each unique channel gets equal credit, not each touchpoint. Compare the results with your original linear model.
Challenge 3 — Lookback window
Add a lookback_days parameter to assign_touchpoints_to_conversions() that only attributes touchpoints that occurred within N days of the conversion. Common values are 30, 60, and 90 days. Run the pipeline with a 30-day lookback and observe which touchpoints are excluded.
Challenge 4 — Cohort-level analysis
Use the conversion_date to assign each conversion to a weekly cohort (using .dt.isocalendar().week). Then produce a summary showing attributed revenue by channel AND by week, for each model. This tells you whether channel importance is changing over time.
Mistake 1: Total attributed revenue doesn't match total actual revenue
Symptom: first_touch_credit.sum() returns a number different from conversions["revenue"].sum().
Cause: Either touchpoints are missing conversions (unmatched → dropped in the join), or a bug in date filtering is excluding valid touchpoints.
Fix: Run validate_attribution_join(). Check whether any conversions have zero touchpoints before them. If all conversions have at least one eligible touchpoint and your join logic is correct, the totals will match by construction.
Mistake 2: Same customer, same channel credited twice under first-touch
Symptom: After running apply_first_touch(), grouping by channel gives unexpectedly high revenue for a specific channel, and it turns out that channel appears as the first touch for the same conversion twice.
Cause: The attributed DataFrame has duplicate touchpoint_id values — the join created duplicates.
Fix: The drop_duplicates(subset=["touchpoint_id"], keep="first") line in assign_touchpoints_to_conversions() prevents this. If you're seeing duplicates, you've likely modified the attribution join and accidentally removed that line.
Mistake 3: Linear credits don't sum to revenue for a conversion
Symptom: For a conversion with 3 touchpoints and $100 revenue, the credits are $33.33, $33.33, $33.33 — which sums to $99.99, not $100.
Cause: Floating-point rounding error in division.
Fix: This is normal and expected. Use np.isclose() instead of == for validation, with a tolerance of rtol=1e-4. If the error is larger than a few cents, you have a real bug — check that your touchpoint count per conversion is correct.
Mistake 4: Time-decay weights look wrong for very old touchpoints
Symptom: A touchpoint 120 days before conversion has essentially zero weight and receives $0.00 credit, effectively turning time-decay into last-touch.
Cause: 2^(-120/7) ≈ 0.0000005. With a 7-day half-life, anything more than ~30 days before conversion has negligible weight.
Fix: Adjust the half_life_days parameter, or cap the minimum weight (e.g., ensure every touchpoint gets at least 5% weight before normalization). This is a modeling decision, not a bug.
Mistake 5: apply() is too slow on large DataFrames
Symptom: The apply_first_touch() and apply_last_touch() functions using lambda row: ... if ... else 0.0 work correctly but take minutes on large datasets.
Cause: Row-wise apply() with a Python lambda is slow — it can't be vectorized by pandas.
Fix: Replace the apply with a vectorized boolean mask:
# Instead of:
df["first_touch_credit"] = df.apply(
lambda row: row["revenue"] if row["touch_rank"] == 1 else 0.0,
axis=1
)
# Use this vectorized version:
df["first_touch_credit"] = np.where(df["touch_rank"] == 1, df["revenue"], 0.0)
np.where is 10–100x faster than row-wise apply on large DataFrames. See Writing Fast pandas Code: Vectorization Instead of apply and Loops for the full explanation of why this matters and how to identify other slowdowns.
You've built a complete, production-grade attribution engine in pandas. Here's what the architecture you've constructed gives you:
The natural next steps from here:
Markov chain attribution — a data-driven model where credit is allocated based on each channel's incremental contribution to reaching conversion, calculated by removing each channel and measuring the drop in conversion probability. This requires more sophisticated linear algebra but the pandas data model you've built is exactly the right input format.
A/B testing attribution results — if you're running experiments across channel mix, you need to understand whether differences in attributed revenue are statistically significant or just noise. This connects into cohort analysis work covered in Cohort Analysis in pandas: Calculating Retention, Churn, and Lifetime Value from Transaction Data.
Automating the pipeline — wrapping this in a parameterized function with date range inputs and scheduling it to run weekly, so marketing leaders see updated attribution numbers automatically.
Scaling for production data — if your touchpoint data lives in a database, you can pull it directly into pandas using Reading from SQL Databases into pandas with SQLAlchemy and slot that query into the beginning of the pipeline.
Attribution is fundamentally a philosophical problem wrapped in an engineering problem. The pandas skills you've developed here solve the engineering side. The philosophical choices — which model to trust, when to switch models, how to present disagreements to stakeholders — those require domain expertise and organizational context that no library can provide. But at least now you have the infrastructure to run all the models and let the numbers speak.