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