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.7.1 Data Pipeline

Schedule the BigQuery extraction daily using targets or cloud schedulers:

# _targets.R
tar_target(attribution_data, extract_ga4_paths(start_date, end_date))
tar_target(markov_model, run_markov_attribution(attribution_data))
tar_target(attribution_report, render_attribution_report(markov_model))

5.7.2 Handling Data Quality Issues

clean_paths <- function(paths_df) {
  paths_df %>%
    filter(!is.na(path), path != "") %>%
    mutate(
      # Remove consecutive duplicates (e.g., "Email > Email > Facebook" → "Email > Facebook")
      path = str_replace_all(path, "(\\w+) > \\1+", "\\1")
    )
}

5.7.3 Shapley Value Alternative

For smaller datasets or when you need game-theoretic fairness, consider Shapley value attribution:

library(ShapleyValue)
# Implementation requires custom wrapper around ChannelAttribution

5.8 Exercises

  1. Implement a second-order Markov model and compare results with first-order.
  2. Build a Shiny app that lets users filter attribution by date range, campaign type, and customer segment.
  3. Calculate the time-decay attribution model and add it to the comparison visualization.
  4. 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.