--- title: "Supervised Changepoint Detection" author: "Youzhi Yu
University of Chicago" bibliography: vignette_reference.bib output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Supervised Changepoint Detection} %\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 supervised detection plot" ) library(ggchangepoint) library(ggplot2) theme_set(theme_ggcpt()) has_pl <- requireNamespace("penaltyLearning", quietly = TRUE) ``` Almost every changepoint method in this package is *unsupervised*: it picks a penalty by an information criterion and hopes the criterion matches what you would have said. Supervised changepoint detection [@hocking2013penalties] does something different. An expert marks regions of the series as containing a change or not, accuracy is measured in **label errors** against those marks, and the penalty is **learned** from them. Three things make it worth a vignette of its own: - it consistently beats unsupervised penalties on labelled data; - it gives a defensible answer to "what penalty should I use?" that does not depend on a modelling assumption; - its central object is a rectangle drawn over a time series, which is a ggplot2-native idea with no ggplot2-native implementation elsewhere. # Labels A label is a stretch of the series with an assertion attached. ```{r labels} set.seed(2026) x <- c(rnorm(80), rnorm(80, 4), rnorm(80, 1)) labs <- cpt_labels( start = c( 1, 60, 100, 140, 190), end = c( 55, 95, 135, 185, 240), change = c("no_change", "one_change", "no_change", "one_change", "no_change") ) labs ``` Three kinds, and the distinction matters: - `"change"`: at least one changepoint lies here. - `"one_change"`: exactly one does. Stricter, and the only one that makes a false positive *inside* a positive region detectable. - `"no_change"`: none does. Without these, a detector is never penalised for a false positive, and the learned penalty collapses to zero. Because labels are just a tidy tibble, they draw directly: ```{r labels-plot, fig.alt = "Series with shaded label regions behind it, coloured by what each label asserts"} d <- data.frame(t = seq_along(x), y = x) ggplot(d, aes(t, y)) + geom_cpt_label(aes(xmin = start, xmax = end, fill = change), data = labs) + geom_line(colour = "grey30") + scale_fill_cpt_label() + labs(fill = "Label", x = "Index", y = "Value") ``` If you already have a plain ground-truth changepoint set (the kind `cpt_metrics()` takes), `as_cpt_labels()` converts it, positives and negatives together, so the package has one notion of an annotation rather than two: ```{r as-labels} as_cpt_labels(c(80, 160), n = 240, margin = 5) ``` # Scoring a segmentation ```{r label-error} fit <- cpt_detect(x, method = "pelt") err <- cpt_label_error(fit, labs) err ``` The three-colour status shading turns model evaluation into a picture: ```{r label-error-plot, fig.alt = "Series with label regions shaded green for correct, orange for false positive and red for false negative"} ggplot(d, aes(t, y)) + geom_cpt_label(aes(xmin = start, xmax = end, fill = status), data = err) + geom_line(colour = "grey30") + geom_changepoint(data = tidy(fit), aes(xintercept = cp), colour = "#0072B2", linewidth = 0.6) + scale_fill_cpt_label() + labs(fill = "Outcome", x = "Index", y = "Value") ``` # The label error curve Label errors are a function of the penalty, and the shape of that function is what penalty learning is fitted to. It also answers a question worth asking before any fitting: can **any** penalty satisfy these labels? ```{r curve} curve <- cpt_label_error_curve(x, labs, method = "pelt") curve ``` ```{r curve-plot, fig.alt = "False positives, false negatives and total label errors against the penalty on a log scale, with the target interval shaded"} autoplot(curve) ``` The shaded band is the **target interval**: the range of log-penalties achieving the minimum error. A wide interval means an easy series; a narrow one means the labels pin the penalty down tightly; an empty minimum at a non-zero error means no penalty satisfies all the labels, and the labels or the method need revisiting. # Learning the penalty With several labelled series, the target intervals become the response in a max-margin interval regression: features of each series predict a log-penalty that lands inside its interval. ```{r learn} set.seed(5301) series <- list( a = c(rnorm(60), rnorm(60, 4)), b = c(rnorm(80), rnorm(80, 2)), c = c(rnorm(70), rnorm(70, 6)), d = c(rnorm(100, 0, 3), rnorm(100, 9, 3)) ) labels <- list( a = as_cpt_labels(60, n = 120), b = as_cpt_labels(80, n = 160), c = as_cpt_labels(70, n = 140), d = as_cpt_labels(100, n = 200) ) model <- cpt_learn_penalty(series, labels, penalties = 2^(0:10)) model ``` The features are scale-free summaries on the log scale, which is why the fourth series (the same jump measured in units three times as wide) does not simply demand a different penalty from the first. The model has a `predict()` method, and (the point of the whole exercise) `cpt_detect()` takes it wherever a penalty goes: ```{r use-model} predict(model, series$d) cpt_detect(series$d, method = "pelt", penalty = model) ``` So does `cpt_penalty()`, and so do the wrappers that accept a numeric penalty: ```{r use-model-2} cpt_penalty(model, series = series$d) ``` Compare that with the unsupervised default on the same series, which reads its penalty against a raw cost calibrated for unit noise: ```{r compare-default} nrow(cpt_detect(series$d, method = "pelt")$changepoints) nrow(cpt_detect(series$d, method = "pelt", penalty = model)$changepoints) ``` When **penaltyLearning** is installed, `cpt_learn_penalty()` delegates the interval regression to `IntervalRegressionCV()` and keeps the built-in squared-hinge fit as a fallback, so the result is the published estimator whenever the published implementation is available: ```{r engine, eval = has_pl} cpt_learn_penalty(series, labels, penalties = 2^(0:10), engine = "native")$fit$engine ``` # Where this fits Labels are annotations, and annotations are also what `cpt_metrics_annotated()` scores against. They are deliberately the same shape here, so a single set of expert marks can drive the metric, the plot and the penalty. The natural next step is `vignette("inference", package = "ggchangepoint")`, which covers what to do once the segmentation is fixed. # References