Skip to content

Fix bugs in Scholar.Manifold.Trimap - #367

Merged
josevalim merged 7 commits into
elixir-nx:mainfrom
RicardoSantos-99:fix-trimap
Aug 30, 2026
Merged

Fix bugs in Scholar.Manifold.Trimap#367
josevalim merged 7 commits into
elixir-nx:mainfrom
RicardoSantos-99:fix-trimap

Conversation

@RicardoSantos-99

@RicardoSantos-99 RicardoSantos-99 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor
  • in1d/2 returns its mask permuted. It applies the sort permutation again
    instead of inverting it. Wrong on 167 of 300 random inputs.
  • Three or four points raise. The local scale reads a fixed
    distances[[.., 3..5]] slice, assuming at least six neighbor columns.
  • Anything wider than 100 features raises. The PCA branch passed
    full_matrices: to a private SVD that takes full_matrices?:. Behind the
    crash it also reconstructed instead of projecting, kept 101 components instead
    of 100, and rebound the result inside the case so it never reached the
    initialization.
  • num_random: 0 is rejected. generate_triplets/3 branches on
    num_random > 0, but the option is typed :pos_integer.
  • Impossible outlier sampling hangs forever. rejection_sample/4 never
    repeats a draw and has no iteration bound. The defaults want 50 outliers, so
    any dataset under 61 points can hang with no output.
  • init_embeddings silently overrides num_components. Three columns while
    asking for two returned a three column embedding.

One behaviour change worth calling out: some configurations that returned a
result on main now raise. rejection_sample/4 fills num_inliers * num_outliers slots from the pool of points outside an anchor's neighbourhood,
never repeating a value, so when the request is wider than that pool it is
unsatisfiable by pigeonhole. Those runs only completed by emitting duplicate
outliers, and with in1d/2 fixed they hang instead, which is why the guard
rejects them up front.

The PCA projection matches an exact numpy SVD to 2.9e-13 in f64. Every fix has a
test that fails when only that fix is reverted. Full suite: 343 doctests, 662
tests, 0 failures

in1d/2 sorts tensor1 so it can binary search each element, then applies the
sorting permutation to the answers a second time instead of inverting it. The
mask comes back permuted: against plain set membership it disagrees on 167 of
300 random inputs.

Its only caller, rejection_sample/4, reads the mask through Nx.any/1 for the
loop condition, and a permutation does not change that, so sampled outliers
still never land in the reject set. What does change is which slots a retry
round overwrites, so the drawn indices differ whenever the first draw collides.
find_scaled_neighbors/3 takes the local scale from the 4th to 6th nearest
neighbor with a fixed distances[[.., 3..5]] slice, which assumes the neighbor
matrix always has at least six columns. It has min(num_inliers + 50, n) + 1 of
them, so a dataset of three or four points raises "index 5 is out of bounds"
even though transform/2 documents and guards for more than two points.

Clamp the slice to the columns that exist, so the scale falls back to however
many of those neighbors the dataset has.
Inputs with more than @dim_pca features are supposed to be reduced before the
triplets are sampled. That branch had never run, and it was wrong in four ways.

It called the private Nx.LinAlg.SVD.svd/2 with full_matrices: false. That module
takes full_matrices? and has no defaults, so the option was read as nil and
building the graph raised a CompileError. Every input wider than 100 features
failed.

The reduction itself multiplied u, s and vt back together, which reconstructs
the input in its original feature space instead of projecting it onto the
principal components. The reference implementation projects, and the embedding
is initialized from the leading columns of the result, so the initialization was
reading original features rather than components. The slice was also inclusive,
keeping 101 components rather than 100, and the reduced tensor was rebound
inside the case clause so it never reached the initialization at all.

Use the public Nx.LinAlg.svd/2, keep u * s, clamp the component count to the
rank the data can support, and return the reduced tensor from the case. Checked
against numpy: the projection matches an exact SVD to 2.9e-13 in f64.
generate_triplets/3 branches on num_random > 0 so that a run can use only the
nearest neighbor triplets, but the option is typed :pos_integer, so passing 0
is rejected during validation and that branch can never be taken.
rejection_sample/4 draws num_inliers * num_outliers outliers per anchor,
rejecting the anchor and its own inliers and discarding any draw that repeats a
value already held. Once every acceptable index is already in hand, no further
draw is ever accepted, and the loop has no iteration bound, so transform/2 never
returns and reports nothing.

That state is reachable whenever the request is wider than the pool of points
outside an anchor's neighborhood. With the defaults it needs 50 outliers, so any
dataset with fewer than 61 points can hang.

Reject the impossible request up front, unless triplets were supplied and no
sampling happens.
A supplied init_embeddings is used as given, so its width silently decides the
size of the embedded space and num_components is ignored. Asking for two
components while passing three columns returns a three column embedding.
@RicardoSantos-99

Copy link
Copy Markdown
Contributor Author

One thing I deliberately left out of this PR is an issue around the interaction
between Trimap's metric and knn_algorithm options.

While testing the different combinations, I found that some combinations exposed
by the current schemas cannot run successfully:

metric :brute :nndescent :large_vis
:euclidean ok ok requires sufficiently large n
:squared_euclidean ok ok requires sufficiently large n
:cosine ok ok rejected
:manhattan ok ok rejected
:chebyshev rejected ok rejected

:auto appears to avoid these cases because it selects the KNN implementation
automaticaly.

I have not included a fix for this in the PR because I still need to investigate
why these combinations behave differently and where the validation or
normalization should live.

The limit only surfaced when the new guard raised.
@josevalim
josevalim merged commit f472928 into elixir-nx:main Aug 30, 2026
2 checks passed
@josevalim

Copy link
Copy Markdown
Contributor

💚 💙 💜 💛 ❤️

@RicardoSantos-99
RicardoSantos-99 deleted the fix-trimap branch August 30, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants