---
title: "Getting Started with scanr"
format:
  html:
    toc: true
    toc-depth: 2
    number-sections: true
vignette: >
  %\VignetteIndexEntry{Getting Started with scanr}
  %\VignetteEngine{quarto::html}
  %\VignetteEncoding{UTF-8}
---

```{r}
#| label: setup
#| include: false
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7,
  fig.height = 4,
  fig.align = "center"
)
```

# Overview

The `scanr` package detects multiple change points in long univariate time series
using window-based SCAN statistics. The method compares local windows along a
series, calibrates evidence for changes using resampling, and combines
information across several window sizes.

This vignette demonstrates a typical workflow:

1. simulate a univariate time series with known change points;
2. run `scan_cpd()` with multiple window sizes;
3. compare estimated change points with the known truth;
4. inspect the result using diagnostic visualizations;
5. repeat the workflow for distributional changes;
6. apply the workflow to hourly Bitcoin closing prices;
7. use the SWAL statistic for single change-point localization.


# Load the Package

Users can install the package from CRAN and load it as follows:

```{r}
#| eval: false
install.packages("scanr")
```

```{r}
library(scanr)
```

Alternatively, the development version of `scanr` can be installed directly
from GitHub:

```{r}
#| eval: false
if (!requireNamespace("pak", quietly = TRUE)) { install.packages("pak") }

pak::pak("Prabashoka/scanr")
```

# Example 1 - Changes in Mean

We first simulate a white-noise time series of length 20,000 with 20 change
points and 21 piecewise-constant mean regimes. The noise variance is fixed, so
this example focuses on changes in the mean.

```{r}
set.seed(1234)

n <- 20000

change_points <- c(
  952, 1905, 2858, 3810, 4763, 5715, 6668, 7620, 8573, 9525,
  10478, 11430, 12383, 13335, 14288, 15240, 16193, 17145, 18098,
  19050
)

means <- c(
  0, 2, -1, 3, 0.5, -2, 2, 5, -0.5, 2.5, 0, -2.5, -1.5, 1.5,
  3, 1, 0, 1.25, -2, 3.5, -1.5
)

segment_starts <- c(1L, change_points + 1L)
segment_ends <- c(change_points, n)
x_mean <- numeric(n)

for (j in seq_along(means)) {
  segment_index <- segment_starts[j]:segment_ends[j]
  x_mean[segment_index] <- rnorm(length(segment_index), mean = means[j], sd = 1)
}

change_points
```

The plot below shows the simulated series.

```{r}
#| echo: false
#| warning: false
#| fig.width: 10
#| fig.height: 4
vis_time_series(
  x_mean,
  x_label = "Time",
  y_label = "Value",
  title = ""
)
```

## Run SCAN Change-Point Detection

The `window_sizes` argument controls the local scales used by the scan. The
window sizes should be smaller than the spacing between nearby change points,
while still being large enough to estimate the local distributions on each side
of a candidate split. Smaller values improve localization and sensitivity to
closely spaced changes, while larger values provide more stable local estimates.

`default_window_sizes()` randomly samples the requested number of distinct
scales. By default, the largest is `floor(n^(2 / 3))`; it is always capped at
`floor(n / 2)` so that both sides of a candidate split can contain a full
window. Its optional `seed` argument makes the sampled grid reproducible
without changing the caller's random-number generator state. Supplying
problem-informed bounds is useful when the approximate minimum segment length
is known. Omitting `window_sizes` makes `scan_cpd()` use the same helper with
its `min_window`, `max_window`, and `n_windows` arguments.

```{r}
mean_window_sizes <- default_window_sizes(
  n = length(x_mean),
  min_window = 100,
  max_window = floor(length(x_mean)^(2 / 3)),
  n_windows = 7,
  seed = 52
)
mean_window_sizes

fit_mean <- scan_cpd(
  x_mean,
  window_sizes = mean_window_sizes,
  n_boot = 400,
  random_state = 1234,
  change_type = "mean",
  n_jobs = -1
)

fit_mean
```

::: {.callout-note}
## Remark on bootstrap calibration

The adaptive threshold for declaring a change point is derived through a
**tapered block bootstrap** across the combined windows. Increasing the number
of bootstrap samples (`n_boot`) produces a more accurate null distribution at
the cost of longer computation time; a value of 400–1,000 is typically
sufficient. The method supports parallel processing via
[Rust's](https://rust-lang.org/) **Rayon** library for computational
efficiency.
:::

The estimated change points can be extracted directly from the fitted object.

```{r}
fit_mean$change_points
```

## Evaluate Detection Accuracy

For simulated data, estimated change points can be compared with the known
truth. The `tolerance` argument controls how close an estimated change point
must be to a true change point to count as a match.

```{r}
cpd_metrics(
  true_cps = change_points,
  estimated_cps = fit_mean$change_points,
  n = length(x_mean),
  tolerance = 20
)
```

## Visualize the Result

The package includes visual helpers for inspecting detected change points,
window-level votes, and the overall vote scree.

```{r}
#| fig.width: 10
#| fig.height: 4.5
vis_change_points(
  x_mean,
  fit_mean,
  true_change_points = change_points,
  x_label = "Time",
  y_label = "Value"
)
```

## Tune the Voting Threshold

The ensemble vote threshold, `vote_threshold`, controls how much agreement is
required across window sizes before a candidate is retained. The default value
is 0.5, meaning that a candidate must be supported by at least half of the
available window-level votes.

```{r}
vis_vote_scree(fit_mean)
```

```{r}
#| fig.width: 10
#| fig.height: 4.5
vis_window_votes(fit_mean)
```

# Example 2 - Changes in Distribution

The next example uses segments from different distribution families, including
normal, exponential, Poisson, gamma, uniform, Student's t, lognormal, beta,
Weibull, and chi-square distributions. Each segment is standardized before
being rescaled, so the signal is not only a simple shift in the mean. This
creates a more challenging distributional change-point problem.

```{r}
set.seed(1234)

n <- 20000

change_points <- c(952, 1905, 2858, 3810, 4763, 5715, 6668, 7620, 8573, 9525, 10478, 11430, 12383, 13335, 14288, 15240, 16193, 17145, 18098, 19050)

segment_starts <- c(1, change_points + 1)
segment_ends <- c(change_points, n)

families <- c("normal", "exponential", "poisson", "t", "gamma", "uniform", "lognormal", "weibull", "chisq", "beta", "normal", "poisson", "exponential", "uniform", "gamma", "t", "lognormal", "beta", "weibull", "chisq", "normal")

scale_factors <- c(0.7, 1.4, 0.6, 1.8, 0.75, 2.5, 0.65, 3.0, 0.7, 2.6, 0.6, 1.7, 0.65, 2.9, 0.7, 2.8, 0.6, 2.6, 0.65, 3.0, 0.7)

simulate_base <- function(m, family) {
  z <- switch(family, normal = rnorm(m, mean = 0, sd = 1), exponential = rexp(m, rate = 1), poisson = rpois(m, lambda = 2), gamma = rgamma(m, shape = 2, rate = 1), uniform = runif(m, min = -sqrt(3), max = sqrt(3)), t = rt(m, df = 3), lognormal = rlnorm(m, meanlog = 0, sdlog = 0.7), beta = rbeta(m, shape1 = 2, shape2 = 5), weibull = rweibull(m, shape = 1.5, scale = 1), chisq = rchisq(m, df = 5))
  as.numeric((z - mean(z)) / sd(z))
}

x_dist <- numeric(n)

for (j in seq_along(families)) {
  segment_index <- segment_starts[j]:segment_ends[j]
  x_dist[segment_index] <-scale_factors[j] * simulate_base(length(segment_index), families[j])
}

change_points
```

The simulated series contains changes in distributional shape and scale.

```{r}
#| echo: false
#| warning: false
#| fig.width: 10
#| fig.height: 4
vis_time_series(
  x_dist,
  x_label = "Time",
  y_label = "Value",
  title = ""
)
```

## Run SCAN Change-Point Detection

For distributional changes, set `change_type = "distribution"`. This uses the
distribution-sensitive local statistic rather than a mean-only statistic.

```{r}
distribution_window_sizes <- default_window_sizes(
  n = length(x_dist),
  min_window = 100,
  max_window = floor(length(x_mean)^(2 / 3)),
  n_windows = 15,
  seed = 52
)

fit_dist <- scan_cpd(
  x_dist,
  window_sizes = distribution_window_sizes,
  n_boot = 1000,
  random_state = 1234,
  change_type = "distribution",
  vote_threshold = 0.5,
  n_jobs = -1
)
```

The set of estimated change points is:

```{r}
fit_dist$change_points
```

## Evaluate Detection Accuracy

```{r}
cpd_metrics(
  true_cps = change_points,
  estimated_cps = fit_dist$change_points,
  n = length(x_dist),
  tolerance = 20
)
```

## Visualize the Result

```{r}
#| fig.width: 10
#| fig.height: 4.5
vis_change_points(
  x_dist,
  fit_dist,
  true_change_points = change_points,
  x_label = "Time",
  y_label = "Value"
)
```

## Real Data Example

This example applies change-point detection to the PIT-502 pressure sensor in
the [Secure Water Treatment (SWaT) dataset](https://www.sutd.edu.sg/itrust/itrust-labs/datasets/dataset-characteristics/swat/).

## Load the Data

```{r}
#| echo: false
swat_path <- system.file(
  "extdata",
  "PIT-502.csv",
  package = "scanr"
)
swat <- read.csv(
  swat_path,
  check.names = FALSE,
  strip.white = TRUE
)
```
Load the dataset and resample it at one-minute intervals for change-point detection.
```{r}
head(swat)
```

## Choose Window Sizes

The largest candidate window is $\lfloor n^{2/3}\rfloor$. The package helper
selects 15 default window sizes between 20 minutes and that upper bound.

```{r}
n <- nrow(swat)
window_sizes <- default_window_sizes(
  n,
  min_window = 100,
  max_window = floor(n^(2 / 3)),
  n_windows = 10,
  seed = 400
  
)
window_sizes
```

The PIT-502 measurements are standardized before detection. Standardization
changes their units but preserves the locations of mean shifts.

```{r}
x <- as.numeric(scale(swat[["PIT502"]]))
```

## Detect change-points

The R interface uses `n_boot` and `random_state` for bootstrap calibration and
reproducibility.

```{r}
fit_swat <- scan_cpd(
  x,
  window_sizes = window_sizes,
  n_boot = 400,
  vote_threshold = 0.15,
  random_state = 100,
  change_type = "mean",
  n_jobs = -1
)

fit_swat
```

The fitted object stores change points as positions in the one-minute series.
We can map those positions back to timestamps, observed pressures, and the
dataset's operating-state labels.

```{r}
detected_changes <- swat[
  fit_swat$change_points,
  c("Timestamp", "PIT502", "Normal/Attack"),
  drop = FALSE
]

cat("Number of change-points:", nrow(detected_changes), "\n")
```

## Visualize the Result

```{r}
#| fig-width: 11
#| fig-height: 5

vis_change_points(
  x,
  fit_swat,
  index = seq_along(x),
  x_label = "Time",
  y_label = "Standardized PIT-502 pressure",
  title = "Mean changes in the SWaT PIT-502 pressure sensor"
)
```

## Inspect the Vote Scree

The vote scree shows how much support each candidate location receives across
the selected window sizes. The horizontal threshold corresponds to the
`vote_threshold = 0.15` used above.

```{r}
#| fig-width: 11
#| fig-height: 5

vis_vote_scree(fit_swat)
```

# SWAL Statistic for Single Change-Point Detection

The SCAN procedure above combines local evidence across many windows. The
package also exposes lower-level localization tools for a single region that is
believed to contain one change point.

For a mean change, the mean version of the SWAL statistic aligns with the CUSUM
localizer. Both methods search for the split that best separates the left and
right sides of the local region.

```{r}
set.seed(1234)

true_single_cp <- 150
mean_region <- c(
  rnorm(true_single_cp, mean = 0, sd = 1),
  rnorm(true_single_cp, mean = 2, sd = 1)
)

c(
  truth = true_single_cp,
  cusum = ts_cusum(mean_region),
  swal_distribution = swal_statistic(mean_region, change_type = "distribution")
)
```

The distributional version is more general. It can localize changes where the
mean is approximately unchanged but other aspects of the distribution, such as
variance, change sharply. In the example below, both segments have mean zero,
but the second segment has a much larger standard deviation.

```{r}
set.seed(1234)

var_region <- c(
  rnorm(true_single_cp, mean = 0, sd = 0.5),
  rnorm(true_single_cp, mean = 0, sd = 2)
)

c(
  truth = true_single_cp,
  cusum = ts_cusum(var_region),
  swal_distribution = swal_statistic(var_region, change_type = "distribution")
)
```

The `ts_wasserstein()` function returns both the estimated split and the full
sequence of split statistics. The plotting helper `vis_swal_curve()` displays
that localization curve, making it easier to inspect where the statistic is
maximized.

```{r}
vis_swal_curve(
  var_region,
  start = 1,
  end = length(var_region),
  x_label = "Candidate split",
  y_label = "Scaled Wasserstein statistic",
  title = ""
)
```

# Session Information

Recording session information helps make the vignette reproducible across R
versions and operating systems.

```{r}
sessionInfo()
```
