Research notes · Dimensionality reduction

From MADR to ScaleRank

I revisited MADR, the multi-agent approach to dimensionality reduction I started in 2020, rebuilt the yardstick for comparing methods, and then prototyped a new method, ScaleRank: rank preservation at scales you can specify. This page explains, with figures, how I investigated the outlier problem found in the first version, rebuilt the loss function in a softmax form, scaled it to 70,000 points with a version that avoids N², and turned it into a browser app with Rust and WebAssembly, together with the results.

As of 27 October 2026 Experiments: github.com/tsjshg/madr (branch proto/scalerank) App: tsjshg.github.io/scalerank-web 日本語

Summary

What was done

Re-read the earlier attempts and built drbench, a benchmark framework that compares 8 datasets × 8 methods on the same metrics. Prototyped a new method and turned it into a version that runs on 70,000 points and a browser app.

What was found

Existing methods trade off being strong locally against being strong globally; no method wins at every scale.

New method

ScaleRank lets you specify the scales to preserve with γ. The current softmax version (γ=1) produced, on all 8 datasets, embeddings that no existing method beats on both local and global measures. Tuned t-SNE and UMAP (30 settings) did not beat it on both either.

Open issues

On local structure alone, t-SNE is still ahead, and the gap widens at 70,000 points. There are many outliers. Most of the individual components already exist in prior work, and comparisons with multi-scale t-SNE and DREAMS are needed.

Background

History

To build an alternative to t-SNE and UMAP, I have been experimenting since 2020 with one idea: treat each data point as an agent and decide the layout by moving the agents according to local rules. About 40 notebooks were left in the folder.

Early 2020
Particle swarm (PSO) style

Each point has a velocity, is attracted to similar points and avoids dissimilar ones.

Summer–autumn 2020
Cellular-automaton MADR2d → continuous-space version

On a grid, points move one cell at a time, using agreement with their neighbourhood as their satisfaction. Later, in continuous space, two rules: move towards the original neighbours (harmonic) and move away from points that are not neighbours (escape).

Early 2021
Varying the move radius with local density (AROB presentation)

A note reads: "Perhaps each instance should optimise some objective function." The turning point from rules to an objective function.

2022
Optimising the co-ranking Q score directly

Implemented the Q score, which measures rank discrepancies, in Rust, and optimised moves of 30 random points at a time with differential evolution (DE).

26 October 2026
Restart: drbench and the first version of ScaleRank

Built the yardstick first, then prototyped ScaleRank on top of it with a triplet loss. Investigated the cause of the outliers.

27 October 2026
To a softmax loss

Switched to a loss that compares one neighbour against 20 non-neighbours at once, and made it the default.

27 October 2026
70,000 points and the browser

Built a version without the N² rank table, rewrote it in Rust, and released it as an app that keeps the data inside the browser.

Past evaluation

How to read the 2022 results

In the final 2022 results, MADR beat the other methods by a wide margin on the Q score. In the same notebook, however, a local score restricted to near ranks (k=5) almost reverses the ranking.

Overall Q score (lower is better)
Pollen data. Sum of rank discrepancies over all point pairs
Local score at k=5 (lower is better)
Looking only at ranks up to the 5th nearest
Figure 1. Numbers from the 2022 results-summary notebook. MADR ranks first overall but loses heavily to t-SNE and UMAP locally.

The reason lies in how the Q score is defined. The Q score is the sum, over every point, of how far the ranks of all other points as seen from that point differ between the original space and the embedding. Discrepancies at far ranks count as much as those at near ranks, so in practice it is a measure of global structure. Optimise that measure directly and it is no surprise that you win on it.

Lesson: If you optimise the metric used for evaluation, the method will always look good on that metric. Before building a new method, you need to fix which metrics measure what.

Benchmark

Building a yardstick: drbench

So, before any new method, I built drbench, a framework for comparing methods under identical conditions. Datasets, methods and metrics are each registered, combinations are run automatically, and results are cached. All distances are Euclidean, and existing methods are called with their default settings.

datasets
8 datasets

3 synthetic (S-curve, hierarchical clusters, clusters of different density), digits, 4 scRNA-seq

methods
8 methods + new method

PCA, MDS, t-SNE, UMAP, densMAP, PaCMAP, TriMap, random. Registering a single function adds a method

metrics
Local, global, density, labels

Aggregated row by row without building an N×N matrix. Tests confirm agreement with sklearn and the earlier implementation

report
Figures and tables

RNX(K) curves, scatter plots, trade-off plots, summary tables

The central metric: RNX(K)

For each point, it measures how much the K nearest neighbours in the original space overlap with the K nearest neighbours in 2-D, rescaled so that a random layout scores 0 and a perfect match scores 1. Varying K from 1 to N shows, in a single curve, how well structure is preserved from local (small K) to global (large K).

Figure 2. The K=5 neighbours of point i. Of the 5 neighbours in the original space (left), 3 are also among the 5 nearest neighbours in 2-D (right). Averaging this agreement rate over all points and plotting it against K gives RNX(K).
MetricTypeWhat it measures
R_NX@10localNeighbourhood preservation at K=10 (random = 0, perfect = 1)
AUC_RNXmostly localArea under RNX over all K, weighted by 1/K
trust@10 / cont@10localWhether false neighbours have crept in / whether true neighbours have been lost
distance correlationglobalRank correlation (Spearman) of the distances over all point pairs
density correlationdensityWhether each point's 10-NN radius (local density) is preserved
kNN accuracylabels10-NN classification accuracy in 2-D; how well the classes separate
outlier fractionappearanceFraction of points whose 10-NN radius in 2-D exceeds 3 times the median

A bug found along the way. annoy, the library PaCMAP and TriMap use for neighbour search, was broken on this Mac. Asked for 5 neighbours, it returned only one, and TriMap returned near-random results without raising an error (kNN accuracy of 23% on digits). PaCMAP was switched to faiss, TriMap was fixed to receive exact neighbours, and a test was added to detect a recurrence.

Existing methods

How existing methods perform

The RNX(K) curves clearly separate the scales at which existing methods do well. t-SNE is highest at small K and is overtaken by MDS and PCA once K exceeds about 50.

Dataset:
Figure 3. RNX(K). The horizontal axis is logarithmic; further right is more global. Hover over the chart to see each method's value.

Across all 8 datasets too, the top method differs between local and global metrics. Switching the metric in the table below shows that t-SNE is strong locally and MDS / PCA are strong globally.

Metric:
Figure 4. Mean over 3 seeds. Within each row, darker is better and the underline marks the row's best value. The three rightmost columns are ScaleRank (the current version at γ=1 and γ=0, and the first version at γ=1).
New method

The idea behind ScaleRank

Existing methods carry an implicit, internal choice of which scales to preserve. ScaleRank makes that choice explicit as a user-specified distribution p(K) and directly optimises neighbourhood preservation weighted by that distribution.

Thinking in terms of a single triplet

Fix a point i and a neighbourhood size K. In the original space, there are points j among i's top-K neighbours and points m that are not. In 2-D, the triplet is correct if j is closer to i than m is. Draw K from p(K), draw j and m, and if the triplet is wrong, pull i and j together and push i and m apart. The first version used this triplet as the unit of the loss. The current version draws 20 m's at once (see below).

Figure 5. The triplet (i, j, m). j, which lies within i's top-K neighbourhood in the original space (dotted circle), is pulled towards i, and m, which lies outside it, is pushed away. p(K) determines which K is drawn.

The knob is γ

If p(K) is proportional to K−γ, then γ=1 concentrates the weight on small K (local-leaning; the same weighting as AUC_RNX), while γ=0 gives every K the same weight (global-leaning). Moving the γ slider changes which scales receive the effort.

Figure 6. Share of the weight assigned to each range of K on digits (N=1797). Between γ=1 and 1.5, the share for K≤10 jumps from 36% to 78%. Hover over a bar to see its value.
First version

Where the first version stumbled

Not an optimisation failure, but a biased surrogate

Even at γ=1, the first version did not reach t-SNE on local metrics (RNX@10 on digits: 0.44 vs 0.58). So I scored the embeddings of existing methods with ScaleRank's own objective function. If t-SNE scored lower, the optimisation would not be working.

Scored with ScaleRank's objective (γ=1, digits, lower is better)
Figure 7. The first version of ScaleRank scores lowest. In other words, the optimisation succeeds, and the objective function itself prefers a different layout from t-SNE's.

The cause lay in how the objective function was built. Triplet correctness counts a distant non-neighbour being slightly out of order just as heavily, but RNX(K) only cares about points that intrude into the top K in 2-D. So, for some of the triplets, I chose m from points that are currently close to i in 2-D but are not true neighbours (hard negatives).

RNX@10 (digits)
AUC_RNX (digits)
Figure 8. Introducing hard negatives improved the local metrics substantially.

Another bug. Right after hard negatives were introduced, the results were exactly the same whether their fraction was 0.3, 0.6 or 0.9. It turned out that the code filling the rank table was writing into a NumPy copy, so the rank table stayed all zeros. Hard negatives had never been used at all. The improvement above appeared after the fix.

Investigation 1

What the first version's outliers really are

In the first version's scatter plots, points were scattered around the clusters. Counting as outliers the points whose 10-NN radius in 2-D exceeds 3 times the median, the first version produced far more of them than t-SNE.

First version γ=1 First version + mitigation (local scaling) Current version γ=1 t-SNE MDS
Figure 9. Outlier fraction (mean over 3 seeds). t-SNE stays below 1% on every dataset. Datasets on which even MDS (a method that preserves distances as they are) produces outliers presumably contain genuinely isolated points.

Two kinds of outliers

  • The outliers on Pollen were isolated in the original space as well (10-NN radius 3.9 times the median). MDS also produces 16% there. To some extent, these are faithful.
  • The outliers on hierarchical clusters (hier_blobs) had ordinary density in the original space (1.06 times). They are artefacts created by the method.

The culprits: hard negatives and anti-hubs

Turning hard negatives off reduced the outliers on hier_blobs from 23% to 2%. The points that became outliers had something in common: they were points that hardly appear in any other point's neighbour list, so-called anti-hubs. In high-dimensional data, some points become neighbours of many points (hubs), while large numbers of points become almost nobody's neighbour (anti-hubs). This is called hubness.

Figure 10. An arrow means "this point's nearest neighbour is that point". The hub in the centre is pointed to by many points, but the anti-hub on the right is pointed to by none. Neighbour relations are not symmetric.
Display:
Figure 11. hier_blobs (3 top-level clusters × 4 sub-clusters, 50 dimensions). Colour shows the top-level cluster. With "Highlight anti-hubs", points that appear in other points' 10-NN lists at most twice in the original space are marked with a black ring. In the first version (leftmost), the anti-hubs are scattered outside the clusters; in the current version (third), they mostly stay inside.
Probability that an anti-hub becomes an outlier (first version)
With hard negatives (0.3) Without hard negatives
Figure 12. Fraction of each dataset's anti-hubs (N10 ≤ 2) that became outliers. The number in brackets is the strength of hubness (the skewness of N10). The stronger the hubness, the larger the effect of hard negatives. Pollen does not change when hard negatives are turned off (because those cells are genuinely isolated).

What forces are at work

I decomposed the force each point receives during training into the individual terms of the gradient and measured the component pointing outwards from the centre of the point's own cluster (first version, hier_blobs, epoch 700).

Points that end up as outliers All other points
Figure 13. Positive values point outwards, negative values inwards. Outliers receive only about 1/3 of the pull from being some other point's neighbour, and about twice the push from hard negatives when they are the anchor.

The net force on outliers points inwards, but it is weaker than for the other points (−0.013 vs −0.038). As the cluster cores shrink in the later part of training (distance from the centre 3.5 → 0.6), these points are left behind rather than pushed out. The root cause is the asymmetry of neighbour relations; t-SNE counteracts it with per-point bandwidths (perplexity calibration).

Remedies tried

RemedyResult
Changing the optimiser (per-point Adam / shared Adam / SGD)No change
Hard positives (preferentially pulling true neighbours that are far away)Worse on every dataset
Weighting hard negatives by rank (the same form as trustworthiness)No change
One-sided hard negatives (the intruding point m is not moved)Only a slight reduction (23.8% → 20.4%)
Decaying hard negatives during trainingFewer outliers on blobs, but the improvement on digits is lost
Using locally scaled ranks for all KOutliers disappear, but distance correlation on Pollen collapses from 0.86 to −0.02
Local scaling for small K (≤30) onlyOutliers 16% → 10–11%. A mitigation, not a solution (kept as an option)
Switching to a softmax loss and dropping hard negativesOutliers 16% → 9%, and local metrics also improve on average. Made the default (see the section after next)
Investigation 2

Why it loses to t-SNE on blobs

In the first version, the gap in local metrics was largest on the two synthetic datasets built from high-dimensional Gaussians (0.24 vs 0.34 on hier_blobs, 0.31 vs 0.41 on density_blobs). I investigated with a version whose objective function is concentrated around K≈10 (a band profile), putting it on the same footing as t-SNE.

DatasetMethodRNX@10Objective (lower is better)
hier_blobst-SNE0.3440.0364
ScaleRank (with hard negatives)0.2330.0425
ScaleRank (no hard negatives, 4× budget)0.3330.0332
density_blobst-SNE0.4080.1019
ScaleRank (no hard negatives, 4× budget)0.3270.0839
Table 1. The objective function is a band profile concentrated around K=10 (k0=10).
  • hier_blobs: With hard negatives, ScaleRank loses to t-SNE on its own objective function. In other words, hard negatives distort the objective itself. Without them, it nearly matches t-SNE and also beats it on the objective.
  • density_blobs: It beats t-SNE on the objective function but loses on RNX@10. A mismatch between the objective and the evaluation metric still remains.
  • Allocation across scales: By design, γ=1 also puts a lot of weight on K>10 (Figure 6). On digits, concentrating on K=10 gives an RNX@10 of 0.599, above t-SNE (0.583).

The hard-negative dilemma. On manifold-like data such as digits, hard negatives are essential (RNX@10 0.44 → 0.54), but on high-dimensional data with strong hubness they create outliers and distort the objective function. The fixed rule of "probability 0.3, within the top 2K" helped on some datasets and did harm on others.

Current version

Switching to a softmax lossNEW

Rather than tuning hard negatives to each dataset, I asked whether the shape of the loss function could produce the same effect. For one neighbour j, Q non-neighbours m are drawn together and the following loss is used (the same form as InfoNCE in contrastive learning).

loss = −log( qij / ( qij + Σm qim ) )  q = 1 / (1 + d²)
  • With Q=1, it is the same formula as the first version's triplet loss (agreement confirmed by a test).
  • As Q increases, the closer m currently is to i in 2-D, the larger qim becomes and the stronger the push. Although m is simply drawn uniformly, non-neighbours that have intruded nearby automatically have a stronger effect: a smooth version of hard negatives.
  • It is also known that the larger Q is, the closer the result gets to t-SNE (Damrich et al. 2023). Q becomes a second "local or global" knob alongside γ.

Sweeping Q

On the 4 datasets used for tuning so far, starting from the setting without hard negatives, I increased Q from 1 → 5 → 20 → 100.

Local RNX@10 (higher is better)
Global distance correlation (higher is better)
Figure 14. γ=1, no hard negatives, mean over 2 seeds. Q=1 is the triplet loss; Q=5 and 20 use 32 groups per point, and Q=100 uses 4 groups. The × at the left edge is the value for the first version (triplets + hard negatives). Local rises almost monotonically with Q, while global falls slightly.

With Q=20 and 8 groups per point, the quality was almost the same as with 32 groups at 1/4 of the computation. I made this the current default (n_neg=20, per_point=8, hard=0). The table below compares it over 8 datasets × 3 seeds.

MethodOutliersLocal RNX@10AUC_RNXGlobal distance correlationDensity correlationTime
Table 2. Mean over 8 datasets × 3 seeds (computed from the drbench results). Outliers are halved compared with the first version, and local also rises on average. Global falls slightly but remains far above t-SNE.

Local drops on low-dimensional manifolds

Not every dataset improved. On s_curve (a 2-D surface embedded in 3-D), local fell from 0.830 to 0.797. A simple test of recovering a uniformly random 2-D square shows the same tendency clearly.

Recovering a uniform 2-D square (300 points), RNX@10
Figure 15. Mean over 2 seeds, random initialisation. The current version plateaus at around 0.80, even with longer training or PCA initialisation. The softmax loss helps on high-dimensional data with strong hubness but loses a little on data of low intrinsic dimension.
Results

Results of the current versionNEW

I ran the current version with γ = 1 / 0.5 / 0 on 8 datasets × 3 seeds and compared it with the 7 existing methods. With local (RNX@10) on the vertical axis and global (distance correlation) on the horizontal axis, γ=1 and 0.5 sit, on all 8 datasets, where no existing method beats them on both axes. γ=0 was beaten by an existing method on two datasets (PaCMAP on density_blobs, TriMap on s_curve).

ScaleRank:
Figure 16. Up and to the right is better. The solid red line is the path of the current version as γ goes 1 → 0.5 → 0; the dashed red line (hollow points) is the first version. Hover over a point to see its values.

Behaviour as γ is lowered (new experiment)

Averaged over the 8 datasets, the current version's whole path has shifted towards local. At γ=1 it is more local than the first version, but even at γ=0 global does not rise as far as in the first version (distance correlation 0.863 vs 0.906). This is probably because the tendency of many negatives to pull the result towards t-SNE persists even when γ is lowered. Also, in both versions, the lower γ is, the more outliers there are.

Trade-off averaged over 8 datasets
γ and outlier fraction (mean over 8 datasets)
Current version (softmax) First version (triplets + hard) t-SNE MDS PaCMAP
Figure 17. Left: mean local and global scores. The current version's path lies above and to the left of the first version's (more local). Right: lowering γ increases outliers in both versions.
Dataset:
Figure 18. ScaleRank seen through RNX(K) curves. γ=1 is close to t-SNE at small K and well above t-SNE at large K. γ=0 takes a shape close to MDS.
Comparison on Pollen
MethodLocal RNX@10Global distance correlationAUC_RNX
Table 3. ScaleRank γ=1 is close to t-SNE locally and on a par with MDS globally.

In the scatter plots, t-SNE separates the clusters cleanly but does not preserve their positions relative to one another. MDS preserves those positions, but the clusters overlap. ScaleRank sits in between: it separates the clusters while trying to keep their arrangement. The scatter around clusters that stood out in the first version is reduced in the current version.

Figure 19. digits (seed 0). Colour shows the digit class; 8 and 9 are triangles. Hover over a point to see its class.
Investigation 3

Comparison with tuned t-SNE and UMAPNEW

Even if t-SNE and UMAP at their default settings do not beat ScaleRank, tuning their parameters might bring them up to where ScaleRank is. So I ran 13 t-SNE settings, perplexity {5, 30, 100, 300} × exaggeration {1, 2, 4} plus random initialisation, and 17 UMAP settings, n_neighbors {5, 15, 50, 200} × min_dist {0, 0.1, 0.5, 0.99} plus random initialisation, on 8 datasets × 3 seeds.

t-SNE (13 settings) UMAP (17 settings) ScaleRank (γ=1, 0.5, 0)
Figure 20. Up and to the right is better. The small dots are the individual settings of tuned t-SNE and UMAP; the red line is ScaleRank's path as γ goes 1 → 0.5 → 0. Hover over a point to see the setting.
  • On no dataset did any of the 30 settings beat ScaleRank on both local and global measures. The same holds when the best setting is chosen for each dataset.
  • Raising t-SNE's perplexity raises global (mean over 8 datasets 0.52 → 0.71) at the cost of local (0.543 → 0.415). Raising exaggeration lowers local sharply and also increases outliers.
  • For UMAP, raising n_neighbors leaves global plateauing at around 0.43, and min_dist has almost no effect.
  • The gaps are smallest on s_curve and density_blobs, where tuned t-SNE comes close to ScaleRank.

Caveat: The global metric (rank correlation of distances) is close to the quantity ScaleRank optimises at small γ, namely the ranks of all points as seen from each point. t-SNE and UMAP were not designed to preserve distances in the first place, so part of the global gap comes from differing design goals. ScaleRank has not yet been compared with methods that aim at both local and global structure (next section).

Literature

Relation to prior workNEW

To find out how much of ScaleRank is new, I surveyed related work. In short, most of the individual components are already known.

Component of ScaleRankPrior work
Moving between local and global with a single knobλ in NeRV (Venna et al. 2010), τ in Local MDS (Chen & Buja 2009), exaggeration in t-SNE (Böhm et al. 2022), the number of negatives in contrastive learning (Damrich et al. 2023), λ in DREAMS (Kury et al. 2026)
Preserving all scales uniformly in log K (equivalent to γ=1)Multi-scale SNE (Lee et al. 2015), multi-scale t-SNE (de Bodt et al. 2018, 2020)
Preserving ranks rather than distancesNon-metric MDS (Kruskal 1964), RankVisu (Lespinats et al. 2009), rank-preserving dimensionality reduction (Onclinx et al. 2010)
Triplet loss, InfoNCE and the Cauchy kernelt-STE (van der Maaten & Weinberger 2012), TriMap (Amid & Warmuth 2019), Damrich et al. 2023
The RNX metric and its AUC on a logarithmic scaleLee & Verleysen 2009, Lee et al. 2015
Comparing on two axes: local (neighbour recall) and global (rank correlation of distances)DREAMS (Kury, Kobak, Damrich; TMLR 2026)
Table 4. The components of ScaleRank and the work that addressed them earlier.

What may be new is the following combination: the user supplies an arbitrary scale distribution p(K); neighbourhood preservation weighted by it is the objective function; and the sampling rules (draw K from p, the positive from the top K and the negatives from outside it) and the triplet weights are derived from that objective. I found no publication that states this combination as such.

However, changing the scale weights of multi-scale t-SNE to follow p(K) is a straightforward extension. Unless experiments can show a difference from it, I consider the novelty weak. The next methods to compare against are multi-scale t-SNE reweighted by p(K), a sweep of the λ in DREAMS, and a sweep of the number of negatives in contrastive learning.

Scale

An N²-free version and 70,000 pointsNEW

Up to this point, ScaleRank kept a table of the ranks of all points as seen from each point (N × N), which limited it to about 10,000 points. So I dropped the rank table and built a version that optimises the same objective function using only structures of size O(N log N).

  • Ranks 1–100 are found with exact k-nearest neighbours.
  • Larger ranks are estimated from random subsamples at several levels. The a-th nearest point in a subsample of size n corresponds to a rank of roughly a·N/(n+1) in the full data. This gives each point a "distance → rank" table.
  • Negatives are chosen from candidates redrawn uniformly at each epoch: the candidates are ranked with the table, and those whose rank exceeds K are used.

Compared with the original version on 8 datasets × 3 seeds, the differences were within about ±0.01 on every metric (Table 5). Evaluation was also changed to estimate from only 2,000 rows for datasets of more than 10,000 points. Each metric is a mean of per-row values, so this estimate is unbiased.

γLocal RNX@10AUC_RNXGlobal distance correlationDensity correlationkNN accuracy
Table 5. N²-free version − original version (mean over 8 datasets × 3 seeds). The closer to 0, the closer to the original version.

Table 6 and Figure 21 show the results on all 70,000 points of MNIST and Fashion-MNIST (reduced to 50 dimensions with PCA).

Dataset:
MethodLocal RNX@10AUC_RNXGlobal distance correlationDensity correlationkNN accuracyTime
Table 6. 70,000 points (seed 0, 10 threads). t-SNE is openTSNE and UMAP is umap-learn. The ScaleRank values are from the Rust version and are almost the same as the Python version's.
Figure 21. A random 5,000 of the 70,000 MNIST points. Colour shows the digit class; 8 and 9 are triangles.
  • Globally (distance correlation), ScaleRank is clearly higher than t-SNE and UMAP.
  • Locally (RNX@10), on the other hand, it weakens as N grows. On MNIST at γ=1, it was 0.27 at 10,000 points but 0.10 at 70,000 points: about a quarter of t-SNE (0.39) and about the same as UMAP.

In Figure 21, ScaleRank γ=1 (neg_scale 0.5) keeps the digits apart, but each class breaks into small clumps, with many scattered points around them. γ=0 keeps the overall arrangement but lets the classes overlap.

Why local weakens as N grows

If the number of negatives Q stays fixed while N grows, the Q negatives stand in for an ever larger set of "all other points", and the repulsion becomes relatively weaker. This is a known property in contrastive learning (Damrich et al. 2023), and the behaviour approaches that of UMAP, which draws few negatives.

So I added a setting that multiplies the negative terms by a weight w = ((N−1−K)/Q)a (neg_scale = a). With a = 0.5, local roughly doubles (MNIST 0.10 → 0.20, Fashion-MNIST 0.12 → 0.20), but it still falls short of t-SNE. On small datasets the density correlation drops (0.66 → 0.48 on digits), so it is not the default.

On 10,000 MNIST points I also tried other settings. Raising γ lifts local to at most 0.36 (γ=2); 100 negatives give 0.35, and neg_scale 0.5 gives 0.36, against 0.28 for the default γ=1 and 0.45 for t-SNE. Four times as many groups per point, or 2,000 epochs instead of 800, change almost nothing (0.29).

Implementation

The Rust version and the browser appNEW

So that ScaleRank can be used without data leaving the browser, I rewrote it in Rust and made it into an app that runs in the browser via WebAssembly. For comparison, t-SNE and UMAP are included in the same Rust code and are computed only when the user asks for them. I also implemented approximate nearest neighbours (NN-descent), but with 50 dimensions and 70,000 points it was not faster than brute force (4.3 s on 10 threads), so brute force is used for now.

I checked with drbench that the Rust versions compute the same thing as the reference implementations (Table 7). t-SNE is compared with openTSNE, UMAP with umap-learn (PCA initialisation), and ScaleRank with the Python version.

Rust versionReferenceLocal RNX@10AUC_RNXGlobal distance correlationDensity correlationkNN accuracyTime (Rust / reference)
Table 7. Rust version − reference (mean over 8 datasets × 3 seeds). The closer to 0, the more similar the results.

The same holds at 70,000 points: RNX@10 is 0.386 for t-SNE (openTSNE 0.391) and 0.108 for UMAP (umap-learn 0.109). With 10 threads, the computation takes 28 s for t-SNE (openTSNE 64 s), 22 s for UMAP (35 s) and 44 s for ScaleRank (Python version 1,347 s).

The browser version is currently single-threaded and takes about 17 s for ScaleRank, 11 s for UMAP and 7 s for t-SNE on 6,000 points. At 20,000 points, ScaleRank takes about a minute; I plan to shorten this with multithreading.

Summary

Caveats and next steps

Caveats

  • On local metrics alone, t-SNE remains first on every dataset (mean 0.543 vs 0.511 for the current version).
  • On low-dimensional manifolds (s_curve, the 2-D square), the current version scores lower locally than the first version.
  • Lowering γ to lean towards global structure increases outliers and does not reach the global scores of the first version.
  • Outliers are still far more frequent (8.8%) than with t-SNE (0.3%). Some are genuinely isolated points, but not all.
  • AUC_RNX measures something close to the γ=1 objective function, so the lead on it must be discounted. RNX@10 and distance correlation are not optimised directly.
  • Hyperparameters were tuned on digits, Pollen, hier_blobs and density_blobs. The 4 datasets not used for tuning showed the same tendencies.
  • At 70,000 points, local drops to about a quarter of t-SNE's. neg_scale roughly doubles it, but it still falls short.
  • Most of the individual components exist in prior work. ScaleRank has not yet been compared with multi-scale t-SNE reweighted by p(K) or with DREAMS.
  • The browser version is single-threaded, and ScaleRank takes about a minute on 20,000 points.

Candidate next steps

  • Estimate ranks in 2-D directly. With Q negatives drawn, the rank of j can be estimated from the fraction of non-neighbours that are closer than j in 2-D. Penalising only when this exceeds K would optimise RNX(K) almost as it stands.
  • Vary Q with K. Use fewer negatives when leaning global (large K) and more when leaning local. This may help with global scores falling short when γ is lowered.
  • Fix the asymmetry on the positive side. As t-SNE does with per-point bandwidths, even out how strongly each point is pulled as a neighbour, so that anti-hubs also receive enough attraction.
  • Keep local quality at large N. Set the default neg_scale according to N. Try a stage corresponding to t-SNE's early exaggeration.
  • Compare directly with prior work. Add multi-scale t-SNE reweighted by p(K), a sweep of the λ in DREAMS and a sweep of the number of negatives in contrastive learning to drbench.
  • Make it faster in the browser. Aim for 100,000 points with multithreading and SIMD. Add an FFT-based version of t-SNE.
Appendix

How to reproduce

The results can be reproduced with the following commands on the proto/scalerank branch of the repository tsjshg/madr. The original pickle files are assumed to be in the parent folder.

uv sync
uv run python scripts/convert_legacy.py      # 2020 pickles → data/raw/*.npz
uv run drbench run                           # existing methods (8 datasets × 3 seeds)

# current version (softmax loss, default settings)
uv run drbench run -m scalerank:gamma=1.0 scalerank:gamma=0.5 scalerank:gamma=0.0
# first version (triplets + hard negatives)
uv run drbench run -m "scalerank:gamma=1.0,hard=0.3,n_neg=1,per_point=32"
uv run drbench report                        # figures and tables in results/report/

# investigation scripts
uv run python scripts/sweep_scalerank.py -d digits --grid n_neg=1,5,20 hard=0.0
uv run python scripts/score_under_objective.py digits --gammas 1
uv run python scripts/diagnose_outliers.py hier_blobs -m tsne "scalerank:gamma=1.0"
uv run python scripts/force_decomposition.py hier_blobs --epochs 700
uv run python scripts/plot_scale_profile.py

# comparison with tuned t-SNE / UMAP (after drbench run with 30 settings × 8 datasets × 3 seeds)
uv run python scripts/compare_tuned_baselines.py

# N²-free version and Rust version (after installing scalerank-web's Python bindings)
uv run drbench run -m scalerank_fast:gamma=1.0 scalerank_fast:gamma=0.5 scalerank_fast:gamma=0.0
uv run drbench run -m scalerank_rs:gamma=1.0 tsne_rs umap_rs umap:init=pca
uv run python scripts/compare_fast.py
uv run python scripts/compare_rs.py
uv run drbench run -d mnist70k fmnist70k -s 0 -m tsne umap scalerank_rs:gamma=1.0 scalerank_rs:gamma=0.0 "scalerank_rs:gamma=1.0,neg_scale=0.5"

The code for the Rust version and the app is in tsjshg/scalerank-web.