ROOT is a general functional optimization framework: by supplying a custom objective function, it can be applied to any problem where the goal is to learn an interpretable binary weight function \(w(X) \in \{0, 1\}\) over a set of groups by covariates of interest.
In general optimization mode
(generalizability_path = FALSE), the user provides a
data.frame of covariates to split on and either a custom
global_objective_fn or an auxiliary column read by the
default objective (vsq, a per-unit variance contribution,
or v, a per-unit score). ROOT searches over tree-structured
weight functions to minimize the objective, then returns a Rashomon set
of near-optimal trees and a single summary tree characterizing the final
weight assignments. The Rashomon-set trees are combined by a vote (a 2/3
threshold by default), or by a user-specified voting function.
This vignette demonstrates ROOT in general optimization mode using a portfolio selection problem: given a universe of 100 simulated assets characterized by their market beta and annualized volatility, ROOT learns an interpretable rule for which assets to include (\(w = 1\)) in an equally weighted portfolio so as to minimize the portfolio’s return variance.
Constructing a minimum-variance portfolio is a classic optimization problem. The standard solution produces continuous weights that can be hard to interpret or communicate. ROOT offers a complementary perspective: it returns a binary inclusion rule, a sparse decision tree that describes in plain language which types of assets belong in a low-variance, equally weighted portfolio.
Let \(\Sigma\) be the covariance matrix of asset returns. The return variance of the equally weighted portfolio of the included assets is \[ L(w) = \frac{w^\top \Sigma\, w}{(\mathbf{1}^\top w)^2} = \frac{1}{(\sum_i w_i)^2} \sum_{i}\sum_{j} w_i w_j \Sigma_{ij}. \] Because every pair of included assets contributes a covariance term \(\Sigma_{ij}\), adding or removing one asset changes the contribution of every other included asset. The objective therefore cannot be written as a sum of independent per-asset (or per-leaf) contributions. This is exactly the kind of non-decomposable objective ROOT is designed for. The objective also encodes a genuine trade-off: including more assets diversifies away idiosyncratic risk, while including high-beta assets increases exposure to the common market factor.
We simulate 100 assets with two features: annualized volatility and market beta. Returns follow a single-factor market model, and the return covariance matrix is estimated from 1,000 simulated return observations.
library(ROOT)
set.seed(123)
n_assets <- 100
# Asset features
volatility <- runif(n_assets, 0.05, 0.40) # annualised volatility
beta <- runif(n_assets, 0.5, 1.8) # market beta
sector <- sample(c("Tech", "Finance", "Energy", "Health"),
n_assets, replace = TRUE)
# Simulate returns: r_i = beta_i * r_market + epsilon_i
market <- rnorm(1000, 0.0005, 0.01)
returns_mat <- sapply(seq_len(n_assets), function(i)
beta[i] * market + rnorm(1000, 0, volatility[i] / sqrt(252))
)
# Estimated return covariance matrix (100 x 100)
Sigma_hat <- cov(returns_mat)
dat_portfolio <- data.frame(
vsq = diag(Sigma_hat), # marginal variances (guide split sampling)
vol = volatility,
beta = beta,
sector = as.integer(factor(sector))
)
head(dat_portfolio)
#> vsq vol beta sector
#> 1 0.0002750578 0.15065213 1.2799856 2
#> 2 0.0004951413 0.32590680 0.9326706 2
#> 3 0.0002835768 0.19314192 1.1351969 3
#> 4 0.0008172707 0.35905609 1.7408160 4
#> 5 0.0007130039 0.37916355 1.1277731 3
#> 6 0.0002929372 0.06594477 1.6574553 3The columns vol, beta, and
sector are the splitting features available to the tree.
The vsq column (each asset’s marginal return variance) is
auxiliary: it is never used for splitting, and here it only guides
ROOT’s feature-importance step, which biases which features are proposed
for splits.
The objective receives the working data frame D, whose
column w holds the current inclusion decisions in the same
row order as dat_portfolio.
plot(
dat_portfolio$beta, dat_portfolio$vol,
xlab = "Market beta", ylab = "Annualised volatility",
pch = 16, col = "#4E79A7AA",
main = "Asset universe: volatility vs beta"
)Because all assets share a common market factor, we expect market beta to drive the inclusion rule, with volatility playing a secondary role.
portfolio_fit <- ROOT(
data = dat_portfolio,
global_objective_fn = portfolio_variance,
num_trees = 20,
top_k_trees = TRUE,
k = 10,
seed = 42
)ROOT grows 20 trees and selects the 10 with the lowest objective value as the Rashomon set. Final asset weights \(w_{\text{opt}}\) are determined by the vote across those 10 trees.
print(portfolio_fit)
#> ROOT object
#> Generalizability mode: FALSE
#>
#> Summary classifier (f):
#> n= 100
#>
#> node), split, n, loss, yval, (yprob)
#> * denotes terminal node
#>
#> 1) root 100 33 0 (0.67000000 0.33000000)
#> 2) beta>=0.9821225 66 4 0 (0.93939394 0.06060606)
#> 4) beta>=1.07473 56 0 0 (1.00000000 0.00000000) *
#> 5) beta< 1.07473 10 4 0 (0.60000000 0.40000000)
#> 10) beta< 1.033107 7 1 0 (0.85714286 0.14285714) *
#> 11) beta>=1.033107 3 0 1 (0.00000000 1.00000000) *
#> 3) beta< 0.9821225 34 5 1 (0.14705882 0.85294118)
#> 6) vol>=0.3798368 2 0 0 (1.00000000 0.00000000) *
#> 7) vol< 0.3798368 32 3 1 (0.09375000 0.90625000)
#> 14) vol< 0.09716281 3 1 0 (0.66666667 0.33333333) *
#> 15) vol>=0.09716281 29 1 1 (0.03448276 0.96551724) *summary(portfolio_fit)
#> ROOT object
#> Generalizability mode: FALSE
#>
#> Summary classifier (f):
#> n= 100
#>
#> node), split, n, loss, yval, (yprob)
#> * denotes terminal node
#>
#> 1) root 100 33 0 (0.67000000 0.33000000)
#> 2) beta>=0.9821225 66 4 0 (0.93939394 0.06060606)
#> 4) beta>=1.07473 56 0 0 (1.00000000 0.00000000) *
#> 5) beta< 1.07473 10 4 0 (0.60000000 0.40000000)
#> 10) beta< 1.033107 7 1 0 (0.85714286 0.14285714) *
#> 11) beta>=1.033107 3 0 1 (0.00000000 1.00000000) *
#> 3) beta< 0.9821225 34 5 1 (0.14705882 0.85294118)
#> 6) vol>=0.3798368 2 0 0 (1.00000000 0.00000000) *
#> 7) vol< 0.3798368 32 3 1 (0.09375000 0.90625000)
#> 14) vol< 0.09716281 3 1 0 (0.66666667 0.33333333) *
#> 15) vol>=0.09716281 29 1 1 (0.03448276 0.96551724) *
#>
#> Global objective function:
#> User-supplied: Yes
#>
#> Diagnostics:
#> Number of trees grown: 20
#> Rashomon set size: 10
#> Split strategy: midpoint
#> % observations with w_opt == 1: 33.0%The Diagnostics section reports the number of trees grown, the Rashomon set size, and the share of assets with \(w_{\text{opt}} = 1\) (included in the portfolio).
The tree reads as a plain-language portfolio rule:
sector does not appear in the tree.This matches the structure of the problem: every asset loads on a common market factor, so the market component of portfolio variance is driven by the average beta of the included assets, while including more assets diversifies away idiosyncratic (volatility-driven) risk.
The final weight assignments are stored in
portfolio_fit$D_rash$w_opt. We can compare the included and
excluded assets and evaluate the objective for the selected
portfolio.
dat_portfolio$w_opt <- portfolio_fit$D_rash$w_opt
included <- dat_portfolio[dat_portfolio$w_opt == 1, ]
excluded <- dat_portfolio[dat_portfolio$w_opt == 0, ]
cat("Included assets (w = 1):", nrow(included), "\n")
#> Included assets (w = 1): 33
cat(" Mean beta: ", round(mean(included$beta), 3), "\n")
#> Mean beta: 0.812
cat(" Mean volatility: ", round(mean(included$vol), 3), "\n\n")
#> Mean volatility: 0.238
cat("Excluded assets (w = 0):", nrow(excluded), "\n")
#> Excluded assets (w = 0): 67
cat(" Mean beta: ", round(mean(excluded$beta), 3), "\n")
#> Mean beta: 1.344
cat(" Mean volatility: ", round(mean(excluded$vol), 3), "\n\n")
#> Mean volatility: 0.218
var_all <- portfolio_variance(data.frame(w = rep(1, n_assets)))
var_opt <- portfolio_variance(data.frame(w = dat_portfolio$w_opt))
cat("Portfolio variance, all assets: ", signif(var_all, 4), "\n")
#> Portfolio variance, all assets: 0.0001406
cat("Portfolio variance, ROOT selection: ", signif(var_opt, 4), "\n")
#> Portfolio variance, ROOT selection: 7.488e-05
cat("Relative reduction: ",
round(100 * (1 - var_opt / var_all), 1), "%\n")
#> Relative reduction: 46.7 %ROOT keeps 33 of the 100 assets. The included assets have a much lower mean beta than the excluded ones (0.81 versus 1.34) but similar mean volatility (0.24 versus 0.22), confirming that market exposure, not idiosyncratic volatility, drives the rule. The equally weighted portfolio of the selected assets has about 47% lower return variance than the equally weighted portfolio of all 100 assets.
plot(
dat_portfolio$beta, dat_portfolio$vol,
xlab = "Market beta", ylab = "Annualised volatility",
pch = ifelse(dat_portfolio$w_opt == 1, 16, 4),
col = ifelse(dat_portfolio$w_opt == 1, "#4E79A7", "#E15759"),
main = "Portfolio inclusion decisions"
)
legend(
"topleft",
legend = c("w = 1 (included)", "w = 0 (excluded)"),
pch = c(16, 4),
col = c("#4E79A7", "#E15759"),
bty = "n"
)If no global_objective_fn is supplied, ROOT uses its
default objective, \(L(w) = \sqrt{\sum_i w_i\,
vsq_i / (\sum_i w_i)^2}\), with vsq read from the
data. With vsq set to each asset’s marginal variance, this
would treat the assets as uncorrelated and ignore the covariance terms
above, which dominate portfolio risk under a common market factor. That
is why this example supplies the full portfolio variance as a custom
objective.
Any function of the form function(D) -> numeric can
be used, where D is the working data frame with a column
w containing the current weight assignments. For example,
to make the included assets as homogeneous as possible in their marginal
risk, we can minimize the interquartile range of their marginal
variances:
iqr_objective <- function(D) {
w <- D$w
if (sum(w) == 0) return(Inf)
included_vsq <- D$vsq[w == 1]
diff(quantile(included_vsq, probs = c(0.25, 0.75)))
}
portfolio_fit_iqr <- ROOT(
data = dat_portfolio[, c("vsq", "vol", "beta", "sector")],
global_objective_fn = iqr_objective,
num_trees = 20,
top_k_trees = TRUE,
k = 10,
seed = 112
)Any scalar-valued function of the weighted dataset can be used as the optimization target, and the resulting characterized tree remains interpretable.
| Parameter | Role | Default |
|---|---|---|
num_trees |
Number of trees to grow in the forest | 10 |
top_k_trees |
If TRUE, select the top k
trees by objective value |
FALSE |
k |
Rashomon set size when
top_k_trees = TRUE |
10 |
cutoff |
Rashomon threshold when
top_k_trees = FALSE; "baseline" uses objective
at \(w \equiv 1\) |
"baseline" |
vote_threshold |
Fraction of Rashomon-set trees that must vote \(w = 1\) for inclusion | 2/3 |
global_objective_fn |
Custom objective
function(D) -> numeric; if NULL, uses the
default objective on vsq or v |
NULL |
seed |
Random seed for reproducibility | NULL |
feature_est |
Feature importance method for split selection
("Ridge", "GBM", or custom) |
"Ridge" |
leaf_proba |
Controls tree depth by increasing the probability of stopping at a leaf | 0.25 |
Parikh, H., Ross, R. K., Stuart, E., & Rudolph, K. E. (2025). Who Are We Missing?: A Principled Approach to Characterizing the Underrepresented Population. Journal of the American Statistical Association, 120(551), 1414–1423. https://doi.org/10.1080/01621459.2025.2495319