R/consensus.R

Defines functions autoplot.ggcpt_recommendation print.ggcpt_recommendation tidy.ggcpt_recommendation isTRUE_vec cpt_recommend print.ggcpt_consensus unclass_ggcpt autoplot.ggcpt_consensus cluster_changepoints cpt_consensus

Documented in autoplot.ggcpt_consensus autoplot.ggcpt_recommendation cpt_consensus cpt_recommend print.ggcpt_consensus print.ggcpt_recommendation tidy.ggcpt_recommendation

# ---------------------------------------------------------------------------
# Theme J: ensembles, consensus and method choice.
#
# With fifty wired methods the user's real question is "which one?".
# ggcpt_compare() shows the disagreement; cpt_consensus() resolves it into a
# single segmentation with a vote count, and cpt_recommend() answers the
# question before any detector is run.
# ---------------------------------------------------------------------------

#' Consensus changepoints across several detectors
#'
#' Runs a set of detectors on one series and reports the locations they agree
#' on. Two detections count as the same changepoint when they fall within
#' \code{tolerance} of each other, the same tolerance window
#' \code{\link{cpt_metrics}()} matches on (van den Burg and Williams, 2020),
#' so the package has one notion of "close enough" and not two. The
#' \emph{grouping} necessarily differs: \code{cpt_metrics()} matches two
#' sets one-to-one, while consensus has to cluster \eqn{K} of them, so
#' detections are swept in order and a new cluster opens as soon as one lies
#' more than \code{tolerance} from the cluster's first member. That cap stops
#' a chain of near-neighbours merging into one arbitrarily wide cluster.
#'
#' @param x A numeric vector (the series).
#' @param methods Character vector of method names. A method that fails on
#'   this series (its engine errors or is not installed, or it does not
#'   offer \code{change_in}) is left out of the vote with a warning; invalid
#'   data or arguments stop the call.
#' @param tolerance Matching window, in positions. Defaults to \code{5}.
#' @param min_votes Minimum number of methods that must find a location for
#'   it to enter the consensus. Defaults to \code{"majority"}: more than
#'   half of the methods that ran (two of three, three of four or five), so
#'   the rule does not silently change with the size of the panel, as a
#'   fixed count of two did (a bare majority of three, a fifth of ten).
#'   \code{"all"} asks for unanimity.
#'
#'   A value \strong{strictly between 0 and 1} is read as a proportion of
#'   the methods that ran; anything else is a count, rounded \emph{up}
#'   (\code{2.5} needs three methods, never two). The boundary is worth
#'   knowing, because it falls exactly where a reader thinking in
#'   proportions would write \dQuote{unanimous}: with three methods,
#'   \code{min_votes = 0.99} needs all three, while
#'   \code{min_votes = 1} (and \code{1.0}, which is the same number)
#'   is a count of one and so the \emph{least} strict setting there is. For
#'   unanimity, pass the number of methods, or a fraction just below 1.
#'
#'   A count larger than the number of methods that ran cannot be reached,
#'   so the consensus would be empty by construction; that warns rather
#'   than returning a result indistinguishable from \dQuote{the methods
#'   agreed on nothing}.
#' @param change_in Passed to each detector.
#' @param index Optional time index, carried onto the result.
#' @param seed Optional seed for reproducibility. The seed is scoped to this
#'   call: \code{.Random.seed} is saved and restored, so a seeded call
#'   inside a simulation loop does not pin the loop's own stream.
#' @param ... Additional arguments passed to each detector.
#'
#' @section Consensus does not remove shared false positives:
#' Voting removes the idiosyncrasies of individual algorithms. It does not
#' remove a false positive that every algorithm makes for the same reason,
#' and a violated assumption is exactly such a reason: measured under
#' AR(1) noise, every one of \code{pelt}'s spurious changepoints was
#' reproduced by \code{wbs} within five observations, and the 2-of-3
#' consensus of \code{pelt}, \code{binseg} and \code{wbs} had more false
#' positives (1.40) than two of its own members. The useful signal in a
#' panel is its \strong{disagreement}: the \code{disagreement} value in
#' the \code{consensus} attribute is the share of the locations any member
#' found that not every member found, and a high value says the members'
#' shared assumption may be what is wrong (see
#' \code{\link{cpt_assumptions}()} and \code{\link{cpt_robustness}()}).
#'
#' @section Consensus is not inference:
#' Agreement among detectors is \strong{not} a p-value, and a location found
#' by six of seven methods is not thereby significant at any level: the
#' methods are run on the same data and are strongly correlated, several of
#' them share an engine, and none of the votes is independent. Read the vote
#' count as a robustness display ("this feature does not depend on which
#' detector I picked"), and use \code{\link{nsp_wrapper}()} or
#' \code{\link{cpt_confint}()} when you need a guarantee.
#'
#' @return A \code{ggcpt} object (so it plots and tidies like any other
#'   result) whose changepoints tibble carries \code{votes} and
#'   \code{methods} (a comma-separated list of the methods that found each
#'   location), with the per-method detections kept in a
#'   \code{consensus} attribute and drawn by \code{autoplot(plot_type =
#'   "agreement")}.
#' @references
#' \insertRef{vandenburg2020evaluation}{ggchangepoint}
#' @seealso \code{\link{ggcpt_compare}()}, \code{\link{cpt_recommend}()},
#'   \code{\link{cpt_metrics}()}.
#' @export
#' @examples
#' set.seed(2026)
#' x <- c(rnorm(80), rnorm(80, 4))
#' cons <- cpt_consensus(x, methods = c("pelt", "binseg", "amoc"))
#' tidy(cons)
#' ggplot2::autoplot(cons)
cpt_consensus <- function(x, methods = c("pelt", "binseg", "amoc"),
                          tolerance = 5, min_votes = "majority",
                          change_in = "mean", index = NULL, seed = NULL,
                          ...) {
  methods <- unique(as.character(methods))
  if (length(methods) < 2) {
    cpt_abort("`methods` needs at least two detectors to reach a consensus; ",
               "got ", length(methods), ".", class = "bad_argument")
  }
  validate_scalar(tolerance, "tolerance", min = 0)
  if (is.character(min_votes)) {
    min_votes <- cpt_match_arg(min_votes, c("majority", "all"),
                               name = "min_votes")
  } else {
    validate_scalar(min_votes, "min_votes", min = 0)
  }
  series <- as_cpt_series(x, index = index)
  data_vec <- as_uni_vector(series$values, "cpt_consensus")
  validate_for_detect(data_vec, list(...))
  n <- length(data_vec)

  local_seed(seed)
  has_future <- requireNamespace("future", quietly = TRUE) &&
    requireNamespace("future.apply", quietly = TRUE) &&
    !inherits(future::plan(), "sequential")

  # A method that fails is left out of the vote; bad input or a bug is not
  # a vote to leave out, so fanout_failure() raises those.
  run_one <- function(m) {
    tryCatch(
      cpt_detect(data_vec, method = m, change_in = change_in,
                 ...)$changepoints$cp,
      error = function(e) fanout_failure(e, "Method `", m, "`: ")
    )
  }
  results <- if (has_future) {
    future.apply::future_lapply(methods, with_session_registry(run_one),
                                future.seed = seed %||% TRUE)
  } else {
    lapply(methods, run_one)
  }
  names(results) <- methods

  failed <- vapply(results, inherits, logical(1), "cpt_failed")
  if (any(failed)) {
    cpt_warn("These methods errored and are excluded from the vote: ",
             paste(methods[failed], collapse = ", "), ".",
             class = "engine_failed")
  }
  ok <- methods[!failed]
  if (length(ok) < 2) {
    cpt_abort("Fewer than two methods ran successfully, so there is no ",
               "consensus ", "to take.", class = "engine_error")
  }
  detections <- results[ok]

  threshold <- if (identical(min_votes, "majority")) {
    floor(length(ok) / 2) + 1L
  } else if (identical(min_votes, "all")) {
    length(ok)
  } else if (min_votes > 0 && min_votes < 1) {
    max(1, ceiling(min_votes * length(ok)))
  } else {
    # ceiling(), not as.integer(): "at least 2.5 methods" means three, and
    # truncating made a fractional count LESS strict than the number asked
    # for (2.5 resolved to a threshold of 2).
    # The tolerance keeps a count computed in floating point (0.1 * 30 is
    # 3.0000000000000004) from being rounded up past the number it means.
    max(1, as.integer(ceiling(min_votes - 1e-8)))
  }
  # A threshold no method can reach makes the empty result certain, and an
  # empty consensus is indistinguishable from "the methods agreed on
  # nothing" -- which is a finding, where this is an arithmetic mistake.
  # `cpt_consensus(x, methods = c("pelt", "binseg"), min_votes = 3)` asked
  # three of two. Same reason `cpt_benchmark()` refuses an empty `methods`.
  if (threshold > length(ok)) {
    cpt_warn("`min_votes` resolves to a threshold of ", threshold, " but only ",
             length(ok), " method(s) ran, so no location can ", "reach it and ",
              "the consensus is empty by construction. Note that ",
             "a value of 1 or more is a count of methods, not a proportion: ",
             "only a fraction strictly between 0 and 1 is read as a ",
             "proportion.", class = "warning")
  }

  clusters <- cluster_changepoints(detections, tolerance)
  # The panel's disagreement: the share of the locations any member found
  # that not every member found. Computed before the vote filters them.
  disagreement <- if (nrow(clusters)) {
    mean(clusters$votes < length(ok))
  } else {
    0
  }
  keep <- clusters$votes >= threshold
  clusters <- clusters[keep, , drop = FALSE]

  res <- ggcpt_build(
    data_vec, clusters$cp,
    method = "consensus",
    change_in = change_in,
    penalty = list(type = paste0("votes >= ", threshold),
                   value = as.numeric(threshold)),
    fit = NULL,
    call = match.call(),
    extra_cp_cols = if (nrow(clusters) > 0) {
      list(votes = clusters$votes, methods = clusters$methods,
           spread = clusters$spread)
    }
  )
  attr(res, "consensus") <- list(detections = detections,
                                 methods = ok, tolerance = tolerance,
                                 threshold = threshold,
                                 disagreement = disagreement,
                                 counts = vapply(detections, length,
                                                 integer(1)))
  class(res) <- c("ggcpt_consensus", class(res))
  attach_index(res, series$index, series$index_label)
}

# Internal: group detections from several methods into clusters, on the same
# tolerance window cpt_metrics() matches within. Locations are swept in order
# and a new cluster opens whenever the next detection is more than
# `tolerance` from the current cluster's FIRST member -- anchoring on the
# first, not the previous, is what stops a chain of near-neighbours (1, 6,
# 11, 16, ...) merging into one cluster of unbounded width. The reported
# location is the (rounded) median of the cluster, which is robust to one
# method landing a few positions out.
#' @noRd
cluster_changepoints <- function(detections, tolerance) {
  all_cp <- unlist(lapply(names(detections), function(m) {
    v <- detections[[m]]
    if (length(v) == 0) return(NULL)
    stats::setNames(v, rep(m, length(v)))
  }))
  if (length(all_cp) == 0) {
    return(tibble::tibble(cp = integer(), votes = integer(),
                          methods = character(), spread = integer()))
  }
  ord <- order(all_cp)
  all_cp <- all_cp[ord]
  owner <- names(all_cp)

  cluster_id <- integer(length(all_cp))
  cid <- 1L
  anchor <- all_cp[1]
  for (i in seq_along(all_cp)) {
    if (all_cp[i] - anchor > tolerance) {
      cid <- cid + 1L
      anchor <- all_cp[i]
    }
    cluster_id[i] <- cid
  }

  rows <- lapply(split(seq_along(all_cp), cluster_id), function(ii) {
    v <- as.integer(all_cp[ii])
    ms <- unique(owner[ii])
    tibble::tibble(cp = as.integer(round(stats::median(v))),
                   votes = length(ms),
                   methods = paste(sort(ms), collapse = ", "),
                   spread = as.integer(diff(range(v))))
  })
  out <- do.call(rbind, rows)
  out[order(out$cp), , drop = FALSE]
}

#' @rdname cpt_consensus
#' @param object A \code{ggcpt_consensus} object (for \code{autoplot()}).
#' @param plot_type \code{"series"} (the consensus segmentation, votes shown
#'   by line width) or \code{"agreement"} (a method-by-location dot matrix
#'   showing exactly who voted for what).
#' @param ... Passed on to \code{\link{autoplot.ggcpt}()} for
#'   \code{plot_type = "series"}.
#' @export
autoplot.ggcpt_consensus <- function(object,
                                     plot_type = c("series", "agreement"),
                                     ...) {
  plot_type <- cpt_match_arg(plot_type)
  info <- attr(object, "consensus")
  idx_vals <- plot_index(object)
  x_lab <- plot_index_label(object)

  if (plot_type == "series") {
    p <- autoplot.ggcpt(unclass_ggcpt(object), ...) +
      ggplot2::labs(title = paste0("Consensus of ",
                                   length(info$methods), " methods"),
                    subtitle = paste0("A location enters when at least ",
                                      info$threshold,
                                      " method(s) find it within ",
                                      info$tolerance, " positions"))
    return(p)
  }

  rows <- do.call(rbind, lapply(names(info$detections), function(m) {
    v <- info$detections[[m]]
    if (length(v) == 0) return(NULL)
    tibble::tibble(method = m, cp = v)
  }))
  if (is.null(rows)) {
    cpt_abort("No method detected a changepoint, so there is no agreement ",
               "matrix ", "to draw.", class = "input_error")
  }
  rows$method <- factor(rows$method, levels = rev(info$methods))
  rows$cp_x <- idx_vals[rows$cp]
  cons <- object$changepoints

  p <- ggplot2::ggplot(rows, ggplot2::aes(cp_x, method))
  if (nrow(cons) > 0) {
    band <- tibble::tibble(
      xmin = idx_vals[pmax(1L, cons$cp - info$tolerance)],
      xmax = idx_vals[pmin(length(idx_vals), cons$cp + info$tolerance)]
    )
    p <- p + geom_cpt_region(ggplot2::aes(xmin = xmin, xmax = xmax),
                             data = band, fill = "#009E73", alpha = 0.15)
  }
  p +
    ggplot2::geom_point(size = 2.4, colour = "#0072B2") +
    ggplot2::labs(x = x_lab, y = NULL,
                  title = "Method agreement",
                  subtitle = "Shaded bands are the consensus locations")
}

# Internal: a ggcpt_consensus is a ggcpt with an extra class, and
# autoplot.ggcpt() must be called on it without re-dispatching.
#' @noRd
unclass_ggcpt <- function(x) {
  class(x) <- "ggcpt"
  x
}

#' @rdname cpt_consensus
#' @param x A \code{ggcpt_consensus} object (for \code{print()}).
#' @export
print.ggcpt_consensus <- function(x, ...) {
  info <- attr(x, "consensus")
  cat("ggcpt_consensus (", length(info$methods), " methods, tolerance ",
      info$tolerance, ", threshold ", info$threshold, " vote(s))\n", sep = "")
  cat("  Methods: ", paste(info$methods, collapse = ", "), "\n", sep = "")
  if (!is.null(info$counts)) {
    cat("  Found by each: ", paste0(names(info$counts), " ", info$counts,
                                    collapse = ", "), "\n", sep = "")
  }
  if (!is.null(info$disagreement)) {
    cat("  Disagreement: ", format(round(info$disagreement, 2)),
        " (share of locations not found by every method)\n", sep = "")
  }
  cat("  Consensus changepoints: ", nrow(x$changepoints), "\n", sep = "")
  if (nrow(x$changepoints) > 0) {
    cat("\n")
    print(x$changepoints, n = 12)
  }
  cat("\nAgreement is a robustness display, not a significance test; ",
      "see ?cpt_consensus.\n", sep = "")
  invisible(x)
}

#' Recommend a detection method
#'
#' Turns the capability matrix and the package's measurements into an
#' answer. Given what the analyst knows about their problem (how many
#' dimensions, what kind of change, what the noise and the data look like,
#' how long the series is, how many changes they expect, whether they need
#' uncertainty or an online alarm), this returns the methods that fit, as
#' calls, with the power and false-alarm rate measured for each in that
#' situation, a reason and the caveats. It is a decision table, not a
#' model: everything it knows is in \code{\link{cpt_methods}()} and the
#' measurement tables (\code{\link{cpt_noise_benchmark}},
#' \code{\link{cpt_data_types}}, \code{\link{cpt_runtimes}}), and making that
#' explicit and printable is the point.
#'
#' @param dimension \code{"univariate"} (default) or \code{"multivariate"}.
#' @param change_in What kind of change is expected: any value accepted by
#'   \code{\link{cpt_detect}()}. Defaults to \code{"mean"}.
#' @param noise Noise structure: \code{"iid"} (default), \code{"heavy"}
#'   (heavy-tailed), \code{"autocorrelated"}, or \code{"heteroscedastic"}.
#' @param n Series length. Methods the runtime table saw fail to finish at
#'   that length are left out (and listed), and the slow ones carry a
#'   caveat. Optional.
#' @param need_uncertainty Does the answer have to come with a confidence
#'   interval or significance region? Defaults to \code{FALSE}.
#' @param online Is detection sequential (alarms as data arrive) rather than
#'   retrospective? Defaults to \code{FALSE}.
#' @param installed_only Restrict to engines that are installed. Defaults to
#'   \code{FALSE}, so the recommendation names the right method even when it
#'   needs an install.
#' @param n_expected How many changes you expect, if you know. The
#'   single-change designs (\code{amoc}, \code{pettitt}, \code{buishand},
#'   \code{snht}) cannot find a second one, so they are ranked down unless
#'   this is \code{1}.
#' @param data_type What the values are: \code{"continuous"} (default),
#'   \code{"counts"}, \code{"binary"} or \code{"proportion"}. Measured, a
#'   Gaussian cost raises about 165 times the false positives on binary data
#'   that it raises on Gaussian data, so engines are scored on their
#'   measured false positives for this data type, and those that can fit a
#'   cost for it (\code{family = "poisson"} or \code{"binomial"}) are
#'   preferred and recommended with that argument.
#' @param family Optional distribution family the method must fit (see
#'   \code{\link{cpt_families}()}).
#' @param fit Optional \code{ggcpt} fit already made. Its dimension, length
#'   and change type fill in the arguments not given; its values set the
#'   data type; and when its residuals are autocorrelated (see
#'   \code{\link{cpt_assumptions}()}) the noise is taken as
#'   \code{"autocorrelated"} unless \code{noise} was given.
#' @param jump Optional expected change size, in noise standard deviations.
#'   With \code{n}, the top three candidates' power to find it is simulated
#'   (\code{\link{cpt_power}()} with 50 replicates), and the printout says
#'   so when no method is likely to.
#'
#' @return A \code{ggcpt_recommendation} tibble ordered by suitability, with
#'   columns \code{method}, \code{engine}, \code{installed}, \code{score},
#'   \code{tie} (candidates with the same score share a number: the
#'   recommender cannot tell them apart on what was supplied), \code{call}
#'   (the \code{cpt_detect()} call to run, including the noise-model or
#'   family argument the recommendation depends on), \code{hits} and
#'   \code{false_positives} (measured in the stated regime: mean real
#'   changes found out of two, and mean spurious ones), \code{power} (when
#'   \code{jump} is given), \code{why} and \code{caveat}. \code{print()}
#'   reads as advice and \code{autoplot()} shows the scores and the ties.
#'
#' @section How the score is built:
#' Where the method was measured in the stated noise regime, the score
#' starts from \eqn{3 - E}, where \eqn{E} is the expected number of errors
#' per series in that benchmark (real changes missed plus false alarms),
#' taking the method's best setting of its noise-model argument (which is
#' then the one in \code{call}). A method not measured there starts at 1.
#' Then: minus 3 for a single-change design unless \code{n_expected = 1};
#' minus the log of one plus its measured false positives on the stated
#' data type, and plus 1.5 if it fits that data type's family; plus 1 for a
#' location interval and 0.5 for a posterior when uncertainty is needed;
#' at \eqn{n \ge 5000}, plus 0.5 for a fast engine and minus 1 for a slow
#' one; and small tie-breakers for the general-purpose methods (0.25) and
#' for an installed engine (0.1). Sort by \code{false_positives} instead if
#' a false alarm costs you more than a miss.
#' @seealso \code{\link{cpt_methods}()}, \code{\link{cpt_consensus}()},
#'   \code{\link{cpt_cite}()}.
#' @export
#' @examples
#' cpt_recommend(noise = "autocorrelated")
#' cpt_recommend(data_type = "counts", n_expected = 2)
#' cpt_recommend(dimension = "multivariate", change_in = "covariance")
cpt_recommend <- function(dimension = c("univariate", "multivariate"),
                          change_in = "mean",
                          noise = c("iid", "heavy", "autocorrelated",
                                    "heteroscedastic"),
                          n = NULL, need_uncertainty = FALSE, online = FALSE,
                          installed_only = FALSE, n_expected = NULL,
                          data_type = c("continuous", "counts", "binary",
                                        "proportion"),
                          family = NULL, fit = NULL, jump = NULL) {
  notes <- character(0)
  noise_given <- !missing(noise)
  if (!is.null(fit)) {
    if (!is_ggcpt(fit)) {
      cpt_abort("`fit` must be a ggcpt object.", class = "bad_argument")
    }
    if (missing(dimension)) {
      dimension <- if (n_coordinates(fit) > 1L) "multivariate" else
        "univariate"
    }
    if (is.null(n)) n <- nrow(fit$data)
    if (missing(change_in) &&
        scalar_chr(fit$change_in) %in% cpt_change_in_levels()) {
      change_in <- scalar_chr(fit$change_in)
    }
    if (missing(data_type)) data_type <- detect_data_type(fit$data$value)
    dep <- fit$diagnostics$residual_dependence %||%
      residual_dependence(fit$data$value, fit$changepoints$cp,
                          fitted = residual_signal(fit))
    if (!noise_given && !is.null(dep) && isTRUE(dep$p_value < 0.05)) {
      noise <- "autocorrelated"
      notes <- c(notes, paste0("the fit's residuals are autocorrelated ",
                               "(Ljung-Box p = ", format(signif(dep$p_value,
                                                                2)),
                               "), so the noise is taken as autocorrelated"))
    }
  }
  dimension <- cpt_match_arg(dimension)
  noise <- cpt_match_arg(noise)
  data_type <- cpt_match_arg(data_type)
  change_in <- cpt_match_arg(change_in, cpt_change_in_levels())
  validate_flag(need_uncertainty, "need_uncertainty")
  validate_flag(online, "online")
  validate_flag(installed_only, "installed_only")
  if (!is.null(n)) validate_scalar(n, "n", min = 1)
  if (!is.null(n_expected)) validate_scalar(n_expected, "n_expected", min = 0)
  if (!is.null(jump)) validate_scalar(jump, "jump", min = 0, min_open = TRUE)
  if (!is.null(family)) {
    family <- cpt_match_arg(family, family_levels(), name = "family")
  }

  reg <- full_registry()
  tab <- cpt_methods()
  reg$installed <- tab$installed[match(reg$method, tab$method)]

  # The same rule validate_method_change_in() applies, and for the same
  # reason: `change_in = "mean"` is accepted by every method, because a
  # method targeting something else (distribution, slope) treats a "mean"
  # request as its native change type. Reading `supports` strictly here
  # dropped every distribution-only method -- np, ecp, geomcp, npmojo -- so
  # the two canonical univariate nonparametric answers were filtered out
  # before scoring and could not be recommended at all.
  supports <- vapply(reg$supports, function(s) {
    identical(change_in, "mean") || change_in %in% s
  }, logical(1))
  # A break in a regression is what the formula interface detects, and the
  # methods that take one list "regression" under no `supports`: asked for
  # it (or handed a formula fit), the recommender found nothing.
  regression <- identical(change_in, "regression") &&
    dimension == "univariate"
  if (regression) supports <- supports | (reg$formula %in% TRUE)
  reg <- reg[supports, , drop = FALSE]
  if (dimension == "multivariate") {
    reg <- reg[reg$multivariate, , drop = FALSE]
  } else {
    # A univariate user must not be pointed at a high-dimensional engine,
    # however well it matches on everything else. Nine of the fourteen do
    # error on a single column; the other four (npmojo, inspect, esac,
    # pilliat) would run and return something, which is exactly why the
    # filter is on intent rather than on whether the call happens to fail.
    reg <- reg[reg$univariate, , drop = FALSE]
  }
  if (online) reg <- reg[reg$online, , drop = FALSE]
  if (need_uncertainty) reg <- reg[reg$ci | reg$posterior, , drop = FALSE]
  if (installed_only) reg <- reg[isTRUE_vec(reg$installed), , drop = FALSE]
  if (!is.null(family)) {
    reg <- reg[reg$method %in% methods_with_family(family), , drop = FALSE]
  }

  # A method the runtime table saw fail to finish at this length (or a
  # shorter one) is not a recommendation at this length: it is a
  # recommendation to wait. ยง208 measured five engines that cannot reach
  # n = 10,000 in two minutes.
  excluded <- character(0)
  rt <- measured_data("cpt_runtimes")
  if (!is.null(n) && !is.null(rt) && nrow(reg)) {
    too_slow <- vapply(reg$method, function(m) {
      r <- rt[rt$method == m, , drop = FALSE]
      if (!nrow(r)) return(FALSE)
      failed <- r$n[r$status != "ok" | !is.finite(r$seconds)]
      length(failed) > 0 && min(failed) <= n
    }, logical(1))
    if (any(too_slow)) {
      excluded <- reg$method[too_slow]
      reg <- reg[!too_slow, , drop = FALSE]
    }
  }

  if (nrow(reg) == 0) {
    cpt_abort("No wired method matches that combination (", dimension, ", ",
              change_in, ", ", noise,
              if (need_uncertainty) ", with uncertainty" else "",
              if (online) ", online" else "",
              if (!is.null(family)) paste0(", family ", family) else "",
              if (length(excluded)) paste0(", finishing at n = ", n) else "",
              "). Relax a requirement, or register an external detector ",
              "with cpt_register_method().", class = "unsupported")
  }

  k <- nrow(reg)
  score <- rep(1, k)
  why <- rep(paste0("handles change_in = \"", change_in, "\""), k)
  caveat <- rep(NA_character_, k)
  add_caveat <- function(i, txt) {
    caveat[i] <<- ifelse(is.na(caveat[i]), txt, paste0(caveat[i], "; ", txt))
  }
  call_args <- rep("", k)
  hits <- rep(NA_real_, k)
  fps <- rep(NA_real_, k)

  # ---- the measured noise regime ----------------------------------------------
  regime <- switch(noise, iid = "iid", heavy = "heavy", autocorrelated = "ar1",
                   heteroscedastic = "hetero")
  bench <- measured_data("cpt_noise_benchmark")
  # The benchmark measured mean shifts in a series; it says nothing about a
  # break in a regression, so it does not score one.
  if (!is.null(bench) && !regression) {
    b <- bench[bench$regime == regime, , drop = FALSE]
    for (i in seq_len(k)) {
      rows <- b[b$method == reg$method[i], , drop = FALSE]
      if (!nrow(rows)) next
      err <- (2 - rows$hits) + rows$fp
      j <- which.min(err)
      hits[i] <- rows$hits[j]
      fps[i] <- rows$fp[j]
      score[i] <- 3 - min(err[j], 6)
      if (!identical(rows$setting[j], "default")) {
        call_args[i] <- paste0(", ", rows$setting[j])
        why[i] <- paste0(why[i], "; its best setting under ", noise,
                         " noise is ", rows$setting[j])
      }
      # One spurious changepoint per series is already as many as a real
      # change: worth saying next to the call, whatever the score.
      if (rows$fp[j] >= 1) {
        add_caveat(i, paste0(format(rows$fp[j]), " spurious changepoints per ",
                             "series measured under ", noise, " noise; check ",
                             "the fit with cpt_assumptions()"))
      }
    }
    if (noise != "iid") {
      unmeasured <- is.na(hits)
      why[!unmeasured] <- paste0(why[!unmeasured], "; measured under ",
                                 noise, " noise")
    }
  }

  # ---- how many changes -------------------------------------------------------
  single <- reg$method %in% names(max_cp_table())[max_cp_table() == 1L] &
    !reg$method %in% c("segmented", "mcp")
  if (is.null(n_expected) || n_expected != 1) {
    score[single] <- score[single] - 3
    for (i in which(single)) {
      add_caveat(i, "finds at most one changepoint")
    }
  } else {
    score[single] <- score[single] + 0.5
    why[single] <- paste0(why[single], "; a single-change design")
  }

  # ---- the data type ------------------------------------------------------------
  if (data_type != "continuous") {
    dt_key <- switch(data_type, counts = "poisson", binary = "bernoulli",
                     proportion = "proportion")
    fam_for <- switch(data_type, counts = "poisson", binary = "binomial",
                      proportion = "binomial")
    dtab <- measured_data("cpt_data_types")
    fam_ok <- reg$method %in% methods_with_family(fam_for)
    for (i in seq_len(k)) {
      if (fam_ok[i] && data_type != "proportion") {
        score[i] <- score[i] + 1.5
        call_args[i] <- paste0(call_args[i], ", family = \"", fam_for, "\"")
        why[i] <- paste0(why[i], "; fits a ", fam_for, " cost")
        next
      }
      if (is.null(dtab)) next
      r <- dtab[dtab$method == reg$method[i] & dtab$data_type == dt_key, ,
                drop = FALSE]
      if (!nrow(r)) next
      score[i] <- score[i] - log1p(r$fp[1])
      if (r$fp[1] > 5) {
        add_caveat(i, paste0(format(r$fp[1]), " spurious changepoints ",
                             "measured on ", data_type, " data"))
      }
    }
  }

  if (need_uncertainty) {
    score[reg$ci] <- score[reg$ci] + 1
    why[reg$ci] <- paste0(why[reg$ci], "; supplies location intervals")
    score[reg$posterior] <- score[reg$posterior] + 0.5
    why[reg$posterior] <- paste0(why[reg$posterior], "; supplies a posterior")
  }

  # ---- length ----------------------------------------------------------------------
  if (!is.null(n) && n >= 5000) {
    meas <- measured_registry_columns(reg$method)
    fast <- meas$cost %in% "fast"
    slow <- meas$cost %in% "slow" | reg$method %in%
      c("segneigh", "ecp", "bcp", "kcp", "mcp", "fabisearch", "hdcov",
        "network", "var", "hdreg")
    score[fast] <- score[fast] + 0.5
    why[fast] <- paste0(why[fast], "; fast at this length")
    score[slow] <- score[slow] - 1
    for (i in which(slow)) {
      add_caveat(i, paste0("slow at n = ", format(n, big.mark = ",")))
    }
    # A few engines return a fit far bigger than the series: strucchange
    # keeps a triangular O(n^2) RSS matrix (~135 MB at n = 2000), and bfast
    # and bocpd are in the tens of MB.
    for (i in which(reg$method %in% c("strucchange", "bfast", "bocpd"))) {
      add_caveat(i, paste0("returns a large raw fit at this length; pass ",
                           "keep_fit = FALSE"))
    }
  }
  # HDCD 1.1's Pilliat() is unusable at power-of-two dimensions, and the
  # wrapper refuses there, so say it before the user picks the method.
  for (i in which(reg$method == "pilliat")) {
    add_caveat(i, paste0("unavailable when the number of coordinates is an ",
                         "exact power of two (upstream threshold bug)"))
  }

  gp <- reg$method %in% general_purpose_methods()
  score[gp] <- score[gp] + 0.25
  installed <- isTRUE_vec(reg$installed)
  score[installed] <- score[installed] + 0.1

  calls <- paste0(if (regression) "cpt_detect(y ~ x, data = d, method = \"" else
                    "cpt_detect(x, method = \"", reg$method, "\"", call_args,
                  ")")
  out <- tibble::tibble(
    method = reg$method, engine = reg$engine, installed = reg$installed,
    score = round(score, 2), tie = NA_integer_, call = calls,
    hits = hits, false_positives = fps, power = NA_real_,
    why = why, caveat = caveat
  )
  # Scores equal to the second decimal share a tie number: the recommender
  # cannot tell those candidates apart on the information supplied, which
  # is a finding to report rather than a ranking to invent (ยง186.3).
  out <- out[order(-out$score, !gp[match(out$method, reg$method)],
                   out$method), , drop = FALSE]
  out$tie <- match(out$score, unique(out$score))

  if (!is.null(jump) && !is.null(n)) {
    top <- utils::head(which(isTRUE_vec(out$installed) |
                               is.na(out$installed)), 3)
    for (i in top) {
      pw <- tryCatch(suppressWarnings(cpt_power(
        n = n, jump = jump, method = out$method[i], n_sim = 50,
        change_in = if (change_in %in% c("mean", "var", "meanvar", "slope"))
          change_in else "mean", parallel = FALSE))$power[1],
        error = function(e) NA_real_)
      out$power[i] <- pw
    }
  }

  attr(out, "query") <- list(dimension = dimension, change_in = change_in,
                             noise = noise, n = n,
                             need_uncertainty = need_uncertainty,
                             online = online, n_expected = n_expected,
                             data_type = data_type, family = family,
                             jump = jump)
  attr(out, "excluded") <- excluded
  attr(out, "notes") <- notes
  class(out) <- c("ggcpt_recommendation", class(out))
  out
}

#' @noRd
isTRUE_vec <- function(v) !is.na(v) & v

#' @rdname cpt_recommend
#' @export
tidy.ggcpt_recommendation <- function(x, ...) {
  tibble::as_tibble(unclass_keep_tbl(x))
}

#' @rdname cpt_recommend
#' @param x A \code{ggcpt_recommendation} object (for \code{print()}).
#' @param top How many candidates to print. Defaults to \code{5}.
#' @param ... Ignored.
#' @export
print.ggcpt_recommendation <- function(x, top = 5, ...) {
  # The only numeric argument in this file that skipped validate_scalar():
  # `top = -1` reached seq_len(-1) and failed with base R's "argument of
  # length 0"-adjacent complaint about a negative length.
  validate_scalar(top, "top", min = 0)
  q <- attr(x, "query")
  cat("Recommended methods for: ", q$dimension, " series, change in ",
      q$change_in, ", ", q$noise, " noise",
      if (!identical(q$data_type, "continuous") && !is.null(q$data_type)) {
        paste0(", ", q$data_type, " data")
      } else "",
      if (!is.null(q$n)) paste0(", n = ", q$n) else "",
      if (!is.null(q$n_expected)) paste0(", ", q$n_expected,
                                         " change(s) expected") else "",
      if (isTRUE(q$need_uncertainty)) ", uncertainty required" else "",
      if (isTRUE(q$online)) ", online" else "", "\n", sep = "")
  for (note in attr(x, "notes")) cat("Note: ", note, ".\n", sep = "")
  cat("\n")
  n_show <- min(top, nrow(x))
  # `installed` is NA for a registered method BY DESIGN -- a registration
  # supplies the detector itself, so there is no package to look for -- and
  # `isTRUE(NA)` is FALSE, so a detector the user wrote and registered this
  # session was advertised as not installed. Only an explicit FALSE means
  # missing.
  tag <- function(i) if (identical(x$installed[i], FALSE)) {
    "  [not installed]"
  } else {
    ""
  }
  for (i in seq_len(n_show)) {
    cat(i, ". ", x$call[i] %||% x$method[i], tag(i), "\n", sep = "")
    if (!is.null(x$hits) && !is.na(x$hits[i])) {
      cat("   measured: ", format(x$hits[i]), " of 2 changes found, ",
          format(x$false_positives[i]), " spurious per series\n", sep = "")
    }
    if (!is.null(x$power) && !is.na(x$power[i])) {
      cat("   power for a shift of ", q$jump, " sd at n = ", q$n, ": ",
          format(round(x$power[i], 2)), "\n", sep = "")
    }
    cat("   why: ", x$why[i], "\n", sep = "")
    if (!is.na(x$caveat[i])) cat("   caveat: ", x$caveat[i], "\n", sep = "")
  }
  # Ties are reported, not ordered: under the default query 31 of 32
  # candidates used to share one score and were printed alphabetically,
  # which read as a ranking that did not exist.
  if (!is.null(x$tie) && nrow(x) > 1L) {
    tied <- x$method[x$tie == x$tie[1]]
    if (length(tied) > 1L) {
      cat("\n", length(tied), " candidates tie for first on the information ",
          "supplied: ", paste(utils::head(tied, 8), collapse = ", "),
          if (length(tied) > 8) ", ..." else "", ". What would separate ",
          "them: `n_expected`, `n`, `data_type`, `need_uncertainty`, or a ",
          "`fit` to read the residuals of.\n", sep = "")
    }
  }
  if (!is.null(x$power) && any(!is.na(x$power)) &&
      max(x$power, na.rm = TRUE) < 0.8) {
    cat("\nNo candidate is likely to find a shift of ", q$jump, " sd at n = ",
        q$n, " (best power ", format(round(max(x$power, na.rm = TRUE), 2)),
        "): collect more data, or treat an empty answer as uninformative.\n",
        sep = "")
  }
  ex <- attr(x, "excluded")
  if (length(ex)) {
    cat("\nLeft out because the runtime table saw them fail to finish at ",
        "this length: ", paste(ex, collapse = ", "), ".\n", sep = "")
  }
  if (nrow(x) > n_show) {
    cat("\n(", nrow(x) - n_show, " further candidate(s); the full table is ",
        "the return value.)\n", sep = "")
  }
  cat("\nCite the method you use with cpt_cite(). Cross-check the choice ",
      "with\ncpt_robustness() and cpt_assumptions().\n", sep = "")
  invisible(x)
}

#' @rdname cpt_recommend
#' @param object A \code{ggcpt_recommendation} object (for
#'   \code{autoplot()}).
#' @export
autoplot.ggcpt_recommendation <- function(object, top = 15, ...) {
  validate_scalar(top, "top", min = 1)
  d <- tibble::as_tibble(unclass_keep_tbl(object))
  d <- utils::head(d, top)
  d$method <- factor(d$method, levels = rev(d$method))
  d$tie_group <- factor(d$tie)
  d$flag <- ifelse(is.na(d$caveat), "", "!")
  n_top <- sum(d$tie == d$tie[1])
  ggplot2::ggplot(d, ggplot2::aes(x = score, y = method, colour = tie_group)) +
    ggplot2::geom_segment(ggplot2::aes(x = min(score) - 0.5, xend = score,
                                       yend = method), linewidth = 0.4) +
    ggplot2::geom_point(size = 3) +
    ggplot2::geom_text(ggplot2::aes(label = flag), nudge_x = 0.15,
                       colour = "#D55E00", fontface = "bold") +
    scale_colour_cpt(guide = "none") +
    ggplot2::labs(
      x = "Score", y = NULL, title = "Recommended methods",
      subtitle = if (n_top > 1) {
        paste0(n_top, " candidates tie for first (same colour = same ",
               "score): the recommender cannot tell them apart on what was ",
               "supplied")
      } else {
        "Same colour = same score; ! = a caveat applies (see print())"
      }) +
    theme_ggcpt()
}

Try the ggchangepoint package in your browser

Any scripts or data that you put into this service are public.

ggchangepoint documentation built on Nov. 5, 2026, 9:06 a.m.