R/ppmodel.R

Defines functions .require_training_data residuals.ppmodel fitted.ppmodel nobs.ppmodel formula.ppmodel projection_importance.ppmodel projection_importance.default projection_importance weighted_importance.pprf weighted_importance.default weighted_importance permuted_importance.pprf permuted_importance.default permuted_importance bag_samples.pprf bag_samples.default bag_samples oob_samples.pprf oob_samples.default oob_samples oob_predictions.pprf_regression oob_predictions.pprf_classification oob_predictions.default oob_predictions oob_error.pprf oob_error.default oob_error .prime_cache .cached_or_compute .new_cache

Documented in bag_samples fitted.ppmodel formula.ppmodel nobs.ppmodel oob_error oob_predictions oob_samples permuted_importance projection_importance residuals.ppmodel weighted_importance

#' @useDynLib ppforest2
#' @importFrom Rcpp evalCpp
NULL

# ---------------------------------------------------------------------------
# Memoization cache (environment-based; mutable across S3 method calls).
#
# Models are S3 lists (copy-on-modify), which can't memoize via field writes.
# Each model carries an environment `$.cache` used as a scratchpad by the
# accessor methods below. `load_json` populates the cache with values that
# were persisted during training-time save.
# ---------------------------------------------------------------------------

# Create a fresh cache environment.
.new_cache <- function() new.env(parent = emptyenv())

# Return the cached value for `key`, computing via `compute_fn` on first access.
# Falls back to uncached compute if `model$.cache` is missing (e.g. models
# assembled manually in tests).
.cached_or_compute <- function(model, key, compute_fn) {
  cache <- model$.cache
  if (is.null(cache)) return(compute_fn())
  if (!exists(key, envir = cache, inherits = FALSE)) {
    assign(key, compute_fn(), envir = cache)
  }
  get(key, envir = cache, inherits = FALSE)
}

# Stash a pre-computed value directly into the cache (used by load_json to
# preserve OOB metrics saved during training-time serialization).
.prime_cache <- function(model, key, value) {
  cache <- model$.cache
  if (is.null(cache)) return(invisible(NULL))
  if (!is.null(value)) assign(key, value, envir = cache)
  invisible(NULL)
}


# ---------------------------------------------------------------------------
# Public OOB accessors (generics + methods).
#
# Only forests have OOB concepts; calling these on a `pptr` model errors.
# The accessors compute lazily using the training data stored on the model
# (`$x`, `$y` — `$y` is integer class labels for classification and the
# continuous response for regression) and cache the result in `$.cache`.
# ---------------------------------------------------------------------------

#' Out-of-bag error for a random forest.
#'
#' Computes (or returns the cached) OOB error using the training data stored
#' on the model. For classification, this is the misclassification rate in
#' `[0, 1]`. For regression, it is the mean squared error against the
#' continuous response.
#'
#' @param model A \code{pprf} forest model.
#' @return A numeric scalar in `[0, 1]` for classification or `[0, Inf)` for
#'   regression. Returns `NA_real_` when no observation has any out-of-bag
#'   tree (e.g. a degenerate forest where every tree saw every row). Callers
#'   should check with `is.na()` rather than comparing against a sentinel
#'   value; in earlier versions this condition was signalled as `-1`, which
#'   was not distinguishable from a (mathematically impossible but
#'   representable) error rate.
#' @seealso \code{\link{oob_predictions}}, \code{\link{oob_samples}}
#' @export
oob_error <- function(model) UseMethod("oob_error")

#' @export
oob_error.default <- function(model) {
  stop(
    "`oob_error()` is only defined for `pprf` forest models, not objects of class '",
    paste(class(model), collapse = "/"), "'.",
    call. = FALSE
  )
}

# Rcpp returns a length-1 `NumericVector` from the C++ OOB-error bindings.
# That already behaves as a length-1 R numeric for every operation callers
# care about (comparisons, `is.na()`, arithmetic, assignment), so no
# `as.numeric()` coercion is needed on either branch. The length-1 vector
# carries either the error value or `NA_real_` (never the legacy `-1`
# sentinel — the C++ side translates `std::nullopt` at the boundary).

#' @export
oob_error.pprf <- function(model) {
  # Mode dispatch lives in `ppforest2_oob_error` on the C++ side: it
  # decodes `y` from R 1-based factor codes only for classification,
  # and computes either misclassification rate or MSE based on the
  # forest's `training_spec$mode`. Both subclasses can share this
  # method as a result — no per-mode wrapper needed at the R level.
  .cached_or_compute(model, "oob_error", function() {
    .require_training_data(model, c("x", "y"))
    ppforest2_oob_error(model, model$x, model$y)
  })
}


#' Out-of-bag predictions for a random forest.
#'
#' Returns predictions for each training row using only trees that did
#' not see that row in their bootstrap sample. Observations with no OOB
#' tree are represented as `NA` in both modes: `NA` at the factor level
#' for classification, and `NA_real_` for regression. Filter with the
#' standard `is.na()` idiom.
#'
#' @param model A \code{pprf} forest model.
#' @return A factor (classification) or numeric vector (regression), length `n`.
#' @seealso \code{\link{oob_error}}, \code{\link{oob_samples}}
#' @export
oob_predictions <- function(model) UseMethod("oob_predictions")

#' @export
oob_predictions.default <- function(model) {
  stop(
    "`oob_predictions()` is only defined for `pprf` forest models, not objects of class '",
    paste(class(model), collapse = "/"), "'.",
    call. = FALSE
  )
}

#' @export
oob_predictions.pprf_classification <- function(model) {
  .cached_or_compute(model, "oob_predictions", function() {
    .require_training_data(model, "x")
    # Sentinel: C++ `oob_predict` emits `NaN` for rows that had no OOB
    # tree, in both modes. The Rcpp wrapper's `to_r_indices` shift adds
    # +1 to every element to convert 0-based C++ group ids to 1-based
    # R factor indices — `NaN + 1` is still `NaN`, so the sentinel
    # survives intact. We then remap `NaN → NA_real_` explicitly (the
    # same step the regression accessor does) so the downstream
    # `as.integer` coercion doesn't emit "NAs introduced by coercion"
    # warnings, and the factor records each no-OOB row as a missing
    # level cleanly.
    raw <- ppforest2_oob_predict(model, model$x)
    raw[is.nan(raw)] <- NA_real_
    factor(model$groups[as.integer(raw)], levels = model$groups)
  })
}

#' @export
oob_predictions.pprf_regression <- function(model) {
  .cached_or_compute(model, "oob_predictions", function() {
    .require_training_data(model, "x")
    # C++ emits NaN for rows with no OOB tree (the natural float sentinel,
    # used identically in classification — see `oob_predictions.pprf_classification`).
    # Remap to NA_real_ here so callers can use the plain `is.na()` idiom
    # without having to remember the underlying NaN.
    out <- as.numeric(ppforest2_oob_predict(model, model$x))
    out[is.nan(out)] <- NA_real_
    out
  })
}


#' Out-of-bag row indices per tree.
#'
#' Returns a list where element `i` is the integer vector of row indices
#' (1-based) that were **not** in the bootstrap sample of tree `i`.
#'
#' @param model A \code{pprf} forest model.
#' @return A list of integer vectors, one per tree.
#' @seealso \code{\link{bag_samples}}, \code{\link{oob_error}}
#' @export
oob_samples <- function(model) UseMethod("oob_samples")

#' @export
oob_samples.default <- function(model) {
  stop(
    "`oob_samples()` is only defined for `pprf` forest models, not objects of class '",
    paste(class(model), collapse = "/"), "'.",
    call. = FALSE
  )
}

#' @export
oob_samples.pprf <- function(model) {
  .require_training_data(model, "x")
  n <- nrow(model$x)
  lapply(model$trees, function(t) setdiff(seq_len(n), t$sample_indices + 1L))
}


#' In-bag row indices per tree.
#'
#' Returns a list where element `i` is the integer vector of row indices
#' (1-based, with replacement) drawn into the bootstrap sample of tree `i`.
#'
#' @param model A \code{pprf} forest model.
#' @return A list of integer vectors, one per tree.
#' @seealso \code{\link{oob_samples}}
#' @export
bag_samples <- function(model) UseMethod("bag_samples")

#' @export
bag_samples.default <- function(model) {
  stop(
    "`bag_samples()` is only defined for `pprf` forest models, not objects of class '",
    paste(class(model), collapse = "/"), "'.",
    call. = FALSE
  )
}

#' @export
bag_samples.pprf <- function(model) {
  lapply(model$trees, function(t) t$sample_indices + 1L)
}


# ---------------------------------------------------------------------------
# Variable-importance accessors (lazy for OOB-requiring variants).
# ---------------------------------------------------------------------------

#' Permuted variable importance for a random forest.
#'
#' For each feature, measures the drop in OOB accuracy (classification) or
#' the increase in normalised MSE (regression) after randomly permuting
#' that feature across the OOB observations. Computed lazily from the
#' training data stored on the model; the result is cached.
#'
#' **Sign semantics.** Entries may be **negative**. That is not an error
#' and not a sentinel: it means permuting the feature did not degrade OOB
#' fit on average — the feature's signal sits at or below the noise floor
#' of the permutation procedure. Interpret negative or near-zero entries
#' as "no evidence of importance"; rely on the ranking rather than
#' clipping at zero or normalizing. The scale is already comparable
#' within a fitted model.
#'
#' @param model A \code{pprf} forest model.
#' @return A numeric vector, one entry per feature. Negative values are
#'   meaningful (see Sign semantics above).
#' @export
permuted_importance <- function(model) UseMethod("permuted_importance")

#' @export
permuted_importance.default <- function(model) {
  stop(
    "`permuted_importance()` is only defined for `pprf` forest models.",
    call. = FALSE
  )
}

#' @export
permuted_importance.pprf <- function(model) {
  # `model$y` is mode-correct for both classification (1-based factor codes,
  # which the C++ binding shifts to 0-based when needed) and regression
  # (the continuous response). Previously the regression branch passed a
  # 0/1 median-split indicator here, making MSE/NMSE meaningless — fixed
  # by unifying `model$y` with the continuous response in `validate_data()`.
  .cached_or_compute(model, "permuted_importance", function() {
    .require_training_data(model, c("x", "y"))
    ppforest2_vi_permuted_forest(model, model$x, model$y, model$seed)
  })
}


#' Weighted projection variable importance for a random forest.
#'
#' Weights each tree's projection-based importance by a per-tree OOB
#' quality score — `1 - error_rate` in `[0, 1]` for classification, and
#' `max(0, 1 - NMSE)` in `[0, 1]` for regression — then aggregates
#' `I_s × |a_j|` over splits. Computed lazily from the training data
#' stored on the model; the result is cached.
#'
#' **Sign semantics.** Entries are non-negative by construction (weights
#' and per-split contributions are both non-negative). A zero entry means
#' "this feature never appeared in a weighted OOB-contributing split,"
#' not "within noise." Contrast with \code{\link{permuted_importance}},
#' where negative values are meaningful. Do not re-normalize — rely on
#' the ranking.
#'
#' @param model A \code{pprf} forest model.
#' @return A non-negative numeric vector, one entry per feature.
#' @export
weighted_importance <- function(model) UseMethod("weighted_importance")

#' @export
weighted_importance.default <- function(model) {
  stop(
    "`weighted_importance()` is only defined for `pprf` forest models.",
    call. = FALSE
  )
}

#' @export
weighted_importance.pprf <- function(model) {
  # See the note on `permuted_importance.pprf` — `model$y` is now
  # mode-correct for both classification and regression.
  .cached_or_compute(model, "weighted_importance", function() {
    .require_training_data(model, c("x", "y"))
    ppforest2_vi_weighted_forest(model, model$x, model$y, model$vi$scale)
  })
}


#' Projection-coefficient variable importance.
#'
#' The projection-based importance (VI2): each split's scaled absolute
#' projection coefficients (`|a_j| * sigma_j`) aggregated into a per-feature
#' score, averaged over the non-degenerate trees of a forest. Unlike
#' \code{\link{permuted_importance}} and \code{\link{weighted_importance}},
#' this measure is not OOB-based — it depends only on the fitted projector
#' geometry, is computed eagerly at fit time (cheap), and is available for
#' both single trees (\code{pptr}) and forests (\code{pprf}).
#'
#' **Sign semantics.** Entries are non-negative by construction (absolute
#' coefficients scaled by each feature's standard deviation). Rely on the
#' ranking rather than re-normalizing.
#'
#' @param model A \code{pptr} or \code{pprf} model.
#' @return A non-negative numeric vector, one entry per feature.
#' @seealso \code{\link{permuted_importance}}, \code{\link{weighted_importance}}
#' @export
projection_importance <- function(model) UseMethod("projection_importance")

#' @export
projection_importance.default <- function(model) {
  stop(
    "`projection_importance()` is only defined for ppforest2 models (`pptr` or `pprf`).",
    call. = FALSE
  )
}

#' @export
projection_importance.ppmodel <- function(model) {
  # Computed eagerly at fit time (cheap, not OOB-based) and stored on
  # `model$vi$projections`; it survives save/load (see `load_json`).
  model$vi$projections
}


# ---------------------------------------------------------------------------
# Shared S3 methods at the ppmodel level (both pptr and pprf).
# ---------------------------------------------------------------------------

#' Formula extractor for ppforest2 models.
#'
#' @param x A \code{pptr} or \code{pprf} model.
#' @param ... Unused.
#' @return The formula the model was trained with, or `NULL` for matrix-interface fits.
#' @export
formula.ppmodel <- function(x, ...) {
  x$formula
}


#' Number of observations used to fit a ppforest2 model.
#'
#' Implements the standard \code{stats::nobs()} contract so downstream tools
#' (\code{step()}, broom's \code{glance()}, information criteria) can ask for
#' the training-sample size.
#'
#' @param object A \code{pptr} or \code{pprf} model.
#' @param ... Unused.
#' @return Integer scalar. Returns \code{NA_integer_} for models loaded from
#'   JSON without their original training data.
#' @export
nobs.ppmodel <- function(object, ...) {
  if (is.null(object$x)) return(NA_integer_)
  nrow(object$x)
}


#' Fitted (in-sample) predictions from a ppforest2 model.
#'
#' Returns predictions on the training data — a factor for classification,
#' a numeric vector for regression. For forests, training predictions are
#' optimistic; use \code{\link{oob_predictions}()} for an honest estimate.
#'
#' @param object A \code{pptr} or \code{pprf} model.
#' @param ... Unused.
#' @return A factor (classification) or numeric vector (regression), length
#'   equal to the number of training observations.
#' @seealso \code{\link{residuals.ppmodel}}, \code{\link{oob_predictions}}
#' @export
fitted.ppmodel <- function(object, ...) {
  .require_training_data(object, "x")
  # Dispatches through the model's own predict method, which is mode- and
  # class-aware (pprf vs pptr × classification vs regression). For
  # classification this returns a factor; for regression a numeric vector.
  predict(object, object$x)
}


#' Residuals from a regression ppforest2 model.
#'
#' Returns \code{y - fitted(model)}. Only defined for regression models —
#' classification residuals have no canonical scalar form, so this method
#' errors on classification models rather than inventing a convention.
#'
#' @param object A \code{pprf} or \code{pptr} regression model.
#' @param ... Unused.
#' @return A numeric vector of length \code{nobs(object)}.
#' @seealso \code{\link{fitted.ppmodel}}
#' @export
residuals.ppmodel <- function(object, ...) {
  if (!identical(object$mode, "regression")) {
    stop(
      "`residuals()` is only defined for regression models. ",
      "For classification, compare `fitted(model)` against the response directly, ",
      "or use `oob_predictions(model)` for an out-of-bag misclassification view.",
      call. = FALSE
    )
  }
  .require_training_data(object, c("x", "y"))
  as.numeric(object$y) - as.numeric(fitted(object))
}


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

# Throws a clear error if required training-data fields are missing on the
# model (e.g. the model was loaded from JSON without the original x/y).
# Distinguishes between "loaded model without data" (JSON doesn't carry x/y,
# user should reattach) and "metrics were never computed at save time" (the
# saved JSON had `include_metrics = FALSE`, so priming the cache was a
# no-op and no recomputation is possible).
.require_training_data <- function(model, fields) {
  for (f in fields) {
    if (is.null(model[[f]])) {
      # Heuristic: if the model has a `.cache` environment and `training_spec`
      # populated, it was almost certainly loaded from JSON. Mention both
      # reattachment and the save-time-metrics case so the user can pick.
      loaded_from_json <- !is.null(model$training_spec) && !is.null(model$.cache)
      if (loaded_from_json) {
        stop(
          "Required field `", f, "` is not available on the loaded model. ",
          "Either (a) re-attach the original training data to `model$", f,
          "` before calling this accessor, or (b) re-save the source model ",
          "with `save_json(model, path, include_metrics = TRUE)` so the ",
          "relevant OOB / variable-importance values are preserved in the JSON.",
          call. = FALSE
        )
      }
      stop(
        "Required field `", f, "` is not available on the model -- ",
        "this model was probably loaded from JSON without the original training data.",
        call. = FALSE
      )
    }
  }
  invisible(NULL)
}

Try the ppforest2 package in your browser

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

ppforest2 documentation built on July 21, 2026, 9:07 a.m.