Chapter 5 Project 3: Marketing Attribution — Markov Chains
Marketing attribution answers a critical question: Which marketing channels deserve credit for conversions? Simple models like first-touch or last-touch are easy to understand but fail to capture the complexity of multi-touch customer journeys. In this chapter, we implement Markov chain attribution — a data-driven approach that models channel transitions and quantifies each channel’s true contribution.
5.1 Business Context
A typical customer journey involves multiple touchpoints:
Google Ads → Email → Facebook → Organic Search → Purchase
Heuristic models assign all credit to one touchpoint: - First-touch: Google Ads gets 100% credit - Last-touch: Organic Search gets 100% credit - Linear: Each channel gets 25% credit
Markov chains model the probability of moving between channels and calculate the removal effect — how much conversion probability drops if a channel is removed from the journey.
5.2 Data Preparation from BigQuery
We extract customer journey paths from Google Analytics 4 (GA4) data stored in BigQuery.
library(bigrquery)
library(tidyverse)
# Authenticate with Google Cloud
bq_auth(path = Sys.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
paths_query <- "
SELECT
user_pseudo_id,
STRING_AGG(traffic_source.source, ' > ' ORDER BY event_timestamp) AS path,
MAX(CASE WHEN event_name = 'purchase' THEN 1 ELSE 0 END) AS conversion,
SUM(CASE WHEN event_name = 'purchase' THEN ecommerce.purchase_revenue ELSE 0 END) AS revenue
FROM `project.analytics_xxx.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 180 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
GROUP BY user_pseudo_id
"
paths_df <- bq_project_query("project", paths_query) %>%
bq_table_download()Note: GA4 tables are sharded by date. The _TABLE_SUFFIX filter efficiently scans only the last 180 days, minimizing query costs.
5.3 Markov Chain Attribution
The ChannelAttribution package implements robust Markov chain attribution with Monte Carlo simulation.
library(ChannelAttribution)
markov_results <- markov_model(
Data = paths_df,
var_path = "path",
var_conv = "conversion",
var_value = "revenue",
order = 1, # First-order Markov (memoryless)
nsim = 100000, # Monte Carlo simulations for stability
max_step = 20, # Maximum touchpoints per path
out_more = TRUE # Return transition matrix and other details
)5.3.1 Understanding the Parameters
| Parameter | Description | Why It Matters |
|---|---|---|
order |
Markov order (1 = current state depends only on previous state) | Higher orders capture sequence effects but require more data |
nsim |
Number of Monte Carlo simulations | More simulations = more stable estimates, but slower |
max_step |
Maximum path length | Prevents infinite loops in simulation |
5.3.2 Removal Effects
The removal effect measures how many conversions would be lost if a channel were removed.
removal_effects <- markov_results$result %>%
as_tibble() %>%
rename(channel = channel_name, removal_effect = total_conversions) %>%
mutate(
attribution_share = removal_effect / sum(removal_effect),
attribution_pct = scales::percent(attribution_share, accuracy = 0.1)
) %>%
arrange(desc(attribution_share))5.4 Comparison with Heuristic Models
To build stakeholder trust, compare Markov results with familiar heuristic models.
heuristic_results <- heuristic_model(
Data = paths_df,
var_path = "path",
var_conv = "conversion",
var_value = "revenue"
)
comparison <- heuristic_results %>%
select(channel, first_touch, last_touch, linear_touch) %>%
left_join(
removal_effects %>% select(channel, markov = attribution_share),
by = "channel"
) %>%
mutate(across(c(first_touch, last_touch, linear_touch, markov),
~.x / sum(.x))) %>%
pivot_longer(cols = -channel, names_to = "model", values_to = "share")5.5 Visualization
library(ggplot2)
ggplot(comparison, aes(x = reorder(channel, share), y = share, fill = model)) +
geom_col(position = "dodge") +
coord_flip() +
scale_y_continuous(labels = scales::percent) +
scale_fill_brewer(palette = "Set2") +
labs(
title = "Marketing Attribution Comparison",
subtitle = "Markov Chain vs. Heuristic Models",
x = "Channel",
y = "Attributed Share",
fill = "Model"
) +
theme_minimal() +
theme(legend.position = "bottom")5.6 Transition Matrix Visualization
Understanding how customers move between channels reveals strategic insights.
library(igraph)
transition_matrix <- markov_results$transition_matrix
edges <- transition_matrix %>%
as_tibble() %>%
filter(channel_from != "(conversion)", channel_to != "(start)") %>%
mutate(
channel_from = str_remove(channel_from, "\\s*\\([0-9]+\\)$"),
channel_to = str_remove(channel_to, "\\s*\\([0-9]+\\)$")
) %>%
group_by(channel_from, channel_to) %>%
summarise(probability = mean(probability, na.rm = TRUE), .groups = "drop") %>%
filter(probability > 0.01) # Only significant transitions
graph <- graph_from_data_frame(edges, directed = TRUE)
plot(graph,
edge.width = E(graph)$probability * 10,
vertex.size = 15,
vertex.color = "lightblue",
edge.arrow.size = 0.5,
main = "Channel Transition Graph")Tip: Channels with high out-degree (many outgoing edges) are effective introducers. Channels with high in-degree are effective closers. Use this to optimize budget allocation.
5.7 Production Considerations
5.8 Exercises
- Implement a second-order Markov model and compare results with first-order.
- Build a Shiny app that lets users filter attribution by date range, campaign type, and customer segment.
- Calculate the time-decay attribution model and add it to the comparison visualization.
- Write a function that identifies “channel pairs” — sequences of two channels that have higher conversion rates than either channel alone.
In the next chapter, we’ll tackle demand forecasting — combining Prophet’s structural time series approach with XGBoost’s pattern recognition in a powerful ensemble.