Extending ggchangepoint

Youzhi Yu
University of Chicago

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 out

The 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.41

cp_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)

Series with the externally supplied changepoints drawn as vertical rules

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:

tidy(as_ggcpt(c(150, 150, 9999, NA, 50), x))$cp
#> [1]  50 150

It also takes the optional extras, so a detector that reports uncertainty does not have to throw it away:

with_ci <- as_ggcpt(c(100, 200), x, method = "external",
                    ci = cbind(c(95, 192), c(107, 205)),
                    extra = list(score = c(12.4, 8.1)))
tidy(with_ci)
#> # A tibble: 2 × 5
#>      cp cp_value ci_lower ci_upper score
#>   <int>    <dbl>    <int>    <int> <dbl>
#> 1   100    0.369       95      107  12.4
#> 2   200    4.04       192      205   8.1

cpt_register_method(): dispatch by name

The 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         1
cpt_unregister_method("biggest_jump")

Registration 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.

Returning a finished result

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)

Series with the registered detector's changepoint marked and the smoothed signal it fitted overlaid

cpt_unregister_method("smoothed_jump")

Recipe: a Python detector through reticulate

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.

Reducing the install

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:

cpt_install_engines("bayesian")
cpt_install_engines(c("highdim", "functional"), dry_run = TRUE)
tab <- cpt_methods()
table(status = tab$status, installed = tab$installed, useNA = "ifany")
#>            installed
#> status      FALSE TRUE <NA>
#>   available     1   50    0
#>   planned       0    0    5

What the contract is

If you are writing a wrapper of your own, this is the whole contract, and as_ggcpt() enforces all of it:

References

Jewell, Sean, Paul Fearnhead, and Daniela Witten. 2022. “Testing for a Change in Mean After Changepoint Detection.” Journal of the Royal Statistical Society: Series B 84 (4): 1082–1104. https://doi.org/10.1111/rssb.12501.
Londschien, Malte, Peter Bühlmann, and Solt Kovács. 2023. “Random Forests for Change Point Detection.” Journal of Machine Learning Research 24 (216): 1–45.