Neighbourhood
The question: if two points are drawn side by side, were they neighbours in the data — and if they were neighbours in the data, are they still drawn together?
These are the measures most people mean by “projection quality”. All eight are read-outs of one shared structure, the co-ranking matrix, so computing all of them costs what computing one costs.
The measures
Section titled “The measures”All eight are ▲ higher is better, and all are bounded.
| measure | penalises | range | |
|---|---|---|---|
trustworthiness | ▲ | points drawn together that were far apart — invented structure | , floor ~0.5 |
continuity | ▲ | points drawn apart that were together — hidden structure | , floor ~0.5 |
qnx | ▲ | both, symmetrically: the fraction of k-neighbourhoods preserved | , floor rises with k |
rnx | ▲ | the same, rescaled so 0 is a random projection | |
lcmc | ▲ | Q_NX corrected for the neighbours you would keep by chance | |
aucLogRnx | ▲ | nothing — it integrates R_NX over all k on a log scale | |
mrreFalse | ▲ | rank errors among the neighbours you see, weighted by rank | |
mrreMissing | ▲ | rank errors among the neighbours you lost |
Trustworthiness and continuity are the pair to reach for first. They are not redundant: they catch opposite errors, and a projection can be excellent at one and poor at the other.
Using them
Section titled “Using them”All eight read out of the same coRanking, so the pass runs once:
import * as sickle from "@saehrimnir/sickle";
const a = sickle.analyze(X, Y, { localK: [20] });const cr = a.coRanking;
// the complementary pair — invented structure, and hidden structuresickle.trustworthiness(cr, 20); // 0.9659sickle.continuity(cr, 20); // 0.9709
// overlap of the k-neighbourhoods, raw and chance-correctedsickle.qnx(cr, 20); // 0.5657sickle.lcmc(cr, 20); // 0.4652 — Q_NX minus the chance levelsickle.rnx(cr, 20); // 0.5172 — rescaled so chance is 0
// rank errors, weighted by how badly the rank movedsickle.mrreFalse(cr, 20); // 0.9232 — among the neighbours you seesickle.mrreMissing(cr, 20); // 0.9400 — among the ones you lost
// every k at once, so there is no k to choosesickle.aucLogRnx(cr); // 0.4658An empty scatterplot.
- Trustworthiness
- —
- parameters
- none
- points
- —
- cost
O(1)
Are the neighbours you see in the projection real?
Every k at once
Section titled “Every k at once”The pass builds the whole curve, not one value. Reading a single k without looking at the curve is a decision made blind, and the curve is free:
const curve = sickle.trustworthinessCurve(a.coRanking);curve.values[20]; // same number as sickle.trustworthiness(cr, 20)The two datasets the curves come from:
loading…
The axis runs from chance to perfect, , rather than fitting itself to the data: for these two measures is the floor that matters, so the height of a curve above the bottom of the panel is the amount by which the projection beats throwing the data away. Both datasets are then read at the same size rather than against each other’s extremes. (Scores below chance are possible, and the axis widens to show them rather than clipping.)
The curves point in opposite directions, and that is the whole reason to plot them. The blobs stay near 1 at every k: PCA keeps four well-separated groups apart, and nothing changes as the neighbourhood grows. The swiss roll starts high and falls — at , at , at — which is exactly what unrolling a manifold does. Points that are close along the sheet stay close, so small neighbourhoods survive; points on adjacent coils were near in the ambient space and end up far apart once the roll is flattened, so large neighbourhoods do not. A single would have called this projection either excellent or mediocre depending on which you picked.
The limit on k
Section titled “The limit on k”Each measure has a largest k it is defined for, and asking for more throws rather
than returning a plausible wrong number:
sickle.maxKTrustworthiness(n); // floor(n / 2) — trustworthiness, continuitysickle.maxKQnx(n); // n - 1 — Q_NX, LCMCsickle.maxKRnx(n); // n - 2 — R_NXThe trustworthiness limit is worth understanding, because it is a property of the measure rather than a safety margin. Writing for the rank of among ‘s neighbours in the data and for its rank in the projection,
where is the set of points drawn inside ‘s neighbourhood that do not belong there.
That constant is the reciprocal of the worst penalty a projection can incur, so the worst case scores exactly . Its value depends on how large the penalised set can get, and there are two regimes:
The split is intuitive. holds at most points, but it can only draw from the points ranked beyond . While there are enough of those to fill every slot, and at exactly: a maximally wrong projection puts the whole far half into the near half’s neighbourhood. Past that, and the worst case is limited by how many wrong points exist rather than by how many slots there are.
Only the first expression matches the constant. The two agree at and diverge immediately after, so above the limit the constant
over-normalises and the score runs below its own range — it reaches on a
random projection at , . Curve.kMax reports the limit, and
entries past it are NaN.
Per-point values
Section titled “Per-point values”trustworthiness, continuity and both MRRE variants decompose per point, with
localKind: "mean" — they average to the total, so you can colour a scatterplot by
them directly on the measure’s own scale.
const local = sickle.localTrustworthiness(a.coRanking, 20);// local[i] is point i's own trustworthiness; mean(local) === sickle.trustworthiness(cr, 20)There is no per-point curve. The aggregate curve is free because the pass already counts every rank, but a per-point value at every would be an table — at that is 5·10⁷ doubles, some 400 MB, for one measure. So the pass collects per-point arrays only at the you name, and naming several gives you the curve sampled where you care:
const a = sickle.analyze(X, Y, { localK: [5, 10, 20, 50] });sickle.localTrustworthiness(a.coRanking, 5); // one array per requested ksickle.localTrustworthiness(a.coRanking, 50);The same four k are available from localContinuity, localMrreFalse and
localMrreMissing — one pass, no extra sweep.
This is the most useful output of the family. An aggregate of 0.96 can hide a dozen points that are completely misplaced, and those points are usually the interesting ones.
What they miss
Section titled “What they miss”Everything that is not a neighbourhood. A projection can preserve every local neighbourhood perfectly and still:
- cut a loop open —
topologicalH1catches it, trustworthiness reads 0.995 - flatten the density contrast —
densityPreservationcatches it, trustworthiness reads 0.93 - split one group into two —
snccatches it, trustworthiness reads 0.997
All three are in the disagreement gallery with the numbers.
for the pass, per read-out afterwards. Past ~5 000 points use
analyzeAsync, which is bit-identical and scales with cores.
Verification
Section titled “Verification”Five are checked against zadu: trustworthiness, continuity and both MRRE variants to 1e-10, LCMC to 1e-9 — zadu’s LCMC path goes through faiss in float32, so near-ties can rank differently.
qnx has no zadu fixture; it is checked to 1e-10 against a naive transcription of
the definition kept in the test suite. rnx and aucLogRnx are checked only against
invariants: exactly 1 for an identity projection, and unchanged under row
permutation and between the sequential and parallel paths.
Tie-breaking under equal distances is the one place implementations can legitimately differ — see Verification.
References
Section titled “References”- Venna & Kaski, Neural Networks 19 (2006) continuity, trustworthiness
- Chen & Buja, JASA 104 (2009) lcmc
- Lee & Verleysen, Neurocomputing 72 (2009) mrreFalse, rnx
- Lee et al., Neurocomputing 169 (2015) aucLogRnx