Metric stress MDS using stochastic gradient descent or SMACOF
Source:R/export_examples.R, R/metric_mds.R
metric.mds.Rdmetric.mds() fits Euclidean coordinates to graph distances. The default
approximation = "full" minimizes raw stress over all shortest-path
distances, using native stochastic gradient descent (SGD, the default)
or smacof::mds(type = "ratio"). With approximation = "sparse",
pivots and sparse SGD approximate long-range interactions without
constructing an all-pairs distance matrix.
Only the SMACOF backend requires the optional smacof package.
Before version 0.2.0.9000,
this name performed classical scaling; use classical.mds() to retain that
behavior. The add and eig arguments belong to classical.mds() only.
Usage
metric.mds(
prepared = NULL,
edges = NULL,
n = NULL,
adj.list = NULL,
weight.list = NULL,
edge.weights = NULL,
dim = 2L,
init = c("classical", "random"),
n.init = 1L,
max.iter = 1000L,
eps = 1e-08,
seed = 1L,
diagnostics = TRUE,
scale.mode = c("profiled", "identity"),
distance.floor = 1e-08,
edge.length.epsilon = 1e-08,
band.quantiles = c(1/3, 2/3),
backend = c("sgd", "smacof"),
sgd.control = list(),
pair.weights = c("uniform", "inverse_squared"),
approximation = c("full", "sparse"),
sparse.control = list(),
constraints = NULL,
distance.matrix = NULL,
resume = NULL
)Arguments
- prepared
An all-pairs prepared graph object containing
distance.matrix, for full MDS only. Sparse MDS requires raw graph inputs. Aprepare.sparse.mds()result is accepted with sparse approximation. Other edge-only preparations are not supported as input.- edges
Two-column integer edge matrix used when
preparedis omitted. Supply either edges/edge.weights or adj.list/weight.list, not both. Raw graph inputs cannot be combined with a prepared object.- n
Finite positive integer vertex count. When supplied with a prepared object, it must match the stored graph size.
- adj.list
Optional adjacency list used when
preparedis omitted.- weight.list
Optional edge-weight list parallel to
adj.list.- edge.weights
Optional positive edge weights parallel to
edges.- dim
Embedding dimension. Full MDS supports integer dimensions of at least two and smaller than the vertex count. Sparse SGD supports integer dimensions of at least two. Both support 10D fits for subsequent PCA projection or initialization of a 3D fit.
- init
"classical"(full default),"random"(sparse default), or a finite numeric matrix withnrows anddimcolumns in input-distance units. Sparse MDS supports only random or supplied starts; random starts use a uniform unit cube in normalized-distance units.- n.init
Positive integer number of starts, including the first start. Sparse MDS currently requires one start.
- max.iter
Positive integer iteration limit per start. For SGD, the number of complete passes over full or retained sparse pairs. Defaults to 1000 for full MDS and 30 for sparse MDS. The sparse default is a provisional calibration choice, not a convergence criterion. With
resume, this is the additional epoch budget (default 1000).- eps
Positive SMACOF tolerance for the change in normalized stress. Do not supply this argument for SGD, which runs its prescribed schedule.
- seed
Integer random seed, or
NULLto use the current RNG stream. With a non-NULL seed, random starts do not change the caller's RNG state.- diagnostics
Attach the common GMDS diagnostic panel. With
FALSEand raw graph inputs in full mode, prepare only the distance matrix, without path caches. Defaults toTRUEfor full MDS andFALSEfor sparse MDS. Sparse MDS rejectsTRUEand explicitly supplied diagnostic controls (scale.mode,distance.floor,edge.length.epsilon,band.quantiles).- scale.mode
Diagnostic scale policy:
"profiled"fits a separate scalar for each diagnostic family, and"identity"uses scale one. To evaluate user-specified scales, callscore.gmds()on the returned coordinates separately. This argument never changes the fitted coordinates.- distance.floor
Positive floor for relative residuals.
- edge.length.epsilon
Small stabilizer for fixed-path embedded lengths.
- band.quantiles
Two quantiles splitting graph distances into short, mid, and long bands.
- backend
"sgd"(default) or"smacof". No automatic fallback occurs.- sgd.control
Named list used only with
backend = "sgd":scheduler("hybrid","exponential"or"zheng"),learning.rate(0.5),final.rate(0.01),switch.ratio(0.4),schedule.epsilon(0.1),checkpoint.every(1),schedule.epochs(initialmax.iter),keep.state(FALSE),stopping(NULL, disabled),trace(NULL, disabled), andmax.workspace.bytes(256 MiB for full MDS, 512 MiB for sparse MDS). The schedule defaults to"zheng"forprepare.sparse.mds()inputs and"hybrid"otherwise. See Learning-rate schedules and evidence below. The hybrid/exponential tuning defaults are provisional calibration choices. Their rates must be positive withfinal.rate <= learning.rate;switch.ratiois between zero and one inclusive. The memory allowance covers native workspace, not the R input matrices, retained continuation state, serialization copies or total process memory. See Stopping, histories and continuation below.- pair.weights
"uniform"(full default) or"inverse_squared"(sparse default and the only supported sparse choice). The latter applies the paper's inverse-squared shortest-path-distance weights in either backend. This is separate from the graph'sedge.weights.- approximation
"full"(default) or"sparse". This selects the distance model separately from the optimizer selected bybackend. Sparse mode currently supports only SGD, in 2D or 3D. The defaultdim = 2is shared by both modes. Mode-specific defaults apply only to omitted arguments; explicitly incompatible choices produce errors.- sparse.control
Named list used only with
approximation = "sparse":n.pivots(200, capped atn) and optional distinct vertex idspivotsin selection order. Explicit pivots override the default count; supplying an inconsistent count is an error. The default count is a provisional calibration choice; increasing it increases memory and work per epoch.- constraints
Optional list for sparse SGD with explicit distance targets:
pairsis a two-column matrix of distinct unordered vertex pairs;targetscontains positive finite distances. Optionalcount_iandcount_jcontain nonnegative endpoint multiplicities (both default to one), divided by squared target distance during fitting. Each pair must have positive total multiplicity. Supplyn; the constraint network must be connected. Cannot accompany graph inputs, prepared objects or pivot controls. Targets are used unchanged, with no shortest-path calculation. Symmetric multiplicities give ordinary sparse inverse-squared stress; asymmetric multiplicities use the existing sparse endpoint updates. Zero distances are unsupported: consolidate duplicate observations before constructing inverse-squared constraints. No triangle-inequality validation is performed.- distance.matrix
Optional square numeric matrix of supplied distances for full MDS. Values are used directly, without graph shortest paths. Must be finite, symmetric and nonnegative with zero diagonal. Cannot accompany graph inputs, prepared objects or constraints. Graph diagnostics are not available:
diagnosticsdefaults to FALSE and TRUE is rejected.- resume
A prior SGD result created with
keep.state = TRUE. When supplied, onlyresumeandmax.itermay be specified; targets, weights, preparation, initialization, seeds and controls are frozen in saved state. Requiresn.init = 1in the original fit. Historical fits without retained state cannot be resumed from their returned coordinates.
Value
A "grip_gmds_layout" object with method "metric_mds".
metadata$engine and metadata$approximation identify both choices.
In full mode, metadata records the objective, backend/version, achieved raw stress,
weighted target-normalized RMSE, both weighted Stress-1 conventions, selected start,
coordinate scale multiplier, and per-start losses and stopping information.
SGD adds per-start elapsed seconds (native fitting including scoring),
native seeds, pair-update counts, best epochs, and checkpoint histories in
metadata$sgd. Histories use RMS-normalized target-distance units and include
both raw and scale-profiled stress. Internally, inverse-squared weights are
1 / (distance / input_rms_distance)^2; the corresponding stress is already
dimensionless. Uniform raw stress is converted to squared input-distance
units for public metadata. Weighted target-normalized RMSE takes the square
root of the ratio of stress to the sum of weighted squared target distances.
SGD reports iteration_limit and converged = FALSE when its
schedule finishes, including when an earlier checkpoint is returned.
Details
With approximation = "full", the objective is
$$S(Z) = \sum_{i<j}w_{ij}(\|z_i-z_j\|_2-\delta_{ij})^2.$$
By default, all pair weights are one. With pair.weights = "inverse_squared",
\(w_{ij}=1/\delta_{ij}^2\), as in Zheng et al. (2018); stress is then the
sum of squared relative distance errors. Edge weights define graph distances;
pair.weights separately specifies their importance in the stress objective.
Both backends, coordinate rescaling, checkpoint/start selection, and stress
metadata use the selected weights. The common score.gmds() diagnostic panel
retains its own definitions and does not become a weighted objective report.
SMACOF normalizes targets internally. Returned coordinates are rescaled to
minimize raw stress against the original input distances. scale.mode
controls the optional diagnostic panel only, not the optimization objective.
Target-normalized raw stress and scale-profiled Stress-1 select the same
shapes when global scale is free and pair weights agree. Their values at a
fixed coordinate scale need not agree. Both the literal and target-profiled
Stress-1 values are independently calculated in metadata; the backend's
own reported stress is retained separately in the start summaries.
The first run uses init; subsequent runs use random configurations.
The smallest achieved raw stress selects the result. A backend result that
increases stress beyond numerical tolerance is rejected in favor of its
scaled start and marked accordingly. Failed starts are recorded, and all
failing starts cause an error. Iteration limits are not convergence or global
optimality certificates. Collinear or planar starts may remain in their
initial span; use multiple starts to investigate this sensitivity.
Newly prepared graph objects supply symmetric strict shortest distances, separately from retained-route lengths used in path diagnostics. Rebuild older cached preparations if their near-tie distance matrix is asymmetric.
Targets must be finite, symmetric, and nonnegative, with zero diagonal and
at least one positive distance. Missing and infinite distances are rejected.
Inverse-squared weighting requires strictly positive off-diagonal targets;
zero distances are rejected, not floored. Uniform weighting still allows zero
distances. Extreme ratios that overflow or underflow the normalized weights
are rejected. Supplied starts may have coincident points but must not be wholly collapsed.
Full MDS uses dense all-pairs matrices; use approximation = "sparse"
when that preparation is too large. Sparse behavior is described below.
Full SGD behavior
Every pass visits all unordered pairs in shuffled order. The pair step is
clipped at min(rate * normalized_pair_weight, 1). Rates use RMS-normalized
distance units for both weighting choices; changing input distance units
therefore preserves the optimization trajectory up to scale. The default
schedule is shared across weighting choices, not a reproduction of the
graph-dependent annealing schedule used in Zheng et al. (2018). Symmetric clipped
updates follow Zheng et al. (2018); the hybrid exponential/harmonic schedule
follows Hangan et al. (2026). The terminal epoch is always scored. The best
independently scored checkpoint, including initialization, is retained using
scale-profiled stress; checkpoint scoring does not rescale the ongoing search.
The R wrapper independently rescales and recomputes its returned stress.
The legacy user-controlled exponential schedule's final rate is its boundary value after the planned
passes, not its last applied rate. The hybrid schedule switches after
floor(switch.ratio * schedule.epochs) passes, at one tenth the initial rate
(or the initial rate if the switch is immediate). Its harmonic segment
approaches final.rate at the boundary only when the switch rate exceeds
final.rate; otherwise that segment stays constant at the switch rate.
With switch.ratio = 1, no harmonic segment occurs and final.rate is unused.
Coincident points with positive target distance are separated along a seeded
direction; zero targets are not floored. Without such collisions, updates
preserve a deficient initial affine span. Use full-dimensional random starts
to examine this sensitivity. This backend retains quadratic pair storage and
work per pass. It does not validate the supplied graph distances.
Sparse approximation
Sparse SGD implements the asymmetric updates of Zheng et al. (2018), Algorithm 2, based on the model of Ortmann et al. (2017). The graph must have positive finite edge lengths and be connected. Disconnected graphs are rejected without repair. Pivots are chosen with probability proportional to distance to the nearest selected pivot, starting from a uniform random vertex. Graph lengths are normalized by their maximum before shortest-path calculations. Exact distance ties in region assignment go to the earliest selected pivot. Near floating-point boundaries, general weighted graphs can still be sensitive to arithmetic. Each nonadjacent vertex receives an influence from a pivot weighted by the number of vertices in that pivot's region within half their separation, divided by squared separation. The reverse influence can be zero or different. Edges have inverse-squared weights at both endpoints.
Edge targets are the supplied edge lengths, as in the upstream sparse implementation. For a weighted edge with a shorter alternative route, that target differs from the shortest-path target of full MDS. Exact full-pivot update parity requires every edge to be a shortest path, as with unit lengths or Euclidean chord lengths, and matched starts, rates and order.
Targets are normalized by RMS over the retained unordered pairs; endpoint
weights use the same units. Legacy graph calls default to grip's hybrid
schedule; prepare.sparse.mds() inputs default to the published fixed-budget
"zheng" schedule. Select the schedule explicitly when comparing methods.
The terminal iterate is
returned, centered and converted back to input units, with no fitted scale.
A checkpoint proxy averages the two endpoint weights for each pair. Asymmetric
updates are not claimed to follow the gradient of that proxy, or to decrease
it monotonically. This diagnostic is not full stress or equation (19) of
Zheng et al. Use independent distance evaluation to compare sparse and full
layouts. No dense diagnostics or classical initialization run implicitly.
Sparse results share the metric_mds method name.
coords is the terminal layout. prepared contains an edge-only graph,
without a distance matrix. metadata$sparse records retained pairs,
original-unit targets, endpoint multiplicities, selected pivots and region
owners. Metadata also records seeds, controls, preparation/fitting seconds,
native workspace estimates (separately checked for preparation and fitting),
sparse_proxy_stress, its normalized root
error, and stopping status. By default termination = "iteration_limit"
and converged = FALSE.
trace reports checkpoint proxy stress, epochs, rates and pair updates.
Recording individual SGD updates
Set sgd.control = list(trace = list(every = 100L)) to record coordinates
after every 100 examined pairs in each epoch, plus the initial state and each
epoch endpoint. Set every = 1L to record every pair, including no-op pairs.
Tracing requires n.init = 1. It is independent of checkpoint.every.
The native solver dispatches once to a compile-time specialization: when
tracing is disabled, no tracing checks, copies or buffers occur in the update
loop. When enabled, recording adds time and memory but never changes solver
arithmetic, the random stream, stopping or the selected result.
fit$update_trace$coordinates is a list of matrices in input-distance units;
fit$update_trace$steps gives the epoch, examined pairs within that epoch,
and cumulative pair_updates. These are raw iterates, without Procrustes
alignment, recentering or the final best-checkpoint scale adjustment; the last
frame can therefore differ from fit$coords. Stress is not recomputed at each
frame. The existing fit$trace remains the epoch-level diagnostic history.
A resumed fit records only its new call, starting at the saved terminal state,
with absolute epoch/update counts. Keep earlier fits to retain their frames.
trace$max.bytes defaults to 64 MiB. Before fitting, the solver checks an
upper-bound allocation for the requested epoch budget (even with early
stopping), including coordinate payloads and a conservative per-frame
allowance. This allocation also counts against max.workspace.bytes.
R result conversion, older retained traces and serialization need additional
memory. Increase every to reduce the number of frames. Completing the epoch
budget is not a convergence certificate.
Stopping, histories and continuation
Default SGD calls still exhaust the prescribed epoch budget. This is reported
as metadata$stopping$reason = "epoch_budget", separately from whether the
planned schedule has completed or a stopping rule was assessed. SGD always
reports converged = FALSE and stopping$certified = FALSE: neither budget
exhaustion nor practical stabilization certifies convergence or optimality.
Full SGD returns its best scale-profiled checkpoint; sparse SGD returns its
terminal iterate. Stopping uses current checkpoint diagnostics, not the
running best. Metadata identifies the returned iterate and reports both
best and terminal diagnostics in normalized target units.
Checkpoint history is preserved in both trace and metadata$history;
full multi-start runs also retain every start's trace in metadata$sgd.
History includes global epoch, diagnostic, learning rate, pair updates,
checkpoint type, relative diagnostic change, centered motion, update residual
and consecutive successful checks. Unassessed quantities are NA.
Opt in with stopping = list() inside sgd.control, or supply any of
relative.tol (1e-6), motion.tol (1e-5), residual.tol (1e-5),
patience (5), and min.epochs (10). At scheduled checkpoints after at
least min.epochs, all three diagnostics must pass for patience
consecutive checks. Relative change is the absolute change in current
diagnostic divided by max(abs(previous), 1e-12). Motion is RMS point
displacement after removing translation, without rotation alignment.
The residual is the largest norm of each vertex's endpoint-weighted mean
distance-error vector. Motion and residual use RMS-normalized target units.
The residual is independent of learning rate, preventing a tiny rate alone
from passing the rule. Positive-target collisions fail the residual check.
For asymmetric sparse updates it is an update-field diagnostic, not a
gradient certificate; the sparse stress proxy need not decrease. Defaults
are practical safeguards, not universally calibrated scientific thresholds.
With keep.state = TRUE, the result retains terminal coordinates, native
RNG, normalized targets and endpoint weights, schedule, checkpoint/stopping
state, best checkpoint and preparation provenance. saveRDS()/readRDS()
preserve this state. Resume with metric.mds(resume = fit, max.iter = extra).
State version, platform and an integrity fingerprint are checked; the
fingerprint detects accidental edits and is not a security mechanism.
Reproducibility is supported on the same platform with a compatible state
version. Already stabilized runs cannot resume under altered controls.
schedule.epochs fixes the initial schedule horizon, independently of call
size. Pausing and resuming continues the same RNG and rate sequence without
restarting or stretching its prefix. Beyond this horizon, rate at extra
epoch j is last.rate / (1 + j / schedule.epochs). This extension is a
practical harmonic tail, not the published unlimited-iteration schedule or
a convergence guarantee. A larger fresh horizon is a different experiment.
Checkpoints occur at global multiples of checkpoint.every and at the
schedule horizon. Other call boundaries are recorded with scheduled = 0;
they do not affect stopping or checkpoint selection in a continued run.
Full fits may return such a boundary when it improves the best diagnostic
for that call, but continuation always uses terminal solver state. With the
same horizon and final budget, split and uninterrupted runs match; split
histories additionally preserve intermediate call boundaries.
Learning-rate schedules and evidence
The original full-SGD backend adapted exponential and hybrid schedules from the implementation accompanying Hangan et al. (2026). The graph sparse-SGD extension retained those controls. The hybrid default is preserved for existing callers; it is not a demonstrated improvement over Zheng et al.'s weight-dependent schedule. The original package design records its settings as provisional calibration choices.
scheduler = "hybrid" starts at learning.rate (0.5), uses exponential
decay during the first floor(switch.ratio * schedule.epochs) epochs (default
fraction 0.4), then a harmonic tail toward final.rate (0.01).
The switch and rate endpoints are user controls, not derived from endpoint
weight extrema. scheduler = "exponential" instead uses the controlled
rates in a single exponential phase. For these legacy schedules, the final
rate is a planned boundary value at epoch schedule.epochs, not necessarily the
last applied rate. These schedules are distinct from "zheng".
Hangan et al. motivate a fixed switch for the usual uniform-weight MDS
setting, where the weight ratio does not distinguish pairs. That rationale
does not establish better performance for inverse-squared, region-weighted
sparse stress. Package backend comparisons and the earlier sparse-MDS
comparison used hybrid controls; they did not isolate schedule effects.
The October 2026 observation-method validation used "zheng" throughout.
These experiments do not establish that either schedule outperforms the
other. A schedule comparison must hold targets, pivots, endpoint weights,
starting coordinates, update-order seeds and budgets fixed, and report
independently evaluated error and runtime.
Set sgd.control = list(scheduler = "zheng", schedule.epsilon = 0.1)
to use Zheng et al.'s Section II-A1 fixed-budget exponential schedule.
Its rates run from 1 / min(positive endpoint weights) to
schedule.epsilon / max(positive endpoint weights), including both
endpoints; one epoch uses the first rate. Weights are computed after grip's
target normalization. schedule.epsilon defaults to 0.1; learning.rate,
final.rate and switch.ratio must be omitted with this schedule.
This is the default for prepare.sparse.mds() inputs, including uniform,
Euclidean regional and geodesic regional preparations. Other inputs retain
the hybrid default, including raw graphs and explicit constraints.
Fits from observation preparations retain constraint pairs and connectivity
metadata but omit duplicate constraint adjacency lists. The local-neighbor
graph remains available in the original preparation's graph field.
Choosing "zheng" reproduces that schedule's rule, not every published
solver setting or trajectory: epoch budget, initialization and scale,
random-number generator and update order can differ. It does not implement
the paper's separate Section II-A2 unlimited-iteration schedule or certify
convergence. Prefer the named published schedule when the purpose is
sparse-stress analysis following Zheng et al.; label hybrid runs as an
alternative configuration rather than assuming they are better.
Workflow guides
Start with vignette("function-guide", package = "grip")
to choose a layout, diagnostic, or reference comparison. List installed
guides with vignette(package = "grip").
References
Ortmann, M., Klimenta, M. and Brandes, U. (2017). A Sparse Stress Model. doi:10.7155/jgaa.00440 . Zheng, J. X., Pawar, S. and Goodman, D. F. (2018). Graph Drawing by Stochastic Gradient Descent. doi:10.1109/TVCG.2018.2859997 . Hangan, D., Kobourov, S. and Miller, J. (2026). Bridging Graph Drawing and Dimensionality Reduction with Stochastic Stress Optimization. https://arxiv.org/abs/2605.00641.
Examples
prepared <- prepare.graph.geodesic.mds(edges.cycle(6), n = 6)
fit <- suppressWarnings(metric.mds(prepared = prepared, dim = 2, max.iter = 30))
fit$metadata$raw_stress
#> [1] 1.287187
fit$metadata$termination # Schedule completion is not a convergence certificate.
#> [1] "iteration_limit"
# Schedule completion is reported explicitly, not as convergence.
fit <- suppressWarnings(metric.mds(edges = edges.cycle(8), n = 8,
dim = 3, init = "random", backend = "sgd", max.iter = 30,
diagnostics = FALSE))
fit$metadata$engine
#> [1] "sgd"
fit$metadata$target_normalized_rmse
#> [1] 0.1433542
sparse <- metric.mds(edges = edges.path(30), dim = 3,
approximation = "sparse", sparse.control = list(n.pivots = 5))
sparse$metadata$sparse_proxy_stress
#> [1] 0.09701287
# Pause after 5 epochs of a 20-epoch schedule, then continue the same run.
first <- suppressWarnings(metric.mds(edges = edges.cycle(8), n = 8,
diagnostics = FALSE, max.iter = 5,
sgd.control = list(keep.state = TRUE, schedule.epochs = 20)))
continued <- metric.mds(resume = first, max.iter = 15)
continued$metadata$stopping
#> $reason
#> [1] "epoch_budget"
#>
#> $schedule_completed
#> [1] TRUE
#>
#> $schedule_epochs
#> [1] 20
#>
#> $epochs_completed
#> [1] 20
#>
#> $assessed
#> [1] FALSE
#>
#> $assessments
#> [1] 0
#>
#> $stabilized
#> [1] FALSE
#>
#> $certified
#> [1] FALSE
#>
#> $rule
#> NULL
#>
#> $returned_iterate
#> [1] "best_profiled_checkpoint"
#>
#> $best_diagnostic
#> [1] 0.543671
#>
#> $best_diagnostic_epoch
#> [1] 0
#>
#> $terminal_diagnostic
#> [1] 0.5438565
#>
#> $diagnostic
#> [1] "RMS-normalized scale-profiled stress"
#>
#> $limitation
#> [1] "Practical stabilization is not certified convergence or global optimality; sparse updates need not minimize the proxy."
#>
tail(continued$metadata$history)
#> epoch raw_stress profiled_stress pair_updates learning_rate scheduled
#> 16 15 0.5439104 0.5439100 420 0.01666667 1
#> 17 16 0.5438983 0.5438981 448 0.01500000 1
#> 18 17 0.5438873 0.5438872 476 0.01363636 1
#> 19 18 0.5438752 0.5438751 504 0.01250000 1
#> 20 19 0.5438652 0.5438651 532 0.01153846 1
#> 21 20 0.5438566 0.5438565 560 0.01071429 1
#> relative_change centered_motion update_residual stable_checkpoints
#> 16 NA NA NA 0
#> 17 NA NA NA 0
#> 18 NA NA NA 0
#> 19 NA NA NA 0
#> 20 NA NA NA 0
#> 21 NA NA NA 0