Skip to content

Add transform to embed new points with the views of a fitted model - #13

Open
josemal98 wants to merge 11 commits into
Mishne-Lab:mainfrom
josemal98:feature/transform
Open

josemal98 wants to merge 11 commits into
Mishne-Lab:mainfrom
josemal98:feature/transform

Conversation

@josemal98

Copy link
Copy Markdown

During my visit to Devika Narain's lab I needed to place new points in an embedding fitted once, without refitting it.

This PR depends on #12 (random_state). Its branch sits on top of that one, and its tests use random_state. Until #12 is merged, the diff here also shows its commits; the changes of this PR are the last six commits.

fit and transform raised NotImplementedError. This PR implements both:

  • fit(X) fits the model and returns self. The embedding is stored in embedding_, which fit_transform now sets too.
  • transform(X, return_residual=False) embeds new points with the local views of the fitted model, without changing the embedding.

How transform places a point

A RATS embedding is an atlas. Each view m is a local (kernel) PCA φ_m, and the alignment gives it a rotation T_m and a translation v_m. The embedding of a training point is the mean of what its views give it, y_i = mean over the views m of i of (φ_m(x_i) T_m + v_m): the least-squares solution of the last alignment step. transform applies the same formula to a new point. Three rules decide which views it uses:

  1. Its views: the views of the training points that would have the new point among their n_neighbors nearest neighbors (closer than their own n_neighbors-th neighbor). This is the rule by which the fit assigns its points to views; applied to the training points, it gives back their views.
  2. The tear: with tearing, only the views that contain the nearest training point are kept. A point near the tear then lands on that neighbor's side, not between the two sides.
  3. Far points: a point that no training point would count as a neighbor is embedded with the views of its nearest training point, and a warning says how many there were. A point equal to a training point gets that point's views.

return_residual=True also returns each point's mean distance to the affine subspaces of its views. Large values flag points off the fitted manifold (linear PCA only).

A fix that torn fits need

The final embedding comes from the incidence used in the last alignment step, but Utildeg is recomputed afterwards from the final embedding. With tearing, the two differ near the tear: rebuilding the embedding of a torn small_spherewithhole fit from Utildeg misses 27 of its 1 986 points, by up to 2.3 typical neighbor distances in the embedding. The fit now keeps the incidence of the last step as Utilde_t, which rebuilds all of them (to 2e-14). compute_final_embedding returns it only with return_incidence=True, so its existing callers, such as examples/cut_and_paste_app.py, are unaffected.

Tests and verification

  • tests/test_transform.py: 10 tests, about 16 s on one core, with small data generated in the files. They check that:

    • fit returns self and stores the embedding of fit_transform;
    • transform reproduces the embedding of the training points, with and without tearing (to 1e-10);
    • held-out points land among their neighbors;
    • near a tear, none falls between the two sides (the test also checks that averaging the neighbors would put some there);
    • held-out points land where a refit that includes them puts them;
    • points far from the data warn, and the residual flags points pushed off the manifold;
    • the input checks work, and kernel PCA is supported.

    A CI step runs them.

  • The fit does not change. I compared this branch with the branch of Add a random_state parameter to RATS (default 42: results unchanged) #12, bit for bit (np.array_equal), on the 56 configurations of the full regression suite (tests/scripts/generate_snapshots.py), on three fits with the default constructor on 5 000 points with several cores, and on nine small configurations that reach every random draw (kernel PCA, the spectral initialization, n_forced_clusters=2, n_repel > 0 with one and with two clusters, min_cluster_size=1, tearing). In all 68 comparisons, y, the tear colors, Utilde, C, n_Utilde_Utilde, Utildeg and c were identical, and two repeated runs of the base branch were identical to each other. Both branches ran in the same environment as in Add a random_state parameter to RATS (default 42: results unchanged) #12.

  • transform reproduces the embedding of every one of those fits. Applied to the training data of each fit, transform returned the embedding of fit_transform, with a largest error of 2.5e-12 in the 59 large fits (on barbell) and of 1.3e-14 in the nine small ones, including kernel PCA, tearing and forced clusters.

Example: points near a tear

I fitted the Klein bottle of the README (2 000 points, with tearing) without 10% of its points, and then placed those 193 points two ways:

  • transform placed all of them next to their neighbors: at most 2.8 typical neighbor distances from the nearest one.
  • Averaging the embeddings of their 28 nearest training points left 58 of them between the two sides of the tear, up to 126 typical distances away.

The README has a short section with the usage.

Not in this PR

inverse_transform (with linear PCA it could use the same views in reverse) and a residual for kernel PCA.

Prepared with the help of Claude Code; reviewed and tested by me.

josemal98 and others added 11 commits November 11, 2026 16:15
pyRATS called np.random.seed(42) at seven places, so every fit reset the
caller's global random state (after fit_transform, np.random was left
freshly seeded with 42). The draws now come from local
numpy.random.RandomState generators seeded with the same value: every
fit draws the same numbers in the same order as before, the results are
unchanged bit for bit, and NumPy's global state is left alone.

- lpca: the ARPACK start vector of every local SVD, reseeded per point
  as before.
- kpca: the seed is now an argument of the function (still 42).
- The noise seeds of the intermediate views, the shuffle that picks the
  far-off points (n_repel > 0) and the start vector of the spectral
  initialization share one generator per fit, reseeded where the global
  one was, so the shuffle continues the same stream as before.
- Tear coloring: the start vector of eigsh in every component.
- The two reseeds with no draw after them (compute_final_embedding,
  spectrum_of_laplacian_from_neighbors) are removed.
- Param's noise draws use a local generator with the same seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RATS(random_state=42) exposes the seed that pyRATS fixed internally. The
default, 42, is the value used so far, so results do not change unless a
different seed is passed. Following scikit-learn, random_state accepts an
int, None (a new seed from NumPy's global generator at every fit) or a
RandomState instance; a numpy.random.Generator is accepted too. The value
is resolved to one int per fit, which seeds every random draw of the fit:
the start vectors of the local PCA (ARPACK), kernel PCA, the spectral
initialization and the tear coloring, k-means when n_forced_clusters > 1,
and the choice of far-off points when n_repel > 0. The seed reaches the
loky workers of the local PCA as an argument, so results do not depend on
n_jobs. compute_color_of_pts_on_tear reuses the seed of the last fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same seed gives the same embedding and another seed another one; the
default is 42; a RandomState or Generator supplies one seed per fit, and
None draws it from NumPy's global generator; the seed reaches the worker
processes of the local PCA (n_jobs=1 and n_jobs=2 agree bit for bit); the
tear coloring uses the seed of the fit; NumPy's global random state is
untouched by a fit; invalid values raise ValueError. The data are
generated in the file (no optional dependencies) and small: the 13 tests
take about 17 s on one core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ci.yml ran only tests/test_e2e.py; this adds a step for
tests/test_random_state.py (about 20 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Explains random_state and its default, says that pyRATS leaves NumPy's
global random state alone, and shows how to measure how much an
embedding moves between seeds after a rigid alignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RATS now stores, as Utilde_t, the incidence (views x points) used in the
last step of the alignment: Utilde restricted by the tear. The final
embedding of each point is the mean of what these views give it.
Utildeg is recomputed from the final embedding, one step later, so it
cannot be used to rebuild the embedding of points near the tear.
compute_final_embedding returns the incidence only with
return_incidence=True, so its existing callers are unaffected. Nothing
else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fit(X) fits the model and returns self; the embedding is stored in
embedding_, which fit_transform now sets too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
transform(X) gives every point the coordinates that the fitted views
give it: the local (kernel) PCA of each view it belongs to, with the
rotation and translation found by the alignment, averaged over its
views as the fit averages its own points. The fitted embedding does not
change, and training points get their own coordinates.

- A new point belongs to the views of the training points that would
  have it among their n_neighbors nearest neighbors (distance below
  their k-th neighbor distance), the rule by which the fit assigns its
  own points to views.
- With tearing, only the views that contain its nearest training point
  are kept, so a point near the tear lands on that neighbor's side
  instead of between the two sides.
- A point that no training point would count as a neighbor is embedded
  with the views of its nearest training point, and a warning says how
  many there were.
- return_residual=True also returns each point's mean distance to the
  affine subspaces of its views, which flags points off the manifold
  (linear PCA only).

Param gains eval_new_ and residual_new_, which map points that are not
in the training data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fit returns self and stores the same embedding as fit_transform;
transform reproduces the embedding of the training points, with and
without tearing; held-out points land among their neighbors, and near
a tear none falls between the two sides (the test checks that averaging
the neighbors' embeddings would put some there); held-out points land
where a refit that includes them puts them; points far from the data
warn; the residual flags points pushed off the manifold; input checks;
kernel PCA. The data are small and generated in the files: the 10 tests
take about 16 s on one core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a step for tests/test_transform.py (about 20 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shows fit followed by transform, the optional residual, and what
transform does with training points and with points far from the data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant