Executive Summary
Did the intervention shift the level or the slope of Monthly Revenue?
The short answer
Both the revenue level and growth trend shifted in month 25. Revenue dropped immediately by 12.89 units, but the monthly growth rate then accelerated sharply, leaving revenue 22.73 units above where the old trend would have taken it by the end of the series. Both effects are statistically robust.
The detail
At the assumed intervention point (2023-01-01), Monthly Revenue shows an immediate level drop of -12.892 (95% CI -15.31 to -10.48, p < 0.001) and a trend break of 1.549 per period (95% CI 1.374 to 1.723, p < 0.001). The lag-1 residual autocorrelation is 0.09, low enough that classical OLS inference is reliable. By the series end, the fitted values sit 22.729 units ahead of the pre-intervention trend projection.
What this can't tell you
The intervention date was assumed at the series midpoint rather than provided — every estimate depends on that placement. As a single-series observational design, any other event at the same time would be indistinguishable from the pricing change itself.
Analysis Overview
Interrupted time series on Monthly Revenue: segmented regression split at 2023-01-01.
The short answer
The pricing change in month 25 (2023-01-01) had two distinct effects: revenue dropped immediately by 12.89, then began growing much faster afterward. The old trend was adding 0.609 per month; the new one adds 1.549 per month. By the end of the data, the faster growth had more than offset the initial drop.
The detail
Segmented regression splits the intervention's impact into two components measured exactly at the break. The level change is -12.892 (95% CI -15.31 to -10.48, p < 0.001) — an immediate drop. The slope change is 1.549 per period (95% CI 1.374 to 1.723, p < 0.001) — the pre-intervention trend of 0.609 per period steepened to 2.158 per period afterward. By the final observation the fitted series sits 22.73 units (12.69% of the counterfactual level) above where the old trend would have gone.
What this can't tell you
The intervention point was assumed at the series midpoint (2022-12-17) rather than provided; if the actual pricing change occurred on a different date, all estimates shift accordingly. No control series exists, so any concurrent event — seasonal pattern, market shift, data collection change — cannot be ruled out as an alternative explanation.
Data Preparation
How the series was assembled and where the break was placed.
The short answer
All 48 monthly observations were retained without loss. The intervention point was placed at the midpoint of the date range (2022-12-17) because no explicit date was provided, creating 24 pre-intervention and 24 post-intervention points with the first post-break month at 2023-01-01.
The detail
No rows were dropped: 48 rows loaded, 48 used, forming 48 time points. The preprocessing step flags that no intervention_date parameter was supplied, so the break was assumed at the series' temporal midpoint rather than placed at the actual pricing change date. This assumption is critical: all coefficient estimates, confidence intervals, and p-values depend on this placement. The split produces exactly 24 pre-intervention and 24 post-intervention observations, with the first post-period point dated 2023-01-01.
What this can't tell you
Without the true intervention date, we cannot confirm whether the break is correctly positioned. Providing the actual pricing change date would anchor all estimates to the real event timing rather than a statistical midpoint assumption.
Actual, Fitted, and Counterfactual
The series split at 2023-01-01, with the pre-trend projected forward.
The short answer
The fitted line shows a sharp downward jump at the 2023-01-01 break, then diverges upward from the counterfactual (old trend) with increasing velocity. The vertical gap at the break is the level change (-12.89); the widening gap afterward is the slope change (1.549 per period) in action.
The detail
Three series overlay the chart: actual Monthly Revenue, the segmented model's fit, and the counterfactual (pre-intervention trend extended forward). At the break, the fitted and counterfactual lines separate vertically by -12.89 (the level change). Thereafter they diverge linearly: the gap grows by 1.549 units each period. By the final observation (2024-12-01) the gap reaches 22.73 units. The counterfactual would place revenue at 179.097; the model places it at 201.826. This separation pattern is consistent with an intervention effect, though any concurrent event would paint the identical picture.
What this can't tell you
Visual separation at the break does not prove causation. The design observes a single series with no control group; confounding events cannot be ruled out. The projection grows more speculative the further it extends past the break.
Level and Slope Changes
Pre and post slopes, the jump at the break, and the trend break — each with its confidence interval.
| Measure | Estimate | Detail |
|---|---|---|
| Pre-intervention slope | 0.609 | Change in Monthly Revenue per period before 2023-01-01 (95% CI 0.486 to 0.732) |
| Post-intervention slope | 2.158 | Change in Monthly Revenue per period from 2023-01-01 on (95% CI 2.035 to 2.281) |
| Level change at intervention | -12.89 | Immediate jump at 2023-01-01; 95% CI -15.31 to -10.48; p < 0.001 |
| Slope change at intervention | 1.549 | Trend break at 2023-01-01; 95% CI 1.374 to 1.723; p < 0.001 |
The short answer
The pricing change fractured the growth trajectory. Pre-intervention, revenue was climbing 0.609 per month. Post-intervention, it climbed 2.158 per month—a 1.549-unit steeper slope. Both the immediate drop (-12.89) and the trend acceleration (1.549) are statistically indistinguishable from zero with less than 0.1% probability under the model.
The detail
Pre-intervention slope: 0.609 per period (95% CI 0.486 to 0.732). Post-intervention slope: 2.158 per period (95% CI 2.035 to 2.281). Level change at intervention: -12.892 (95% CI -15.31 to -10.48, p < 0.001). Slope change at intervention: 1.549 (95% CI 1.374 to 1.723, p < 0.001). Both confidence intervals exclude zero, indicating changes larger than residual noise would produce by chance.
What this can't tell you
The confidence intervals assume classical OLS conditions hold and no residual autocorrelation biases them. Lag-1 autocorrelation is 0.09 (low), so this approximation is reasonable here. The intervals do not account for the midpoint assumption on intervention placement.
The Counterfactual Gap
Actual versus the pre-trend projection at the end of the series, in units and percent.
| Measure | Estimate | Detail |
|---|---|---|
| Counterfactual Monthly Revenue at series end | 179.1 | Where the pre-intervention trend would have put Monthly Revenue by 2024-12-01 had nothing changed |
| Model-fitted Monthly Revenue at series end | 201.8 | Where the segmented model places Monthly Revenue at 2024-12-01 given the level and slope changes |
| Gap at series end (units) | 22.73 | Fitted minus counterfactual at 2024-12-01 (22.73) |
| Gap at series end (% of counterfactual) | 12.69 | Relative to the counterfactual level of 179.10 (12.69%) |
| Average post-period gap (units) | 4.918 | Mean fitted-minus-counterfactual gap across the 24 post-intervention points |
The short answer
The pricing change left revenue 22.73 units above where the old trend would have taken it by series end—a 12.69% gain relative to the counterfactual level. The gap averaged 4.92 units across all 24 post-intervention months, growing as the steeper slope compounded.
The detail
Counterfactual Monthly Revenue at series end (2024-12-01): 179.097. Model-fitted Monthly Revenue at series end: 201.826. Gap at end (units): 22.729. Gap at end (% of counterfactual): 12.69%. Average post-period gap: 4.918 units. The gap widens monotonically because the slope change is positive (1.549 per period): each month the faster post-intervention trend pulls further ahead of the old trend's projection.
What this can't tell you
This projection assumes the pre-intervention trend would have continued unchanged. The further past the break the projection extends, the more speculative it becomes. Any structural shift in the underlying business (market saturation, competitive entry, demand shock) would violate the constant-trend assumption and render the counterfactual unreliable.
Method & Assumptions
The design, the model, the autocorrelation check, and the assumptions the causal read rests on.
| Item | Detail |
|---|---|
| Design | Observational interrupted time series (segmented regression) on a single series — no control group. Not a randomized experiment. |
| Intervention point | No intervention_date parameter was provided, so the intervention point was ASSUMED at the midpoint of the observed date range (2022-12-17) — points after it count as post-intervention. This midpoint is an assumption, not data: provide an intervention_date parameter to place the break where the intervention actually happened. |
| Model | OLS regression of Monthly Revenue on time, a post-intervention indicator, and time-since-intervention: the indicator's coefficient is the level change and the time-since coefficient is the slope change. |
| Inference | Classical OLS standard errors and 95% confidence intervals. No autocorrelation-robust (e.g. Newey-West) correction is applied — when residual autocorrelation is high, the intervals here are too narrow. |
| Autocorrelation check | The lag-1 residual autocorrelation is 0.09, low enough that classical OLS standard errors are a reasonable approximation for this series. |
| Counterfactual | The pre-intervention trend extended forward unchanged; the gap between it and the fitted series at the end of the data is the cumulative divergence. |
| Key assumptions | The pre-intervention trend would have continued unchanged absent the intervention; no other event shifted the series at the same time — any concurrent event is indistinguishable from the intervention itself; the series' measurement is consistent throughout. |
The short answer
Segmented OLS regression on a single revenue series, interrupted at an assumed midpoint. The level and slope changes are the regression coefficients at the break. No randomization, no control group, no autocorrelation correction—only the assumption that nothing else moved the series at the same time.
The detail
Design: observational interrupted time series (segmented regression) on Monthly Revenue alone—no control group or randomization. Intervention point: assumed at the series midpoint (2022-12-17) because no explicit date was provided; this placement is an assumption, not data. Model: OLS regression of revenue on time, a post-intervention indicator, and time-since-intervention; the indicator coefficient is the level change (-12.892), the time-since coefficient is the slope change (1.549). Inference: classical OLS standard errors and 95% confidence intervals; no autocorrelation-robust correction applied. Lag-1 residual autocorrelation: 0.09 (low, so classical intervals are reasonable). Counterfactual: pre-intervention trend extended forward unchanged.
What this can't tell you
The causal reading rests entirely on the assumption that no other event shifted the series at the intervention date. A concurrent campaign, seasonal pattern, data collection change, or market shock would be indistinguishable from the pricing intervention itself. The midpoint assumption anchors all results; the true pricing date may differ.
Methodology
Statistical methodology and diagnostics for Interrupted Time Series
Statistical Method
Standard-library analysis: did the intervention shift the level or the slope? Map a date column and a numeric series, tell it when the intervention happened (or let it assume the series midpoint, clearly flagged), and get a segmented regression: the immediate jump in the series at the break (level change) and the bend in the trend (slope change), each with a 95% confidence interval and p-value, plus the pre-intervention trend projected forward as a counterfactual and the gap between actual and counterfactual at the end of the series, in units and percent. A lag-1 residual autocorrelation check warns when the classical p-values are anti-conservative.
- The pre-intervention trend would have continued unchanged absent the intervention
- No other event shifted the series at the same time as the intervention
- The trend on each side of the break is approximately linear
- The series is measured consistently before and after the break
- This is a single-series observational design with no control group — the result is consistent with an intervention effect, never proof of one
- Any event coinciding with the intervention date is indistinguishable from the intervention itself
- Standard errors are classical OLS — when residual autocorrelation exceeds 0.3 the report warns that intervals and p-values are anti-conservative (no Newey-West correction is applied)
- If no intervention date is provided, the break is assumed at the series midpoint and flagged — the estimates hang on that placement
Analysis Code
Complete R source code for this analysis
Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# n_dropped $ rows dropped for missing date/value
# date_h/value_h $ humanized user names for prose
# n_points $ distinct time points used
# agg_note $ note when several rows per date were averaged
# split_rule $ how the intervention point was chosen
# midpoint_used $ TRUE when the midpoint fallback was assumed
# int_date_str $ ISO date used as the break (or midpoint date)
# t0/n_pre/n_post $ break index + points each side
# b0/b1 (pre trend) $ intercept + pre slope
# level/level_se/level_p/level_lo/level_hi $ LEVEL change (jump)
# slope/slope_se/slope_p/slope_lo/slope_hi $ SLOPE change (trend break)
# post_slope/post_slope_lo/post_slope_hi $ pre slope + slope change
# r1/ac_flag/ac_verdict $ lag-1 residual autocorrelation check
# cf_end/fit_end/gap/gap_pct/gap_pct_txt $ counterfactual gap at end
# avg_gap $ mean post-period gap (actual fit - counterfactual)
# its_trends_df $ period_date (ISO), series_value, series_label
# segments_df $ measure, estimate, detail
# gap_df $ measure, estimate, detail
# methods_df $ item, detail
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Resolve mapped columns (humanized for all prose)
initial_rows <- nrow(df)
date_h <- humanize_semantic("date", col_map)
value_h <- humanize_semantic("value", col_map)
for (req in c("date", "value")) {
if (!(req %in% names(df))) {
stop(sprintf("Interrupted time series needs '%s' (%s) mapped.",
humanize_semantic(req, col_map),
c(date = "the date/time column",
value = "the numeric series to analyse")[[req]]))
}
}Step 2: Coerce the value to numeric (95% rule)
y_raw <- df$value
if (!is.numeric(y_raw)) {
ch <- as.character(y_raw)
non_blank <- !is.na(ch) & trimws(ch) != ""
conv <- suppressWarnings(as.numeric(ch))
if (sum(non_blank) == 0 ||
sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
stop(sprintf("The value column '%s' does not look numeric — fewer than 95%% of its values parse as numbers. Pick a numeric column.",
value_h))
}
y_raw <- conv
}Step 3: Parse the date column (ISO, mdy, dmy, or integer years)
parse_dates_vec <- function(ch) {
ok <- function(dd) sum(!is.na(dd)) >= 0.95 * sum(!is.na(ch) & trimws(ch) != "")
d <- suppressWarnings(as.Date(ch, format = "%Y-%m-%d"))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::ymd(ch, quiet = TRUE)))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::mdy(ch, quiet = TRUE)))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::dmy(ch, quiet = TRUE)))
if (!ok(d)) {
nv <- suppressWarnings(as.numeric(ch))
nv_ok <- stats::na.omit(nv)
if (length(nv_ok) >= 0.95 * sum(!is.na(ch) & trimws(ch) != "") &&
length(nv_ok) > 0 && all(nv_ok == round(nv_ok)) &&
all(nv_ok >= 1900 & nv_ok <= 2100)) {
d <- as.Date(ifelse(is.na(nv), NA, sprintf("%04d-01-01", nv)),
format = "%Y-%m-%d")
}
}
if (ok(d)) d else NULL
}
parse_date_one <- function(s) {
s <- trimws(as.character(s))
d <- suppressWarnings(as.Date(s, format = "%Y-%m-%d"))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::ymd(s, quiet = TRUE)))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::mdy(s, quiet = TRUE)))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::dmy(s, quiet = TRUE)))
if (is.na(d)) {
ny <- suppressWarnings(as.numeric(s))
if (!is.na(ny) && ny == round(ny) && ny >= 1900 && ny <= 2100)
d <- as.Date(sprintf("%04d-01-01", ny))
}
d
}
d_ch <- trimws(as.character(df$date))
dts <- parse_dates_vec(d_ch)
if (is.null(dts)) {
stop(sprintf("The date column '%s' could not be read as dates — fewer than 95%% of its values parse as calendar dates (or integer years). Map a date column so the series can be ordered in time.",
date_h))
}Step 4: Keep rows complete on date and value; average duplicates
keep <- !is.na(y_raw) & !is.na(dts)
n_dropped <- sum(!keep)
y_k <- y_raw[keep]; d_k <- dts[keep]
if (length(y_k) < 4) {
stop(sprintf("Only %d usable rows remained after dropping rows missing %s or %s — far too few for an interrupted time series.",
length(y_k), date_h, value_h))
}
agg <- stats::aggregate(list(y = y_k), by = list(date_iso = format(d_k, "%Y-%m-%d")), FUN = mean)
agg <- agg[order(agg$date_iso), , drop = FALSE]
n_points <- nrow(agg)
final_rows <- length(y_k)
rows_removed <- initial_rows - final_rows
agg_note <- if (n_points < final_rows) {
sprintf("Several rows share the same %s value, so the %s values were first averaged to one point per date(%s rows became %d points).",
date_h, value_h, format(final_rows, big.mark = ","), n_points)
} else ""
if (n_points < 16) {
stop(sprintf("The series in '%s' has only %d distinct time points — interrupted time series needs at least 16 (8 on each side of the intervention).",
date_h, n_points))
}
dd <- as.Date(agg$date_iso)
dnum <- as.numeric(dd)
yv <- agg$y
idx <- seq_len(n_points)Step 5: Place the intervention point (parameter or midpoint ASSUMPTION)
int_param <- params$intervention_date %||% params$treatment_date %||% NULL
midpoint_used <- FALSE
if (!is.null(int_param)) {
tsd <- parse_date_one(int_param)
if (is.na(tsd)) {
stop(sprintf("The intervention_date parameter('%s') could not be read as a date.",
as.character(int_param)))
}
is_post <- dnum >= as.numeric(tsd)
int_date_str <- format(tsd, "%Y-%m-%d")
split_rule <- sprintf("The intervention date was provided as %s: points on or after it count as post-intervention.",
int_date_str)
} else {
midpoint_used <- TRUE
cut_v <- min(dnum) + (max(dnum) - min(dnum)) / 2
is_post <- dnum > cut_v
int_date_str <- format(as.Date(cut_v, origin = "1970-01-01"), "%Y-%m-%d")
split_rule <- sprintf("No intervention_date parameter was provided, so the intervention point was ASSUMED at the midpoint of the observed date range(%s) — points after it count as post-intervention. This midpoint is an assumption, not data: provide an intervention_date parameter to place the break where the intervention actually happened.",
int_date_str)
}
n_pre <- sum(!is_post)
n_post <- sum(is_post)
if (n_pre < 8) {
stop(sprintf("Only %d point(s) in '%s' fall before the intervention date (%s) — interrupted time series needs at least 8 points on each side of the break to separate a level shift from the pre-existing trend.",
n_pre, date_h, int_date_str))
}
if (n_post < 8) {
stop(sprintf("Only %d point(s) in '%s' fall on or after the intervention date (%s) — interrupted time series needs at least 8 points on each side of the break to estimate the post-intervention trend.",
n_post, date_h, int_date_str))
}
t0 <- min(idx[is_post])
break_date_str <- agg$date_iso[t0]Step 6: Segmented OLS — value ~ time + post + time_since_intervention
post01 <- as.integer(is_post)
tsi <- pmax(0L, idx - t0)
af <- data.frame(y = yv, t = idx, post = post01, tsi = tsi)
fit <- stats::lm(y ~ t + post + tsi, data = af)
co <- summary(fit)$coefficients
if (!all(c("t", "post", "tsi") %in% rownames(co)) ||
any(is.na(coef(fit)[c("t", "post", "tsi")]))) {
stop("The segmented regression could not be estimated — the series may be too short or degenerate around the intervention point.")
}
b0 <- unname(coef(fit)["(Intercept)"])
b1 <- unname(coef(fit)["t"])
level <- unname(coef(fit)["post"])
slope <- unname(coef(fit)["tsi"])
level_se <- co["post", "Std. Error"]; level_p <- co["post", 4]
slope_se <- co["tsi", "Std. Error"]; slope_p <- co["tsi", 4]
ci <- stats::confint(fit)
level_lo <- ci["post", 1]; level_hi <- ci["post", 2]
slope_lo <- ci["tsi", 1]; slope_hi <- ci["tsi", 2]
b1_lo <- ci["t", 1]; b1_hi <- ci["t", 2]Post slope = pre slope + slope change, with a combined-variance CI
V <- stats::vcov(fit)
post_slope <- b1 + slope
ps_se <- sqrt(V["t", "t"] + V["tsi", "tsi"] + 2 * V["t", "tsi"])
tcrit <- stats::qt(0.975, df = fit$df.residual)
post_slope_lo <- post_slope - tcrit * ps_se
post_slope_hi <- post_slope + tcrit * ps_seStep 7: Lag-1 residual autocorrelation check
res <- stats::residuals(fit)[order(idx)]
r1 <- if (length(res) >= 10) {
suppressWarnings(stats::cor(res[-1], res[-length(res)]))
} else NA_real_
ac_flag <- !is.na(r1) && abs(r1) > 0.3
ac_verdict <- if (is.na(r1)) {
"The lag-1 residual autocorrelation could not be computed."
} else if (ac_flag) {
sprintf("CAUTION: the lag-1 residual autocorrelation is %s — beyond the 0.3 threshold. This analysis uses classical OLS standard errors, so the confidence intervals and p-values reported here are anti-conservative(too narrow): treat any significance claim with caution, as the effective sample is smaller than the point count suggests.",
r2(r1))
} else {
sprintf("The lag-1 residual autocorrelation is %s, low enough that classical OLS standard errors are a reasonable approximation for this series.",
r2(r1))
}Step 8: Counterfactual projection and the gap at series end
t_end <- n_points
cf_end <- b0 + b1 * t_end
fit_end <- b0 + b1 * t_end + level + slope * (t_end - t0)
gap <- fit_end - cf_end
gap_pct <- if (abs(cf_end) > 1e-8) 100 * gap / abs(cf_end) else NA_real_
gap_pct_txt <- if (is.na(gap_pct)) "not defined(counterfactual level near zero)" else paste0(r2(gap_pct), "%")
cf_v <- b0 + b1 * idx
fit_v <- as.numeric(stats::fitted(fit))
avg_gap <- mean(fit_v[is_post] - cf_v[is_post])Step 9: Chart dataset — actual, fitted, counterfactual (ISO dates)
keep_i <- idx
if (n_points > 500) {
keep_i <- sort(unique(c(1, n_points, t0 - 1, t0,
round(seq(1, n_points, length.out = 500)))))
}
cf_i <- keep_i[keep_i >= (t0 - 1)] # counterfactual from the last pre point on
its_trends_df <- rbind(
data.frame(period_date = agg$date_iso[keep_i],
series_value = round(yv[keep_i], 3),
series_label = "Actual", stringsAsFactors = FALSE),
data.frame(period_date = agg$date_iso[keep_i],
series_value = round(fit_v[keep_i], 3),
series_label = "Fitted", stringsAsFactors = FALSE),
data.frame(period_date = agg$date_iso[cf_i],
series_value = round(cf_v[cf_i], 3),
series_label = "Counterfactual", stringsAsFactors = FALSE)
)
rownames(its_trends_df) <- NULL