--- title: "Extending ggchangepoint" author: "Youzhi Yu
University of Chicago" bibliography: vignette_reference.bib output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Extending ggchangepoint} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 8, fig.height = 5, dpi = 72, message = FALSE, warning = FALSE, fig.alt = "ggchangepoint plot of a result from an externally supplied detector" ) library(ggchangepoint) library(ggplot2) theme_set(theme_ggcpt()) ``` This package wraps fifty detectors. It will never wrap all of them, and some of the most interesting ones it *cannot* wrap: `changeforest` [@londschien2023changeforest] is on conda-forge only, `ChangepointInference` [@jewell2022testing] 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. ```{r as-ggcpt} 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 ``` `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: ```{r as-ggcpt-uses, fig.alt = "Series with the externally supplied changepoints drawn as vertical rules"} tidy(fit) glance(fit) cpt_metrics(tidy(fit)$cp, truth = c(100, 200), n = 300) 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: ```{r as-ggcpt-contract} tidy(as_ggcpt(c(150, 150, 9999, NA, 50), x))$cp ``` It also takes the optional extras, so a detector that reports uncertainty does not have to throw it away: ```{r as-ggcpt-extras} 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) ``` # `cpt_register_method()`: dispatch by name The repeatable case: you want `cpt_detect(x, method = "yours")` and everything that keys off a method name. ```{r register} 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 ``` Note what the print method says. A registered method is **visibly** user-supplied everywhere it appears: ```{r register-visible} subset(cpt_methods(), status == "registered") cpt_cite("biggest_jump") ``` 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: ```{r register-tools} cpt_consensus(x, methods = c("pelt", "binseg", "biggest_jump"), min_votes = 2) cpt_benchmark(cpt_datasets(n = 200, seed = 1, names = c("step", "teeth")), methods = c("pelt", "biggest_jump"), progress = FALSE) ``` ```{r unregister} 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: ```{r register-full, fig.alt = "Series with the registered detector's changepoint marked and the smoothed signal it fitted overlaid"} 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) 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: ```{r reticulate, eval = FALSE} 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: ```{r install, eval = FALSE} cpt_install_engines("bayesian") cpt_install_engines(c("highdim", "functional"), dry_run = TRUE) ``` ```{r install-status} tab <- cpt_methods() table(status = tab$status, installed = tab$installed, useNA = "ifany") ``` # 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: - `$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. - The optional slots (`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". # References