A B2B software company runs demand generation on a weekly cycle. Finance wants one question answered before next year’s budget is set:
Marketing spend and closed deals move together. If we raise spend, do we get deals?
Two years of weekly operating data are on hand. This review runs the correlation matrix, then tests whether the headline relationship survives the two questions every correlation has to answer: what else could be driving both, and is it just time.
The answer to the budget question is no, not directly — and the second half of this document shows a correlation in the same dataset that passes a careful significance test and is still an artefact.
One row per operating week. All figures are the company’s own weekly totals.
| column | meaning | unit |
|---|---|---|
week |
week index since the start of the window | 1–104 |
week_start |
Monday of that week | date |
marketing_spend_usd |
demand-gen spend booked that week | USD |
sessions |
unique website sessions | count |
demos_booked |
demos booked from those sessions | count |
closed_deals |
new business closed that week | count |
support_tickets |
inbound support tickets from the installed base | count |
The dataset is simulated to a stated ground truth so
that this document can be used to teach: spend buys sessions, sessions
produce demos, demos produce deals, and support_tickets is
generated from the growth of the installed base alone — it has
no dependency on spend whatsoever. Generation is
deterministic (set.seed(90210)); re-knitting reproduces
data.csv byte-for-byte. Every number reported below is
computed from the rounded, written-out data.csv — the same
file the platform tool consumes.
set.seed(90210)
n <- 104
w <- 1:n
# Demand-gen budget: an annual plan that ramps, an annual seasonal shape, and noisy
# monthly execution against it.
spend <- round(pmax(9000 + 52 * w + 900 * sin(2 * pi * w / 52) + rnorm(n, 0, 1800), 3000))
# The funnel, in order. Note there is NO direct spend -> deals term: every dollar's
# effect on deals is routed through sessions, then demos.
sessions <- round(380 + 0.048 * spend + rnorm(n, 0, 62))
demos <- round(0.062 * sessions + rnorm(n, 0, 2.8))
deals <- round(0.34 * demos + rnorm(n, 0, 1.1))
# Support load tracks the installed base, which grows on its own trend.
tickets <- round(140 + 1.9 * w + rnorm(n, 0, 46))
d <- data.frame(
week = w,
week_start = as.character(seq(as.Date("2024-09-02"), by = "week", length.out = n)),
marketing_spend_usd = spend,
sessions = sessions,
demos_booked = demos,
closed_deals = deals,
support_tickets = tickets
)
write.csv(d, "data.csv", row.names = FALSE)
nrow(d)
## [1] 104
num <- d[, c("marketing_spend_usd", "sessions", "demos_booked",
"closed_deals", "support_tickets")]
knitr::kable(
data.frame(
metric = names(num),
min = sapply(num, min),
median = sapply(num, median),
mean = round(sapply(num, mean), 1),
max = sapply(num, max),
sd = round(sapply(num, sd), 1),
row.names = NULL
),
caption = "Weekly operating metrics, 104 weeks"
)
| metric | min | median | mean | max | sd |
|---|---|---|---|---|---|
| marketing_spend_usd | 5470 | 11299.0 | 11594.0 | 18288 | 2587.3 |
| sessions | 607 | 954.0 | 934.2 | 1393 | 134.3 |
| demos_booked | 39 | 58.0 | 57.5 | 86 | 8.8 |
| closed_deals | 12 | 20.0 | 19.7 | 30 | 3.1 |
| support_tickets | 101 | 230.5 | 234.1 | 397 | 66.9 |
M <- cor(num)
knitr::kable(round(M, 4), caption = "Pearson correlation matrix")
| marketing_spend_usd | sessions | demos_booked | closed_deals | support_tickets | |
|---|---|---|---|---|---|
| marketing_spend_usd | 1.0000 | 0.8832 | 0.8120 | 0.7848 | 0.4613 |
| sessions | 0.8832 | 1.0000 | 0.9282 | 0.8953 | 0.3409 |
| demos_booked | 0.8120 | 0.9282 | 1.0000 | 0.9361 | 0.2963 |
| closed_deals | 0.7848 | 0.8953 | 0.9361 | 1.0000 | 0.2977 |
| support_tickets | 0.4613 | 0.3409 | 0.2963 | 0.2977 | 1.0000 |
op <- par(mar = c(8, 8, 2, 2))
image(1:5, 1:5, M[, 5:1], axes = FALSE, xlab = "", ylab = "", zlim = c(-1, 1),
col = colorRampPalette(c("#5fa9dd", "#f2f0ec", "#F97316"))(41))
axis(1, 1:5, gsub("_", " ", colnames(M)), las = 2, cex.axis = 0.85)
axis(2, 1:5, rev(gsub("_", " ", colnames(M))), las = 2, cex.axis = 0.85)
for (i in 1:5) for (j in 1:5) {
text(i, 6 - j, sprintf("%.2f", M[i, j]), cex = 0.9)
}
box()
par(op)
Every pair is worth a sentence, but the budget question is one cell: spend against closed deals.
ct_headline <- cor.test(d$marketing_spend_usd, d$closed_deals)
ct_headline
##
## Pearson's product-moment correlation
##
## data: d$marketing_spend_usd and d$closed_deals
## t = 12.79, df = 102, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.6976972 0.8490800
## sample estimates:
## cor
## 0.7848293
plot(d$marketing_spend_usd, d$closed_deals, pch = 19, col = "#F97316",
xlab = "marketing spend (USD / week)", ylab = "closed deals / week",
main = sprintf("r = %.3f (p = %.2g)", ct_headline$estimate, ct_headline$p.value))
abline(lm(closed_deals ~ marketing_spend_usd, data = d), col = "#333", lwd = 2)
r = 0.7848 is a strong relationship on 104 weeks of real operating data. Most budget decks stop here.
Spend is not the only thing correlated with deals. Sessions and demos are correlated with deals more strongly — and both sit on the path between spend and deals.
cand <- rbind(
data.frame(pair = "sessions ~ closed_deals",
r = cor(d$sessions, d$closed_deals),
p = cor.test(d$sessions, d$closed_deals)$p.value),
data.frame(pair = "demos_booked ~ closed_deals",
r = cor(d$demos_booked, d$closed_deals),
p = cor.test(d$demos_booked, d$closed_deals)$p.value),
data.frame(pair = "marketing_spend_usd ~ sessions",
r = cor(d$marketing_spend_usd, d$sessions),
p = cor.test(d$marketing_spend_usd, d$sessions)$p.value)
)
knitr::kable(transform(cand, r = round(r, 4), p = signif(p, 4)),
caption = "The candidate drivers")
| pair | r | p |
|---|---|---|
| sessions ~ closed_deals | 0.8953 | 0 |
| demos_booked ~ closed_deals | 0.9361 | 0 |
| marketing_spend_usd ~ sessions | 0.8832 | 0 |
Partial correlation answers the question directly: hold sessions statistically fixed, and ask what is left between spend and deals. Mechanically, regress each of the two variables on sessions, keep the residuals, and correlate those.
partial_cor <- function(x, y, z) {
cor.test(residuals(lm(x ~ z)), residuals(lm(y ~ z)))
}
pc_sessions <- partial_cor(d$marketing_spend_usd, d$closed_deals, d$sessions)
pc_sessions
##
## Pearson's product-moment correlation
##
## data: residuals(lm(x ~ z)) and residuals(lm(y ~ z))
## t = -0.28633, df = 102, p-value = 0.7752
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## -0.2197287 0.1651499
## sample estimates:
## cor
## -0.02833973
bars <- c(`spend ~ deals` = unname(ct_headline$estimate),
`holding sessions fixed` = unname(pc_sessions$estimate))
bp <- barplot(bars, ylim = c(-0.1, 0.9), col = c("#F97316", "#9aa0a6"),
ylab = "correlation with closed deals")
abline(h = 0, col = "#333")
text(bp, bars + 0.05 * sign(bars + 1e-9), sprintf("%.3f", bars), cex = 1.1)
The relationship collapses: 0.7848 → -0.0283 (p = 0.775).
Among weeks that drew the same number of sessions, spending more closed no additional deals. That is exactly what the ground truth says: spend has no direct path to deals. Its entire apparent effect was sessions wearing a marketing badge.
The same dataset holds a second, more dangerous correlation.
ct_drift <- cor.test(d$marketing_spend_usd, d$support_tickets)
ct_drift
##
## Pearson's product-moment correlation
##
## data: d$marketing_spend_usd and d$support_tickets
## t = 5.2515, df = 102, p-value = 8.275e-07
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.2949530 0.6005647
## sample estimates:
## cor
## 0.4613353
Spend against support tickets: r = 0.4613, p = 8.3e-07. A plausible story writes itself — paid acquisition brings lower-quality accounts, and they file more tickets. The story is false. Tickets in this dataset are generated from the installed base’s own growth and never see the spend column.
A permutation test is the careful analyst’s move here: shuffle one column many times, rebuild the correlation each time, and see how often chance alone produces something this big.
set.seed(11)
obs <- cor(d$marketing_spend_usd, d$support_tickets)
perm <- replicate(20000, cor(d$marketing_spend_usd, sample(d$support_tickets)))
n_extreme <- sum(abs(perm) >= abs(obs))
p_perm <- (n_extreme + 1) / (length(perm) + 1)
c(observed = obs, n_at_least_as_extreme = n_extreme, p_permutation = p_perm)
## observed n_at_least_as_extreme p_permutation
## 0.4613353445 0.0000000000 0.0000499975
hist(perm, breaks = 60, col = "#d7d9dc", border = "white",
main = "Permutation null vs the observed correlation",
xlab = "correlation under shuffling", xlim = c(-0.6, 0.6))
abline(v = obs, col = "#F97316", lwd = 3)
text(obs, par("usr")[4] * 0.8, sprintf(" observed %.3f", obs), col = "#F97316", pos = 4)
The permutation test confirms the correlation: 0 of 20,000 shuffles reached it. And the permutation test is wrong, because shuffling assumes the weeks are interchangeable. They are not — both columns climb across the two years, and shuffling destroys the very trend that created the correlation. The null it builds is a null that never existed.
Ask the third-variable question again, with time as the third variable:
pc_week <- partial_cor(d$marketing_spend_usd, d$support_tickets, d$week)
pc_week
##
## Pearson's product-moment correlation
##
## data: residuals(lm(x ~ z)) and residuals(lm(y ~ z))
## t = 0.64496, df = 102, p-value = 0.5204
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## -0.1304588 0.2532110
## sample estimates:
## cor
## 0.06373066
op <- par(mfrow = c(1, 2), mar = c(4, 4, 3, 1))
plot(d$week, scale(d$marketing_spend_usd), type = "l", col = "#F97316", lwd = 2,
ylim = c(-3, 3), xlab = "week", ylab = "standardised", main = "Both simply drift up")
lines(d$week, scale(d$support_tickets), col = "#5fa9dd", lwd = 2)
legend("topleft", c("spend", "tickets"), col = c("#F97316", "#5fa9dd"), lwd = 2, bty = "n", cex = 0.85)
plot(residuals(lm(d$marketing_spend_usd ~ d$week)), residuals(lm(d$support_tickets ~ d$week)),
pch = 19, col = "#9aa0a6", xlab = "spend, trend removed", ylab = "tickets, trend removed",
main = sprintf("r = %.3f (p = %.2f)", pc_week$estimate, pc_week$p.value))
abline(h = 0, v = 0, col = "#ccc")
par(op)
0.4613 → 0.0637 (p = 0.520). Nothing left.
res <- data.frame(
question = c("Does spend move deals?",
"…holding sessions fixed",
"Do tickets track spend?",
"…permutation test",
"…holding week fixed"),
statistic = c(sprintf("r = %.4f", ct_headline$estimate),
sprintf("r = %.4f", pc_sessions$estimate),
sprintf("r = %.4f", ct_drift$estimate),
sprintf("%d / 20000 shuffles", n_extreme),
sprintf("r = %.4f", pc_week$estimate)),
p = c(signif(ct_headline$p.value, 4), round(pc_sessions$p.value, 4),
signif(ct_drift$p.value, 4), signif(p_perm, 4), round(pc_week$p.value, 4)),
verdict = c("strong", "gone", "moderate", "'confirmed'", "gone")
)
knitr::kable(res, caption = "Every quotable figure in this review")
| question | statistic | p | verdict |
|---|---|---|---|
| Does spend move deals? | r = 0.7848 | 0.0000000 | strong |
| …holding sessions fixed | r = -0.0283 | 0.7752000 | gone |
| Do tickets track spend? | r = 0.4613 | 0.0000008 | moderate |
| …permutation test | 0 / 20000 shuffles | 0.0000500 | ‘confirmed’ |
| …holding week fixed | r = 0.0637 | 0.5204000 | gone |
standard_correlation takes one row per
period with each metric in its own numeric column, and a
column_mapping of feature_1 … feature_12. This
file is directly consumable: five numeric features plus the week
index.
The constraint that matters: partial correlation can only control for a column you actually collected. If the weekly export had carried spend and deals but not sessions, nothing in this document could have been run — the analysis would have reported r = 0.78 and the budget would have gone up. Log the mediating steps of your funnel, and log the date, or you cannot ask either question.