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 with an optional column vsq (a
per-unit variance proxy, or the outcome to minimize) and any covariates
to split on. ROOT searches over tree-structured weight functions to
minimize the supplied objective, then returns a Rashomon set of
near-optimal trees and a single summary tree characterizing the final
weight assignments. The decision to vote on a single summary tree can be
by default majority vote, or the user can specify their own voting
functions.
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 order to minimize portfolio return variance.
Constructing a minimum-variance portfolio could be framed as an optimization problem. The standard approach could produce weights that are continuous and 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 the low-variance portfolio.
The default global objective function ROOT minimizes is as follows: \(L(w, D) = \sqrt{\frac{\sum_i w_i \cdot v_i^2}{\left(\sum_i w_i\right)^2}}\)
where \(v_i^2\) (vsq)
is a pseudo outcome value, known as the per-unit variance proxy in the
optimization example. In the portfolio context, we set vsq
to the historical return variance of each asset. ROOT then finds the
binary weight \(w(X) \in \{0, 1\}\),
described as a decision tree over asset features such as beta and
volatility that minimizes this quantity. Users can also supply the
global objective function they wish to minimize as well.
We simulate 100 assets with two features: annualized volatility and market beta. Returns are generated as a market factor model, and per-asset return variance is computed 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))
)
# Per-asset return variance (the objective proxy ROOT will minimize)
vsq <- apply(returns_mat, 2, var)
dat_portfolio <- data.frame(
vsq = vsq,
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 vsq column is recognized by ROOT’s default objective
function as the per-unit variance proxy. The columns vol,
beta, and sector are the splitting features
available to the tree.
plot(
dat_portfolio$beta, dat_portfolio$vol,
xlab = "Market beta", ylab = "Annualised volatility",
pch = 16, col = "#4E79A7AA",
main = "Asset universe: volatility vs beta"
)We expect ROOT to identify the high-beta, high-volatility corner of this space as the region to exclude.
portfolio_fit <- ROOT(
data = dat_portfolio,
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 majority 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 4 1 (0.0400000 0.9600000)
#> 2) beta>=1.658134 12 4 1 (0.3333333 0.6666667)
#> 4) vol>=0.3285209 4 0 0 (1.0000000 0.0000000) *
#> 5) vol< 0.3285209 8 0 1 (0.0000000 1.0000000) *
#> 3) beta< 1.658134 88 0 1 (0.0000000 1.0000000) *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 4 1 (0.0400000 0.9600000)
#> 2) beta>=1.658134 12 4 1 (0.3333333 0.6666667)
#> 4) vol>=0.3285209 4 0 0 (1.0000000 0.0000000) *
#> 5) vol< 0.3285209 8 0 1 (0.0000000 1.0000000) *
#> 3) beta< 1.658134 88 0 1 (0.0000000 1.0000000) *
#>
#> Global objective function:
#> User-supplied: No (default objective used)
#>
#> Diagnostics:
#> Number of trees grown: 20
#> Rashomon set size: 10
#> % observations with w_opt == 1: 96.0%The Diagnostics section confirms that 20 trees were grown, 10 were selected into the Rashomon set, and 96% of assets received \(w_{\text{opt}} = 1\) (included in the portfolio). Only the 4 most risk-concentrated assets are screened out.
The tree encodes a simple, actionable rule:
beta < 1.7: include the asset
unconditionally (88 assets).beta >= 1.7 and
vol < 0.33: include the asset despite high
market sensitivity, because some risk may be controlled (8 assets).beta >= 1.7 and
vol >= 0.33: exclude the asset — high market
sensitivity combined with high volatility drives up portfolio variance
(4 assets, \(w = 0\)).The final weight assignments are stored in
portfolio_fit$D_rash$w_opt. We can compare the included and
excluded assets directly.
dat_portfolio$w_opt <- portfolio_fit$D_rash$w_opt
# Summary statistics by inclusion decision
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): 96
cat(" Mean beta: ", round(mean(included$beta), 3), "\n")
#> Mean beta: 1.146
cat(" Mean volatility: ", round(mean(included$vol), 3), "\n\n")
#> Mean volatility: 0.219
cat("Excluded assets (w = 0):", nrow(excluded), "\n")
#> Excluded assets (w = 0): 4
cat(" Mean beta: ", round(mean(excluded$beta), 3), "\n")
#> Mean beta: 1.701
cat(" Mean volatility: ", round(mean(excluded$vol), 3), "\n")
#> Mean volatility: 0.368The excluded assets have substantially higher beta and volatility than the included ones, confirming that ROOT correctly targets the risk-concentrated corner of the asset universe.
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"
)ROOT’s general optimization mode is not limited to the default
variance objective. You can supply any function of the form
function(D) -> numeric where D is the
working data frame with a column w containing the current
weight assignments.
For example, suppose we want to minimize the interquartile range of portfolio returns rather than variance. We can define a custom objective:
iqr_objective <- function(D) {
w <- D$w
if (sum(w) == 0) return(Inf)
# Weighted IQR: compute quantiles using the included assets only
included_vsq <- D$vsq[w == 1]
diff(quantile(included_vsq, probs = c(0.25, 0.75)))
}
portfolio_fit_iqr <- ROOT(
data = dat_portfolio,
global_objective_fn = iqr_objective,
num_trees = 20,
top_k_trees = TRUE,
k = 10,
seed = 112
)The custom objective illustrates ROOT’s flexibility: any scalar-valued function of the weighted dataset can be used as the optimization target, and the resulting characterized tree would still be 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
default variance objective |
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