Optimization Path Example: Portfolio Selection via Variance Minimization

Overview

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.


Problem Setup

Why portfolio selection?

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.

A globally coupled objective

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.


Simulating the Data

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      3

The 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 function

The objective receives the working data frame D, whose column w holds the current inclusion decisions in the same row order as dat_portfolio.

portfolio_variance <- function(D) {
  w <- D$w
  if (sum(w) == 0) return(Inf)
  sum(Sigma_hat[w == 1, w == 1]) / sum(w)^2  # = w' Sigma w / (1'w)^2
}

# Benchmark: equally weighted portfolio of all 100 assets
portfolio_variance(data.frame(w = rep(1, n_assets)))
#> [1] 0.0001405926

Distribution of asset risk

plot(
  dat_portfolio$beta, dat_portfolio$vol,
  xlab = "Market beta", ylab = "Annualised volatility",
  pch  = 16, col = "#4E79A7AA",
  main = "Asset universe: volatility vs beta"
)

Scatter plot of asset beta vs volatility

Because all assets share a common market factor, we expect market beta to drive the inclusion rule, with volatility playing a secondary role.


Fitting ROOT

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.


Inspecting the Results

Detailed summary

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).


Visualizing the Characterized Tree

plot(portfolio_fit)

Characterized tree for portfolio selection

The tree reads as a plain-language portfolio rule:

  • Market beta drives the decision. Assets with beta of about 0.98 or higher are almost all excluded (\(w = 0\)); assets with lower beta are almost all included (\(w = 1\)).
  • Volatility refines the rule among low-beta assets. Low-beta assets with very high volatility (about 0.38 or higher) are excluded.
  • The remaining splits (a narrow band of beta between about 1.03 and 1.07, and very low volatility below about 0.10) each involve only two to seven assets, and some of those leaves are not pure. They reflect disagreement among the Rashomon-set trees over a handful of borderline assets rather than a substantive pattern; the characteristic tree is a summary of the vote, not a separately optimized 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.


Examining the Weights

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.

Visualizing the inclusion decision

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"
)

Asset universe with inclusion decisions highlighted


The Default Objective

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.

Using Other Custom Objectives

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.


Key Parameters

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

Reference

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