Executive Summary
Did the event move Daily Revenue? The measured gap vs its expected path.
Daily Revenue moved 9.6% above its expected path after the launch, averaging +24.51 per day across 40 post-event days for a cumulative gain of +980.3. All 40 post-event points (100%) fell outside the 95% prediction band, with none below it. The 95% confidence interval for average effect (23.59 to 25.42) excludes zero, consistent with a real impact. However, this is an observational before/after design: concurrent changes in seasonality, other campaigns, pricing, or market conditions could contribute to the observed gap alongside the launch itself.
Analysis Overview
Interrupted time-series measurement of the event's impact on Daily Revenue.
The counterfactual—what Daily Revenue would have been without the launch—was built from 80 pre-event days using ordinary least squares regression with a linear time trend and day-of-week adjustments, then projected forward across the 40 post-event days with 95% prediction intervals. This approach is essential because Daily Revenue carried a pre-existing upward drift of +0.512 per day; a naive before/after average comparison would falsely attribute that drift to the launch. The reported intervals combine per-point prediction bands under a normal approximation assuming independent post-event errors; if errors are autocorrelated, true uncertainty is somewhat wider than stated. The effect is the vertical gap between actual observed values and this expected path after day 80.
Data Preparation
How the raw rows became a before/after time series.
The dataset contained 120 rows, one per date, with no duplicates requiring aggregation. Daily cadence was inferred from the median gap between consecutive dates. In the 'Campaign Period' column, rows labeled 'after' were coded as post-event and 'before' as pre-event, yielding 80 pre-event days and 40 post-event days, with the launch dated 2024-03-21. No rows were dropped, leaving 120 final observations ready for analysis.
Actual vs Expected Path
Daily Revenue over time against its pre-event counterfactual.
The actual Daily Revenue series (plotted from day 0 onward) is compared against the counterfactual expected path, which extends only into the post-event window (day 80 forward). The vertical separation between the two series after day 80 represents the measured launch effect. Throughout the 40 post-event days, actual revenue consistently ran above the expected trajectory, visibly widening the gap and reflecting the +24.51 daily average effect quantified elsewhere.
Effect Size
The event's measured effect: per period, relative, and cumulative, with 95% intervals.
| Measure | Value | CI Low | CI High | Interpretation |
|---|---|---|---|---|
| Average effect per day | 24.51 | 23.59 | 25.42 | Actual Daily Revenue minus its expected value, averaged over the post-event window; the 95% interval excludes zero |
| Relative effect (% of expected level) | 9.6 | 9.3 | 10 | The average gap as a share of the expected post-event level (254.98) |
| Cumulative effect over 40 post-event days | 980.3 | 943.8 | 1017 | Total Daily Revenue gained (positive) or lost (negative) relative to the expected path |
| Post-event points outside 95% band (%) | 100 | — | — | 40 of 40 post-event points escape the band (40 above, 0 below); under a correct no-effect model about 5% would |
The launch is associated with an average Daily Revenue lift of +24.51 per day (95% CI: 23.59 to 25.42), equivalent to 9.6% above the expected post-event level of 254.98. Over the full 40 post-event days, this yields a cumulative effect of +980.3 (95% CI: 943.8 to 1,016.8). Critically, 40 of 40 post-event points (100%) fell outside the 95% prediction band, all on the upside; under a correct no-effect model, only ~5% would escape the band. This combination—a sizable average effect with a tight confidence interval excluding zero, plus near-perfect band escape—indicates the observed gap is unlikely to be noise.
Model Check
How trustworthy the counterfactual is.
| Metric | Value | Interpretation |
|---|---|---|
| Pre-event model R-squared | 0.96 | Share of pre-event variation in Daily Revenue explained by the trend plus day-of-week adjustments — a strong counterfactual |
| Pre-event residual SD | 2.551 | Typical day-level wiggle of Daily Revenue around the pre-event fit, in the metric's own units |
| Post vs pre residual t-test p-value | < 0.001 | Welch t-test comparing post-event gaps to pre-event residuals: p < 0.001 — the post-event points deviate from the pre-event noise pattern |
| Seasonality adjustment | — | Day-of-week adjustments — the series is daily with 80 pre-event days — enough to estimate day-of-week effects |
The pre-event model achieves an R-squared of 0.96, explaining 96% of pre-event Daily Revenue variation through trend and day-of-week adjustments, establishing a strong counterfactual foundation. Pre-event residuals have a standard deviation of 2.551—the typical daily wiggle around the fitted path, making effects smaller than this difficult to distinguish from noise. A Welch t-test comparing post-event gaps to pre-event residuals yields p < 0.001, confirming that post-event deviations are statistically distinct from the pre-event noise pattern. Day-of-week adjustments were applied, appropriate for the daily frequency and 80 pre-event observation count.
Methodology
Statistical methodology and diagnostics for Event Impact — Did the Change Move the Metric?
Statistical Method
Standard-library analysis: interrupted time-series measurement of an event's impact. Mark which rows fall before and after the event (a launch, price change, campaign start); the tool builds a counterfactual from the pre-event trend and seasonality, projects it across the post-event window with 95% prediction intervals, and measures the gap — average, relative, and cumulative effect, each with honest uncertainty. Works on any dataset: map a date column, a numeric metric, and a before/after column.
- The pre-event trend and seasonal pattern would have continued unchanged had the event not happened
- The period column cleanly splits the rows into exactly two groups (before vs after)
- Post-event errors are roughly independent (the aggregate-effect interval uses a normal approximation)
- Observational, not experimental — anything else that changed around the same time (seasonal shifts, other campaigns, pricing, market conditions) can contribute to the measured gap
- A weak pre-event fit (low R-squared) widens the prediction band and softens any verdict
- Autocorrelated errors make the reported intervals somewhat too narrow
Analysis Code
Complete R source code for this analysis
Event Impact — Did the Change Move the Metric?
Interrupted time-series analysis: the user marks which rows fall before and after an event (launch, price change, campaign). A counterfactual — the expected path of the metric had nothing changed — is fitted on the pre-event rows alone (linear trend + day-of-week or month-of-year adjustments when the history supports them), projected across the post-event window with 95% prediction intervals, and the effect is the gap between actual and expected: average per period, relative %, and cumulative, each with a 95% interval.
Why This Method?
A naive before/after comparison of averages credits any pre-existing trend to the event. The counterfactual subtracts the path the metric was already on, which is the honest base-R answer to "causal impact". Base R only (stats::lm + prediction intervals); no CausalImpact/bsts.
What This Analysis Covers
- Actual vs expected counterfactual path (chart)
- Average / relative / cumulative effect with 95% intervals
- Share of post-event points outside the 95% prediction band
- Model diagnostics (pre-event R-squared, residual t-test, seasonality)
Honest Framing
This is OBSERVATIONAL: the result can be consistent with an effect of the event, never proof of it — concurrent changes can contribute.
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {date, value, period}. 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))Core Analysis Pipeline
Step 1: Parse dates robustly (init() may already have Date-coerced)
raw_date <- df$date
non_blank <- !(is.na(raw_date) | !nzchar(trimws(as.character(raw_date))))
d <- parse_dates_robust(raw_date)
n_nonblank <- sum(non_blank)
if (n_nonblank == 0) {
stop(sprintf("The '%s' column is empty — no dates to anchor the event to.", date_name))
}
n_unparsed <- sum(non_blank & is.na(d))
if (n_unparsed / n_nonblank > 0.05) {
stop(sprintf(
"%d of %d values in '%s' (%.0f%%) could not be read as dates. Please use a recognizable date format (e.g. 2024-01-31 or 01/31/2024).",
n_unparsed, n_nonblank, date_name, 100 * n_unparsed / n_nonblank))
}Step 2: Coerce the metric (95% rule)
v <- df$value
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & nzchar(as.character(v)))
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
v <- conv
} else {
stop(sprintf(
"The '%s' column is not numeric — measuring an event impact needs a numeric metric.",
value_name))
}
}
v <- as.numeric(v)Step 3: Binarize the period column — exactly 2 levels; the after
level matches (after|post|1|true|treatment|new) case-insensitive, else the level whose dates fall later on average. The choice is stated.
per <- trimws(as.character(df$period))
per[is.na(df$period) | !nzchar(per)] <- NA_character_
keep <- !is.na(d) & !is.na(v) & !is.na(per)
n_dropped_rows <- initial_rows - sum(keep)
work <- data.frame(date = d[keep], value = v[keep], period = per[keep],
stringsAsFactors = FALSE)
levs <- sort(unique(work$period))
if (length(levs) != 2) {
stop(sprintf(
"The '%s' column must contain exactly 2 labels marking before vs after the event — found %d (%s).",
period_name, length(levs), paste(head(levs, 5), collapse = ", ")))
}
after_regex <- "^(after|post|1|true|treatment|new)$"
is_after <- grepl(after_regex, levs, ignore.case = TRUE)
if (sum(is_after) == 1) {
after_level <- levs[is_after]
after_reason <- sprintf(
"'%s' matches a standard after-the-event label (after/post/1/true/treatment/new)",
after_level)
} else {
mean_dates <- tapply(as.numeric(work$date), work$period, mean)
after_level <- names(mean_dates)[which.max(mean_dates)]
after_reason <- sprintf(
"neither label is a standard before/after word, and '%s' dates fall later on average",
after_level)
}
pre_level <- setdiff(levs, after_level)Step 4: Aggregate duplicate dates by SUM within each period group
(rows treated as amounts that add up within a period — stated in prose)
agg <- aggregate(value ~ date + period, data = work, FUN = sum)
n_dup_rows <- nrow(work) - nrow(agg)
both <- intersect(agg$date[agg$period == after_level],
agg$date[agg$period == pre_level])
if (length(both) > 0) {
stop(sprintf(
"%d %s in '%s' %s marked both '%s' and '%s' in '%s' — every date must fall entirely before or after the event.",
length(both), pl(length(both), "date"), date_name,
pl(length(both), "is", "are"), pre_level, after_level, period_name))
}
agg <- agg[order(agg$date), , drop = FALSE]
pre <- agg[agg$period == pre_level, , drop = FALSE]
post <- agg[agg$period == after_level, , drop = FALSE]
n_pre <- nrow(pre); n_post <- nrow(post)
if (n_pre < 8) {
stop(sprintf(
"Only %d %s before the event('%s' in '%s') — at least 8 pre-event points are needed to build a counterfactual for '%s'.",
n_pre, pl(n_pre, "point"), pre_level, period_name, value_name))
}
if (n_post < 3) {
stop(sprintf(
"Only %d %s after the event('%s' in '%s') — at least 3 post-event points are needed to measure an impact on '%s'.",
n_post, pl(n_post, "point"), after_level, period_name, value_name))
}
event_date <- min(post$date)
n_interleaved <- sum(pre$date >= event_date)Step 5: Infer cadence from the median date gap
gaps <- as.numeric(diff(sort(unique(agg$date))))
med_gap <- median(gaps)
if (med_gap <= 1.5) {
freq_label <- "daily"; period_word <- "day"; step_days <- 1
} else if (med_gap >= 5.5 && med_gap <= 8.5) {
freq_label <- "weekly"; period_word <- "week"; step_days <- 7
} else if (med_gap >= 26 && med_gap <= 35) {
freq_label <- "monthly"; period_word <- "month"; step_days <- 30.44
} else {
step_days <- max(1, round(med_gap)); freq_label <- "irregular"
period_word <- sprintf("%d-day period", round(step_days))
}Step 6: Time index (days since first date) + seasonality choice:
day-of-week dummies if daily with >=21 pre points (3 full weeks), month dummies if monthly with >=24 pre points, else trend-only.
t0 <- min(agg$date)
pre$tidx <- as.numeric(pre$date - t0)
post$tidx <- as.numeric(post$date - t0)
event_tidx <- min(post$tidx)
seasonality <- "none"
season_reason <- ""
if (freq_label == "daily") {
if (n_pre >= 21) {
seasonality <- "dow"
} else {
season_reason <- sprintf(
"only %d pre-event days are available and 3 full weeks are needed, so day-of-week effects were not estimated",
n_pre)
}
} else if (freq_label == "monthly") {
if (n_pre >= 24) {
seasonality <- "month"
} else {
season_reason <- sprintf(
"only %d pre-event months are available and 24 are needed, so month-of-year effects were not estimated",
n_pre)
}
} else {
season_reason <- sprintf(
"the series cadence is %s, so only the trend is modelled", freq_label)
}
if (seasonality != "none") {
fmt_code <- if (seasonality == "dow") "%u" else "%m"
pre$season <- format(pre$date, fmt_code)
post$season <- format(post$date, fmt_code)
if (!all(unique(post$season) %in% unique(pre$season))) {
seasonality <- "none"
season_reason <- "the post-event window contains calendar positions never seen before the event, so seasonal adjustment was dropped"
} else if ((n_pre - 1 - length(unique(pre$season))) < 3) {
seasonality <- "none"
season_reason <- "too few pre-event points remain once seasonal terms are added, so seasonal adjustment was dropped"
} else {
season_reason <- if (seasonality == "dow") {
sprintf("the series is daily with %d pre-event days — enough to estimate day-of-week effects", n_pre)
} else {
sprintf("the series is monthly with %d pre-event months — enough to estimate month-of-year effects", n_pre)
}
}
}
seasonality_label <- switch(seasonality,
dow = "day-of-week adjustments",
month = "month-of-year adjustments",
none = "no seasonal adjustment")Step 7: Fit the counterfactual on PRE only; project over POST with
95% prediction intervals
fml <- if (seasonality != "none") value ~ tidx + season else value ~ tidx
fit <- lm(fml, data = pre)
pr <- suppressWarnings(
predict(fit, newdata = post, interval = "prediction", level = 0.95))
if (any(!is.finite(pr))) {
stop(sprintf(
"The pre-event model for '%s' could not produce finite predictions over the post-event window — the pre-event history may be too sparse.",
value_name))
}
expected <- as.numeric(pr[, "fit"])
lwr <- as.numeric(pr[, "lwr"])
upr <- as.numeric(pr[, "upr"])Step 9: Diagnostics — pre-event fit quality + t-test of post gaps
vs pre residuals
resid_pre <- as.numeric(residuals(fit))
r2 <- suppressWarnings(summary(fit)$r.squared)
sd_pre <- sd(resid_pre)
p_t <- tryCatch(stats::t.test(gap, resid_pre)$p.value,
error = function(e) NA_real_)
significant <- all(is.finite(ci_avg)) && (ci_avg[1] > 0 || ci_avg[2] < 0)
direction_word <- if (avg_effect >= 0) "above" else "below"
verdict <- if (significant) {
"consistent with a real impact of the event"
} else {
"not consistent with a clearly measurable impact of the event"
}Step 10: Round ONCE — the same rounded numbers feed both the
tables and every sentence that quotes them.
avg_r <- round(avg_effect, 2)
avg_lo <- round(ci_avg[1], 2); avg_hi <- round(ci_avg[2], 2)
rel_r <- if (is.na(rel_effect)) NA_real_ else round(rel_effect, 1)
rel_lo <- if (is.na(ci_rel[1])) NA_real_ else round(ci_rel[1], 1)
rel_hi <- if (is.na(ci_rel[2])) NA_real_ else round(ci_rel[2], 1)
cum_r <- round(cum_effect, 1)
cum_lo <- round(ci_cum[1], 1); cum_hi <- round(ci_cum[2], 1)
pct_out_r <- round(100 * n_outside / n_post, 1)
pct_above_r <- round(100 * n_above / n_post, 1)
r2_r <- if (is.finite(r2)) round(r2, 3) else NA_real_
sd_pre_r <- if (is.finite(sd_pre)) round(sd_pre, 3) else NA_real_
p_tab <- if (is.na(p_t)) NA_real_ else max(round(signif(p_t, 3), 4), 0.001)
trend_per_period <- round(as.numeric(coef(fit)[["tidx"]]) * step_days, 3)
baseline_r <- round(baseline, 2)