How to Perform Online (or Real-Time) Changepoint Detection in R

Keywords: #software #changepoint #online #R

In the world of data analysis, detecting changes in data streams is a crucial task. This article will guide you through performing online (or real-time) changepoint detection using the focus R package, to identify abrupt shifts in your data streams as quickly as they arrive.

Changepoint detection is a statistical technique used to pinpoint moments in a time series where the underlying characteristics of the data significantly change, such as a change in the mean, variance, or distribution of the data. This can be invaluable in various applications, from monitoring financial markets for sudden price fluctuations to tracking sensor readings for equipment failures.

Online changepoint detection, in particular, is useful when dealing with large datasets or real-time data streams. It allows us to detect changes in the data as they occur, rather than waiting until the end of the dataset, and this can be particularly useful in monitoring applications where timely detection is critical.

The focus package implements the FOCuS family of algorithms on top of an efficient C++ backend. These algorithms compute the Generalised Likelihood Ratio test for a single changepoint exactly, with a per-iteration cost of roughly O(log(n)) in the univariate case, making them ideal for processing large data streams efficiently, even with few (or no) training observations. The package supports various univariate and multivariate distribution families, including Gaussian, Poisson, Gamma, and Bernoulli. It can handle scenarios where the pre-change parameter is known or unknown, one-sided and two-sided alternatives, non-parametric detection, and even data streams with autocorrelated noise.

If you prefer working in Python, check out the companion guide: How to Perform Online Changepoint Detection in Python. The focus-cpt Python package shares the same interface and the very same C++ core, and produces the same results.

If you have found the focus package useful, or encountered any limitations or issues, please get in touch – we would love to hear from you!

Getting Started

The focus package is readily available through CRAN:

install.packages("focus")

Alternatively, the development version can be installed from the GitHub repository:

devtools::install_github("gtromano/unified_focus", subdir = "focus")

Detecting a Change in Mean for Univariate Gaussian Distribution

As a first example, here, we’ll demonstrate how to detect a change in mean for a univariate Gaussian distribution. We’ll simulate some data with a shift in mean at a specific point and then use the package to pinpoint this changepoint.

Simulating the Data

First, we’ll generate some sample data that embodies a change in mean.

# Set random seed for replicability
set.seed(0)

# Define data means and sizes
mean_pre <- 0
mean_post <- 2
size_total <- 50000  # Total data size

# Generate data with a changepoint in the middle
Y <- c(rnorm(size_total / 2, mean = mean_pre),
       rnorm(size_total / 2, mean = mean_post))

To generate the example in this snippet, we define the means (pre-change and post-change) and the total data size (size_total), and define the pre-change and post-change segments by dividing the total size in half. This generates a changepoint in the middle, as it can be seen in the plot below:

plot(Y, type = "l", xlab = "Data Point", ylab = "Data Value",
     main = "Sample Data with Change in Mean")
abline(h = mean_pre, col = "blue", lty = 2)
abline(h = mean_post, col = "red", lty = 2)
legend("topleft", legend = c("Pre-change Mean", "Post-change Mean"),
       col = c("blue", "red"), lty = 2)

Online Changepoint Detection

Time to run our changepoint analysis! In real-world monitoring scenarios, data typically arrives as a stream. The focus package is designed for this type of online or real-time analysis. Imagine the data is continuously fed into a buffer, and the changepoint detection algorithm analyzes this buffer to identify potential shifts in the underlying distribution.

For the sake of this article, we simulate this process by iterating over the data points one by one. In a streaming application, the loop would be replaced by a mechanism to pull data from the buffer whenever new data arrives.

library(focus)

det <- detector_create(type = "univariate")
threshold <- 25

for (i in seq_along(Y)) {
  # Sequentially update the detector with each data point
  detector_update(det, Y[i])
  result <- get_statistics(det, family = "gaussian")
  if (result$stat >= threshold) break
}

cat("Change detected at time", result$stopping_time,
    "- changepoint estimated at", result$changepoint, "\n")
Change detected at time 25011 - changepoint estimated at 24996

After 25,000 data points, we detected our changepoint only after 11 iterations!

Explanation:

  • We create a detector object with detector_create(type = "univariate") and we set a threshold (threshold <- 25) for the detection statistic. In this example we go with a fixed threshold, however this can be tailored to each application’s needs.

The core loop iterates through the data (Y), mimicking how the detector would process a stream of data points arriving one at a time:

  • Inside the loop, each data point is fed to the detector using detector_update(det, Y[i]). This function internally updates the detector’s candidate changepoints (in place) to track the evolving data stream.

  • The get_statistics(det, family = "gaussian") call computes the current value of the detection statistic — a likelihood ratio statistic quantifying how likely a change has occurred based on the data seen so far. Note how the distribution family is specified here, at query time, and not when the detector is created: the state of the detector is independent of the statistic computed on it (more on this later).

  • If the detection statistic surpasses the predefined threshold, it suggests a significant shift in the data stream is probable. We break out of the loop and assume a changepoint has been detected.

  • The result is a list reporting the stopping_time (when we detected the change), the estimated changepoint location, and the value of the test statistic stat.

By the way, the online interface plays nicely with the native R pipe, so the update-and-query step can also be written as:

result <- det |> detector_update(Y[i]) |> get_statistics(family = "gaussian")

Offline Mode

If the data is already sitting in memory — say, for a retrospective analysis, or for calibrating a threshold on some training data — the focus_offline function runs the very same analysis with the whole loop executed in C++, which is considerably faster:

res <- focus_offline(Y, threshold = 25, type = "univariate", family = "gaussian")
cat("Detection time:", res$detection_time, "\n")
cat("Estimated changepoint:", res$detected_changepoint, "\n")
Detection time: 25011 
Estimated changepoint: 24996 

Passing threshold = Inf computes the full trajectory of the test statistic without stopping, which is handy to study the behaviour of the statistic and pick a sensible threshold:

res_full <- focus_offline(Y, threshold = Inf, type = "univariate", family = "gaussian")

plot(res_full$stat[, 1], type = "l", lwd = 2, xlab = "Time", ylab = "Statistic",
     main = "FOCuS Detection Statistic")
abline(h = 25, col = "red", lty = 2)

Beyond the Gaussian Case

The focus package can also detect changes in one-parameter exponential family distributions, such as Poisson change-in-rate, useful for count data, Gamma change-in-scale (or rate), for positively defined data, and Bernoulli change-in-probability, for binary data. For instance, detecting a change in rate over count data is as simple as:

set.seed(101)
Y_counts <- c(rpois(1000, lambda = 2), rpois(1000, lambda = 6))

res <- focus_offline(Y_counts, threshold = 13, type = "univariate", family = "poisson")
cat("Detection time:", res$detection_time, "\n")
cat("Estimated changepoint:", res$detected_changepoint, "\n")
Detection time: 1006 
Estimated changepoint: 1001 

A neat design feature of the package is that the detector state is independent of the statistic computed on it. This means that the same detector can be queried under different distribution families, without re-running the analysis:

set.seed(2024)
Y_counts <- c(rpois(500, lambda = 10), rpois(500, lambda = 15))

det <- detector_create(type = "univariate")
for (i in seq_along(Y_counts)) detector_update(det, Y_counts[i])

result_gaussian <- get_statistics(det, family = "gaussian")
result_poisson <- get_statistics(det, family = "poisson")

cat("Gaussian statistic:", round(result_gaussian$stat, 2),
    "- changepoint:", result_gaussian$changepoint, "\n")
cat("Poisson statistic:", round(result_poisson$stat, 2),
    "- changepoint:", result_poisson$changepoint, "\n")
Gaussian statistic: 6426.22 - changepoint: 500 
Poisson statistic: 261.66 - changepoint: 500 

Other options include one-sided detection (type = "univariate_one_sided", to only flag increases or decreases), a known pre-change parameter (via the theta0 argument, e.g. get_statistics(det, family = "gaussian", theta0 = 0)), and detection of transient anomalies (via the anomaly_intensity argument). For more comprehensive examples and applications, refer to the documentation on the project’s GitHub repository https://github.com/gtromano/unified_focus.

More examples

Non-Parametric Changepoint Detection with Unknown Distribution

The focus package also offers non-parametric changepoint detection through the NP-FOCuS algorithm. This is beneficial when the underlying data distribution is unknown or the change nature is unpredictable beforehand.

Here, we simulate data with a changepoint, but unlike previous examples, the data is a combination of Gamma distributions with added Gaussian noise, making the underlying distribution less clear.

set.seed(123)

# Mixed data: a change in the gamma component, plus Gaussian noise
Y <- c(rgamma(5000, shape = 4, scale = 6) + rnorm(5000),
       rgamma(5000, shape = 4, scale = 3) + rnorm(5000))

plot(Y, type = "l", xlab = "Data Point", ylab = "Data Value",
     main = "Sample Mixed Gamma Data with Change")

With NP-FOCuS, we don’t make assumptions about the underlying data distribution. To achieve this, the algorithm monitors the empirical cumulative distribution function of the data at a set of quantiles. When you initialize the detector, you provide initial quantiles, such as the 25th percentile (dividing the data into fourths, marking the first quartile) or the median (splitting the data in half). These quantiles essentially act as markers to monitor different portions of the data distribution.

As new data arrives, NP-FOCuS calculates a separate statistic for each provided quantile, tracking how the distribution of data points around each quantile evolves over time. The detector then reports two aggregated statistics: the sum and the maximum across all quantiles. Monitoring the sum makes the detector sensitive to changes affecting the whole distribution, while the maximum is better suited to changes concentrated in one portion of it, e.g. in the tails. In our example, we monitor the 25th, 50th (median) and 75th percentiles, and place a threshold on the sum statistic:

# Initial quantiles estimated on training data (first 100 observations)
quants <- quantile(Y[1:100], probs = c(0.25, 0.5, 0.75))

det <- detector_create(type = "npfocus", quantiles = quants)

for (i in seq_along(Y)) {
  detector_update(det, Y[i])
  result <- get_statistics(det, family = "npfocus")
  if (result$stat[1] > 25) break  # stat is c(sum, max) across quantiles
}

cat("Change detected at time", i,
    "- sum statistic:", round(result$stat[1], 2), "\n")
Change detected at time 5022 - sum statistic: 25.25 

Multivariate Data

It is possible to run a multivariate analysis with a detector of type = "multivariate", which implements the MD-FOCuS generalization, by feeding, at each iteration, a vector of observations. Here’s an example of detecting a Gaussian change-in-mean over a 3-dimensional stream:

set.seed(123)

# Pre-change and post-change means (independent dimensions),
# with a changepoint at time 5000
Y_pre <- cbind(rnorm(5000, 0), rnorm(5000, 0), rnorm(5000, 5))
Y_post <- cbind(rnorm(500, 1), rnorm(500, 1), rnorm(500, 4.5))
Y_multi <- rbind(Y_pre, Y_post)

det <- detector_create(type = "multivariate")
threshold <- 25

for (i in seq_len(nrow(Y_multi))) {
  detector_update(det, Y_multi[i, ])  # feed one 3-dimensional observation
  result <- get_statistics(det, family = "gaussian")
  if (any(result$stat >= threshold)) break
}

cat("Change detected at time", result$stopping_time,
    "- changepoint estimated at", result$changepoint, "\n")
Change detected at time 5009 - changepoint estimated at 5001 

For higher-dimensional streams (5 dimensions or more), the exact computation becomes expensive, and the package offers a fast approximation based on low-dimensional projections: see the generate_projection_indexes helper in the documentation.

Autocorrelated Data

Real data streams often exhibit autocorrelation, which, if ignored, leads to frequent false alarms. The latest addition to the FOCuS family is a detector for a change in mean in the presence of autoregressive noise. We simulate an AR(1) process with a shift in mean:

set.seed(123)

n <- 2000
rho <- 0.7
mu <- c(rep(0, 1000), rep(2, 1000))  # mean shift at time 1000

# Generate AR(1) noise and add it to the signal
innovations <- rnorm(n)
noise <- numeric(n)
for (t in 2:n) noise[t] <- rho * noise[t - 1] + innovations[t]
Y_ar <- mu + noise

plot(Y_ar, type = "l", xlab = "Time", ylab = "Data Value",
     main = "AR(1) Process with a Change in Mean")
abline(v = 1000, col = "darkgreen", lty = 3, lwd = 2)

The detector takes the autoregressive coefficients (rho, here treated as known — in practice, estimate them from historical data) and the pre-change mean (mu0_arp):

det <- detector_create(type = "arp", rho = rho, mu0_arp = 0)
threshold <- 25

for (i in seq_along(Y_ar)) {
  detector_update(det, Y_ar[i])
  result <- get_statistics(det, family = "arp")
  if (result$stat >= threshold) break
}

cat("Change detected at time", result$stopping_time,
    "- changepoint estimated at", result$changepoint, "\n")
Change detected at time 1023 - changepoint estimated at 1006 

Despite the strong autocorrelation, the change is detected quickly and located close to the truth. For details on the underlying methodology, see our preprint on changepoint detection in the presence of autocorrelation.

Conclusions

In conclusion, focus is a versatile and efficient R package for online changepoint detection. Whether you’re dealing with univariate or multivariate data, known or unknown distributions, independent or autocorrelated observations, focus has got you covered.

For a full comprehensive read on the methodology and both implementations, check out the software paper:

G Romano, K Ward, Y Fan, G Rigaill, V Runge, IA Eckley, P Fearnhead (2026). focus and focus-cpt: Fast Online Changepoint Detection in R and Python. arXiv preprint arXiv:2607.19961.

And again, if you prefer the Python interface, the same functionalities are available in the focus-cpt Python package: visit the introductory guide for Python. For any question, don’t hesitate to drop me a message :)