manifoldsculpting manifoldsculpting website

manifoldsculpting implements Manifold Sculpting (Gashler, Ventura and Martinez, 2008), an iterative nonlinear dimensionality reduction method, with a fast C++ engine and a recipes step for the tidymodels ecosystem.

Manifold Sculpting records the distances and angles between each point and its nearest neighbors, then gradually scales the data out of the dimensions to be removed, while moving the points in the dimensions that remain to restore these local relationships, as if surface tension were acting on the manifold. Because it preserves distances, the embedding keeps the scale of the data, and the points stay spread evenly across the manifold.

Features

Installation

Install the development version as a prebuilt binary from r-universe, which needs no compiler:

install.packages(
  "manifoldsculpting",
  repos = c("https://christiangoueguel.r-universe.dev", "https://cloud.r-project.org")
)

Or build it from GitHub with:

# install.packages("pak")
pak::pak("ChristianGoueguel/manifold-sculpting")

Example

The Swiss roll is a two-dimensional sheet rolled up in three dimensions. Its intrinsic coordinates, u (along the roll) and v (across it), are known.

library(manifoldsculpting)

set.seed(42)
roll <- sim_swiss_roll(2000)
roll
#> # A tibble: 2,000 × 5
#>        x      y      z     u      v
#>    <dbl>  <dbl>  <dbl> <dbl>  <dbl>
#>  1  1.82  4.98  -0.832  2.96  4.98 
#>  2  1.82  5.24  -0.841  2.97  5.24 
#>  3  1.82 -2.57  -0.850  2.98 -2.57 
#>  4  1.82  3.97  -0.859  2.98  3.97 
#>  5  1.82  1.70  -0.868  2.99  1.70 
#>  6  1.82  0.229 -0.877  3.00  0.229
#>  7  1.82  2.84  -0.886  3.01  2.84 
#>  8  1.82 -4.38  -0.895  3.02 -4.38 
#>  9  1.82  1.88  -0.904  3.03  1.88 
#> 10  1.82  2.46  -0.913  3.04  2.46 
#> # ℹ 1,990 more rows
set.seed(1)
fit <- manifold_sculpting(roll[c("x", "y", "z")], ndim = 2, neighbors = 20, nstart = 3)
fit
#> Manifold Sculpting embedding
#> * 2000 samples, 3 variables to 2 dimensions
#> * 20 neighbors, squishing rate 0.99
#> * 746 iterations (converged), final error 0.01363
#> * Best of 3 runs, with final errors 0.01363, 0.0142, 192.8

One of the three runs ended with a much larger error: it left part of the roll folded, and was discarded. PCA flattens the roll onto itself, while Manifold Sculpting unrolls it (the points are colored by their position along the roll, and the embedding is rotated onto its principal axes for display):

The embedding matches the intrinsic coordinates up to a rotation, with no rescaling needed:

# Best rotation of the embedding onto (u, v)
truth <- scale(as.matrix(roll[c("u", "v")]), scale = FALSE)
s <- svd(crossprod(fit$embedding, truth))
aligned <- fit$embedding %*% s$u %*% t(s$v)
sqrt(mean(rowSums((aligned - truth)^2))) # RMSE, the roll is 49 x 12
#> [1] 0.01832235

Embedding new samples

Manifold Sculpting has no explicit mapping from the data to the embedding. predict() places each new sample at the combination of the embeddings of its nearest training samples given by its locally linear reconstruction weights, then moves it to minimize the Manifold Sculpting error against these neighbors. Training samples get their embedding back.

set.seed(2)
idx <- sample(nrow(roll), 1500)
train <- roll[idx, ]
test <- roll[-idx, ]

set.seed(3)
fit_train <- manifold_sculpting(train[c("x", "y", "z")], nstart = 3)
head(predict(fit_train, test))
#>            MS1       MS2
#> [1,] -4.546955 -19.37233
#> [2,] -5.663599 -18.74245
#> [3,] -7.651568 -17.63838
#> [4,] -6.643554 -18.17561
#> [5,] -6.805621 -18.03481
#> [6,] -4.456182 -19.27834

With recipes

step_manifold_sculpting() adds the embedding to a recipe. The training set gets the embedding found by the algorithm, and new data are embedded with predict(). Since the method works on Euclidean distances, the predictors should usually be normalized first.

library(recipes)

rec <- recipe(~ x + y + z, data = train) |>
  step_manifold_sculpting(
    all_numeric_predictors(),
    num_comp = 2,
    neighbors = 20,
    options = list(nstart = 3),
    seed = 1
  )

rec_trained <- prep(rec)
rec_trained

bake(rec_trained, new_data = test)
#> # A tibble: 500 × 2
#>      MS1    MS2
#>    <dbl>  <dbl>
#>  1 -16.8 -10.6 
#>  2 -17.2  -9.37
#>  3 -17.8  -7.19
#>  4 -17.5  -8.28
#>  5 -17.5  -8.07
#>  6 -16.7 -10.6 
#>  7 -16.9  -9.37
#>  8 -17.3  -8.23
#>  9 -16.6 -10.2 
#> 10 -17.6  -6.76
#> # ℹ 490 more rows

num_comp, neighbors and squishing_rate can be tuned with the tune package.

Choosing the parameters

Performance

The iterations work on the ndim preserved dimensions only, whatever the number of variables. Unfolding the 2000-point Swiss roll takes about 2 seconds per run on a laptop (Apple M-series), similar to the C++ implementation of Waffles. The nearest neighbor search uses a k-d tree for data with up to 10 variables, and blocked BLAS cross-products otherwise, so it benefits from an optimized BLAS.

References

Gashler, M., Ventura, D. and Martinez, T. (2008). Iterative non-linear dimensionality reduction with manifold sculpting. In Advances in Neural Information Processing Systems 20, 513–520. https://proceedings.neurips.cc/paper/2007/hash/c06d06da9666a219db15cf575aff2824-Abstract.html

Gashler, M. S. (2011). Waffles: A machine learning toolkit. Journal of Machine Learning Research, 12, 2383–2387.

Roweis, S. T. and Saul, L. K. (2000). Nonlinear dimensionality reduction by locally linear embedding. Science, 290, 2323–2326.

Acknowledgments

The C++ engine follows the reference implementation of Manifold Sculpting in Waffles by Mike Gashler and contributors, which is dedicated to the public domain.