Standard Event Impact
Executive Summary

Executive Summary

Did the event move Daily Revenue? The measured gap vs its expected path.

Pre-Event Points
80
Post-Event Points
40
Average Effect
24.51
Relative Effect (%)
9.6
Cumulative Effect
980.3
Points Above Band (%)
100
Points Outside Band (%)
100
Daily Revenue ran 9.6% above its expected path after the event — an average of +24.51 per day and a cumulative +980.3 across the 40 post-event days. 40 of 40 post-event points fall outside the 95% prediction band (40 above, 0 below), and the average-effect 95% interval (23.59 to 25.42) excludes zero, so the data are consistent with a real impact of the event. This is an observational before/after comparison: anything else that changed around the same time (seasonal shifts, other campaigns, pricing, market conditions) could contribute to the gap, so read the result as consistent with an effect of the event rather than proof of it.
Interpretation

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.

Overview

Analysis Overview

Interrupted time-series measurement of the event's impact on Daily Revenue.

N Pre80
N Post40
Frequencydaily
Seasonalityday-of-week adjustments
Interpretation

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

Data Preparation

How the raw rows became a before/after time series.

Initial Rows120
Final Rows120
N Duplicate Rows0
N Dropped0
Pre Points80
Post Points40
Frequencydaily
Interpretation

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.

Visualization

Actual vs Expected Path

Daily Revenue over time against its pre-event counterfactual.

Interpretation

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.

Data Table

Effect Size

The event's measured effect: per period, relative, and cumulative, with 95% intervals.

MeasureValueCI LowCI HighInterpretation
Average effect per day24.5123.5925.42Actual Daily Revenue minus its expected value, averaged over the post-event window; the 95% interval excludes zero
Relative effect (% of expected level)9.69.310The average gap as a share of the expected post-event level (254.98)
Cumulative effect over 40 post-event days980.3943.81017Total Daily Revenue gained (positive) or lost (negative) relative to the expected path
Post-event points outside 95% band (%)10040 of 40 post-event points escape the band (40 above, 0 below); under a correct no-effect model about 5% would
Interpretation

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.

Data Table

Model Check

How trustworthy the counterfactual is.

MetricValueInterpretation
Pre-event model R-squared0.96Share of pre-event variation in Daily Revenue explained by the trend plus day-of-week adjustments — a strong counterfactual
Pre-event residual SD2.551Typical 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.001Welch 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 adjustmentDay-of-week adjustments — the series is daily with 80 pre-event days — enough to estimate day-of-week effects
Interpretation

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

Methodology

Statistical methodology and diagnostics for Event Impact — Did the Change Move the Metric?

Statistical Method

Event Impact — Did the Change Move the Metric?

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.

Data
N = 120 observations
Assumptions
  • 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)
Limitations
  • 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
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

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 &#x27;%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 &#x27;%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 &#x27;%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 &#x27;%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(
      "&#x27;%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 &#x27;%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 &#x27;%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(&#x27;%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(&#x27;%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 &#x27;%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)
Your data has more stories to tell. Run any analysis on your own data — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing