Executive Summary
End-to-end conversion and the biggest leak across 5 steps
The short answer
Of 2,507 users entering the funnel, 457 (18.2%) reach Purchase Complete. The biggest single loss by volume is Landing Page → Signup Form, where 1,003 users drop. By conversion rate, Email Verified → Payment Info is the leakiest step at 39.7%, though it affects fewer users because fewer arrive there. Mobile users convert worst end-to-end at 13.7% versus desktop at 25.3%.
The detail
End-to-End Conversion: 18.2% (457 of 2,507 users). Biggest Leak (absolute): Landing Page → Signup Form loses 1,003 users—40% of all entrants. Worst Step Conversion (rate): Email Verified → Payment Info at 39.7%, passing only 39.7% of the 1,353 who reach Email Verified. By Device, mobile (1,375 entrants) converts at 13.7% end-to-end; desktop (875 entrants) at 25.3%—a gap of 11.6 percentage points. Mobile's worst step is Email Verified → Payment Info at 29.9%.
What this can't tell you
The order of the four fastest transitions (Landing Page → Signup Form through Payment Info → Purchase Complete) could not be confidently determined from timestamps, so conversion rates may be mis-assigned between these boundaries. Mobile's lower conversion could reflect device-specific UX friction, traffic quality, or audience composition—the segment difference does not establish cause.
Analysis Overview
Conversion funnel across 5 steps and 2,507 User ID values.
The short answer
The funnel spans 5 steps from Landing Page to Purchase Complete, tracking 2,507 distinct users. The inferred step order is reconstructed from event timestamps; progression is treated as monotonic so missing intermediate events don't create false drops. End-to-end conversion is 18.2%, meaning 457 of 2,507 users complete the full journey.
The detail
6,998 events covering 2,507 User ID values between 2026-03-01 and 2026-04-30 were loaded and used in full. Step order was inferred by median time reached: Landing Page (0 sec), Signup Form (0 sec), Email Verified (0 sec), Payment Info (0 sec), Purchase Complete (0 sec). All 6,998 rows were retained; no rows were removed. Caution: Landing Page → Signup Form, Signup Form → Email Verified, Email Verified → Payment Info, and Payment Info → Purchase Complete transitions occur within 30 seconds of each other, so their relative order cannot be reliably determined from timestamps alone.
What this can't tell you
If the inferred step order does not match your actual flow, all downstream conversion figures are mis-assigned. Confirm the order against your product design before acting on step-level findings. The funnel also cannot show users who never reached Landing Page.
Data Quality
Rows used, the inferred step order, and how missing events were handled.
The short answer
All 6,998 rows loaded were usable; no data quality issues required removal. The inferred step order is Landing Page → Signup Form → Email Verified → Payment Info → Purchase Complete. No back-fill of missing intermediate events was needed because every user had a logged event at each step they reached. Repeat events at the same step were collapsed to the first occurrence.
The detail
6,998 rows loaded and 6,998 events used, covering 2,507 distinct User ID values. Every row had a readable Event Time, non-blank User ID, and non-blank Funnel Step. The inferred order is: Landing Page (2,507 users), Signup Form (1,504 users), Email Verified (1,353 users), Payment Info (537 users), Purchase Complete (457 users). All transitions occur within 30 seconds, making their relative order ambiguous from timestamps. No rows were removed; rows_removed = 0.
What this can't tell you
The ambiguous ordering within the 30-second window means the step-level conversion rates between these four transitions may be mis-assigned to the wrong boundary. Confirm step order from your product timeline or event schema to validate which step is actually the leak.
Users Remaining at Each Step
How many users are still in the flow at each step, in inferred funnel order.
The short answer
The funnel loses people sharply at the first step: Landing Page to Signup Form sheds 1,003 users—the largest single drop in the entire flow. After that, the decline is more gradual. Of the 2,507 who landed, only 457 complete purchase, but the steepest leak is at the entry to signup.
The detail
The funnel starts at 2,507 users at Landing Page and falls to 1,504 at Signup Form, a loss of 1,003 users (about 40% of the starting cohort). The next step, Email Verified, holds 1,353 users—a loss of only 151 from Signup Form. Payment Info drops to 537 users (a loss of 816 from Email Verified), and Purchase Complete ends at 457. The largest absolute loss occurs between Landing Page and Signup Form; the second-largest occurs between Email Verified and Payment Info (816 users). The final two steps (Payment Info to Purchase Complete) lose 80 users.
What this can't tell you
This count does not distinguish whether users abandoned, were filtered by validation rules, or encountered errors. A transaction-level export showing user actions and timestamps at each step would clarify whether the Landing Page → Signup Form drop reflects user choice, technical friction, or missing data.
Step-by-Step Conversion
Users, step conversion, cumulative conversion, users lost, and median time to advance.
| Stage Name | Users | Step Conversion PCT | Cumulative Conversion PCT | Dropped | Median Time To Advance |
|---|---|---|---|---|---|
| Landing Page | 2507 | — | 100 | — | n/a |
| Signup Form | 1504 | 60 | 60 | 1003 | 0 sec |
| Email Verified | 1353 | 90 | 54 | 151 | 0 sec |
| Payment Info | 537 | 39.7 | 21.4 | 816 | 0 sec |
| Purchase Complete | 457 | 85.1 | 18.2 | 80 | 0 sec |
The short answer
Landing Page → Signup Form loses the most users in absolute terms (1,003 dropped, 60% conversion), but Email Verified → Payment Info is the worst-converting step at 39.7%, losing 816 users. These represent different priorities: the first is a volume problem; the second is a rate problem. All transitions occur at a median of 0 seconds, so timing is not a distinguishing factor.
The detail
Landing Page: 2,507 users, 100% cumulative. Signup Form: 1,504 users, 60% step conversion, 60% cumulative, 1,003 dropped. Email Verified: 1,353 users, 90% step conversion, 54% cumulative, 151 dropped. Payment Info: 537 users, 39.7% step conversion, 21.4% cumulative, 816 dropped. Purchase Complete: 457 users, 85.1% step conversion, 18.2% cumulative, 80 dropped. Median time to advance is 0 sec for all four transitions. The Payment Info step shows the steepest rate drop (39.7%), while Signup Form's 60% rate on a large base (1,504 users) creates the largest absolute loss.
What this can't tell you
The four fastest transitions are ambiguous in order (all within 30 seconds), so these step-level rates may be assigned to the wrong boundary. Consider a transaction-level export with precise event sequence to clarify which step users actually fail at.
Conversion by Segment
The same funnel computed separately for each segment, small segments excluded.
The short answer
Mobile users (1,375 entrants) convert worst end-to-end at 13.7%, versus desktop at 25.3%—an 11.6 percentage point gap. Mobile's weakest step is Email Verified → Payment Info at 29.9%. Desktop and tablet are stronger, with desktop showing the best end-to-end rate. The gap widens most sharply at the Payment Info step, where mobile drops to 16.1% cumulative while desktop holds at 29.7%.
The detail
Device segments with ≥30 users: desktop (875 entrants, 25.3% end-to-end), mobile (1,375 entrants, 13.7%), tablet (242 entrants, 18.2%). Excluded: Smart TV (15 users, too small). Mobile's worst step is Email Verified → Payment Info at 29.9% cumulative; desktop's is the same step at 29.7%, but desktop recovers to 25.3% by Purchase Complete while mobile ends at 13.7%. By Channel (mapped separately): 'paid ads' is weakest at 17% end-to-end, with worst step Email Verified → Payment Info at 39.4%. Segment differences do not establish cause—mobile and desktop audiences differ in composition, device UX, and traffic source.
What this can't tell you
Mobile's lower conversion could stem from UX friction on smaller screens, lower-intent traffic, or audience demographics—the data shows association, not cause. A device-level UX audit or cohort analysis controlling for traffic source would clarify whether the gap is mechanical or compositional. The ambiguous step order (within 30 seconds) compounds this uncertainty at the Payment Info boundary.
Conversion Funnel Drop-Off Analysis — Where Are You Losing People?
Reconstructs a conversion flow (signup, checkout, onboarding) from a raw event log, infers the canonical step order from the data, and quantifies how many users survive each step, where the biggest leak is, and how long people take to advance.
Why This Method?
A conversion funnel is the cheapest way to turn a pile of events into a single actionable question: which ONE step is costing the most customers. Because the step order is inferred from observed timing rather than assumed, the same analysis works on any flow without configuration — and because progression is treated as monotonic, logs with missing intermediate events still produce a coherent funnel.
What This Analysis Covers
- The funnel shape: users remaining at each step, in inferred order
- Step-over-step conversion, cumulative conversion, and users lost
- The biggest ABSOLUTE leak and the WORST-CONVERTING step (rarely the same)
- Median time to advance between consecutive steps (stalls vs leaks)
- The same funnel per segment, with small segments suppressed
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {user, stage, timestamp, segment_1..N}. All narrative is derived from the user's own column names and computed values.
Note: this is the CONVERSION funnel (user progression through a flow). It is unrelated to the meta-analysis publication-bias chart that shares the word.
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))Epoch seconds (plausible range 1973..2096) — only if essentially all parse
num <- suppressWarnings(as.numeric(s))
if (n_nonblank > 0 && sum(!is.na(num)) >= 0.95 * n_nonblank &&
all(is.na(num) | (num > 1e8 & num < 4e9))) {
return(as.POSIXct(num, origin = "1970-01-01", tz = "UTC"))
}Date-time formats first: strptime tolerates trailing text, so a bare "%Y-%m-%d" would silently swallow date-times and zero out the clock.
formats <- c(
"%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M:%S", "%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M",
"%d/%m/%Y %H:%M", "%b %d, %Y %H:%M",
"%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y",
"%m-%d-%Y", "%d-%m-%Y", "%d.%m.%Y", "%b %d, %Y", "%d %b %Y", "%Y%m%d"
)
best <- as.POSIXct(rep(NA_real_, length(s)), origin = "1970-01-01", tz = "UTC")
best_n <- 0L
for (f in formats) {
d <- suppressWarnings(as.POSIXct(s, format = f, tz = "UTC"))
n <- sum(!is.na(d))
if (n > best_n) { best_n <- n; best <- d }
}
best
}Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# user_h / stage_h / time_h $ humanized user column names
# n_users $ distinct users in the funnel
# stages $ character — step names in INFERRED funnel order
# users_at $ integer — users reaching each step (monotonic)
# raw_at $ integer — users with an actual event at each step
# funnel_df $ data.frame(stage_name, users) — chart, funnel order
# step_df $ data.frame(stage_name, users, step_conversion_pct,
# cumulative_conversion_pct, dropped, median_time_to_advance)
# end_to_end_pct $ numeric — last step / first step
# worst_abs_* $ biggest ABSOLUTE leak (step label, users lost)
# worst_rel_* $ worst RELATIVE step (step label, conversion %)
# ambiguous_pairs $ character — adjacent steps too close in timing to order confidently
# backfilled_users $ integer — users counted at a step with no logged event there
# seg_cols / seg_summ / segment_df / seg_primary_h / seg_suppressed
# metrics / json_output
# === /SHARED EXPORTS ===
MAX_STAGES <- 12L # keep at most this many steps (largest by reach)
MIN_USERS <- 30L # refuse below this — percentages would be noise
MIN_SEG_USERS <- 30L # suppress segment levels smaller than this
MAX_SEG_LEVELS <- 6L # chart at most this many segment levels
MIN_PAIRS_TIME <- 5L # min users needed to report a median time-to-advance
initial_rows <- nrow(df)
user_h <- humanize_semantic("user", col_map)
stage_h <- humanize_semantic("stage", col_map)
time_h <- humanize_semantic("timestamp", col_map)Step 1: Check the mapping
need <- c("user", "stage", "timestamp")
miss <- setdiff(need, names(df))
if (length(miss) > 0) {
stop(sprintf(paste0("Conversion funnel analysis needs three columns mapped: the person or session(%s), ",
"the step they reached(%s), and when they reached it(%s). Missing: %s."),
user_h, stage_h, time_h,
and_list(humanize_semantic(miss, col_map))))
}
u <- trimws(as.character(df$user))
st <- trimws(as.character(df$stage))
raw_t <- df$timestamp
ts <- parse_event_times(raw_t)
n_nonblank_t <- sum(!is.na(raw_t) & trimws(as.character(raw_t)) != "")
if (n_nonblank_t == 0 || sum(!is.na(ts)) < 0.95 * n_nonblank_t) {
stop(sprintf(paste0("The %s column could not be read as dates or times — expected values like ",
"2026-03-04 09:12:00, 2026-03-04, or 3/4/2026. The step order of the funnel ",
"is inferred from it, so it cannot be skipped."), time_h))
}
keep <- !is.na(ts) & !is.na(u) & u != "" & !is.na(st) & st != ""
rows_bad <- initial_rows - sum(keep)
seg_cols <- grep("^segment_[0-9]+$", names(df), value = TRUE)
seg_cols <- seg_cols[order(as.integer(sub("^segment_", "", seg_cols)))]
seg_raw <- if (length(seg_cols) > 0) df[keep, seg_cols, drop = FALSE] else NULL
ev <- data.frame(user = u[keep], stage = st[keep],
t = as.numeric(ts[keep]), stringsAsFactors = FALSE)
if (nrow(ev) < 2) {
stop(sprintf("Too few usable rows after cleaning — check %s, %s and %s for blanks.",
user_h, stage_h, time_h))
}
date_min <- min(ev$t); date_max <- max(ev$t)Step 2: Cap the number of steps (keep the most-reached ones)
raw_reach_all <- sapply(split(ev$user, ev$stage), function(x) length(unique(x)))
dropped_stages <- character(0)
if (length(raw_reach_all) > MAX_STAGES) {
keep_st <- names(sort(raw_reach_all, decreasing = TRUE))[seq_len(MAX_STAGES)]
dropped_stages <- setdiff(names(raw_reach_all), keep_st)
in_scope <- ev$stage %in% keep_st
ev <- ev[in_scope, , drop = FALSE]
if (!is.null(seg_raw)) seg_raw <- seg_raw[in_scope, , drop = FALSE]
}
n_stages <- length(unique(ev$stage))
if (n_stages < 2) {
stop(sprintf(paste0("A conversion funnel needs at least two distinct steps, but %s contains only ",
"%d(%s). Map the column that names each step of the flow."),
stage_h, n_stages, and_list(unique(ev$stage))))
}Step 3: First arrival per (user, step); per-user offsets
ord <- order(ev$t) # chronological; the SAME order is
ev <- ev[ord, , drop = FALSE] # applied to the segment columns so
if (!is.null(seg_raw)) { # they stay aligned row-for-row
seg_raw <- seg_raw[ord, , drop = FALSE]
}
ukey <- paste0(ev$user, "\r", ev$stage)
fo <- ev[!duplicated(ukey), , drop = FALSE] # first arrival per user+step
user_first <- tapply(fo$t, fo$user, min) # named: user -> first event time
fo$off <- fo$t - as.numeric(user_first[fo$user])
n_users <- length(user_first)
if (n_users < MIN_USERS) {
stop(sprintf(paste0("Only %d distinct %s values were found; a funnel needs at least %d for the ",
"conversion percentages to mean anything. Supply a longer event log."),
n_users, user_h, MIN_USERS))
}Step 4: Infer the step order
Primary key: the median time after a user's FIRST event at which each step is reached. This is robust to users arriving on different calendar days. Steps whose medians are within a tolerance are treated as tied and ordered by median absolute clock time, then by how many users reached them.
med_off <- tapply(fo$off, fo$stage, median)
med_abs <- tapply(fo$t, fo$stage, median)
raw_cnt <- table(fo$stage)
stats_df <- data.frame(
stage = names(med_off),
med_off = as.numeric(med_off),
med_abs = as.numeric(med_abs[names(med_off)]),
cnt = as.integer(raw_cnt[names(med_off)]),
stringsAsFactors = FALSE
)
span <- max(stats_df$med_off) - min(stats_df$med_off)
tol <- max(30, 0.01 * span) # seconds
stats_df <- stats_df[order(stats_df$med_off), , drop = FALSE]
grp <- integer(nrow(stats_df)); grp[1] <- 1L; anchor <- stats_df$med_off[1]
if (nrow(stats_df) > 1) {
for (i in 2:nrow(stats_df)) {
if (stats_df$med_off[i] - anchor > tol) {
grp[i] <- grp[i - 1] + 1L; anchor <- stats_df$med_off[i]
} else grp[i] <- grp[i - 1]
}
}
stats_df$grp <- grp
stats_df <- stats_df[order(stats_df$grp, stats_df$med_abs, -stats_df$cnt), , drop = FALSE]
stages <- stats_df$stage
k <- length(stages)Ambiguity: consecutive steps whose median timing is inside the tolerance
ambiguous_pairs <- character(0)
if (k > 1) {
d <- diff(stats_df$med_off)
amb <- which(abs(d) <= tol)
if (length(amb) > 0) {
ambiguous_pairs <- paste0(stages[amb], " -> ", stages[amb + 1L])
}
}Step 5: Refuse an incoherent funnel rather than draw a nonsense chart
raw_at <- as.integer(raw_cnt[stages])
if (k > 1 && raw_at[1] < max(raw_at[-1])) {
worst_i <- which.max(raw_at[-1]) + 1L
stop(sprintf(paste0("The step inferred as first(\"%s\") was reached by only %s distinct %s values, ",
"but the later step \"%s\" was reached by %s. Either the log is missing early ",
"events or the step order could not be inferred from %s, so a funnel would be ",
"misleading. Check that every user's entry step is logged."),
stages[1], format(raw_at[1], big.mark = ","), user_h,
stages[worst_i], format(raw_at[worst_i], big.mark = ","), time_h))
}Step 6: Monotonic progression — a user at step j counts at steps 1..j
wt <- tapply(fo$t, list(fo$user, fo$stage), min) # users x steps, NA = never reached
wt <- wt[, stages, drop = FALSE]
reached <- !is.na(wt)
furthest <- apply(reached, 1, function(r) max(which(r)))
users_at <- sapply(seq_len(k), function(i) sum(furthest >= i))
backfilled_users <- sum(users_at - raw_at)Step 7: Step-by-step conversion, drop-off and time to advance
step_conv <- c(NA_real_, round(100 * users_at[-1] / users_at[-k], 1))
cum_conv <- round(100 * users_at / users_at[1], 1)
dropped <- c(NA_integer_, as.integer(users_at[-k] - users_at[-1]))
med_advance <- rep(NA_real_, k)
n_out_of_order <- 0L
if (k > 1) {
for (i in 2:k) {
d <- wt[, i] - wt[, i - 1]
d <- d[!is.na(d)]
n_out_of_order <- n_out_of_order + sum(d < 0)
d <- d[d >= 0]
if (length(d) >= MIN_PAIRS_TIME) med_advance[i] <- as.numeric(median(d))
}
}
funnel_df <- data.frame(stage_name = stages, users = as.integer(users_at),
stringsAsFactors = FALSE)
step_df <- data.frame(
stage_name = stages,
users = as.integer(users_at),
step_conversion_pct = step_conv,
cumulative_conversion_pct = cum_conv,
dropped = dropped,
median_time_to_advance = sapply(med_advance, fmt_duration),
stringsAsFactors = FALSE
)
end_to_end_pct <- cum_conv[k]Biggest ABSOLUTE leak and WORST RELATIVE step — NA-safe index selection
worst_abs_label <- worst_rel_label <- NA_character_
worst_abs_lost <- NA_integer_; worst_rel_conv <- NA_real_
worst_abs_i <- worst_rel_i <- NA_integer_
cand <- which(!is.na(dropped))
if (length(cand) > 0) {
worst_abs_i <- cand[which.max(dropped[cand])]
worst_abs_label <- paste0(stages[worst_abs_i - 1L], " -> ", stages[worst_abs_i])
worst_abs_lost <- as.integer(dropped[worst_abs_i])
}
candr <- which(!is.na(step_conv))
if (length(candr) > 0) {
worst_rel_i <- candr[which.min(step_conv[candr])]
worst_rel_label <- paste0(stages[worst_rel_i - 1L], " -> ", stages[worst_rel_i])
worst_rel_conv <- step_conv[worst_rel_i]
}
same_step <- !is.na(worst_abs_i) && !is.na(worst_rel_i) && worst_abs_i == worst_rel_iSlowest step to advance (a stall, not necessarily a leak)
slow_label <- NA_character_; slow_secs <- NA_real_; slow_i <- NA_integer_
cands <- which(!is.na(med_advance))
if (length(cands) > 0) {
slow_i <- cands[which.max(med_advance[cands])]
slow_label <- paste0(stages[slow_i - 1L], " -> ", stages[slow_i])
slow_secs <- med_advance[slow_i]
}Step 8: Segment breakdown (optional mapping)
seg_summ <- list() # per mapped segment column: findings
segment_df <- NULL # chart dataset (primary segment column only)
seg_primary_h <- NA_character_
seg_suppressed <- character(0)
if (length(seg_cols) > 0 && !is.null(seg_raw)) {
users_vec <- rownames(wt)Per-user segment value = the value on the user's earliest event row
first_row_of_user <- !duplicated(ev$user)
for (sc in seg_cols) {
sc_h <- humanize_semantic(sc, col_map)
vals_all <- trimws(as.character(seg_raw[[sc]]))
vals_all[is.na(vals_all) | vals_all == ""] <- "Missing"
per_user <- setNames(vals_all[first_row_of_user], ev$user[first_row_of_user])
lv <- per_user[users_vec]
lv[is.na(lv)] <- "Missing"
tab <- table(lv)
big <- names(tab)[tab >= MIN_SEG_USERS]
small <- names(tab)[tab < MIN_SEG_USERS]Cap the number of charted levels — keep the largest
if (length(big) > MAX_SEG_LEVELS) {
big <- names(sort(tab[big], decreasing = TRUE))[seq_len(MAX_SEG_LEVELS)]
}
if (length(small) > 0) {
seg_suppressed <- c(seg_suppressed, sprintf("%s: %s", sc_h,
and_list(sprintf("%s(%d users)", small, as.integer(tab[small])))))
}
if (length(big) == 0) {
seg_summ[[sc]] <- list(col_h = sc_h, ok = FALSE, levels = character(0))
next
}
rows <- list(); lev_worst <- list()
for (L in big) {
idx <- which(lv == L)
f_l <- furthest[idx]
ua <- sapply(seq_len(k), function(i) sum(f_l >= i))
cum_l <- round(100 * ua / ua[1], 1)
sc_l <- c(NA_real_, round(100 * ua[-1] / ua[-k], 1))
rows[[L]] <- data.frame(stage_name = stages, segment_value = L,
users = as.integer(ua), cumulative_pct = cum_l,
stringsAsFactors = FALSE)
ci <- which(!is.na(sc_l))
if (length(ci) > 0) {
wi <- ci[which.min(sc_l[ci])]
lev_worst[[L]] <- list(level = L, end_pct = cum_l[k],
worst_step = paste0(stages[wi - 1L], " -> ", stages[wi]),
worst_conv = sc_l[wi], n = as.integer(ua[1]))
}
}
dfl <- do.call(rbind, rows); rownames(dfl) <- NULLKeep the chart in funnel order, then by segment
dfl <- dfl[order(match(dfl$stage_name, stages), dfl$segment_value), , drop = FALSE]
end_v <- sapply(lev_worst, function(z) z$end_pct)
worst_lv <- best_lv <- NULL
if (length(end_v) > 0) {
okv <- which(!is.na(end_v))
if (length(okv) > 0) {
worst_lv <- lev_worst[[okv[which.min(end_v[okv])]]]
best_lv <- lev_worst[[okv[which.max(end_v[okv])]]]
}
}
seg_summ[[sc]] <- list(col_h = sc_h, ok = TRUE, levels = big,
df = dfl, worst = worst_lv, best = best_lv,
n_levels = length(big))
if (is.null(segment_df)) {
segment_df <- dfl[, c("stage_name", "segment_value", "cumulative_pct", "users")]
seg_primary_h <- sc_h
}
}
}
final_rows <- nrow(ev)
rows_removed <- initial_rows - final_rows
metrics <- list(
`Users Entering` = as.integer(users_at[1]),
`Steps` = k,
`End-to-End Conversion %` = end_to_end_pct,
`Biggest Leak` = if (!is.na(worst_abs_label))
sprintf("%s(%s users lost)", worst_abs_label, format(worst_abs_lost, big.mark = ",")) else "n/a",
`Worst Step Conversion` = if (!is.na(worst_rel_label))
sprintf("%s(%.1f%%)", worst_rel_label, worst_rel_conv) else "n/a"
)
json_output <- list(
answer = paste0(
"Conversion funnel over ", format(n_users, big.mark = ","), " ", user_h,
" values and ", k, " steps(", and_list(stages), "): ",
format(users_at[1], big.mark = ","), " enter and ",
format(users_at[k], big.mark = ","), " reach ", stages[k],
", an end-to-end conversion of ", end_to_end_pct, "%. ",
if (!is.na(worst_abs_label)) paste0(
"The largest single loss is ", worst_abs_label, " (",
format(worst_abs_lost, big.mark = ","), " users). ") else "",
if (!is.na(worst_rel_label)) paste0(
"The worst-converting step is ", worst_rel_label, " at ", worst_rel_conv, "%",
if (same_step) " — the same step" else " — a different step", ". ") else "",
"A funnel shows where users leave, not why."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "funnel_shape",
"step_breakdown", "segment_funnel"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed, rows_bad = rows_bad,
user_h = user_h, stage_h = stage_h, time_h = time_h,
n_users = n_users, stages = stages, k = k,
users_at = as.integer(users_at), raw_at = raw_at,
backfilled_users = as.integer(backfilled_users),
dropped_stages = dropped_stages,
funnel_df = funnel_df, step_df = step_df,
step_conv = step_conv, cum_conv = cum_conv, dropped = dropped,
med_advance = med_advance, n_out_of_order = as.integer(n_out_of_order),
end_to_end_pct = end_to_end_pct,
worst_abs_label = worst_abs_label, worst_abs_lost = worst_abs_lost,
worst_rel_label = worst_rel_label, worst_rel_conv = worst_rel_conv,
same_step = same_step,
slow_label = slow_label, slow_secs = slow_secs, slow_i = slow_i,
ambiguous_pairs = ambiguous_pairs, tol = tol,
med_off = setNames(stats_df$med_off, stats_df$stage),
date_min = as.POSIXct(date_min, origin = "1970-01-01", tz = "UTC"),
date_max = as.POSIXct(date_max, origin = "1970-01-01", tz = "UTC"),
seg_cols = seg_cols, seg_summ = seg_summ, segment_df = segment_df,
seg_primary_h = seg_primary_h, seg_suppressed = seg_suppressed,
min_seg_users = MIN_SEG_USERS,
metrics = metrics, json_output = json_output
)
}Describe the shape from the data rather than asserting a canned pattern
shape_word <- {
dd <- drops[!is.na(drops)]
if (length(dd) == 0) "flat"
else if (max(dd) >= 0.5 * sum(dd)) "dominated by one step — most of the loss happens in a single place"
else if (max(dd) <= 1.6 * (sum(dd) / length(dd))) "an even taper — losses are spread across the steps"
else "uneven — a few steps carry most of the loss"
}
list(
title = "Users Remaining at Each Step",
description = "How many users are still in the flow at each step, in inferred funnel order.",
text = paste0(
"Each bar is the number of ", shared$user_h, " values that reached that step, from ",
format(shared$users_at[1], big.mark = ","), " at ", shared$stages[1], " down to ",
format(shared$users_at[shared$k], big.mark = ","), " at ", shared$stages[shared$k], ". ",
"The profile is ", shape_word, ". ", biggest_txt,
"Bar length is a count, not a rate — a short bar late in the funnel can still represent ",
"good conversion among the people who got there. Read this chart together with the ",
"step-by-step table."
),
chart_labels = list(
stage_name = paste0("Step(", shared$stage_h, ")"),
users = paste0("Distinct ", shared$user_h, " values reaching the step")
),
data = list(funnel_stages = shared$funnel_df)
)
}
# Card: step_breakdown (table)
card_step_breakdown <- function(shared, df, params) {
n_timed <- sum(!is.na(shared$med_advance))
stall_txt <- if (!is.na(shared$slow_label)) {
paste0("The longest wait is ", shared$slow_label, " at a median of ",
fmt_duration(shared$slow_secs),
"; if that step also converts well, the delay is a stall to shorten rather than a leak to plug. ")
} else ""
compare_txt <- if (!is.na(shared$worst_abs_label) && !is.na(shared$worst_rel_label)) {
if (shared$same_step) {
paste0("The biggest loss by count and the worst conversion rate are the same step, ",
shared$worst_abs_label, " (", format(shared$worst_abs_lost, big.mark = ","),
" lost, ", shared$worst_rel_conv, "% pass). ")
} else {
paste0("The two views disagree, which is the useful part: ", shared$worst_abs_label,
" loses the most people(", format(shared$worst_abs_lost, big.mark = ","),
"), while ", shared$worst_rel_label, " has the worst pass rate(",
shared$worst_rel_conv, "%). The first is the bigger prize in volume; the second is ",
"the more broken experience. ")
}
} else ""
list(
title = "Step-by-Step Conversion",
description = "Users, step conversion, cumulative conversion, users lost, and median time to advance.",
text = paste0(
"Reading down the table: 'users' is how many reached the step, 'step conversion' is the ",
"share of the previous step's users who continued, 'cumulative conversion' is the share of ",
shared$stages[1], "'s users still present, and 'dropped' is the absolute number lost at that ",
"step. ", compare_txt,
"Median time to advance is shown for ", n_timed, " of ", max(0, shared$k - 1),
" transitions(a transition needs enough users with a logged event at both steps). ", stall_txt,
"Blank cells in the first row are expected: there is no previous step to convert from."
),
data = list(step_breakdown = shared$step_df)
)
}
# Card: segment_funnel (grouped_bar)
card_segment_funnel <- function(shared, df, params) {
if (length(shared$seg_cols) == 0 || is.null(shared$segment_df)) {
reason <- if (length(shared$seg_cols) == 0) {
paste0("No segment column was mapped, so there is nothing to compare. Map a column such as ",
"device, channel or plan to see which audience falls out at which step.")
} else {
paste0("A segment column was mapped, but no level of it had at least ", shared$min_seg_users,
" ", shared$user_h, " values, so every breakdown would have been noise. ",
"Reporting a confident percentage from a handful of users would be misleading, so it is omitted.")
}
return(list(
title = "Conversion by Segment",
description = "Funnel conversion broken down by segment.",
text = reason,
data = list()
))
}
s1 <- shared$seg_summ[[1]]
parts <- character()
parts <- c(parts, paste0(
"Each group of bars is one step of the funnel, with one bar per ", s1$col_h,
" value, showing the share of that segment's entrants still present. ",
s1$n_levels, " level(s) of ", s1$col_h, " had at least ", shared$min_seg_users,
" ", shared$user_h, " values and are shown."))
if (!is.null(s1$worst)) {
parts <- c(parts, paste0(
"'", s1$worst$level, "' converts worst end-to-end (", s1$worst$end_pct, "% of ",
format(s1$worst$n, big.mark = ","), " entrants), and its weakest step is ",
s1$worst$worst_step, " at ", s1$worst$worst_conv, "%."))
}
if (!is.null(s1$best) && !is.null(s1$worst) && !identical(s1$best$level, s1$worst$level)) {
parts <- c(parts, paste0(
"'", s1$best$level, "' is the strongest at ", s1$best$end_pct,
"% end-to-end, a gap of ", round(s1$best$end_pct - s1$worst$end_pct, 1),
" percentage points."))
}Additional mapped segment columns: report their findings even though the chart shows only the first.
if (length(shared$seg_summ) > 1) {
extra <- character()
for (nm in names(shared$seg_summ)[-1]) {
z <- shared$seg_summ[[nm]]
if (isTRUE(z$ok) && !is.null(z$worst)) {
extra <- c(extra, paste0("by ", z$col_h, ", '", z$worst$level, "' is weakest (",
z$worst$end_pct, "% end-to-end, worst step ",
z$worst$worst_step, " at ", z$worst$worst_conv, "%)"))
}
}
if (length(extra) > 0) {
parts <- c(parts, paste0("The chart shows ", s1$col_h,
"; the other mapped breakdowns give: ", and_list(extra), "."))
}
}
if (length(shared$seg_suppressed) > 0) {
parts <- c(parts, paste0("Suppressed as too small to report(under ", shared$min_seg_users,
" users) — ", and_list(shared$seg_suppressed), "."))
}
parts <- c(parts, paste0("Segment differences show where audiences diverge; they do not explain it, ",
"and segments are not randomised, so the groups differ in other ways too."))
list(
title = "Conversion by Segment",
description = "The same funnel computed separately for each segment, small segments excluded.",
text = paste(parts, collapse = " "),
chart_labels = list(
stage_name = paste0("Step(", shared$stage_h, ")"),
cumulative_pct = "Share of that segment still present(%)",
segment_value = shared$seg_primary_h
),
data = list(segment_funnel = shared$segment_df)
)
}Your turn
Bring your own data and the question you actually need answered.
CympleData Scientist Send me your data and question, I’ll send you the analytics. ds@mcpanalytics.ai