Skip to content

ControlVector.train() is non-deterministic: PCA svd_solver="auto" selects an unseeded randomized SVD #78

Description

@bamdadd

Thanks again for repeng — extracting per-layer directions for a whole model in a
couple of lines has been great to build on.

Following up the Secondary note in #77: that note observed that sklearn picks
the randomized SVD solver on LM activations, but said "in our runs the resulting
pca_diff direction happened to be stable run-to-run … so we're not claiming
non-determinism."
This issue closes that open question — ControlVector.train()
is non-deterministic in general, and it's easy to demonstrate on weak-signal
concepts. It's related to #77 but a separate, solver-level root cause: #77 is
about pca_diff concept-blindness under a constant-positive contrast; this is
about svd_solver="auto" giving unseeded results in train() regardless of
method.

Root cause

In read_representations (repeng/extract.py):

pca_model = PCA(n_components=1, whiten=False).fit(train)

PCA defaults to svd_solver="auto". sklearn's auto heuristic chooses the
solver from the input shape:

  • max(X.shape) <= 500"full"
  • else if 1 <= n_components < 0.8 * min(X.shape)"randomized"
  • else → "full"

train is (n_pairs, hidden_size). hidden_size is > 500 for every real
transformer (3584 for Qwen2.5-7B, 4096 for Llama-3-8B, …), so max(X.shape) is
always > 500 and n_components=1 always lands in the middle branch → the
randomized solver. randomized_svd takes a random_state, which PCA
leaves at None, so every call seeds a fresh RNG → different directions.

(The PCA sign ambiguity is already handled by the projection step below, so
that's not what this is — it's solver-level.)

Minimal repro (no model / no GPU — just numpy + scikit-learn)

import numpy as np
from sklearn.decomposition import PCA

def direction(train, solver="auto"):
    pca = PCA(n_components=1, whiten=False, svd_solver=solver).fit(train)
    return pca.components_.astype(np.float32).squeeze(axis=0)

rng = np.random.default_rng(0)
train = rng.standard_normal((40, 3584)).astype(np.float32)  # 20 pairs, Qwen-7B width

print("auto selects:", PCA(n_components=1).fit(train)._fit_svd_solver)  # -> 'randomized'
a, b = direction(train), direction(train)                 # same input, two runs
cos = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
print("identical?", np.array_equal(a, b), " cosine:", round(cos, 3))

f1, f2 = direction(train, "full"), direction(train, "full")
print("full identical?", np.array_equal(f1, f2))

Typical output (scikit-learn 1.7.2):

auto selects: randomized
identical? False  cosine: 0.58        # varies every run — this is the point
full identical? True

Two regimes, both real:

  • Well-separated concept (a dominant contrastive axis): the direction is
    stable (cosine ≈ 1.0), but the vector is still not bit-identical across
    runs — enough to break exact reproducibility, caching, and regression tests.
  • Weakly-separated concept (subtle/abstract, low signal-to-noise): the
    direction itself diverges — cosine between two runs on identical data lands
    roughly in the 0.5–0.9 range and changes every run. Here the non-determinism
    isn't cosmetic; it changes the vector you ship. (This is also the regime where
    pca_diff returns a concept-independent axis when the positive prompt is held constant across pairs #77's constant-positive contrast produces a weak signal, which is likely why
    the effect is easy to surface there.)

Proposed fix (one line, no API change)

svd_solver="full" is exact and deterministic, and is cheap for
n_components=1:

-            pca_model = PCA(n_components=1, whiten=False).fit(train)
+            pca_model = PCA(n_components=1, whiten=False, svd_solver="full").fit(train)

With full, the repro above is bit-identical across runs in both regimes. I've
opened a small PR with exactly this change that references this issue. If you'd
rather keep the randomized solver's speed for very large datasets, an alternative
is to thread an optional random_state through trainread_representations
PCA(...); glad to switch to whichever you prefer.

Thanks!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions