Executive Summary
Model agreement across 6 channels and 705 converting journeys
Channel ranking flips across models. Last touch names Paid Search the top channel (505 conversions), linear names Display (343.2 conversions), and Markov removal names Display (256.4 conversions). The widest rank swing is 4 places, affecting both Display and Paid Search. Display is credited 685 conversions by First touch but 0 by Last touch—a spread of 685. Affiliate appears in fewer than 8 journeys and carries unstable removal-effect estimates; treat its Markov credit as directional only. These are credit allocations for conversions that already happened, not incremental causal effects.
Analysis Overview
Six attribution models compared across 6 channels and 705 converting journeys.
Six attribution models were applied to 705 converting journeys across 6 channels, with each model dividing conversion credit along the same paths using different weighting rules: last touch, first touch, linear, position-based (40/20/40), time-decay (7-day half-life), and Markov removal effect. The critical finding is that channel ranking does not survive the model choice. Two different channels take first place depending on the model, and the widest rank swing for a single channel is 4 places. All six models are correlational allocations of credit for conversions that already happened; none measures the incremental lift a channel caused, which requires a holdout or geo experiment.
Data Quality
Row accounting, touch ordering, and channel level handling.
Data quality was clean: 4,937 touch rows loaded and all 4,937 remained usable after validation. Touch order was read as date/time, so chronological ordering and time-decay weights use real elapsed days. The dataset produced 1,508 journeys with 705 converting (46.8%). All 100.0% of converting journeys have more than one touch, averaging 3.80 touches with a maximum of 5. The 'Converted' flag was consistent within every journey, confirming internal data integrity. No rows were removed for missing journey ID, channel, touch order, or conversion flag.
Credited Conversions by Model
How each model splits the same conversions across channels.
Every model divides the same 705 conversions; only the split rule changes. Display gains the most when credit is shared: it increases by 343.2 conversions from last touch (0) to linear (343.17), the signature of an assist channel. Paid Search loses the most: it falls by 366.6 conversions from last touch (505) to first touch (0), the signature of a closer. Bars of very different heights for one channel visualize model disagreement; bars of similar height mean that channel's standing is independent of the weighting rule. Display and Paid Search show the largest visual swings, confirming they are the contested channels.
Model × Channel Credit Matrix
Credited conversions and rank for every channel under every model.
| Channel | Last Touch | Last Touch Rank | First Touch | First Touch Rank | Linear | Linear Rank | Position Based | Position Based Rank | Time Decay | Time Decay Rank | Markov | Markov Rank | Rank Range |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Display | 0 | 5 | 685 | 1 | 343.2 | 1 | 343.3 | 1 | 214.4 | 2 | 256.4 | 1 | 4 |
| Paid Search | 505 | 1 | 0 | 5 | 138.4 | 2 | 202 | 2 | 252 | 1 | 209.5 | 2 | 4 |
| Social | 70 | 2 | 11 | 2 | 80 | 3 | 57.8 | 3 | 84.9 | 3 | 86.5 | 3 | 1 |
| Organic Search | 63 | 4 | 6 | 3 | 74 | 4 | 51.8 | 4 | 79.8 | 4 | 75.5 | 5 | 2 |
| 67 | 3 | 3 | 4 | 67.9 | 5 | 49.1 | 5 | 72.4 | 5 | 75.6 | 4 | 2 | |
| Affiliate | 0 | 5 | 0 | 5 | 1.7 | 6 | 1 | 6 | 1.4 | 6 | 1.5 | 6 | 1 |
Display ranks 1st under first touch, linear, position-based, and Markov removal, but drops to 5th under last touch—a rank range of 4. Paid Search ranks 1st under last touch and time-decay but falls to 5th under first touch—also a rank range of 4. Social, Organic Search, and Email show smaller rank ranges (1–2 places), indicating more stable standing across models. Affiliate ranks 6th or ties for 5th under all models. The weakest agreement between any two models is a rank correlation of −0.25, confirming that the two models tell materially different stories about channel contribution.
Markov Removal Effect
How far conversion probability falls when each channel is deleted.
Removing Display from the transition graph drops the modelled conversion probability by 91.5%, the largest fall of any channel, based on 1,380 journeys that touched it. Paid Search removal drops probability by 74.8% (1,154 journeys touched). Social, Organic Search, and Email each drop probability by roughly 27–31% (420–500 journeys each). Affiliate removal drops probability by only 0.5%, but this estimate is unstable—it appears in only 8 journeys, too few transitions for reliable inference. Unlike fixed-rule models, Markov removal is derived from the data's own paths and rewards channels that sit on routes reaching conversion, even if they never close.
Model Disagreement by Channel
Per channel, the gap between its best and worst-case credit.
| Channel | Most Generous Model | Highest Credit | Least Generous Model | Lowest Credit | Spread | Spread PCT Of Mean | Rank Range |
|---|---|---|---|---|---|---|---|
| Display | First touch | 685 | Last touch | 0 | 685 | 223.1 | 4 |
| Paid Search | Last touch | 505 | First touch | 0 | 505 | 231.9 | 4 |
| Social | Markov removal | 86.5 | First touch | 11 | 75.5 | 116.1 | 1 |
| Organic Search | Time-decay | 79.8 | First touch | 6 | 73.8 | 126.5 | 2 |
| Markov removal | 75.6 | First touch | 3 | 72.6 | 130 | 2 | |
| Affiliate | Linear | 1.7 | Last touch | 0 | 1.7 | 179.9 | 1 |
Display is the most contested channel: First touch credits it 685 conversions while Last touch credits it 0, a spread of 685 (223.1% of its mean credit). Paid Search follows closely: Last touch credits it 505 while First touch credits it 0, a spread of 505 (231.9% of its mean). Social, Organic Search, and Email each show spreads of 72–76 conversions (116–130% of mean). Affiliate is the least contested, with a spread of only 1.7 conversions (180% of mean, but the absolute numbers are small). Channels at the top of this ranking are the ones your current reporting is most likely over- or under-crediting depending on which model is in use.
Methodology
Statistical methodology and diagnostics for Multi-Touch Attribution Model Comparison
Statistical Method
Standard-library analysis: every channel that touched a converting journey claims the same conversion — this splits the credit six ways and shows you whether your channel ranking survives the choice of model. Last touch, first touch, linear, position-based (40/20/40), time-decay, and a Markov removal effect computed from your own path transitions, side by side. Works on any touchpoint log: map a journey id, a channel, a touch order (date or step number), and a converted flag.
- Each journey id groups the touches of one customer path, and the conversion flag is a property of the whole journey
- Touches can be ordered within a journey by the mapped date or sequence column
- The Markov model treats the next channel as depending only on the current one (first-order chain)
- Time-decay uses a 7-period half-life (7 days when the touch order is a date, 7 steps when it is a sequence index)
- Attribution is a correlational allocation of conversions that already happened — it is NOT an incremental causal effect, and no model here can tell you what would happen if you switched a channel off. That needs a holdout, geo test, or incrementality experiment.
- Only channels present in the touchpoint log can receive credit; offline, organic and unmeasured touches are invisible and their credit is silently reassigned to what is measured
- The Markov removal effect needs enough transitions to be stable — channels appearing in fewer than 30 journeys are flagged rather than reported as confident numbers
- The Markov model is first order: it does not remember anything about a path beyond the current channel, so sequence effects longer than one step are not captured
Analysis Code
Complete R source code for this analysis
Multi-Touch Attribution — Does Your Channel Ranking Survive the Model?
Every channel that touched a converting journey claims the same conversion. This analysis splits the credit six different ways — last touch, first touch, linear, position-based (40/20/40), time-decay, and a Markov removal effect — and then compares them, because the number that actually matters is whether your ranking of channels holds up or flips when the model changes.
Why This Method?
Single-model attribution is a hidden assumption presented as a fact. Running six models side by side turns the assumption into a measurement: channels that lead under every model are robust; channels whose rank swings are artifacts of the model you happened to pick. The Markov removal effect is the only one of the six that is derived from the data's own transition structure rather than a fixed weighting rule.
What This Analysis Covers
- Credited conversions per channel under six attribution models
- The full model x channel credit matrix with per-model ranks
- Markov removal effect by channel
- Model disagreement: which channels are over- or under-credited by the model
you use today
Standard Library
Platform standard-library module (LAT-1544): runs on ANY touchpoint log via the semantic mapping {journey, channel, touch_order, converted}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Model definitions
Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# nm_journey/nm_channel/nm_order/nm_conv $ humanized user column names
# order_mode $ "date" | "sequence"
# n_journeys / n_converting / n_channels
# path_len_mean / path_len_max / pct_multi_touch
# channels $ character — analysed channel levels
# lumped_channels $ character — levels folded into "Other"
# credit_mat $ matrix model x channel — credited conversions
# model_credit_df $ long data.frame(channel, model, credited_conversions)
# credit_matrix_df $ wide data.frame(channel, <model>, <model>_rank, rank_range)
# markov_removal_df $ data.frame(channel, removal_effect_pct, journeys, stability)
# disagreement_df $ data.frame(channel, highest/lowest model + credit, spread…)
# winner / winner_by_model / ranking_stable / n_distinct_winners
# min_rank_cor $ numeric — weakest pairwise Spearman between model ranks
# unstable_channels $ character — channels below the Markov stability floor
# markov_base_prob $ numeric — baseline conversion probability
# metrics / json_output
# === /SHARED EXPORTS ===
initial_rows <- nrow(df)Step 1: Require the four mapped columns
need <- c("journey", "channel", "touch_order", "converted")
nm <- setNames(humanize_semantic(need, col_map), need)
nm_journey <- nm[["journey"]]; nm_channel <- nm[["channel"]]
nm_order <- nm[["touch_order"]]; nm_conv <- nm[["converted"]]
missing_cols <- need[!need %in% names(df)]
if (length(missing_cols) > 0) {
stop(paste0(
"Attribution needs four mapped columns — a journey/path id, a channel, ",
"a touch order(date or sequence number), and a converted flag. ",
"Missing: ", paste(nm[missing_cols], collapse = ", "), "."
))
}Step 2: Clean journey + channel
jv <- trimws(as.character(df$journey))
cv <- trimws(as.character(df$channel))
cv[is.na(cv) | cv == ""] <- "Missing"Step 3: Detect how touch order is expressed — sequence index or date
ov_raw <- df$touch_order
n_nonblank <- sum(!is.na(ov_raw) & trimws(as.character(ov_raw)) != "")
order_mode <- NA_character_; ov <- NULL
if (is.numeric(ov_raw)) {
order_mode <- "sequence"; ov <- as.numeric(ov_raw)
} else {
num_try <- suppressWarnings(as.numeric(as.character(ov_raw)))
if (n_nonblank > 0 && sum(!is.na(num_try)) >= 0.95 * n_nonblank) {
order_mode <- "sequence"; ov <- num_try
} else {
dt_try <- suppressWarnings(lubridate::parse_date_time(
as.character(ov_raw),
orders = c("Ymd HMS", "Ymd HM", "Ymd", "mdY HMS", "mdY HM", "mdY",
"dmY HMS", "dmY", "Ymd IMp", "mdY IMp"),
quiet = TRUE, tz = "UTC"))
if (n_nonblank > 0 && sum(!is.na(dt_try)) >= 0.95 * n_nonblank) {
order_mode <- "date"; ov <- as.numeric(dt_try) / 86400 # days since epoch
}
}
}
if (is.na(order_mode)) {
stop(paste0(
"'", nm_order, "' could not be read as either a date/time or a sequence ",
"number, so the touches on each journey cannot be put in order. Map a ",
"column holding the touch timestamp, or a 1, 2, 3 ... step index."
))
}Step 4: Normalise the converted flag to 0/1
conv_raw <- df$converted
pos_tokens <- c("1", "yes", "y", "true", "t", "converted", "conversion",
"convert", "won", "win", "success", "purchased", "purchase")
neg_tokens <- c("0", "no", "n", "false", "f", "not converted", "notconverted",
"lost", "loss", "failure", "none", "")
cvt <- rep(NA_real_, length(conv_raw))
conv_num <- if (is.numeric(conv_raw)) as.numeric(conv_raw) else
suppressWarnings(as.numeric(as.character(conv_raw)))
conv_num_ok <- sum(!is.na(conv_num)) >= 0.95 * max(1, n_nonblank)
uniq_num <- sort(unique(conv_num[!is.na(conv_num)]))
if (conv_num_ok && length(uniq_num) > 0 && all(uniq_num %in% c(0, 1))) {
cvt <- ifelse(is.na(conv_num), NA_real_, conv_num)
} else {
lc <- tolower(trimws(as.character(conv_raw)))
lc[is.na(lc)] <- ""
lvls <- setdiff(unique(lc), "")
if (length(lvls) > 0 && all(lvls %in% c(pos_tokens, neg_tokens))) {
cvt <- ifelse(lc == "", NA_real_, ifelse(lc %in% pos_tokens, 1, 0))
} else if (conv_num_ok && length(uniq_num) == 2) {
cvt <- ifelse(is.na(conv_num), NA_real_,
ifelse(conv_num == max(uniq_num), 1, 0))
} else {
stop(paste0(
"'", nm_conv, "' does not look like a yes/no conversion flag — it holds ",
length(lvls), " distinct value(s) (",
paste(utils::head(lvls, 6), collapse = ", "),
"). Map a column that is 1/0, Yes/No, or True/False."
))
}
}Step 5: Drop unusable rows
keep <- !is.na(jv) & jv != "" & !is.na(cv) & !is.na(ov) & !is.na(cvt)
d <- data.frame(journey = jv[keep], channel = cv[keep], ord = ov[keep],
conv = cvt[keep], row_idx = which(keep),
stringsAsFactors = FALSE)
final_rows <- nrow(d)
rows_removed <- initial_rows - final_rows
if (final_rows == 0) {
stop(paste0("No usable touch rows remained after removing blanks in '",
nm_journey, "', '", nm_channel, "', '", nm_order, "' and '",
nm_conv, "'."))
}Step 6: Lump rare channel levels beyond 12 into "Other"
ch_counts <- sort(table(d$channel), decreasing = TRUE)
lumped_channels <- character(0)
if (length(ch_counts) > 12) {
lumped_channels <- names(ch_counts)[12:length(ch_counts)]
d$channel[d$channel %in% lumped_channels] <- "Other"
}Step 7: Guards — every one names the user's own columns
n_journeys <- length(unique(d$journey))
if (n_journeys < 30) {
stop(sprintf(paste0(
"Attribution needs at least 30 journeys to compare models; '%s' holds ",
"only %d distinct journey(s). Attribution on fewer paths is noise."),
nm_journey, n_journeys))
}
channels <- sort(unique(d$channel))
n_channels <- length(channels)
if (n_channels < 2) {
stop(sprintf(paste0(
"Attribution compares how credit splits BETWEEN channels, but '%s' holds ",
"only one distinct value('%s'). With a single channel every model gives ",
"it 100%% of the credit."), nm_channel, channels[1]))
}Conversion is a property of the journey — collapse to one flag per journey.
jconv_max <- tapply(d$conv, d$journey, max)
jconv_min <- tapply(d$conv, d$journey, min)
n_inconsistent <- sum(jconv_max != jconv_min)
journey_conv <- jconv_max # any converting touch => converted
n_converting <- sum(journey_conv == 1)
if (n_converting == 0) {
stop(sprintf(paste0(
"No converting journeys found — every value of '%s' reads as not ",
"converted across all %d journeys in '%s'. Attribution can only split ",
"credit for conversions that happened."),
nm_conv, n_journeys, nm_journey))
}Step 8: Build ordered paths
d <- d[order(d$journey, d$ord, d$row_idx), ]
paths <- split(d$channel, d$journey)
ord_by_j <- split(d$ord, d$journey)
jids <- names(paths)
conv_ids <- jids[journey_conv[jids] == 1]
conv_lens <- vapply(paths[conv_ids], length, integer(1))
path_len_mean <- mean(conv_lens)
path_len_max <- max(conv_lens)
pct_multi_touch <- 100 * mean(conv_lens > 1)
if (path_len_max <= 1) {
stop(sprintf(paste0(
"Every converting journey in '%s' has exactly one touch, so there is ",
"nothing to attribute BETWEEN touches — last touch, first touch, linear, ",
"position-based, time-decay and the Markov removal effect would all ",
"return the identical answer. Presenting six models here would be ",
"misleading. Use a channel-level conversion count instead, or supply a ",
"touchpoint log where journeys carry more than one row."), nm_journey))
}Step 9: Heuristic model credit
credit_mat <- matrix(0, nrow = length(.ATTR_MODELS), ncol = n_channels,
dimnames = list(.ATTR_MODELS, channels))
for (j in conv_ids) {
p <- paths[[j]]; n <- length(p); tt <- ord_by_j[[j]]
credit_mat["Last touch", p[n]] <- credit_mat["Last touch", p[n]] + 1
credit_mat["First touch", p[1]] <- credit_mat["First touch", p[1]] + 1
for (i in seq_len(n)) {
credit_mat["Linear", p[i]] <- credit_mat["Linear", p[i]] + 1 / n
}U-shape: 40% first, 40% last, 20% shared by the middle. With two touches there is no middle, so the 40/40 renormalises to 50/50.
wpos <- if (n == 1) 1 else if (n == 2) c(0.5, 0.5)
else c(0.4, rep(0.2 / (n - 2), n - 2), 0.4)
for (i in seq_len(n)) {
credit_mat["Position-based", p[i]] <- credit_mat["Position-based", p[i]] + wpos[i]
}Exponential decay toward the conversion, half-life 7 (days or steps).
delta <- tt[n] - tt
wdec <- 2 ^ (-delta / .HALF_LIFE)
wdec <- if (sum(wdec) > 0) wdec / sum(wdec) else rep(1 / n, n)
for (i in seq_len(n)) {
credit_mat["Time-decay", p[i]] <- credit_mat["Time-decay", p[i]] + wdec[i]
}
}Step 10: Markov removal effect (first-order chain over ALL journeys)
states <- c("(start)", channels, "(conversion)", "(null)")
M <- matrix(0, length(states), length(states), dimnames = list(states, states))
for (j in jids) {
p <- paths[[j]]; n <- length(p)
M["(start)", p[1]] <- M["(start)", p[1]] + 1
if (n > 1) for (i in seq_len(n - 1)) M[p[i], p[i + 1]] <- M[p[i], p[i + 1]] + 1
endst <- if (journey_conv[[j]] == 1) "(conversion)" else "(null)"
M[p[n], endst] <- M[p[n], endst] + 1
}
markov_base_prob <- .markov_conv_prob(M)
removal_raw <- setNames(rep(NA_real_, n_channels), channels)
if (!is.na(markov_base_prob) && markov_base_prob > 0) {
for (ch in channels) {
pc <- .markov_conv_prob(M, drop_channel = ch)
if (!is.na(pc)) {
removal_raw[[ch]] <- max(0, (markov_base_prob - pc) / markov_base_prob)
}
}
}
markov_ok <- any(!is.na(removal_raw)) && sum(removal_raw, na.rm = TRUE) > 0
if (markov_ok) {
rr <- removal_raw; rr[is.na(rr)] <- 0
credit_mat["Markov removal", ] <- n_converting * rr / sum(rr)
} else {Chain is degenerate (e.g. one transition pattern) — fall back to zero credit and say so, rather than inventing a number.
credit_mat["Markov removal", ] <- NA_real_
}Journeys per channel — the stability floor for the removal effect
ch_journeys <- vapply(channels, function(ch)
sum(vapply(paths, function(p) ch %in% p, logical(1))), integer(1))
unstable_channels <- channels[ch_journeys < .MIN_JOURNEYS_FOR_STABLE_MARKOV]Step 11: Ranks, stability, disagreement
rank_mat <- t(apply(credit_mat, 1, function(r) {
if (all(is.na(r))) return(rep(NA_real_, length(r)))
rank(-r, ties.method = "min", na.last = "keep")
}))
dimnames(rank_mat) <- dimnames(credit_mat)
winner_by_model <- vapply(seq_len(nrow(credit_mat)), function(i) {
r <- credit_mat[i, ]
ok <- which(!is.na(r))
if (length(ok) == 0) return(NA_character_)
channels[ok[which.max(r[ok])]]
}, character(1))
names(winner_by_model) <- .ATTR_MODELS
wins_known <- winner_by_model[!is.na(winner_by_model)]
n_distinct_winners <- length(unique(wins_known))
rank_range <- apply(rank_mat, 2, function(x)
if (all(is.na(x))) NA_real_ else max(x, na.rm = TRUE) - min(x, na.rm = TRUE))
max_rank_range <- if (all(is.na(rank_range))) NA_real_ else max(rank_range, na.rm = TRUE)
ranking_stable <- isTRUE(n_distinct_winners == 1) &&
isTRUE(!is.na(max_rank_range) && max_rank_range <= 1)Weakest pairwise Spearman agreement between model rank vectors
min_rank_cor <- NA_real_
usable_models <- which(apply(rank_mat, 1, function(x) sum(!is.na(x)) >= 2))
if (length(usable_models) >= 2 && n_channels >= 3) {
cors <- c()
um <- names(usable_models)
for (a in seq_along(um)) for (b in seq_along(um)) if (b > a) {
cc <- suppressWarnings(stats::cor(rank_mat[um[a], ], rank_mat[um[b], ],
method = "spearman",
use = "pairwise.complete.obs"))
if (!is.na(cc)) cors <- c(cors, cc)
}
if (length(cors) > 0) min_rank_cor <- min(cors)
}Overall winner = best average rank across the models that produced one.
mean_rank <- apply(rank_mat, 2, function(x)
if (all(is.na(x))) NA_real_ else mean(x, na.rm = TRUE))
ok_mr <- which(!is.na(mean_rank))
winner <- if (length(ok_mr) == 0) NA_character_
else channels[ok_mr[which.min(mean_rank[ok_mr])]]