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
17 changes: 17 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ S3method(tidypredict_class_exprs,rpart)
S3method(tidypredict_class_trees,default)
S3method(tidypredict_class_trees,randomForest)
S3method(tidypredict_class_trees,ranger)
S3method(tidypredict_combine_trees,C5.0)
S3method(tidypredict_combine_trees,ObliqueForest)
S3method(tidypredict_combine_trees,blackboost)
S3method(tidypredict_combine_trees,catboost.Model)
S3method(tidypredict_combine_trees,cforest)
S3method(tidypredict_combine_trees,default)
S3method(tidypredict_combine_trees,lgb.Booster)
S3method(tidypredict_combine_trees,randomForest)
S3method(tidypredict_combine_trees,ranger)
S3method(tidypredict_combine_trees,xgb.Booster)
S3method(tidypredict_fit,"_xgb.Booster")
S3method(tidypredict_fit,C5.0)
S3method(tidypredict_fit,H2OBinomialModel)
Expand Down Expand Up @@ -119,7 +129,10 @@ S3method(tidypredict_interval,default)
S3method(tidypredict_interval,glm)
S3method(tidypredict_interval,list)
S3method(tidypredict_interval,lm)
S3method(tidypredict_n_trees,ObliqueForest)
S3method(tidypredict_n_trees,blackboost)
S3method(tidypredict_n_trees,catboost.Model)
S3method(tidypredict_n_trees,cforest)
S3method(tidypredict_n_trees,default)
S3method(tidypredict_n_trees,lgb.Booster)
S3method(tidypredict_n_trees,randomForest)
Expand Down Expand Up @@ -226,7 +239,10 @@ S3method(tidypredict_test,rpart)
S3method(tidypredict_test,sda)
S3method(tidypredict_test,xgb.Booster)
S3method(tidypredict_test,xrf)
S3method(tidypredict_trees,ObliqueForest)
S3method(tidypredict_trees,blackboost)
S3method(tidypredict_trees,catboost.Model)
S3method(tidypredict_trees,cforest)
S3method(tidypredict_trees,default)
S3method(tidypredict_trees,lgb.Booster)
S3method(tidypredict_trees,randomForest)
Expand Down Expand Up @@ -260,6 +276,7 @@ export(set_catboost_categories)
export(tidy)
export(tidypredict_class_exprs)
export(tidypredict_class_trees)
export(tidypredict_combine_trees)
export(tidypredict_fit)
export(tidypredict_interval)
export(tidypredict_load)
Expand Down
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# tidypredict (development version)

- `tidypredict_combine_trees()` is a new generic that turns per-tree expressions back into a model's prediction. `tidypredict_trees()` alone is not enough to do this: `mboost::blackboost()` combines as `offset + nu * sum(trees)`, `aorsf` averages inside a guard that returns `NA` for an incomplete row, CatBoost applies a scale and a bias, and boosters then apply their objective's inverse link. Summing or averaging the trees, as the shape of the list invites, is wrong for all of those. (#436)

- `tidypredict_combine_trees()` has methods for `randomForest`, `ranger`, xgboost, LightGBM, CatBoost, `cforest`, `blackboost` and `aorsf`, so a caller that computes each tree into its own column can combine references to those columns without knowing which backend it is holding. Every one satisfies `tidypredict_combine_trees(x, tidypredict_trees(x))` computing the same values as `tidypredict_fit(x)`. (#436)

- Boosted `C50::C5.0()` models deliberately have no `tidypredict_trees()` method, and `tidypredict_combine_trees()` refuses them with an explanation. Their trials vote with a class label and a confidence rather than contributing numbers, so there is nothing to sum or average and splitting the trees apart would only enable a wrong answer. (#436)

- `tidypredict_trees()` and `tidypredict_n_trees()` gain methods for `partykit::cforest()`, `mboost::blackboost()` and `aorsf::orsf()`. (#436)

- New generics describe what a model's fitted expressions compute, which the expressions themselves do not say: `tidypredict_output_type()` returns one of `"numeric"`, `"prob"`, `"decision"` or `"class"`, `tidypredict_outcome_levels()` returns the outcome levels in model order, and `tidypredict_normalized()` reports whether per-level probabilities already sum to one. See `?tidypredict_metadata`. (#435)

- The distinctions these generics record cannot be recovered from the shape of a `tidypredict_fit()` result. A `LiblineaR` SVM classifier and a `LiblineaR` logistic regression both return a single expression, but the first is an uncalibrated decision value whose sign picks the class, so thresholding it at 0.5 as though it were a probability is wrong. A multiclass probability list and a `quantreg::rq()` fit with several `tau` are both named lists of expressions of the same length. (#435)
Expand Down
71 changes: 71 additions & 0 deletions R/combine-trees.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#' Combine per-tree expressions into a model's prediction
#'
#' @description
#' [tidypredict_trees()] returns one expression per tree. Turning those back
#' into the model's prediction is not simply summing or averaging them, and the
#' rule differs by backend: `mboost::blackboost()` needs an offset and a
#' shrinkage factor, CatBoost needs a scale and a bias, `aorsf` needs a guard
#' that returns `NA` for an incomplete row, and boosters then apply their
#' objective's inverse link on top.
#'
#' This generic holds that rule, so a caller that has split the trees apart
#' can put them back together without knowing which backend it is holding.
#'
#' @param x A fitted model object.
#' @param trees A list of expressions, one per tree, in the order
#' [tidypredict_trees()] returns them. Typically either that return value
#' itself, or symbols naming the columns the individual trees were written
#' to.
#' @param ... Additional arguments passed to methods.
#'
#' @returns A single language object.
#'
#' @details
#' The point of separating `trees` from this function is that a caller can
#' compute each tree into its own column, for a database to evaluate in
#' parallel, and then pass symbols referring to those columns rather than the
#' expressions themselves. The combination is the same either way.
#'
#' Every ensemble satisfies
#' `tidypredict_combine_trees(x, tidypredict_trees(x))` computing the same
#' values as `tidypredict_fit(x)`, and that identity is what the tests for
#' these methods assert.
#'
#' Not every ensemble has a method. `C50::C5.0()` boosting combines its trees
#' by a confidence-weighted vote that yields a class label, so there is no
#' arithmetic to apply to per-tree numbers and no method is provided.
#'
#' @examplesIf rlang::is_installed("randomForest")
#' model <- randomForest::randomForest(mpg ~ ., data = mtcars, ntree = 3)
#'
#' trees <- tidypredict_trees(model)
#' tidypredict_combine_trees(model, trees)
#'
#' # Or referring to columns the trees were written to first.
#' tidypredict_combine_trees(model, rlang::syms(c("t1", "t2", "t3")))
#'
#' @export
tidypredict_combine_trees <- function(x, trees, ...) {
UseMethod("tidypredict_combine_trees")
}

#' @export
tidypredict_combine_trees.default <- function(x, trees, ...) {
cli::cli_abort(
"{.fn tidypredict_combine_trees} is not available for models of class
{.cls {class(x)[[1]]}}.",
class = "tidypredict_no_combiner"
)
}

check_trees_arg <- function(trees, call = rlang::caller_env()) {
if (!is.list(trees) || length(trees) == 0) {
cli::cli_abort(
"{.arg trees} must be a non-empty list of expressions, not
{.obj_type_friendly {trees}}.",
call = call
)
}

invisible(trees)
}
28 changes: 28 additions & 0 deletions R/model-C5.0.R
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,34 @@ c50_boosted_tree_info <- function(trees, classes) {
lapply(trees, function(tree) c50_tree_info(tree, classes, tree$root_class))
}

# Extractors --------------------------------------

# The per-trial trees are available, but they cannot be recombined by any
# arithmetic on their values. Boosted C5.0 predicts by a confidence-weighted
# vote across trials, where each tree contributes a *class label* plus a
# confidence, and `SelectClassGen()` breaks ties using that trial's own root
# class. Splitting the trees apart and adding or averaging them is not an
# approximation of that, it is a different model.
#
# So `tidypredict_trees()` is deliberately not implemented here: exposing the
# trees without a way to put them back together would only enable a wrong
# answer. `tidypredict_combine_trees()` refuses for the same reason, with a
# message saying why rather than the generic "no method" one.
#' @export
tidypredict_combine_trees.C5.0 <- function(x, trees, ...) {
rlang::check_dots_empty()

cli::cli_abort(
c(
"Boosted {.pkg C5.0} trees cannot be recombined arithmetically.",
i = "Each trial votes with a class label and a confidence, so there are
no per-tree numbers to sum or average.",
i = "Use {.fn tidypredict_fit} for the whole model instead."
),
class = "tidypredict_no_combiner"
)
}

# Test ---------------------------------------------

# `C5.0()` keeps no copy of the training data, so `df` has no useful default.
Expand Down
42 changes: 35 additions & 7 deletions R/model-aorsf.R
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,45 @@ parse_model.ObliqueForest <- function(model) {
tidypredict_fit.ObliqueForest <- function(model, ...) {
aorsf_check_supported(model)

n_trees <- length(model$forest$child_left)
tree_exprs <- map(
seq_len(n_trees),
tidypredict_combine_trees(model, tidypredict_trees(model))
}

# Extractors --------------------------------------

#' @export
tidypredict_trees.ObliqueForest <- function(x, ...) {
rlang::check_dots_empty()
aorsf_check_supported(x)

map(
seq_len(length(x$forest$child_left)),
function(tree_no) {
generate_nested_case_when_tree(aorsf_tree_info_full(model, tree_no))
generate_nested_case_when_tree(aorsf_tree_info_full(x, tree_no))
}
)
}

# `aorsf` refuses to predict from an incomplete row ("Please remove missing
# values from new data, or impute them."), so there is no value to match.
expr_na_if_incomplete(expr_mean(tree_exprs, n_trees), model$get_names_x())
#' @export
tidypredict_n_trees.ObliqueForest <- function(x, ...) {
rlang::check_dots_empty()

length(x$forest$child_left)
}

# The mean is only half of it. `aorsf` refuses to predict from an incomplete
# row ("Please remove missing values from new data, or impute them."), so the
# result is wrapped to return `NA` for such a row. A caller that averaged the
# trees itself would silently produce a number where the model produces
# nothing.
#' @export
tidypredict_combine_trees.ObliqueForest <- function(x, trees, ...) {
rlang::check_dots_empty()
check_trees_arg(trees)

expr_na_if_incomplete(
expr_mean(trees, length(trees)),
x$get_names_x()
)
}

build_tree_formula.pm_tree_aorsf <- function(model) {
Expand Down
57 changes: 49 additions & 8 deletions R/model-catboost.R
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,18 @@ build_fit_formula_catboost_nested <- function(parsedmodel) {
cli::cli_abort("Model has no trees.")
}

objective <- parsedmodel$general$params$objective %||% "RMSE"
objective <- catboost_parsed_objective(parsedmodel)
catboost_check_objective(parsedmodel)

if (objective %in% catboost_multiclass_objectives) {
return(build_fit_formula_catboost_multiclass_nested(parsedmodel, objective))
}

catboost_combine(extract_catboost_trees_nested(parsedmodel), parsedmodel)
}

catboost_check_objective <- function(parsedmodel) {
objective <- catboost_parsed_objective(parsedmodel)

if (!objective %in% catboost_supported_objectives) {
cli::cli_abort(
Expand All @@ -728,14 +739,15 @@ build_fit_formula_catboost_nested <- function(parsedmodel) {
)
}

if (objective %in% catboost_multiclass_objectives) {
return(build_fit_formula_catboost_multiclass_nested(parsedmodel, objective))
}
invisible(parsedmodel)
}

# Extract nested trees
trees <- extract_catboost_trees_nested(parsedmodel)
f <- reduce_addition(trees)
f <- apply_catboost_scale_bias(f, parsedmodel)
# Combine per-tree expressions into the model's prediction: an additive sum
# rescaled by the model's `scale` and `bias`, then the objective's inverse link.
catboost_combine <- function(trees, parsedmodel) {
objective <- catboost_parsed_objective(parsedmodel)

f <- apply_catboost_scale_bias(reduce_addition(trees), parsedmodel)

if (objective %in% catboost_sigmoid_objectives) {
f <- expr_logistic(f)
Expand Down Expand Up @@ -911,6 +923,35 @@ tidypredict_trees.catboost.Model <- function(x, ...) {
extract_catboost_trees_nested(parse_model(x))
}

#' @export
tidypredict_combine_trees.catboost.Model <- function(x, trees, ...) {
rlang::check_dots_empty()
check_trees_arg(trees)

parsedmodel <- parse_model(x)
catboost_check_objective(parsedmodel)

# A multiclass fit is one expression per class rather than one for the model.
# `tidypredict_trees()` returns the trees of such a model as a flat list in
# which they belong to different classes round-robin, so summing them is not
# an approximation of the fit.
if (
catboost_parsed_objective(parsedmodel) %in% catboost_multiclass_objectives
) {
cli::cli_abort(
c(
"Multiclass {.pkg catboost} trees cannot be combined into one
expression.",
i = "The fit is one expression per class.",
i = "Use {.fn tidypredict_fit} for the whole model instead."
),
class = "tidypredict_no_combiner"
)
}

catboost_combine(trees, parsedmodel)
}

#' @export
tidypredict_n_trees.catboost.Model <- function(x, ...) {
rlang::check_dots_empty()
Expand Down
32 changes: 27 additions & 5 deletions R/model-cforest.R
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,38 @@ parse_model.cforest <- function(model) {
tidypredict_fit.cforest <- function(model, ...) {
cforest_check_regression(model)

n_trees <- length(model$nodes)
tree_exprs <- map(
seq_len(n_trees),
tidypredict_combine_trees(model, tidypredict_trees(model))
}

# Extractors --------------------------------------

#' @export
tidypredict_trees.cforest <- function(x, ...) {
rlang::check_dots_empty()
cforest_check_regression(x)

map(
seq_len(length(x$nodes)),
function(tree_no) {
tree_info <- partykit_tree_info_full(cforest_gettree(model, tree_no))
tree_info <- partykit_tree_info_full(cforest_gettree(x, tree_no))
generate_nested_case_when_tree(tree_info, missing = "na")
}
)
}

#' @export
tidypredict_n_trees.cforest <- function(x, ...) {
rlang::check_dots_empty()

length(x$nodes)
}

#' @export
tidypredict_combine_trees.cforest <- function(x, trees, ...) {
rlang::check_dots_empty()
check_trees_arg(trees)

expr_mean(tree_exprs, n_trees)
expr_mean(trees, length(trees))
}

build_tree_formula.pm_tree_cforest <- function(model) {
Expand Down
Loading
Loading