30  Variable priority with varPro

30.1 When to use it

Permutation importance (the VIMP chapter) answers “how much does the forest lean on this variable?” by breaking the variable and watching error rise. varPro (Lu et al. 2026) asks a different question: “how much do the forest’s own splitting rules depend on this variable?” Rather than permuting predictors, it scores each one by how much a tree’s release rules rely on it, and calls the result variable priority. Reach for it when you want a selection that comes with a built-in cutoff (so the method tells you which variables to keep, not just how to rank them), when you want the direction and magnitude of a local effect, or when you want to flag unusual observations the same forest can isolate.

ggRandomForests (Ehrlinger 2026) provides a family of gg_* extractors that pull these scores out of a fitted varpro object and render them as tidy ggplot2 graphics. This chapter works through the main extractors on a regression fit and a classification fit. As elsewhere, each plot() hands back a bare ggplot you finish with a house theme.

30.2 The data it needs

The extractors read from a fitted varpro object, so the input is whatever varpro() itself needs: a formula and a data frame. The outcome type decides the family, and that matters here because several of the extractors are family-specific. We start with a regression fit on the housing data shipped with randomForestSRC (Ishwaran and Kogalur 2026) — 2,930 homes and 80 predictors, modelling sale price (SalePrice). This is the same kind of dataset the random-forest chapters use, and the extra width is the point: variable priority earns its keep when there are far more candidates than you can eyeball.

set.seed(42)
data(housing, package = "randomForestSRC")
o <- varpro(SalePrice ~ ., data = housing, ntree = 100)
plot(gg_varpro(o, nvar = 20)) + theme_hv_manuscript()
Figure 30.1: Variable priority for the top 20 predictors of the housing regression fit, with the dashed cutoff line separating selected from unselected variables

gg_varpro() returns an honest 15/85 boxplot for each variable. With 80 predictors we pass nvar = 20 to show the top-ranked; the box spans the importance values obtained when the variable is released across the rule ensemble, and the dashed reference line at the cutoff (default 0.79 of the top score on the local-standardized scale) separates selected from unselected variables. Variables whose boxes sit to the right of the cutoff are retained.

30.3 Build it

The plot in Figure 30.1 is the core figure. Two variants make it more informative. Setting faithful = TRUE shows the per-tree importance draws as jittered points behind the boxplot, exposing the spread that the summary hides:

plot(gg_varpro(o, faithful = TRUE, nvar = 20)) + theme_hv_manuscript()
Figure 30.2: The same priority boxplots with per-tree importance draws shown as jittered points behind each box, exposing the spread the summary hides

Setting nvar lower restricts the display further, handy when a wide problem like this one would otherwise crowd the panel and you only want the leaders:

plot(gg_varpro(o, nvar = 8)) + theme_hv_manuscript()
Figure 30.3: Variable priority restricted to the top eight predictors, the leaders-only view for a crowded wide problem

30.4 Read it

The 15/85 boxplot is the figure to learn to read, and the cutoff line is what makes it a selection rather than just a ranking.

  • The cutoff line decides membership. Boxes to the right of the dashed line are the variables varPro keeps; boxes to the left are set aside. Unlike a VIMP bar chart, where you eyeball the break yourself, the method draws the line for you. You can move it with the cutoff argument when you want to be stricter or looser.
  • Box width is stability. A tight box means the variable scored consistently across the rule ensemble; a wide box straddling the cutoff is a variable whose selection you should not bet the paper on. Turn on faithful = TRUE to see the raw draws behind the summary.
  • Priority is not the same as importance. A variable can carry high priority (the rules depend on it) without the large error jump that permutation VIMP rewards, and vice versa. Read the two methods as complementary views, not as two attempts at the same number.

30.5 Variations

30.5.1 Beta-refined importance

gg_beta_varpro() is regression-only. Within each release region it fits a lasso and reports the refined slope (β) coefficients instead of a VIMP-style importance, so you get not just whether a variable matters but the direction and magnitude of its local linear effect.

plot(gg_beta_varpro(o)) + theme_hv_manuscript()
Figure 30.4: Beta-refined importance for the housing regression fit, reporting lasso slope coefficients that carry direction and magnitude rather than VIMP-style scores

The per-rule lasso refinement (beta.varpro) is the expensive step; under Quarto’s freeze the result is cached after the first render. Because these are lasso-refined β coefficients rather than importance scores, they can be negative.

gg_beta_uvarpro() is the same idea for a fit with no outcome at all. varPro::uvarpro() grows the forest without a response (the unsupervised section later in this chapter has the full treatment), and the lasso inside each region asks how well the other variables explain the released one. Average those absolute coefficients and you get one importance per variable, on the same lasso scale but answering a question about the design rather than about SalePrice. We fit it on the numeric housing predictors and reuse that fit for the dependency graph in Figure 30.10.

set.seed(42)
num_cols <- names(housing)[vapply(housing, is.numeric, logical(1))]
uv <- uvarpro(housing[, setdiff(num_cols, "SalePrice")], ntree = 100)
plot(gg_beta_uvarpro(uv)) + theme_hv_manuscript()
Figure 30.5: Beta-refined importance for the unsupervised housing fit, the outcome-free counterpart to the regression beta plot above

The two are told apart by the object you hand them. gg_beta_varpro() needs a regression varpro fit and reports a slope on the outcome; gg_beta_uvarpro() needs a uvarpro fit and reports how much the rest of the design explains each variable. Bars at or above the dashed cutoff are flagged as selected, and that cutoff sits at mean(beta_mean) unless you set cutoff yourself.

30.5.2 Individual variable priority

gg_ivarpro() computes individual (observation-level) variable priority, decomposing the forest importance down to each case. On a regression fit with weak per-observation signal the decomposition can collapse to nothing (it returns zero rows), so we demonstrate it on a classification forest, which also lets us show the which_class argument. We fit status ~ . on the breast data (also from randomForestSRC): 198 patients, 32 predictors, and a two-level recurrence outcome (N = no recurrence, R = recurrence).

set.seed(42)
data(breast, package = "randomForestSRC")
oc <- varpro(status ~ ., data = breast, ntree = 100)
plot(gg_ivarpro(oc)) + theme_hv_manuscript()
Figure 30.6: Individual variable priority for the breast classification fit, decomposing forest importance down to each observation

Each panel summarises how strongly individual observations rely on a given variable. You can focus on a single outcome level with which_class — here the recurrence class:

plot(gg_ivarpro(oc, which_class = "R")) + theme_hv_manuscript()
Figure 30.7: Individual variable priority focused on the recurrence class of the breast fit via the which_class argument

30.5.3 Conditional priority by class

The classification fit also supports conditional = TRUE in gg_varpro(), which facets the priority bars by outcome class so you can read which predictors separate which classes. This option needs a classification forest; it errors on the regression fit.

plot(gg_varpro(oc, conditional = TRUE, nvar = 15)) + theme_hv_manuscript()
Figure 30.8: Conditional variable priority faceted by outcome class, showing which predictors separate which classes in the breast fit

30.5.4 Anomaly / isolation scores

gg_isopro() visualises in-sample anomaly (isolation) scores. These come from an isopro object, and there is no .varpro method that builds one for you: you construct it first with varPro::isopro() on the fitted forest, then pass that to gg_isopro().

iso <- isopro(object = o, ntree = 100)
plot(gg_isopro(iso)) + theme_hv_manuscript()
Figure 30.9: In-sample anomaly scores from an isolation forest, where higher scores flag observations the forest separates with few splits

Higher isolation scores flag observations the forest separates with few splits, candidate outliers. Pass newdata to gg_isopro() to score held-out observations against the fitted isolation forest.

30.5.5 Partial dependence

The extractors so far rank and refine variables; partial dependence asks how a selected variable moves the prediction. gg_partial_varpro() runs varPro::partialpro() on the fitted forest and draws a marginal-effect curve per variable, with a scale argument that sets the y-axis to suit the outcome family. Because that treatment spans regression, classification, and survival, it gets its own chapter — see varPro partial dependence. (gg_partialpro() is the superseded name for this extractor; it is now a thin alias for gg_partial_varpro(), so reach for the new name.)

30.6 Unsupervised variable dependence

The beta plot in Figure 30.5 was a first taste of an unsupervised fit. varPro::uvarpro() grows a forest with no response to predict, then scores how strongly each variable depends on the others. gg_udependent() reads those cross-variable scores and draws them as a dependency graph — nodes are variables, edges the dependencies between them — so you can see which predictors travel together before any modelling. Reach for it when you want to understand the structure among the predictors themselves, for example to spot a cluster of collinear measurements.

plot(gg_udependent(uv)) + theme_hv_manuscript()
Figure 30.10: Unsupervised dependency graph among the numeric housing predictors, where thick edges mark strongly interdependent variables

This is the same uv fit, built on the numeric housing predictors with the SalePrice outcome and the factor columns dropped, because the dependency graph comes from a numeric distance and cannot square a mixed-type design. A thick edge between two variables means the forest finds them strongly interdependent; an isolated node is a variable that carries information the others do not.

30.6.1 Signal-variable detection

The graph shows you the structure; gg_sdependent() gives you the ranked table behind it. Both read the same lasso-coefficient matrix, but where gg_udependent() draws nodes and edges, gg_sdependent() returns one row per candidate variable carrying a signal score, that variable’s degree in the graph, and a flag for whether it landed in sdependent()’s detected signal set. Reach for it when you need to name the variables rather than point at a picture.

plot(gg_sdependent(uv)) + theme_hv_manuscript()
Figure 30.11: Signal-variable detection for the same unsupervised housing fit, ranking each variable by its dependency signal score with the detected signal set highlighted

Highlighted variables are the detected signal set, and the caption reports how many of them cleared the threshold. Four arguments carry over from gg_udependent(): threshold, q.signal, directed, and min.degree. threshold (default 0.25) only builds the dependency graph — it decides which entries of the lasso-coefficient matrix count as an edge — so raising it shrinks the signal set solely by isolating nodes, which then get dropped before scoring ever runs. The argument that actually sizes the signal set is q.signal (default 0.75): a variable is flagged as signal when its degree in that graph clears min.degree (NULL by default, which falls back to 1 for a directed graph or 2 for an undirected one) and its score clears the q.signal quantile of every score in the fit. Raise q.signal for a shorter, more conservative list; lower it for a longer one. directed (default TRUE) keeps the two directions of a pair apart, because “j explains i” and “i explains j” are separate lasso coefficients and need not agree; set it FALSE when you only want to know that two variables are dependent, not which way the dependency reads. The degree column rides along in the returned data frame even though the plot does not draw it, so a variable with a middling score but a high degree is worth a second look.

30.7 Pitfalls

  • gg_isopro() needs an isopro object first. There is no .varpro method, so calling gg_isopro() on the varpro object directly fails. Build iso <- isopro(object = o, ...) and then plot iso.
  • gg_beta_varpro() is regression-only. The lasso β refinement is defined for a regression fit; do not expect it off a classification varpro object.
  • conditional = TRUE needs a classification forest. gg_varpro(conditional = TRUE) facets priority by class, so it errors on a regression fit like the housing model. Use it on a classification fit such as the breast one.
  • gg_ivarpro() needs real signal. Individual priority decomposes importance to each observation, and on a weak problem that decomposition can return zero rows and an empty plot. It works on a strong-signal fit like the breast classification forest, which is why the individual-priority examples use it.
  • gg_udependent() needs all-numeric data. The unsupervised dependency graph is built from a numeric distance, so a mixed-type design (factors plus numerics, like the full housing frame) fails with “Adjacency matrices must be square.” Pass only the numeric columns, as the example does.
  • Wide data needs nvar/nvars. With 80 predictors the bare gg_varpro() and gg_partial_varpro() displays are unreadable walls; cap them with nvar (the boxplot) and nvars (which variables partialpro even computes, the expensive step).
  • o$xvar.names is not a list of columns you can subset with. A varpro fit reports the features its rules actually use, and it one-hot encodes every factor it keeps, so a Neighborhood column comes back as NeighborhoodNoRidge, NeighborhoodNridgHt, and NeighborhoodStoneBr. Hand those to housing[, ...] and you get an undefined-columns error, which is a confusing way to find out that the name you asked for was never a column. varpro_feature_names() maps them back.

For the housing fit above, o$xvar.names runs 30 feature names against the 81-column frame we handed varpro(), 14 of them encoded level names, and varpro_feature_names() recovers the 26 original columns:

ncol(housing)                                # what you handed it
[1] 81
length(o$xvar.names)                         # what the fit reports
[1] 30
setdiff(o$xvar.names, names(housing))        # encoded level names
 [1] "MS.ZoningRM"         "NeighborhoodNoRidge" "NeighborhoodNridgHt"
 [4] "NeighborhoodStoneBr" "Bldg.Type1Fam"       "Roof.MatlClyTile"   
 [7] "Exter.QualEx"        "Exter.QualTA"        "Bsmt.QualEx"        
[10] "Bsmt.ExposureGd"     "BsmtFin.Type.1GLQ"   "Kitchen.QualEx"     
[13] "Kitchen.QualTA"      "Misc.FeatureElev"   
varpro_feature_names(o$xvar.names, housing)  # mapped back to real columns
 [1] "PID"            "Lot.Area"       "Overall.Qual"   "Year.Built"    
 [5] "Year.Remod.Add" "Mas.Vnr.Area"   "BsmtFin.SF.1"   "Total.Bsmt.SF" 
 [9] "X1st.Flr.SF"    "X2nd.Flr.SF"    "Gr.Liv.Area"    "Full.Bath"     
[13] "Fireplaces"     "Garage.Yr.Blt"  "Garage.Cars"    "Garage.Area"   
[17] "MS.Zoning"      "Exter.Qual"     "Bsmt.Qual"      "Bsmt.Exposure" 
[21] "Kitchen.Qual"   "BsmtFin.Type.1" "Bldg.Type"      "Misc.Feature"  
[25] "Neighborhood"   "Roof.Matl"     

That gap between what a fit reports and what your data frame actually holds is the same seam that runs through every extractor in this chapter: the score, the graph, and the ranked table all read straight from the fitted object, and varpro_feature_names() is the one step that translates its column names back into ones you can hand to housing[, ...].