This package wraps 51 detectors. It will never wrap all of them, and
some of the most interesting ones it cannot wrap:
changeforest (Londschien, Bühlmann,
and Kovács 2023) is on conda-forge only,
ChangepointInference (Jewell,
Fearnhead, and Witten 2022) is on GitHub only, gfpop
and cpss were removed from CRAN, deep-learning detectors
live in Python, and your in-house method lives with you.
That is a smaller problem than it looks, because almost nothing in
this package is about the detectors. autoplot(), the geoms,
tidy(), glance(), augment(),
cpt_metrics(), cpt_consensus(),
cpt_stability(), cpt_benchmark() and
cpt_report() all speak to the ggcpt contract,
not to any engine. Two functions let anything into it.
as_ggcpt(): changepoints in, result outThe one-off case: you have locations from somewhere else and want the package’s machinery.
set.seed(2026)
x <- c(rnorm(100), rnorm(100, 4), rnorm(100, 1))
# Pretend these came from a Python detector, a paper, or an analyst.
external <- c(101, 199)
fit <- as_ggcpt(external, x, method = "ruptures::Pelt",
cp_convention = "right")
fit
#> ggcpt (changepoint detection result)
#> Method: ruptures::Pelt
#> Change in: mean
#> Changepoints found: 2
#> CP convention: left
#> Penalty: user
#> Series length: 300
#>
#> Changepoints:
#> # A tibble: 2 × 2
#> cp cp_value
#> <int> <dbl>
#> 1 100 0.369
#> 2 198 2.41cp_convention = "right" matters: some engines report the
first index of the new segment and some the last index of the old one,
and getting it wrong shifts every ___location by one. The conversion
happens on the way in, so the stored result is always on this package’s
convention.
Everything now works:
tidy(fit)
#> # A tibble: 2 × 2
#> cp cp_value
#> <int> <dbl>
#> 1 100 0.369
#> 2 198 2.41
glance(fit)
#> # A tibble: 1 × 11
#> n n_changepoints method change_in penalty_type penalty_value cp_convention
#> <int> <int> <chr> <chr> <chr> <dbl> <chr>
#> 1 300 2 ruptu… mean user NA left
#> # ℹ 4 more variables: total_cost <dbl>, runtime <dbl>, engine_version <chr>,
#> # family <chr>
cpt_metrics(tidy(fit)$cp, truth = c(100, 200), n = 300)
#> # A tibble: 1 × 14
#> n n_pred n_truth precision recall f1 covering covering_floor
#> <int> <int> <int> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 300 2 2 1 1 1 0.987 0.333
#> # ℹ 6 more variables: covering_scaled <dbl>, hausdorff <dbl>, rand_index <dbl>,
#> # annotation_error <int>, mae_matched <dbl>, rmse_matched <dbl>
autoplot(fit, show_segments = TRUE)as_ggcpt() runs the same contract checks as every
built-in wrapper (sorting, de-duplication, range checks, aligned extra
columns, derived segments), so an external result cannot violate the
invariants the rest of the package relies on:
It also takes the optional extras, so a detector that reports uncertainty does not have to throw it away:
cpt_register_method(): dispatch by nameThe repeatable case: you want
cpt_detect(x, method = "yours") and everything that keys
off a method name.
cpt_register_method(
"biggest_jump",
fn = function(x, window = 1, ...) {
d <- abs(diff(as.numeric(x)))
which.max(stats::filter(d, rep(1, window) / window, sides = 2))
},
change_in = "mean",
engine = "example",
citation = "No citation supplied (illustration only)."
)
res <- cpt_detect(x, method = "biggest_jump", window = 5)
res
#> ggcpt (changepoint detection result)
#> Method: biggest_jump [user-registered]
#> Change in: mean
#> Changepoints found: 1
#> CP convention: left
#> Penalty: MBIC
#> Series length: 300
#>
#> Changepoints:
#> # A tibble: 1 × 2
#> cp cp_value
#> <int> <dbl>
#> 1 92 1.47
#>
#> This result came from a user-registered detector; the package validated
#> its shape, not its statistics. See ?cpt_register_method.Note what the print method says. A registered method is visibly user-supplied everywhere it appears:
subset(cpt_methods(), status == "registered")
#> # A tibble: 1 × 29
#> method change_in engine status installed target_release multivariate
#> <chr> <chr> <chr> <chr> <lgl> <chr> <lgl>
#> 1 biggest_jump mean example register… NA <NA> FALSE
#> # ℹ 22 more variables: univariate <lgl>, online <lgl>, ci <lgl>, fitted <lgl>,
#> # posterior <lgl>, statistic <lgl>, path <lgl>, scale_space <lgl>,
#> # families <chr>, choices <chr>, formula <lgl>, min_segment <chr>,
#> # na_handling <chr>, cp_convention_upstream <chr>, scale_invariant <lgl>,
#> # sequential <lgl>, max_cp <int>, tier <chr>, noise_model_arg <chr>,
#> # rate_arg <chr>, cost <chr>, max_n <dbl>
cpt_cite("biggest_jump")
#> [biggest_jump] No citation supplied (illustration only).The package validates the shape of what your function
returns; it does not and cannot validate the method. That distinction is
the reason for the labelling, and it is why cpt_cite() says
plainly when no citation was given rather than inventing one.
Registered methods take part in everything that keys off a method name:
cpt_consensus(x, methods = c("pelt", "binseg", "biggest_jump"),
min_votes = 2)
#> ggcpt_consensus (3 methods, tolerance 5, threshold 2 vote(s))
#> Methods: pelt, binseg, biggest_jump
#> Found by each: pelt 2, binseg 2, biggest_jump 1
#> Disagreement: 0.5 (share of locations not found by every method)
#> Consensus changepoints: 2
#>
#> # A tibble: 2 × 5
#> cp cp_value votes methods spread
#> <int> <dbl> <int> <chr> <int>
#> 1 100 0.369 3 biggest_jump, binseg, pelt 0
#> 2 200 4.04 2 binseg, pelt 0
#>
#> Agreement is a robustness display, not a significance test; see ?cpt_consensus.
cpt_benchmark(cpt_datasets(n = 200, seed = 1, names = c("step", "teeth")),
methods = c("pelt", "biggest_jump"), progress = FALSE)
#> ggcpt_benchmark (2 dataset(s) x 2 method(s), tolerance 5)
#>
#> Mean rank across datasets (1 = best):
#> # A tibble: 2 × 4
#> method mean_rank n_datasets n_datasets_total
#> <chr> <dbl> <int> <int>
#> 1 pelt 1.25 2 2
#> 2 biggest_jump 1.75 2 2
#>
#> # A tibble: 4 × 4
#> dataset method covering f1
#> <chr> <chr> <dbl> <dbl>
#> 1 step pelt 0.952 1
#> 2 teeth pelt 1 1
#> 3 step biggest_jump 0.362 0
#> 4 teeth biggest_jump 1 1Registration is session state. It is deliberately not persisted to disk: a script that behaved differently depending on what some earlier script had run would be worse than the problem it solved.
fn may return a bare vector of indices, as above, or a
finished ggcpt built with as_ggcpt(), which is
how you pass along an engine’s confidence intervals, fitted signal or
raw fit object:
cpt_register_method(
"smoothed_jump",
fn = function(x, ...) {
sm <- stats::filter(x, rep(1, 11) / 11, sides = 2)
sm[is.na(sm)] <- x[is.na(sm)]
as_ggcpt(which.max(abs(diff(sm))), x, fitted = as.numeric(sm))
},
engine = "example"
)
autoplot(cpt_detect(x, method = "smoothed_jump"), show_fit = TRUE)ruptures is the standard Python changepoint library. The
recipe is four lines, and this package never depends on
reticulate to support it:
library(reticulate)
rpt <- import("ruptures")
cpt_register_method(
"ruptures_pelt",
fn = function(x, model = "l2", pen = 10, ...) {
algo <- rpt$Pelt(model = model)$fit(matrix(as.numeric(x), ncol = 1))
# ruptures returns 1-based *right* endpoints, with n as the last entry
as.integer(unlist(algo$predict(pen = pen)))
},
change_in = "mean",
engine = "ruptures (Python)",
cp_convention = "left",
citation = paste("Truong, C., Oudre, L. and Vayatis, N. (2020).",
"Selective review of offline change point detection",
"methods. Signal Processing, 167, 107299.")
)
cpt_detect(x, method = "ruptures_pelt", pen = 20)The same shape works for a detector loaded from GitHub, a compiled
binary called through system2(), or a neural network scored
in torch: if it returns changepoint indices, it joins the
grammar.
The other side of extensibility is not needing everything. Only three
engines are hard dependencies. cpt_methods() reports what
is installed, and cpt_install_engines() installs a family
at a time:
If you are writing a wrapper of your own, this is the whole contract,
and as_ggcpt() enforces all of it:
$changepoints is a tibble with at least cp
(integer, sorted, de-duplicated, in 1..n-1, on the “left”
convention) and cp_value; engine extras such as
ci_lower/ci_upper or
posterior_prob are additional columns aligned to those
rows.$segments has one row per segment (seg_id,
start, end, n,
param_estimate) and always has one more row than
$changepoints.$data has index (positions
1..n) and value, plus fitted when
an engine supplies a signal and index_value when the result
carries a time index.$method, $change_in,
$penalty, $cp_convention are length-one
metadata; $fit is the raw upstream object.data_wide, index,
regions, diagnostics, registered)
are absent unless something supplies them, so
is.null(fit$regions) is the test for “this engine does not
do regions”.