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
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

* `extract_rules()` for lightgbm models now assigns left and right child nodes correctly and compares numeric thresholds as numbers rather than strings.

* `extract_rules()` methods now error when given an argument they don't use, such as a misspelled `trees = 2`, instead of silently ignoring it. This includes `data` for C5.0 models, which was accepted but never used.

* `extract_rules()` for aorsf (`ObliqueForest`), BART (`bart`), catboost, lightgbm, and xgboost models now accepts a vector of tree numbers, as the other ensemble methods do, and reads the model once instead of once per tree. Rules for each tree are also built in a single pass, which makes these methods and `extract_rules()` for rpart and grf models much faster.

* `extract_rules()` is much faster for ranger, randomForest, C5.0, grf, cforest, and partykit models. Rules are now collected in a single pass over the tree instead of searching for each node's parent, and the ranger and randomForest methods no longer compute fitted node ids for the training data.

* `extract_rules()` methods gain an `add_statistics` argument that appends the columns `num_rule_terms` and `num_rule_predictors`.

* `rect_split_to_expr()` now accepts numeric vectors for the `%in%` operator.
Expand Down
26 changes: 6 additions & 20 deletions R/C5.0.R
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ as.party.C5.0 <- function(obj, tree = 1L, data = NULL, ...) {
}

# Validate tree against trials
num_trials <- obj$trials["Actual"]
num_trials <- num_ensemble_members(obj)
if (tree > num_trials) {
cli::cli_warn(
"{.arg tree} = {tree} exceeds number of trials ({num_trials}). Using {.arg tree} = {num_trials} instead."
Expand Down Expand Up @@ -826,7 +826,7 @@ c5_extract_rules_from_rules_text <- function(x, tree) {
tree,
c5_extract_rules_one_trial,
rules_lines = rules_lines,
num_trials = x$trials["Actual"]
num_trials = num_ensemble_members(x)
)

# Combine and format
Expand Down Expand Up @@ -1029,32 +1029,18 @@ c5_extract_rules_one <- function(tree_num, x, data) {
#' For rule-based models, this parameter refers to the trial number.
#' @export
extract_rules.C5.0 <- function(x, tree = 1L, add_statistics = FALSE, ...) {
rlang::check_dots_empty()
check_bool(add_statistics)
rlang::check_installed("C50")

# Detect model type
is_rule_model <- !is.null(x$rules) && nchar(x$rules) > 0

# Validate tree argument
if (!is.numeric(tree) || !all(tree == as.integer(tree))) {
cli::cli_abort(
"{.arg tree} must be an integer vector, not {.obj_type_friendly {tree}}.",
call = rlang::caller_env()
)
}

tree <- as.integer(tree)

# Get number of trials
num_trials <- x$trials["Actual"]
tree <- check_tree_type(tree)

# Validate tree range
if (any(tree < 1L) || any(tree > num_trials)) {
cli::cli_abort(
"{.arg tree} values must be between 1 and {num_trials}.",
call = rlang::caller_env()
)
}
check_tree_range(tree, num_ensemble_members(x), noun = "trials")

if (is_rule_model) {
# Parse rule-based models directly
Expand Down Expand Up @@ -1122,7 +1108,7 @@ active_predictors.C5.0 <- function(x, tree = 1L, ...) {
tree <- as.integer(tree)
tree <- unique(tree)

num_trials <- as.integer(x$trials["Actual"])
num_trials <- num_ensemble_members(x)
if (any(tree < 1L) || any(tree > num_trials)) {
cli::cli_abort(
"{.arg tree} values must be between 1 and {num_trials}, not {tree}.",
Expand Down
156 changes: 60 additions & 96 deletions R/ObliqueForest.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
#'
#' @inheritParams extract_rules
#' @param x An `ObliqueForest` object from the \pkg{aorsf} package
#' @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 tree Integer vector specifying which trees to extract rules from
#' (1-based). Default is `1L` for the first tree. Values must be between 1
#' and the number of trees in the forest (`x$n_tree`).
#' @param ... Other arguments passed to methods
#' @return A tibble with columns:
#' * `tree`: integer, the tree number (1-based).
Expand Down Expand Up @@ -103,94 +103,75 @@ extract_rules.ObliqueForest <- function(
add_statistics = FALSE,
...
) {
rlang::check_dots_empty()
check_bool(add_statistics)
rlang::check_installed("aorsf")
# Validate tree argument (1-based for user convenience)
if (!is.numeric(tree) || length(tree) != 1 || tree != as.integer(tree)) {
cli::cli_abort("{.arg tree} must be a single integer.")
}
if (tree < 1 || tree > x$n_tree) {
cli::cli_abort(
"{.arg tree} must be between 1 and {x$n_tree}, not {tree}."
)
}
tree <- check_tree_type(tree)
check_tree_range(tree, num_ensemble_members(x))

# Get terminal node IDs for this tree
# Note: tree parameter is 1-based and maps directly to R's 1-based list indexing
# terminal_ids are 0-indexed (aorsf internal convention), converted to 1-based below
terminal_ids <- oblique_get_terminal_nodes(tree, x$forest)

# Extract rules for each terminal node (using 0-indexed IDs internally)
rules_list <- lapply(
terminal_ids,
oblique_extract_node_rule,
tree = tree,
x = x
)
# The R6 accessors are slow, so read them once for all trees and splits
x_cached <- oblique_cache_model(x)

results <- lapply(tree, function(tree_num) {
tree_rules <- oblique_tree_rules(tree_num, x_cached)
tree_rules$tree <- tree_num
tree_rules
})

tibble::tibble(
tree = as.integer(tree),
rules = rules_list,
id = as.integer(terminal_ids) + 1L # Convert from 0-indexed to 1-indexed
) |>
dplyr::arrange(id) |>
dplyr::bind_rows(results) |>
dplyr::relocate(tree, rules, id) |>
dplyr::arrange(tree, id) |>
tibble::new_tibble(class = c("rule_set_ObliqueForest", "rule_set")) |>
maybe_add_rule_statistics(add_statistics)
}

# Internal helper to get terminal nodes for a tree
oblique_get_terminal_nodes <- function(tree_num, forest) {
# tree_num is 1-based (for R list indexing)
# Returns 0-indexed node IDs (aorsf convention)
child_left <- forest$child_left[[tree_num]]

# Terminal nodes have child_left == 0
# which() returns 1-indexed positions, subtract 1 to get 0-indexed node IDs
which(child_left == 0) - 1
# Internal helper: snapshot of the pieces of an ObliqueForest that the rule
# helpers read through slow R6 accessors
oblique_cache_model <- function(x) {
means <- x$get_means()
stdevs <- x$get_stdev()
fctr_info <- x$get_fctr_info()
names_x <- x$get_names_x()
list(
forest = x$forest,
get_means = function() means,
get_stdev = function() stdevs,
get_fctr_info = function() fctr_info,
get_names_x = function() names_x
)
}

# Internal helper to find parent node
oblique_find_parent <- function(node_id, tree_num, forest) {
# node_id is 0-indexed (aorsf convention)
# tree_num is 1-based (for R list indexing)
# Returns 0-indexed parent node ID
if (node_id == 0) {
return(NA_integer_)
}

child_left <- forest$child_left[[tree_num]]

# Search for parent node
# i is 1-indexed from seq_along, so we subtract 1 to get 0-indexed node ID
for (i in seq_along(child_left)) {
# Check if this node is the parent of node_id
if (child_left[i] == node_id || child_left[i] + 1 == node_id) {
return(i - 1)
# Internal helper: rules for every terminal node of one tree from a single
# top-down walk. Node ids are 0-indexed (aorsf convention), `tree_num` is
# 1-based, a terminal node has `child_left == 0`, and the right child of a node
# is `child_left + 1`.
oblique_tree_rules <- function(tree_num, x) {
child_left <- x$forest$child_left[[tree_num]]
ids <- integer()
rules <- list()

walk <- function(node_id, exprs) {
left_id <- child_left[node_id + 1]
if (left_id == 0) {
ids[[length(ids) + 1L]] <<- as.integer(node_id) + 1L
rules[[length(rules) + 1L]] <<- combine_rule_elements(exprs)
return(invisible(NULL))
}
}

NA_integer_
}

# Internal helper to build path from root to target node
oblique_build_node_path <- function(node_id, tree_num, forest) {
# node_id is 0-indexed (aorsf convention)
# tree_num is 1-based (for R list indexing)
# Returns vector of 0-indexed node IDs from root to target
path <- integer()
current <- node_id

while (current >= 0) {
path <- c(current, path)
if (current == 0) {
break
for (child_id in c(left_id, left_id + 1)) {
split_info <- oblique_get_split_info(node_id, child_id, tree_num, x)
# Build expression with scaling transformations included
expr <- oblique_split_to_scaled_expr(split_info, x)
# Replace indicator variables with factor comparisons for interpretability
expr <- oblique_replace_indicators(expr, x)
walk(child_id, c(exprs, list(expr)))
}

current <- oblique_find_parent(current, tree_num, forest)
if (is.na(current)) break
invisible(NULL)
}

path
walk(0, list())

tibble::tibble(rules = rules, id = ids)
}

# Internal helper to unscale coefficients and threshold
Expand Down Expand Up @@ -333,24 +314,6 @@ oblique_split_to_scaled_expr <- function(split_info, x) {
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 Down Expand Up @@ -526,9 +489,10 @@ active_predictors.ObliqueForest <- function(x, tree = 1L, ...) {

tree <- as.integer(tree)

if (any(tree < 1L) || any(tree > x$n_tree)) {
num_trees <- num_ensemble_members(x)
if (any(tree < 1L) || any(tree > num_trees)) {
cli::cli_abort(
"{.arg tree} values must be between 1 and {x$n_tree}, not {tree}.",
"{.arg tree} values must be between 1 and {num_trees}, not {tree}.",
call = rlang::caller_env()
)
}
Expand Down
Loading
Loading