Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 121 additions & 87 deletions R/ObliqueForest.R
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,14 @@
#' @param tree Integer specifying which tree to extract rules from (1-based).
#' Default is `1L` for the first tree. Must be between 1 and the number of
#' trees in the forest (`x$n_tree`).
#' @param node_id An integer for the node ID to process.
#'
#' @param ... Other arguments passed to methods
#' @return A tibble with columns:
#' * `tree`: integer, the tree number (1-based).
#' * `rules`: list of R expressions, one per terminal node.
#' * `id`: integer, the terminal node ID (1-based for user convenience).
#'
#' @details
#'
#' ## Known Limitation
#'
#' **Important**: The extracted rules currently do NOT perfectly match aorsf's
#' internal node assignments. This is a known issue being investigated. The
#' rules are structurally correct (using the right splits and operators) but
#' may not evaluate to the exact same terminal nodes as aorsf's predictions.
#'
#' The rules are still useful for:
#' - Understanding the general structure of oblique splits
#' - Seeing which variables and linear combinations are used
#' - Interpreting model behavior qualitatively
#'
#' But should NOT be used for:
#' - Exactly replicating aorsf's predictions
#' - Validating which observations belong to which nodes
#'
#' ## Tree and Node Indexing
#'
#' Both the `tree` parameter and the `id` column use **1-based indexing** for
Expand All @@ -45,44 +28,45 @@
#' is automatically converted to 1-based indexing in the output for consistency
#' with R conventions.
#'
#' ## Factor Variables and One-Hot Encoding
#'
#' The \pkg{aorsf} package internally converts unordered factor variables to binary
#' indicator (dummy) variables during tree building. However, the extracted
#' rules automatically convert these back to factor comparisons for better
#' interpretability:
#' ## Factor Variables and Reference Coding
#'
#' - Instead of `2.1 * county_adams`, rules show `2.1 * (county == "adams")`
#' - This allows rules to be evaluated directly on data with the original factor
#' columns (no need to create indicator variables)
#' - For example, a factor `color` with levels `["red", "blue", "green"]` will
#' appear in rules as `(color == "red")`, `(color == "blue")`, etc.
#' The \pkg{aorsf} package internally converts unordered factor variables using
#' **reference coding** (also called dummy coding). For a factor with k levels,
#' aorsf creates k-1 binary indicator variables, with the first level serving as
#' the reference category:
#'
#' Ordered factors are converted to a single integer variable representing the
#' ordinal level, not to multiple indicators.
#' - A factor `color` with levels `["red", "blue", "green"]` creates indicators
#' for `blue` and `green` only. When both indicators are 0, it represents `red`.
#' - The extracted rules automatically convert these back to factor comparisons:
#' `2.1 * color_blue` becomes `2.1 * (color == "blue")`.
#' - Rules can be evaluated directly on data with the original factor columns
#' (no need to manually create indicator variables).
#' - Ordered factors are converted to a single integer variable representing the
#' ordinal level, not to multiple indicators.
#'
#' Factor indicators are not scaled since they are binary 0/1 values.
#' Reference coding prevents collinearity in the internal regression computations
#' used to find optimal splits.
#'
#' ## Predictor Scaling and Unscaling
#' ## Predictor Scaling
#'
#' The \pkg{aorsf} package can optionally center and scale numeric predictors when
#' computing linear combinations for splits. This is controlled by the `scale_x`
#' parameter in `orsf_control_*()` functions (default is `TRUE`).
#' The \pkg{aorsf} package **always scales data during prediction**, regardless of
#' the `scale_x` parameter setting. The coefficients stored in trees are for
#' scaled data: `(x - mean) / sd` for numeric predictors.
#'
#' When `scale_x = TRUE`, \pkg{aorsf} uses `(x - mean) / sd` for numeric predictors
#' during split computations. The extracted rules automatically **unscale** the
#' coefficients and thresholds back to the original units, so rules can be
#' directly evaluated on the original (unscaled) data.
#' To make rules work with unscaled input data, the extracted rules automatically
#' **include the scaling transformation** in the expressions themselves. For example,
#' instead of showing a pre-computed unscaled coefficient, rules show:
#' ```
#' 728.58 * ((flipper_length_mm - 200.97) / 14.02) > 400.23
#' ```
#'
#' When `scale_x = FALSE`, coefficients are already in the original units and
#' no unscaling is performed.
#' This approach:
#' - Allows rules to be evaluated directly on original (unscaled) data
#' - Preserves full floating-point precision (avoids errors from pre-computing
#' unscaled coefficients)
#' - Makes the scaling transformation explicit and transparent
#'
#' Factor indicator variables (from one-hot encoding) are not scaled since they
#' are binary 0/1 values.
#'
#' Note: `x$get_means()` and `x$get_stdev()` always return the data statistics
#' regardless of the `scale_x` setting. To determine if scaling was used, check
#' `x$control$lincomb_scale`.
#' Factor indicator variables are not scaled since they are binary 0/1 values.
#'
#' @examples
#' if (rlang::is_installed(c("aorsf", "palmerpenguins"))) {
Expand All @@ -107,23 +91,6 @@
#' rules_reg <- extract_rules(forest_reg, tree = 1L)
#' }
#'
# Internal helper: extract rule for a single terminal node
oblique_extract_node_rule <- function(node_id, tree, x) {
path <- oblique_build_node_path(node_id, tree, x$forest)

split_exprs <- list()
for (i in seq_along(path)[-length(path)]) {
parent_id <- path[i]
child_id <- path[i + 1]
split_info <- oblique_get_split_info(parent_id, child_id, tree, x)
expr <- obliq_split_to_expr(split_info)
# Replace indicator variables with factor comparisons for interpretability
split_exprs[[i]] <- oblique_replace_indicators(expr, x)
}

combine_rule_elements(split_exprs)
}

#' @export
extract_rules.ObliqueForest <- function(x, tree = 1L, ...) {
rlang::check_installed("aorsf")
Expand Down Expand Up @@ -263,10 +230,11 @@ oblique_replace_indicators <- function(expr, x) {
}

# Build mapping from indicator name to (factor, level)
# Use reference coding: skip first level (reference)
indicator_map <- list()
for (var_name in fctr_info$cols) {
indicators <- fctr_info$keys[[var_name]]
levels <- fctr_info$lvls[[var_name]]
indicators <- fctr_info$keys[[var_name]][-1] # Skip reference
levels <- fctr_info$lvls[[var_name]][-1] # Skip reference
for (i in seq_along(indicators)) {
indicator_map[[indicators[i]]] <- list(
factor = var_name,
Expand Down Expand Up @@ -297,6 +265,80 @@ oblique_replace_indicators <- function(expr, x) {
replace_symbols(expr)
}

# Internal helper: build oblique split expression with scaling
oblique_split_to_scaled_expr <- function(split_info, x) {
# Build linear combination with scaling: coef * ((var - mean) / sd)
means <- x$get_means()
stdevs <- x$get_stdev()

terms <- list()
for (i in seq_along(split_info$columns)) {
var_name <- split_info$columns[i]
coef <- split_info$values[i]

# Check if variable needs scaling (is numeric)
if (var_name %in% names(means)) {
# Numeric: coef * ((var - mean) / sd)
var_sym <- rlang::sym(var_name)
mean_val <- means[[var_name]]
sd_val <- stdevs[[var_name]]

scaled_var <- rlang::call2(
"/",
rlang::call2("-", var_sym, mean_val),
sd_val
)
term <- rlang::call2("*", coef, scaled_var)
} else {
# Factor indicator: just coef * var (no scaling)
term <- rlang::call2("*", coef, rlang::sym(var_name))
}

# Build cumulative sum
if (i == 1) {
terms[[i]] <- term
} else {
if (coef < 0 && var_name %in% names(means)) {
# For negative coefficients, use subtraction
abs_coef <- abs(coef)
var_sym <- rlang::sym(var_name)
mean_val <- means[[var_name]]
sd_val <- stdevs[[var_name]]
scaled_var <- rlang::call2(
"/",
rlang::call2("-", var_sym, mean_val),
sd_val
)
abs_term <- rlang::call2("*", abs_coef, scaled_var)
terms[[i]] <- rlang::call2("-", terms[[i - 1]], abs_term)
} else {
terms[[i]] <- rlang::call2("+", terms[[i - 1]], term)
}
}
}

lhs <- terms[[length(terms)]]
rlang::call2(split_info$operator, lhs, split_info$threshold)
}

# Internal helper: extract rule for a single terminal node
oblique_extract_node_rule <- function(node_id, tree, x) {
path <- oblique_build_node_path(node_id, tree, x$forest)

split_exprs <- list()
for (i in seq_along(path)[-length(path)]) {
parent_id <- path[i]
child_id <- path[i + 1]
split_info <- oblique_get_split_info(parent_id, child_id, tree, x)
# Build expression with scaling transformations included
expr <- oblique_split_to_scaled_expr(split_info, x)
# Replace indicator variables with factor comparisons for interpretability
split_exprs[[i]] <- oblique_replace_indicators(expr, x)
}

combine_rule_elements(split_exprs)
}

# Internal helper to get expanded variable names including one-hot encoded factors
oblique_get_var_names <- function(x) {
# Get base predictor names
Expand All @@ -315,8 +357,8 @@ oblique_get_var_names <- function(x) {

for (var_name in pred_names) {
if (var_name %in% fctr_info$cols) {
# This is a factor - add all one-hot encoded columns
expanded_names <- c(expanded_names, fctr_info$keys[[var_name]])
# This is a factor - add non-reference indicators (reference coding)
expanded_names <- c(expanded_names, fctr_info$keys[[var_name]][-1])
} else {
# Not a factor - add as-is
expanded_names <- c(expanded_names, var_name)
Expand Down Expand Up @@ -366,25 +408,16 @@ oblique_get_split_info <- function(parent_id, child_id, tree_num, x) {
)
}

# Unscale coefficients and adjust threshold to original units if scaling was used
# Only unscale if lincomb_scale = TRUE (controlled by scale_x parameter in orsf_control)
# When lincomb_scale = FALSE, coefficients are already in original units
if (isTRUE(x$control$lincomb_scale)) {
unscale_result <- oblique_unscale_split(columns, coef_vals, threshold, x)
} else {
unscale_result <- list(
columns = columns,
values = coef_vals,
threshold = threshold
)
}

# Return in format expected by obliq_split_to_expr()
# IMPORTANT: aorsf ALWAYS scales data during prediction (orsf_R6.R line 3168),
# regardless of the scale_x setting. The coefficients in the tree are for
# SCALED data. We DON'T unscale here - instead, we build expressions that
# include the scaling transformation directly (in oblique_split_to_scaled_expr).
# This avoids floating point precision errors from pre-computing unscaled values.
list(
columns = unscale_result$columns,
values = unscale_result$values,
columns = columns,
values = coef_vals,
operator = operator,
threshold = unscale_result$threshold
threshold = threshold
)
}

Expand Down Expand Up @@ -432,9 +465,10 @@ oblique_collapse_factor_names <- function(var_names, x) {

# Build mapping from indicator name to base factor name
# e.g., "county_adams" -> "county", "county_benton" -> "county"
# Use reference coding: only non-reference indicators exist
indicator_to_base <- list()
for (factor_name in fctr_info$cols) {
indicators <- fctr_info$keys[[factor_name]]
indicators <- fctr_info$keys[[factor_name]][-1] # Skip reference
for (ind in indicators) {
indicator_to_base[[ind]] <- factor_name
}
Expand Down
53 changes: 49 additions & 4 deletions inst/aorsf_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,17 @@ scaled_x = (x - mean) / sd

**Critical finding**: `get_means()` and `get_stdev()` are ALWAYS populated regardless of `scale_x`. Must check `x$control$lincomb_scale` to determine if scaling was actually used.

#### 2. Factor One-Hot Encoding
#### 2. Factor Reference Coding

aorsf creates k indicator variables for k-level factors (NOT k-1 reference coding):
aorsf uses **reference coding** (k-1 indicators for k-level factors):

```r
# Factor with levels ["red", "blue", "green"] becomes:
# color_red, color_blue, color_green (all 3 levels)
# color_blue, color_green (red is the reference)
# When both indicators are 0, it represents red
```

This prevents collinearity in internal regression computations.
Factor indicators are NOT scaled (remain 0/1).

**Enhancement**: We automatically convert indicators back to factor comparisons for interpretability:
Expand Down Expand Up @@ -366,7 +368,50 @@ print(paste("Our rules assign to node:", our_node))
- **2026-03-14**: Initial implementation completed
- **2026-03-14**: Node assignment mismatch discovered during validation test creation
- **2026-03-14**: Extensive debugging conducted, issue documented but unresolved
- **Status**: Issue remains open, awaiting deeper C++ investigation
- **2026-03-30**: Root causes identified and fixed

## Resolution

### Root Cause #1: Reference Coding

**Problem**: The implementation incorrectly assumed aorsf uses full one-hot encoding (k indicators for k levels), but aorsf actually uses reference coding (k-1 indicators, with the first level as reference).

**Evidence**: In `aorsf/R/ref_code.R` line 39: `seq(length(fi$keys[[i]]) - 1)` creates only k-1 indicators, and line 84: `fi$keys[[i]][-1]` excludes the first level.

**Impact**: Variable indices were off by one for each factor, causing coefficients to map to wrong variables.

**Fix**: Modified three functions in `R/ObliqueForest.R`:
- `oblique_get_var_names()`: Use `fctr_info$keys[[var_name]][-1]`
- `oblique_replace_indicators()`: Use `[-1]` for both keys and levels
- `oblique_collapse_factor_names()`: Use `fctr_info$keys[[factor_name]][-1]`

### Root Cause #2: Floating Point Precision Loss

**Problem**: The `oblique_unscale_split()` function pre-computed unscaled coefficients and thresholds. Multiple floating-point operations accumulated precision errors, causing comparisons like `5238.959... <= 5238.959...` to fail due to tiny differences (9e-13).

**Evidence**: Manual tracing with scaled data matched aorsf predictions, but rules with unscaled coefficients did not. Direct evaluation showed threshold values differing in the last decimal places.

**Key insight**: aorsf ALWAYS scales data during prediction (line 3168 in `orsf_R6.R`), regardless of `scale_x` setting. Coefficients in trees are for SCALED data.

**Fix**: Instead of pre-computing unscaled coefficients, include scaling transformations directly in rule expressions:
```r
# Before (loses precision):
51.98 * flipper_length_mm + 32.87 * bill_depth_mm > 11411.28

# After (full precision):
728.58 * ((flipper_length_mm - 200.97) / 14.02) +
64.73 * ((bill_depth_mm - 17.16) / 1.97) > 400.23
```

Implementation: New function `oblique_split_to_scaled_expr()` builds expressions with scaling included, avoiding pre-computation errors.

### Validation

All 17 tests pass, including the two that were previously skipped:
- `extract_rules.ObliqueForest() rules match aorsf node assignments`
- `extract_rules.ObliqueForest() node assignments are consistent`

Rules now exactly replicate aorsf's internal predictions.

## Contact

Expand Down
Loading
Loading