R/preprocess.R

Defines functions summary.preprocess_result as.data.frame.preprocess_result print.preprocess_result .preprocess_lm_slope .preprocess_unit_root_screen .preprocess_welch_df .preprocess_split_drift .preprocess_diagnostics .preprocess_counts .preprocess_pairs_table .preprocess_detrend_difference .preprocess_detrend_linear .preprocess_apply_detrend_map .preprocess_apply_detrend .preprocess_apply_checks .preprocess_auto_map .preprocess_build_map .preprocess_applied_label .preprocess_uniform_method .preprocess_detrend_spec .preprocess_lag_frame .preprocess_advice preprocess

Documented in preprocess print.preprocess_result summary.preprocess_result

# ---- Preprocessing audit for idiographic time-series models ----

#' Preprocess and audit idiographic time-series data
#'
#' @description
#' Builds the same lag-1 design used by [fit_graphical_var()] and [fit_var()],
#' optionally detrends or differences each series, and returns tidy diagnostics
#' for missingness, day-boundary drops, simple linear trends, AR(1) persistence,
#' split-half mean/variance drift, an ADF-style unit-root screen, and
#' zero-variance variables. It makes the modelling input explicit before
#' estimating VAR, graphical VAR, uSEM, GIMME, or mlVAR models; with `detrend`
#' it also cleans a non-stationary series in place so the flags can be
#' rechecked on the transformed data.
#'
#' @param data A `data.frame` or matrix with columns for variables and optional
#'   id/day/beep columns.
#' @param vars Character vector of variable names.
#' @param id Character. Name of the person-ID column, or `NULL` for a single
#'   series.
#' @param day Character. Name of the day/session column, or `NULL`.
#' @param beep Character. Name of the measurement-occasion column, or `NULL`.
#' @param scale Logical. Whether to standardize variables before lagging.
#'   Default `TRUE`.
#' @param center_within Logical. Whether to centre within person when more than
#'   one id is present. Default `TRUE`.
#' @param detrend How to remove non-stationarity from each series before
#'   lagging. Either a single string applied to every variable, or a
#'   **named character vector** giving a per-variable method (unlisted variables
#'   are left untouched, e.g. `c(planning = "difference", value = "linear")`).
#'   The available methods are:
#'   \describe{
#'     \item{`"none"`}{Default; diagnose only, transform nothing.}
#'     \item{`"auto"`}{Detrend only the subject-series that are flagged, leaving
#'       the stationary ones untouched: differencing a stochastic trend (unit
#'       root or near-unit-root persistence) and linearly detrending a
#'       deterministic trend. The "clean whoever needs it" option -- no
#'       subsetting, one call over everyone. Can be set per variable too.}
#'     \item{`"linear"`}{Replace the series with the residuals of a within-person
#'       regression on a linear time index.}
#'     \item{`"difference"`}{First-difference the series within id/day blocks.}
#'   }
#'   The diagnostics and the returned design reflect the detrended series, so
#'   the trend and unit-root flags can be rechecked after cleaning.
#' @param checks Character vector selecting which stationarity checkups to run:
#'   any of `"trend"`, `"high_ar"`, `"unit_root"`, `"mean_shift"`, `"sd_shift"`,
#'   `"zero_variance"`. Defaults to all of them. Deselecting a check turns its
#'   flag off in the report, in the `flag_stationarity_risk` roll-up, and in the
#'   `"auto"` detrend decision, so you can screen for only what you care about.
#' @param delete_missings Logical. If `TRUE`, `$pairs` contains only complete
#'   current/lagged rows; if `FALSE`, first rows of blocks and incomplete rows
#'   are retained with `NA` lags, matching `.gvar_tsdata()`. Default `TRUE`.
#' @param min_obs Integer or `NULL`. Keep only subjects with at least this many
#'   observations.
#' @param subject Optional vector naming the subject(s) to preprocess.
#' @param trend_alpha Numeric p-value cutoff for the trend flag. Default `0.05`.
#' @param ar_threshold Numeric absolute AR(1) cutoff for the high-persistence
#'   flag. Default `0.95`.
#' @param mean_shift_threshold Numeric absolute standardized split-half mean
#'   shift cutoff. Default `0.8`.
#' @param sd_ratio_threshold Numeric split-half SD ratio cutoff. Default `2`.
#' @param unit_root_t_cutoff Numeric cutoff for the ADF-style lag-level
#'   t-statistic. Values greater than this cutoff are flagged as unit-root risk.
#'   Default `-2.86`, a common large-sample intercept-only screening cutoff.
#'
#' @return A `preprocess_result` object with:
#' \describe{
#'   \item{`pairs`}{The ordered current/lagged design table, including
#'     `intercept` and `L1_*` columns.}
#'   \item{`counts`}{Per-subject/per-day row and lag-pair counts.}
#'   \item{`diagnostics`}{Per-subject/per-variable missingness, trend, AR(1),
#'     split-half drift, unit-root screen, and stationarity risk indicators.}
#'   \item{`matrices`}{The exact `data_c` and `data_l` matrices returned by
#'     the VAR/GVAR preprocessing path.}
#' }
#' @examples
#' set.seed(1)
#' d <- data.frame(id = 1, day = 1, beep = 1:40,
#'                 A = cumsum(rnorm(40)), B = rnorm(40))
#' pp <- preprocess(d, vars = c("A", "B"), id = "id", day = "day", beep = "beep")
#' pp$counts
#' pp$diagnostics
#' # Difference the trending series and recheck the flags:
#' preprocess(d, vars = c("A", "B"), id = "id", day = "day", beep = "beep",
#'            detrend = "difference")$diagnostics
#' @export
preprocess <- function(data, vars, id = NULL, day = NULL, beep = NULL,
                       scale = TRUE,
                       center_within = TRUE,
                       detrend = "none",
                       checks = c("trend", "high_ar", "unit_root",
                                  "mean_shift", "sd_shift", "zero_variance"),
                       delete_missings = TRUE,
                       min_obs = NULL,
                       subject = NULL,
                       trend_alpha = 0.05,
                       ar_threshold = 0.95,
                       mean_shift_threshold = 0.8,
                       sd_ratio_threshold = 2,
                       unit_root_t_cutoff = -2.86) {
  call <- match.call()
  stopifnot(is.data.frame(data) || is.matrix(data))
  stopifnot(is.character(vars), length(vars) >= 2L)
  .ido_check_flag(scale, "scale")
  .ido_check_flag(center_within, "center_within")
  .ido_check_flag(delete_missings, "delete_missings")
  checks <- match.arg(checks, c("trend", "high_ar", "unit_root", "mean_shift",
                                "sd_shift", "zero_variance"), several.ok = TRUE)
  spec <- .preprocess_detrend_spec(detrend, vars)
  stopifnot(is.numeric(trend_alpha), length(trend_alpha) == 1L,
            is.finite(trend_alpha), trend_alpha > 0, trend_alpha < 1)
  stopifnot(is.numeric(ar_threshold), length(ar_threshold) == 1L,
            is.finite(ar_threshold), ar_threshold > 0, ar_threshold <= 1)
  stopifnot(is.numeric(mean_shift_threshold),
            length(mean_shift_threshold) == 1L,
            is.finite(mean_shift_threshold), mean_shift_threshold >= 0)
  stopifnot(is.numeric(sd_ratio_threshold), length(sd_ratio_threshold) == 1L,
            is.finite(sd_ratio_threshold), sd_ratio_threshold >= 1)
  stopifnot(is.numeric(unit_root_t_cutoff), length(unit_root_t_cutoff) == 1L,
            is.finite(unit_root_t_cutoff))

  data <- as.data.frame(data)
  if (!all(vars %in% names(data))) {
    stop("Variables not found in data: ",
         paste(setdiff(vars, names(data)), collapse = ", "), call. = FALSE)
  }
  .ido_check_col(id, "id", data)
  .ido_check_col(day, "day", data)
  .ido_check_col(beep, "beep", data)
  data <- .ido_keep(data, id, min_obs, subject)
  .ido_check_numeric_vars(data, vars, check_variance = FALSE)

  diagnose <- function(p) {
    d <- .preprocess_diagnostics(p, vars, trend_alpha, ar_threshold,
                                 mean_shift_threshold, sd_ratio_threshold,
                                 unit_root_t_cutoff)
    .preprocess_apply_checks(d, checks)
  }
  lag_frame <- function(method) {
    .preprocess_lag_frame(data, vars, id, day, beep, scale, center_within,
                          method)
  }
  # A single plain method for every variable takes the fast uniform path;
  # anything with "auto" or a per-variable mix diagnoses first, then transforms
  # only the series each variable's spec calls for.
  uniform <- .preprocess_uniform_method(spec)
  if (!is.null(uniform)) {
    prep <- lag_frame(uniform)
    diagnostics <- diagnose(prep)
    applied <- uniform
  } else {
    prep <- lag_frame("none")
    diagnostics <- diagnose(prep)
    map <- .preprocess_build_map(diagnostics, spec)
    if (any(map$method != "none")) {
      prep <- lag_frame(map)
      diagnostics <- diagnose(prep)
      applied <- .preprocess_applied_label(spec)
    } else {
      applied <- "none"
    }
  }
  keep <- if (isTRUE(delete_missings)) prep$complete else rep(TRUE, prep$n)
  pairs <- .preprocess_pairs_table(prep, vars, keep)
  counts <- .preprocess_counts(prep, keep)
  matrices <- list(
    data_c = as.matrix(pairs[, vars, drop = FALSE]),
    data_l = as.matrix(pairs[, c("intercept", paste0("L1_", vars)),
                             drop = FALSE])
  )
  colnames(matrices$data_l) <- c("(Intercept)", vars)

  advice <- .preprocess_advice(diagnostics, call, applied)
  if (!is.null(advice)) message(advice)

  out <- list(
    pairs = pairs,
    counts = counts,
    diagnostics = diagnostics,
    matrices = matrices,
    advice = advice,
    n_rows = prep$n,
    n_retained = nrow(pairs),
    labels = vars,
    config = list(id = id, day = day, beep = beep, scale = scale,
                  center_within = center_within,
                  detrend = applied,
                  checks = checks,
                  delete_missings = delete_missings,
                  trend_alpha = trend_alpha,
                  ar_threshold = ar_threshold,
                  mean_shift_threshold = mean_shift_threshold,
                  sd_ratio_threshold = sd_ratio_threshold,
                  unit_root_t_cutoff = unit_root_t_cutoff)
  )
  class(out) <- "preprocess_result"
  out
}

#' Build an actionable detrend suggestion from the diagnostics. Returns `NULL`
#' when the data is already being detrended or shows no trend/unit-root risk.
#' Otherwise it recommends `"difference"` for a stochastic trend (unit root or
#' near-unit-root persistence) or `"linear"` for a deterministic linear trend,
#' and rewrites the user's own call so the message shows the exact line to run.
#' @noRd
.preprocess_advice <- function(diagnostics, call, detrend) {
  if (!identical(detrend, "none")) return(NULL)
  n_trend <- sum(diagnostics$flag_trend, na.rm = TRUE)
  n_unit  <- sum(diagnostics$flag_unit_root, na.rm = TRUE)
  n_ar    <- sum(diagnostics$flag_high_ar, na.rm = TRUE)
  n_series <- nrow(diagnostics)
  if (n_trend == 0L && n_unit == 0L && n_ar == 0L) return(NULL)

  n_flagged <- sum(diagnostics$flag_trend | diagnostics$flag_unit_root |
                     diagnostics$flag_high_ar, na.rm = TRUE)
  suggested <- call
  suggested$detrend <- "auto"
  paste0(
    sprintf("%d of %d subject-series show a trend or unit-root", n_flagged,
            n_series),
    " that can bias the temporal network. preprocess() only diagnosed this; ",
    "to clean just the series that need it, re-run with:\n  ",
    paste(deparse(suggested, width.cutoff = 500L), collapse = " ")
  )
}

#' @noRd
.preprocess_lag_frame <- function(data, vars, id, day, beep, scale,
                                  center_within, detrend = "none") {
  data <- as.data.frame(data)
  original_row <- seq_len(nrow(data))

  for (v in vars) {
    data[[v]] <- as.numeric(scale(data[[v]], center = TRUE, scale = scale))
  }

  idv <- if (is.null(id)) rep(1L, nrow(data)) else data[[id]]
  dayv <- if (is.null(day)) rep(1L, nrow(data)) else data[[day]]
  beepv <- if (is.null(beep)) seq_len(nrow(data)) else data[[beep]]

  if (isTRUE(center_within) && length(unique(idv)) > 1L) {
    for (v in vars) {
      m <- stats::ave(data[[v]], idv, FUN = function(z) mean(z, na.rm = TRUE))
      data[[v]] <- data[[v]] - m
    }
  }

  ord <- order(idv, dayv, beepv)
  data <- data[ord, , drop = FALSE]
  idv <- idv[ord]
  dayv <- dayv[ord]
  beepv <- beepv[ord]
  original_row <- original_row[ord]

  data <- .preprocess_apply_detrend(data, vars, idv, dayv, detrend)

  Y <- as.matrix(data[, vars, drop = FALSE])
  blk <- paste(idv, dayv, sep = "\r")
  same <- c(FALSE, blk[-1L] == blk[-length(blk)])
  lag <- matrix(NA_real_, nrow(Y), ncol(Y))
  lag[same, ] <- Y[which(same) - 1L, , drop = FALSE]
  colnames(lag) <- paste0("L1_", vars)

  complete <- !(rowSums(is.na(Y)) > 0 | rowSums(is.na(lag)) > 0)
  list(data = data, Y = Y, lag = lag, complete = complete, same = same,
       id_value = idv, day_value = dayv, beep_value = beepv,
       original_row = original_row, n = nrow(data))
}

#' Normalize the `detrend` argument to a per-variable named character vector
#' over `vars`, each entry one of `"none"`, `"auto"`, `"linear"`,
#' `"difference"`. A single unnamed string is recycled to every variable; a
#' named vector sets the listed variables and leaves the rest at `"none"`.
#' @noRd
.preprocess_detrend_spec <- function(detrend, vars) {
  allowed <- c("none", "auto", "linear", "difference")
  if (!is.character(detrend) || length(detrend) == 0L) {
    stop("`detrend` must be a character value or a named character vector.",
         call. = FALSE)
  }
  if (is.null(names(detrend))) {
    if (length(detrend) != 1L) {
      stop("An unnamed `detrend` must be a single value; name the entries to ",
           "set per-variable methods.", call. = FALSE)
    }
    method <- match.arg(detrend, allowed)
    return(stats::setNames(rep(method, length(vars)), vars))
  }
  bad_names <- setdiff(names(detrend), vars)
  if (length(bad_names)) {
    stop("`detrend` names must be variables in `vars`; unknown: ",
         paste(bad_names, collapse = ", "), call. = FALSE)
  }
  bad_vals <- setdiff(detrend, allowed)
  if (length(bad_vals)) {
    stop("`detrend` values must be one of ", paste(allowed, collapse = ", "),
         "; got: ", paste(bad_vals, collapse = ", "), call. = FALSE)
  }
  spec <- stats::setNames(rep("none", length(vars)), vars)
  spec[names(detrend)] <- detrend
  spec
}

#' If every variable shares the same plain (non-`"auto"`) method, return that
#' single method so the caller can take the fast uniform path; otherwise `NULL`.
#' @noRd
.preprocess_uniform_method <- function(spec) {
  u <- unique(spec)
  if (length(u) == 1L && u %in% c("none", "linear", "difference")) u else NULL
}

#' Short label describing an applied per-variable spec, for printing.
#' @noRd
.preprocess_applied_label <- function(spec) {
  u <- unique(spec[spec != "none"])
  if (length(u) == 1L && identical(u, "auto")) "auto" else "custom"
}

#' Per subject-series detrend decision from a per-variable `spec`. For a
#' variable set to `"auto"` it picks `"difference"` for a stochastic trend
#' (unit root or near-unit-root persistence), `"linear"` for a deterministic
#' trend, and `"none"` otherwise; any other method is applied as given. Returns
#' a `data.frame(subject, variable, method)`.
#' @noRd
.preprocess_build_map <- function(diagnostics, spec) {
  method <- vapply(seq_len(nrow(diagnostics)), function(i) {
    s <- spec[[diagnostics$variable[i]]]
    if (!identical(s, "auto")) return(s)
    if (isTRUE(diagnostics$flag_unit_root[i]) ||
          isTRUE(diagnostics$flag_high_ar[i])) {
      "difference"
    } else if (isTRUE(diagnostics$flag_trend[i])) {
      "linear"
    } else {
      "none"
    }
  }, character(1))
  data.frame(subject = as.character(diagnostics$subject),
             variable = diagnostics$variable, method = method,
             stringsAsFactors = FALSE)
}

#' Back-compat helper: the all-`"auto"` map.
#' @noRd
.preprocess_auto_map <- function(diagnostics) {
  vars <- unique(diagnostics$variable)
  .preprocess_build_map(diagnostics,
                        stats::setNames(rep("auto", length(vars)), vars))
}

#' Deactivate the flags not in `checks` (set them `FALSE`) and recompute the
#' `flag_stationarity_risk` roll-up from the selected checks only, so both the
#' report and the `"auto"` decision honour the requested checkups.
#' @noRd
.preprocess_apply_checks <- function(diagnostics, checks) {
  all_checks <- c("trend", "high_ar", "unit_root", "mean_shift", "sd_shift",
                  "zero_variance")
  for (chk in setdiff(all_checks, checks)) {
    diagnostics[[paste0("flag_", chk)]] <- FALSE
  }
  sel <- lapply(checks, function(chk) diagnostics[[paste0("flag_", chk)]])
  diagnostics$flag_stationarity_risk <- if (length(sel)) {
    Reduce(`|`, sel)
  } else {
    rep(FALSE, nrow(diagnostics))
  }
  diagnostics
}

#' Detrend each series before lagging. Operates on time-ordered data. `method`
#' is either a single string applied to every series (`"none"` no-op,
#' `"linear"` per person, `"difference"` per id/day block) or a per-series
#' `data.frame(subject, variable, method)` from the internal auto-map helper.
#' Returns the frame with the `vars` columns replaced.
#' @noRd
.preprocess_apply_detrend <- function(data, vars, idv, dayv, method) {
  if (is.data.frame(method)) {
    return(.preprocess_apply_detrend_map(data, vars, idv, dayv, method))
  }
  if (identical(method, "none")) return(data)
  block <- if (identical(method, "difference")) {
    paste(idv, dayv, sep = "\r")
  } else {
    as.character(idv)
  }
  fun <- if (identical(method, "linear")) {
    .preprocess_detrend_linear
  } else {
    .preprocess_detrend_difference
  }
  data[vars] <- lapply(vars, function(v) stats::ave(data[[v]], block, FUN = fun))
  data
}

#' Apply a per subject-series detrend map: linear detrend is per person,
#' difference is within that person's day blocks, and `"none"` series are left
#' untouched. Data must already be time-ordered.
#' @noRd
.preprocess_apply_detrend_map <- function(data, vars, idv, dayv, map) {
  subs <- as.character(idv)
  for (v in vars) {
    mv <- map[map$variable == v, , drop = FALSE]
    method_by_sub <- stats::setNames(mv$method, as.character(mv$subject))
    z <- data[[v]]
    for (s in unique(subs)) {
      m <- method_by_sub[[s]]
      if (is.null(m) || identical(m, "none")) next
      sel <- subs == s
      z[sel] <- if (identical(m, "linear")) {
        .preprocess_detrend_linear(z[sel])
      } else {
        stats::ave(z[sel], dayv[sel], FUN = .preprocess_detrend_difference)
      }
    }
    data[[v]] <- z
  }
  data
}

#' Residuals of a within-block regression on a linear time index. Preserves
#' length and NA positions; a flat or too-short block is returned unchanged.
#' @noRd
.preprocess_detrend_linear <- function(z) {
  ok <- is.finite(z)
  if (sum(ok) < 3L || stats::sd(z[ok]) == 0) return(z)
  tt <- seq_along(z)
  fit <- stats::lm.fit(cbind(1, tt[ok]), z[ok])
  out <- z
  out[ok] <- z[ok] - as.numeric(cbind(1, tt[ok]) %*% fit$coefficients)
  out
}

#' First difference within a block; the block-initial observation becomes `NA`
#' and is dropped by the same completeness rule as the lag.
#' @noRd
.preprocess_detrend_difference <- function(z) {
  c(NA_real_, diff(z))
}

#' @noRd
.preprocess_pairs_table <- function(prep, vars, keep) {
  out <- data.frame(
    original_row = prep$original_row[keep],
    subject = as.character(prep$id_value[keep]),
    day = as.character(prep$day_value[keep]),
    beep = prep$beep_value[keep],
    stringsAsFactors = FALSE
  )
  out <- cbind(out,
               as.data.frame(prep$Y[keep, , drop = FALSE]),
               data.frame(intercept = rep(1, sum(keep))),
               as.data.frame(prep$lag[keep, , drop = FALSE]))
  names(out)[match(vars, names(out))] <- vars
  rownames(out) <- NULL
  out
}

#' @noRd
.preprocess_counts <- function(prep, keep) {
  key <- paste(prep$id_value, prep$day_value, sep = "\r")
  split_idx <- split(seq_len(prep$n), key)
  rows <- lapply(split_idx, function(idx) {
    data.frame(
      subject = as.character(prep$id_value[idx[1L]]),
      day = as.character(prep$day_value[idx[1L]]),
      n_rows = length(idx),
      n_lag_possible = sum(prep$same[idx]),
      n_complete_pairs = sum(prep$complete[idx]),
      n_retained = sum(keep[idx]),
      n_boundary_dropped = sum(!prep$same[idx]),
      stringsAsFactors = FALSE
    )
  })
  out <- do.call(rbind, rows)
  rownames(out) <- NULL
  out
}

#' @noRd
.preprocess_diagnostics <- function(prep, vars, trend_alpha, ar_threshold,
                                    mean_shift_threshold, sd_ratio_threshold,
                                    unit_root_t_cutoff) {
  ids <- unique(prep$id_value)
  rows <- list()
  pos <- 0L
  for (sid in ids) {
    idx <- which(prep$id_value == sid)
    time_idx <- seq_along(idx)
    for (j in seq_along(vars)) {
      y <- prep$Y[idx, j]
      lag <- prep$lag[idx, j]
      trend <- .preprocess_lm_slope(y, time_idx)
      ar <- .preprocess_lm_slope(y, lag)
      drift <- .preprocess_split_drift(y)
      unit_root <- .preprocess_unit_root_screen(y)
      s <- stats::sd(y, na.rm = TRUE)
      flag_zero <- !is.finite(s) || s == 0
      flag_trend <- is.finite(trend$p) && trend$p < trend_alpha
      flag_high_ar <- is.finite(ar$slope) && abs(ar$slope) >= ar_threshold
      flag_mean_shift <- is.finite(drift$mean_shift_std) &&
        drift$mean_shift_std >= mean_shift_threshold
      flag_sd_shift <- is.finite(drift$sd_ratio) &&
        drift$sd_ratio >= sd_ratio_threshold
      flag_unit_root <- is.finite(unit_root$t) &&
        unit_root$t > unit_root_t_cutoff
      pos <- pos + 1L
      rows[[pos]] <- data.frame(
        subject = as.character(sid),
        variable = vars[j],
        n = length(y),
        n_observed = sum(!is.na(y)),
        missing_prop = mean(is.na(y)),
        mean = mean(y, na.rm = TRUE),
        sd = s,
        trend_slope = trend$slope,
        trend_t = trend$t,
        trend_p = trend$p,
        ar1 = ar$slope,
        ar1_t = ar$t,
        ar1_p = ar$p,
        mean_first_half = drift$mean_first,
        mean_second_half = drift$mean_second,
        mean_shift = drift$mean_shift,
        mean_shift_std = drift$mean_shift_std,
        mean_shift_p = drift$mean_shift_p,
        sd_first_half = drift$sd_first,
        sd_second_half = drift$sd_second,
        sd_ratio = drift$sd_ratio,
        unit_root_coef = unit_root$coef,
        unit_root_t = unit_root$t,
        flag_zero_variance = flag_zero,
        flag_trend = flag_trend,
        flag_high_ar = flag_high_ar,
        flag_mean_shift = flag_mean_shift,
        flag_sd_shift = flag_sd_shift,
        flag_unit_root = flag_unit_root,
        flag_stationarity_risk = flag_trend || flag_high_ar ||
          flag_mean_shift || flag_sd_shift || flag_unit_root,
        stringsAsFactors = FALSE
      )
    }
  }
  out <- do.call(rbind, rows)
  rownames(out) <- NULL
  out
}

#' @noRd
.preprocess_split_drift <- function(y) {
  y <- y[is.finite(y)]
  if (length(y) < 6L || stats::sd(y) == 0) {
    return(list(mean_first = NA_real_, mean_second = NA_real_,
                mean_shift = NA_real_, mean_shift_std = NA_real_,
                mean_shift_p = NA_real_, sd_first = NA_real_,
                sd_second = NA_real_, sd_ratio = NA_real_))
  }
  cut <- floor(length(y) / 2)
  y1 <- y[seq_len(cut)]
  y2 <- y[seq.int(cut + 1L, length(y))]
  m1 <- mean(y1)
  m2 <- mean(y2)
  s1 <- stats::sd(y1)
  s2 <- stats::sd(y2)
  se <- sqrt(stats::var(y1) / length(y1) + stats::var(y2) / length(y2))
  tval <- if (is.finite(se) && se > 0) (m2 - m1) / se else NA_real_
  df <- .preprocess_welch_df(y1, y2)
  p <- if (is.finite(tval) && is.finite(df) && df > 0) {
    2 * stats::pt(abs(tval), df = df, lower.tail = FALSE)
  } else {
    NA_real_
  }
  sd_ratio <- if (all(is.finite(c(s1, s2))) && min(s1, s2) > 0) {
    max(s1, s2) / min(s1, s2)
  } else {
    NA_real_
  }
  list(mean_first = m1, mean_second = m2,
       mean_shift = m2 - m1,
       mean_shift_std = abs(m2 - m1) / stats::sd(y),
       mean_shift_p = p,
       sd_first = s1, sd_second = s2, sd_ratio = sd_ratio)
}

#' @noRd
.preprocess_welch_df <- function(y1, y2) {
  v1 <- stats::var(y1) / length(y1)
  v2 <- stats::var(y2) / length(y2)
  den <- v1^2 / (length(y1) - 1L) + v2^2 / (length(y2) - 1L)
  if (!is.finite(den) || den <= 0) return(NA_real_)
  (v1 + v2)^2 / den
}

#' @noRd
.preprocess_unit_root_screen <- function(y) {
  y <- y[is.finite(y)]
  if (length(y) < 8L || stats::sd(y) == 0) {
    return(list(coef = NA_real_, t = NA_real_))
  }
  dy <- diff(y)
  lag <- y[-length(y)]
  fit <- .preprocess_lm_slope(dy, lag)
  list(coef = fit$slope, t = fit$t)
}

#' @noRd
.preprocess_lm_slope <- function(y, x) {
  ok <- is.finite(y) & is.finite(x)
  if (sum(ok) < 3L || stats::sd(x[ok]) == 0 || stats::sd(y[ok]) == 0) {
    return(list(slope = NA_real_, t = NA_real_, p = NA_real_))
  }
  X <- cbind(1, x[ok])
  fit <- stats::lm.fit(X, y[ok])
  df <- length(y[ok]) - fit$rank
  if (df <= 0L) return(list(slope = fit$coefficients[2L],
                            t = NA_real_, p = NA_real_))
  rss <- sum(fit$residuals^2)
  sigma2 <- rss / df
  xtx_inv <- tryCatch(solve(crossprod(X)), error = function(e) NULL)
  if (is.null(xtx_inv)) {
    return(list(slope = fit$coefficients[2L], t = NA_real_, p = NA_real_))
  }
  se <- sqrt(sigma2 * xtx_inv[2L, 2L])
  tval <- fit$coefficients[2L] / se
  pval <- 2 * stats::pt(abs(tval), df = df, lower.tail = FALSE)
  list(slope = fit$coefficients[2L], t = tval, p = pval)
}

#' Print method for preprocessing results
#'
#' @param x A `preprocess_result` object.
#' @param ... Ignored.
#' @return `x`, invisibly.
#' @export
print.preprocess_result <- function(x, ...) {
  diag <- x$diagnostics
  cat("Idiographic Preprocessing\n")
  cat(sprintf("  Variables:      %d (%s)\n",
              length(x$labels), paste(x$labels, collapse = ", ")))
  if (!identical(x$config$detrend, "none")) {
    cat(sprintf("  Detrend:        %s\n", x$config$detrend))
  }
  all_checks <- c("trend", "high_ar", "unit_root", "mean_shift", "sd_shift",
                  "zero_variance")
  if (!setequal(x$config$checks, all_checks)) {
    cat(sprintf("  Checks:         %s\n", paste(x$config$checks, collapse = ", ")))
  }
  cat(sprintf("  Ordered rows:   %d\n", x$n_rows))
  cat(sprintf("  Retained pairs: %d\n", x$n_retained))
  cat(sprintf("  Trend flags:    %d\n", sum(diag$flag_trend, na.rm = TRUE)))
  cat(sprintf("  High AR flags:  %d\n", sum(diag$flag_high_ar, na.rm = TRUE)))
  cat(sprintf("  Drift flags:    %d\n",
              sum(diag$flag_mean_shift | diag$flag_sd_shift, na.rm = TRUE)))
  cat(sprintf("  Unit-root risk: %d\n",
              sum(diag$flag_unit_root, na.rm = TRUE)))
  cat(sprintf("  Zero variance:  %d\n",
              sum(diag$flag_zero_variance, na.rm = TRUE)))
  cat("  Tables:         x$pairs | x$counts | x$diagnostics\n")
  if (!is.null(x$advice)) cat("\n", x$advice, "\n", sep = "")
  invisible(x)
}

#' @export
as.data.frame.preprocess_result <- function(x, row.names = NULL,
                                            optional = FALSE, ...) {
  x$diagnostics
}

#' Summary method for preprocessing results
#'
#' Compact per-variable roll-up of the diagnostics: one row per variable with
#' its mean spread and the number of subject-series that tripped each
#' stationarity flag. Use `x$diagnostics` for the full per-subject table.
#'
#' @param object A `preprocess_result` object.
#' @param ... Ignored.
#' @return A tidy per-variable `data.frame`.
#' @export
summary.preprocess_result <- function(object, ...) {
  d <- object$diagnostics
  vars <- object$labels
  count <- function(flag) {
    as.integer(tapply(d[[flag]], factor(d$variable, levels = vars),
                      function(z) sum(z, na.rm = TRUE)))
  }
  data.frame(
    variable    = vars,
    n_series    = as.integer(table(factor(d$variable, levels = vars))),
    mean_sd     = round(as.numeric(tapply(d$sd,
                        factor(d$variable, levels = vars),
                        function(z) mean(z, na.rm = TRUE))), 3),
    n_trend     = count("flag_trend"),
    n_high_ar   = count("flag_high_ar"),
    n_unit_root = count("flag_unit_root"),
    n_flagged   = count("flag_stationarity_risk"),
    stringsAsFactors = FALSE
  )
}

Try the idiographic package in your browser

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

idiographic documentation built on Aug. 4, 2026, 1:07 a.m.