Animating metric MDS: paraboloid and saddle
Source:vignettes/metric-mds-animation.Rmd
metric-mds-animation.RmdHow does a random cloud become a curved surface when only its
pairwise distances are known? The SGD backend of
metric.mds() adjusts a pair of points at a time. Its
optional update trace lets us inspect those actual
states and animate the reconstruction.
This example reconstructs a paraboloid and a saddle from the ambient distances between 250 points. Both have two intrinsic dimensions, but preserving their straight-line distances generally requires three embedding dimensions. Distances along the surface would define a different reconstruction problem.
The vignette uses the tracing interface in the development version of grip (after 0.2.0). It rebuilds the fits from public R calls, without a separate instrumented solver or saved fitting results. The optional ivue and rgl packages provide the interactive view; numerical examples still run without them. See Metric MDS for other weighting choices.
Sample the same points on two surfaces
On the unit disk, take for the paraboloid and for the saddle. Both have area element . To sample uniformly over surface area, draw uniformly on , an independent uniform angle, and use
Thus the same sampled positions give a surface-area sample on either surface. Only heights and target distances change.
set.seed(20261020)
n <- 250L
u <- runif(n)
theta <- runif(n, 0, 2*pi)
r <- sqrt(((1 + u*(5^(3/2) - 1))^(2/3) - 1)/4)
paraboloid <- cbind(x = r*cos(theta), y = r*sin(theta), z = r^2)
saddle <- paraboloid
saddle[, "z"] <- saddle[, "x"]^2 - saddle[, "y"]^2
stopifnot(identical(paraboloid[, 1:2], saddle[, 1:2]))We fit all unordered pairs. The view draws only 1,000 sampled chords to keep the surface visible; this display subset does not change the fit.
Choose the target error and the initial cloud
Let be a target distance and the distance in the current embedding. We display the mean squared relative error
pair.weights = "inverse_squared" weights each squared
distance residual by
,
giving this relative-error objective up to a constant factor. The chart
shows
;
the supplementary readout shows
,
the RMS relative error in percent. Neither is the aggregate relative RMS
error
.
We recompute
from each saved state rather than substituting the solver’s diagnostic
stress.
truth <- paraboloid
target <- as.numeric(dist(truth))
relative.stress <- function(X) mean((as.numeric(dist(X))/target - 1)^2)
set.seed(20261021)
initial <- matrix(rnorm(n*3L), n, 3L)
initial <- sweep(initial, 2, colMeans(initial))
ratio <- as.numeric(dist(initial))/target
initial <- initial * sum(ratio)/sum(ratio^2)The final line chooses the positive scale minimizing the initial cloud’s relative error. This initialization uses target distances, not the true coordinates. Display alignment below is a separate operation.
Record genuine SGD states
Enable recording through sgd.control$trace. The call
below records every 32nd examined pair, plus the initial state and the
epoch endpoint. One start is required. keep.state = TRUE
allows the next call to continue the same trajectory, including the
random-generator state and learning-rate schedule.
control <- list(
scheduler = "hybrid", learning.rate = 0.5, final.rate = 0.01,
switch.ratio = 0.4, schedule.epochs = 500L,
checkpoint.every = 1L, keep.state = TRUE,
trace = list(every = 32L, max.bytes = 64*1024^2)
)
fit <- surface.epoch(
distance.matrix = as.matrix(dist(truth)), init = initial, dim = 3L,
backend = "sgd", n.init = 1L, max.iter = 1L, seed = 20261022L,
pair.weights = "inverse_squared", diagnostics = FALSE,
sgd.control = control
)
head(fit$update_trace$steps)
#> epoch update_in_epoch pair_updates
#> 1 0 0 0
#> 2 1 32 32
#> 3 1 64 64
#> 4 1 96 96
#> 5 1 128 128
#> 6 1 160 160surface.epoch() is a small wrapper around
grip::metric.mds() supplied by the helper script loaded
above. It suppresses only the expected iteration_limit
warning for these deliberately short calls; other warnings remain
visible. Calling metric.mds() directly gives the same
result.
fit$update_trace$coordinates contains raw matrices in
input-distance units, with no alignment. Its
steps table records the epoch, the position within that
epoch, and the absolute number of examined pairs
(pair_updates). An examined pair can produce no movement;
recording it does not change the algorithm or consume extra random
numbers. The existing fit$trace is the separate epoch-level
diagnostic history.
recorded <- fit$update_trace
first <- recorded$coordinates[[1L]]
last <- tail(recorded$coordinates, 1L)[[1L]]
c(initial = relative.stress(first), after_one_epoch = relative.stress(last))
#> initial after_one_epoch
#> 0.738738329 0.001816587The last raw state need not equal fit$coords: the
returned fit can select and scale-profile a best checkpoint. For an
honest animation of the solver, use
update_trace$coordinates consistently.
Continue, stop, and keep a compact playback sequence
The next call adds one epoch without restarting:
next.fit <- surface.epoch(resume = fit, max.iter = 1L)
stopifnot(identical(next.fit$update_trace$coordinates[[1L]], last))
tail(next.fit$update_trace$steps, 1L)
#> epoch update_in_epoch pair_updates
#> 974 2 31125 62250Each update trace covers only that call. Save wanted
frames before replacing the fit, and omit the duplicated initial frame
on continuation. The helper surface.trace() repeats this
process for at most ten epochs, stopping earlier when
,
evaluated at epoch endpoints. It preserves the demonstration’s 500-epoch
hybrid rate schedule; stopping early does not compress that schedule
into ten epochs.
For playback, it retains every 32nd update through update 1,024 of each epoch, then every 256th update, and every endpoint. All retained coordinates and errors are actual evaluated states, not interpolations. Individual updates can increase all-pair error even while an epoch improves the fit.
cases <- list(
paraboloid = surface.trace(paraboloid),
saddle = surface.trace(saddle)
)Completing an epoch budget is not a convergence certificate. Here the external stopping rule establishes only that the specified error threshold was reached on these synthetic targets.
Align for display, without changing the fit
Each frame, including the initial cloud, is translated and orthogonally rotated or reflected to align corresponding points with the truth:
align <- function(X, truth) {
center <- colMeans(truth)
centered <- sweep(X, 2, colMeans(X))
decomposition <- svd(crossprod(centered, sweep(truth, 2, center)))
sweep(centered %*% decomposition$u %*% t(decomposition$v), 2, center, "+")
}
aligned <- align(last, truth)
stopifnot(max(abs(as.numeric(dist(aligned)) - as.numeric(dist(last)))) < 1e-12)There is no rescaling in this Procrustes alignment. It preserves distances and stress and is never fed back to the optimizer. It uses the known truth only to make the animation easier to follow; real datasets may lack such a reference. Independent alignment can rotate the whole displayed cloud even though a single raw SGD update moves at most two points.
Watch the reconstruction
Choose a Surface, then press Start. The stages show the surface, the sample, the chords, the aligned random cloud, and saved SGD updates. The first epoch takes 16 seconds; subsequent epochs take four seconds at normal speed. Stop, Reset, the stage selector and the saved-update slider allow inspection. Point spheres keep their original height colors; reference points and chords can be toggled. The transparent surface stays in the original coordinate system.
Drag to rotate; scroll to zoom. Colors retain original point height.