--- title: "Proximity matrices as statistical objects" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Proximity matrices as statistical objects} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") has_rf <- requireNamespace("randomForest", quietly = TRUE) has_seriation <- has_rf && requireNamespace("seriation", quietly = TRUE) has_igraph <- has_rf && requireNamespace("igraph", quietly = TRUE) ``` A random forest partitions the predictor space many times over. Two observations that keep landing in the same leaf are, as far as the forest is concerned, the same kind of observation. Collecting that co-occurrence over every tree gives the *proximity matrix* $$P_{ij} = \frac{1}{B} \sum_{b=1}^{B} \mathbb{I}\left[\ell_b(x_i) = \ell_b(x_j)\right],$$ where $\ell_b(x)$ is the leaf of tree $b$ reached by $x$. Most implementations treat $P$ as a by-product, handed back on request and otherwise ignored. `Proximum` treats it as the object of interest. ## Extracting a proximity matrix ```{r, eval = has_rf} library(Proximum) set.seed(1) rf <- randomForest::randomForest( Species ~ ., data = iris, ntree = 200, keep.inbag = TRUE ) px <- as_proximity(rf, newdata = iris) px ``` `randomForest` does not keep its training data, so `newdata` has to be supplied. A warning about the matrix `randomForest` hands back on its own. Its signature reads `oob.prox = proximity`, so a forest fitted with `proximity = TRUE` and nothing else stores the **out-of-bag** matrix, not the in-bag one, and the fit records nothing that says which. `Proximum` recovers the flag from the call and refuses to relabel the matrix behind your back: ```{r, eval = has_rf, error = TRUE} rf_stored <- randomForest::randomForest( Species ~ ., data = iris, ntree = 50, proximity = TRUE ) as_proximity(rf_stored) # asks for in-bag; the forest has out-of-bag ``` ## Diagnostics `summary()` reports what the matrix looks like and whether the dissimilarity it induces can be embedded in a Euclidean space, the condition under which classical multidimensional scaling of the proximity is exact rather than approximate. ```{r, eval = has_rf} summary(px) ``` The matrix is mostly zeros. That is not a defect: most pairs of irises never share a leaf, and the sparsity is what makes the block structure of the matrix informative. ## In-bag against out-of-bag The definition above averages over *all* trees, including the trees that were fitted on $i$ and $j$. Those trees have seen both observations and are inclined to separate them correctly, which inflates the proximity of same-class pairs. Restricting the average to the trees for which both observations are out-of-bag removes that bias. ```{r, eval = has_rf} px_oob <- as_proximity(rf, newdata = iris, type = "oob") px_oob ``` The two matrices agree closely here, because 200 trees leave every pair out-of-bag together in plenty of trees: ```{r, eval = has_rf} cor(px[upper.tri(px)], px_oob[upper.tri(px_oob)]) ``` With few trees the out-of-bag denominator can be empty for some pairs. Those entries come back as `NA`, not as `0`: the forest has no evidence about the pair, which is a different statement from "the pair is maximally dissimilar". ```{r, eval = has_rf} set.seed(1) small <- randomForest::randomForest( Species ~ ., data = iris, ntree = 3, keep.inbag = TRUE ) sum(is.na(as_proximity(small, newdata = iris, type = "oob"))) ``` ## The out-of-bag proximity is not a kernel Debiasing the proximity costs something, and the price is worth stating plainly. Stack the leaf indicators of all $B$ trees into one matrix $Z$, with a column per leaf. Two observations share a leaf exactly when they agree in that column, so the in-bag proximity is $$P = \frac{1}{B} Z Z^{\top},$$ a Gram matrix over $B$. It is therefore positive semi-definite, and $\sqrt{1 - P}$ is Euclidean. (This identity is also why `Proximum` computes the matrix with a sparse cross-product instead of a loop over trees: the same formula that settles the geometry is two orders of magnitude faster to evaluate.) The out-of-bag proximity masks $Z$ to the out-of-bag entries and divides by the number of trees in which each *pair* was jointly out-of-bag. With $M$ the out-of-bag mask, $$P^{\text{oob}} = \left( Z_{\text{oob}} Z_{\text{oob}}^{\top} \right) \oslash \left( M M^{\top} \right),$$ an elementwise quotient of two Gram matrices. The Hadamard quotient of two positive semi-definite matrices need not be positive semi-definite, and here it is not: ```{r, eval = has_rf} c( inbag = min(eigen(unclass(px), symmetric = TRUE, only.values = TRUE)$values), oob = min(eigen(unclass(px_oob), symmetric = TRUE, only.values = TRUE)$values) ) ``` The in-bag minimum is zero up to rounding; the out-of-bag one is not close to it. `summary()` reports this rather than letting it pass silently: ```{r, eval = has_rf} summary(px)$euclidean summary(px_oob)$euclidean ``` This matters downstream. Classical multidimensional scaling of the out-of-bag dissimilarity has negative eigenvalues, so its low-dimensional configuration is an approximation of something that has no exact Euclidean representation, and any method that assumes a kernel, centered kernel alignment included, needs an explicit correction first. Choose `type = "oob"` for an unbiased estimate of the proximities themselves, and `type = "inbag"` when you need the geometry. When you need both, `make_psd()` projects the out-of-bag matrix onto the cone of positive semi-definite matrices and records what it did: ```{r, eval = has_rf} repaired <- make_psd(px_oob, method = "clip") repaired summary(repaired)$euclidean ``` Three corrections are offered. `"clip"` discards the negative directions and is the nearest positive semi-definite matrix in Frobenius norm; `"flip"` keeps them with their sign reversed; `"shift"` adds a constant to the diagonal, which is the only one of the three that leaves every off-diagonal proximity exactly where it was. None is right in general, which is why the choice is yours and is recorded on the object. ## Other engines The proximity is a property of the ensemble, not of the package that fitted it. `Proximum` extracts it from `ranger` with the same definitions and the same out-of-bag handling: ```{r, eval = requireNamespace("ranger", quietly = TRUE)} set.seed(1) rg <- ranger::ranger(Species ~ ., data = iris, num.trees = 200, keep.inbag = TRUE) as_proximity(rg, newdata = iris) ``` Because both engines produce the same object, "do two implementations of the same forest represent the data the same way?" becomes a question the inference layer can answer, rather than a question nobody can ask. ## The views `autoplot()` draws the object three ways, and which of them answers a question depends on the question. The heatmap orders the rows and columns by a seriation of the induced dissimilarity, so that the groups the forest learned line up along the diagonal rather than being scattered by the order the rows happened to arrive in. The axis labels are dropped with the original ordering: after the seriation an index is a position, not an observation. ```{r, eval = has_seriation, fig.width = 5, fig.height = 4.2} autoplot(px, type = "heatmap") ``` The `"mds"` view is the configuration `embedding()` returns, which is the classical scaling of $\sqrt{1 - P}$: the same coordinates as `cmdscale(as.dist(px), k = 2)`, agreeing here to 9.3e-16 up to the sign of each axis. The colouring is yours to pass. A `proximity` object carries the engine, the number of trees and the definition used, and nothing about the response, so a plot that coloured by class on its own would be inventing the class. ```{r, eval = has_rf, fig.width = 6, fig.height = 4.2} autoplot(px, type = "mds", colour = iris$Species) ``` The `"network"` view keeps the pairs above a threshold and reads the communities off the graph they form. That clustering is the forest's own, recovered from the proximity, rather than one imposed on the observations from outside: ```{r, eval = has_igraph, fig.width = 6, fig.height = 4.6} autoplot(px, type = "network", threshold = 0.3) ``` The first and the last of these need `seriation` and `igraph`, which are suggested rather than required and are refused by name when absent. The `"mds"` view needs neither. ## What comes next The inference layer, which compares two proximity matrices with a Mantel test, partitions their variation with PERMANOVA and aligns them with CKA, is the subject of `vignette("comparing-forests")`. Scaling past a few thousand observations is the subject of `vignette("large-n")`.