
Ten Ways to Get a Changepoint Wrong
Youzhi
Yu
University of Chicago
Source: vignettes/articles/pitfalls.Rmd
pitfalls.RmdA changepoint analysis rarely fails loudly. The engine returns, the plot looks plausible, and the answer is wrong. Each section below is one way that happens: a few lines that produce the wrong answer, why, and the call that avoids it. Every number on this page is computed when the page is built, so none of them is a claim you have to take on trust.
1. Autocorrelated noise read as a sequence of changes
The commonest real-world error. Noise that remembers its past wanders, and a detector that assumes independent noise reads each excursion as a new level.
set.seed(1)
ar <- as.numeric(arima.sim(list(ar = 0.8), 300)) # no change anywhere
fit <- quiet(cpt_detect(ar, method = "pelt"))
nrow(fit$changepoints)
#> [1] 7
cpt_assumptions(fit)
#> ggcpt_assumptions (method: pelt)
#> ! residual_dependence Ljung-Box at lag 10; lag-1 autocorrelation 0.54
#> The residuals are autocorrelated, the commonest cause of spurious
#> changepoints. cpt_detect(x, method = "kcp") (0.3 spurious, 1.9 of 2
#> real changes found); cpt_detect(x, method = "bfast") (1.0 spurious,
#> 1.6 of 2 real changes found); cpt_detect(x, method = "nsp", variant =
#> "selfnorm") (0.8 spurious, 1.2 of 2 real changes found)
#> scale_sensitivity noise sd 0.696; this engine's cost assumes unit noise
#> expected_false_positives measured on pure noise at n = 1,000 (50 replicates)
#> ! count_plausibility 7 changepoints in 300 observations (2.33 per hundred)
#> More than one changepoint per hundred observations is more often a
#> penalty on the wrong scale or dependent noise than a finding; check
#> the two rows above.
#> data_type looks continuous; family gaussianAvoid it: check before you believe the count.
cpt_assumptions() tests the residuals and names the
measured alternatives; engines that model the dependence answer
differently:
nrow(quiet(cpt_detect(ar, method = "decafs"))$changepoints)
#> [1] 0cpt_robustness(fit) re-runs the detector under its other
noise models and reports which changepoints survive, which is the check
that touches the cause: resampling (cpt_stability()) and
other algorithms (cpt_consensus()) reproduce an artifact of
the noise path.
2. A penalty on the wrong scale
pelt, binseg, segneigh and
fpop compare the penalty with a raw segment cost that
assumes the noise has standard deviation 1. Annual Nile flows have a
standard deviation near 170.
nrow(quiet(cpt_detect(Nile, method = "pelt"))$changepoints)
#> [1] 42
nrow(cpt_detect(as.numeric(scale(Nile)), method = "pelt")$changepoints)
#> [1] 1Avoid it: the package warns when a scale-sensitive
engine is handed noise far from unit scale. Standardise the series, use
change_in = "meanvar", or a method whose answer does not
depend on the units (subset(cpt_methods(), scale_invariant)
lists the ones measured).
3. Counts and 0/1 outcomes under a Gaussian cost
A binary series has two values, and a Gaussian cost that estimates the noise from the data sees a perfect fit in every run of equal values.
set.seed(3)
b <- c(rbinom(150, 1, 0.2), rbinom(150, 1, 0.6)) # one change, at 150
nrow(quiet(cpt_detect(b, method = "smuce"))$changepoints)
#> [1] 103
cpt_detect(b, method = "smuce", family = "binomial")$changepoints
#> # A tibble: 1 × 4
#> cp cp_value ci_lower ci_upper
#> <int> <dbl> <int> <int>
#> 1 151 0 137 162Measured across the engines, Gaussian costs raise about 247 times as
many false positives on Bernoulli data as on Gaussian data
(cpt_data_types).
Avoid it: say what the data are.
family = "poisson", "binomial" or
"exponential" fits a cost written for them
(cpt_families() lists which methods can), and the package
warns when a series looks like counts or 0/1 data and no family was
chosen.
4. An abrupt detector on a gradual change
A detector of level shifts, given a trend, approximates it with a staircase.
set.seed(4)
trend <- seq(0, 6, length.out = 300) + rnorm(300) # a line, no change
nrow(cpt_detect(trend, method = "pelt")$changepoints)
#> [1] 3Avoid it: ask for the change you mean. A change in
slope is change_in = "slope":
nrow(quiet(cpt_detect(trend, method = "cpop", change_in = "slope"))$changepoints)
#> [1] 05. A value that is not a measurement, coerced into one
A factor’s level codes, a timestamp’s seconds since 1970 and a survival object’s two columns all convert to numbers without complaint, and each then has a “mean shift” that means nothing. The package refuses them:
stamps <- as.POSIXct("2026-01-01", tz = "UTC") + 3600 * (0:99)
tryCatch(cpt_detect(stamps), error = function(e) conditionMessage(e))
#> [1] "`x` holds dates or timestamps, which convert to an increasing count of days or seconds: every detector would report the calendar. If these are the times of events, count them per period and detect on the counts (with `family = \"poisson\"`); if they label observations, pass them as `index`."Avoid it: pass the measurement as x and
the time as index.
6. A p-value at a location the data chose
A test at a detected changepoint reuses the data that put the changepoint there, so the difference it tests is the largest the noise allowed. On pure noise, forced to report one changepoint:
set.seed(6)
p <- vapply(1:200, function(i) {
f <- cpt_detect(rnorm(200), method = "amoc", penalty = "None")
quiet(cpt_test(f))$p_value[1]
}, numeric(1))
mean(p < 0.05, na.rm = TRUE) # a valid 5% test would give about 0.05
#> [1] 0.5233161Avoid it: cpt_test() marks such rows
selection_adjusted = FALSE. When the date was fixed in
advance, cpt_test_at() is an exact test; to ask whether
anything changed at all, cpt_test_null() tests the global
null without locating anything first. The calibration article has the
measured size of each.
7. The method chosen after seeing the answer
Fifty methods behind one call make it easy to run ten and report the one that found a change. A method chosen for its answer carries no valid p-value, and agreement between methods is not a correction: they share engines, costs and assumptions.
Avoid it: choose before you look.
cpt_recommend() answers from what you know about the
problem (dimension, noise, data type, expected number of changes), not
from the series, and returns the call to run:
head(cpt_recommend(noise = "autocorrelated")[, c("method", "call", "hits",
"false_positives")], 3)
#> # A tibble: 3 × 4
#> method call hits false_positives
#> <chr> <chr> <dbl> <dbl>
#> 1 kcp "cpt_detect(x, method = \"kcp\")" 1.9 0.3
#> 2 binseg "cpt_detect(x, method = \"binseg\")" 1.7 1.3
#> 3 bfast "cpt_detect(x, method = \"bfast\")" 1.6 18. The effect size read off where the change was found
The same selection, applied to the size of a change: the detector put the boundary where the two sides differ most.
set.seed(8)
sizes <- t(vapply(1:100, function(i) {
x <- c(rnorm(150), rnorm(150, 0.5)) # the true shift is 0.5
f <- cpt_detect(x, method = "pelt")
if (!nrow(f$changepoints)) return(c(NA, NA))
naive <- cpt_effect(f)
split <- cpt_effect(f, method = "split", seed = i)
c(naive$delta[which.min(abs(naive$cp - 150))],
if (nrow(split)) split$delta[which.min(abs(split$cp - 150))] else NA)
}, numeric(2)))
colMeans(sizes, na.rm = TRUE)
#> [1] 0.6342823 0.5178504Avoid it:
cpt_effect(fit, method = "split") locates on half the
observations and measures on the other half; the naive estimate is
labelled selection_adjusted = FALSE.
9. “No changepoints” read as “no change”
An empty answer means the series is stable, or the change was too small to see at this length and noise level. Those call for opposite conclusions.
set.seed(9)
x <- c(rnorm(100), rnorm(100, 0.3)) # a real, small shift
fit <- cpt_detect(x, method = "pelt")
nrow(fit$changepoints)
#> [1] 0
cpt_null_power(fit, n_sim = 30, seed = 1)
#> ggcpt_null_power (method: pelt, n = 200)
#> At this length and noise level (sd 1.08), a single shift of about 1.06 (0.98 sd) is
#> detected with power 0.8. A smaller change could be present and missed.Avoid it: report the smallest shift the analysis
could have found (cpt_null_power()), and test for a change
anywhere (cpt_test_null()) rather than inferring stability
from silence.
10. Outliers read as changepoints
A single wild observation is a segment of length one to a mean-change detector, so it arrives as a pair of changepoints.
set.seed(10)
o <- rnorm(200)
o[140] <- -9
cpt_detect(o, method = "pelt")$changepoints$cp
#> [1] 139 141Avoid it: a robust cost changes in the median, not the mean:
cpt_detect(o, method = "binsegrcpp", family = "l1")$changepoints
#> # A tibble: 0 × 2
#> # ℹ 2 variables: cp <int>, cp_value <dbl>And two that are about the data, not the model
Missing values. Some engines drop a missing
observation without a word and report positions in the shortened series.
cpt_detect() refuses them by default;
na_action = "omit" detects on what was observed and reports
every location in the original positions:
set.seed(11)
y <- c(rnorm(50), NA, rnorm(50, 3))
cpt_detect(y, method = "pelt", na_action = "omit")$changepoints
#> # A tibble: 1 × 2
#> cp cp_value
#> <int> <dbl>
#> 1 50 0.220A monitor trained on a baseline that already
changed. An online detector learns “in control” from the
baseline it is given; a change inside it teaches the wrong normal.
cpt_monitor() checks the baseline, for dependence and (for
cpm) for changes inside it, and says so:
set.seed(12)
tryCatch(cpt_monitor("cpm", baseline = c(rnorm(100), rnorm(100, 2))),
ggchangepoint_assumption = function(w) conditionMessage(w))
#> [1] "cpm detected 1 change(s) inside `baseline`, so the baseline is not in control. The monitor was restarted after each, and only the stretch after the last one informs it; check the baseline, or build the monitor without one."Where these came from
Several of these were found in this package before they were found in
anyone’s analysis: the 0.5.0 and 0.6.0 audits measured each engine’s
behaviour on autocorrelated, heavy-tailed, heteroscedastic, binary and
count data (cpt_noise_benchmark,
cpt_data_types), on rescaled and reversed series
(cpt_invariances) and on pure noise at scale
(cpt_null_sizes), and every warning quoted above exists
because a measurement showed the failure first.