Skip to content

Commit bfba90d

Browse files
committed
bench: measure the three SVD backends, speed and fidelity
The SVD is ~55% of a run now that counting and tokenization have been fixed, and `calc_svd` has offered three backends without the package ever saying which to pick. Codex's advice was not to write an SVD but to measure the ones already there; this does that. Fidelity is measured in the terms the package consumes, not on the raw factors. Singular vectors are defined only up to sign -- and up to a rotation within any tied subspace -- so two correct factorizations can differ arbitrarily column by column and a direct comparison would report noise. What users read are cosines and nearest neighbours, so the script compares those: Spearman correlation of pairwise cosines against the exact backend, and mean top-10 neighbour overlap, plus the relative error of the singular values. The result is a genuine trade, not a free speedup. On a 5001x5001 PPMI matrix at dim 100/300/500: scikit 2.4-3.7x faster nn_overlap@10 0.69-0.86 corr 0.88-0.91 gensim 1.2-1.7x faster nn_overlap@10 0.59-0.82 corr 0.78-0.87 A third of the nearest neighbours changing is not a rounding difference, so `scipy` stays the default and docs/usage.md now says plainly to keep it unless the SVD is your bottleneck. Accuracy improves with dim, so the trade is least bad exactly where the speedup is largest. `gensim` is dominated: `scikit` is faster AND closer to exact at every dimension tested. It stays available because it needs no extra dependency and because silently changing a default would move existing results, but there is now no reason to choose it deliberately. The script also states what it does NOT measure: whether the approximation is *worse for a task*. Truncation is itself denoising, and nothing guarantees the exact top-dim subspace maximizes agreement with human judgement. Answering that needs a real corpus and a gold set; this answers the narrower, still useful question of how much the backend moves the answer and what you get for it.
1 parent cd6c326 commit bfba90d

4 files changed

Lines changed: 250 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- **`bench/bench_svd.py` — which SVD backend to use, measured.** The package
8+
offered three (`scipy` exact, `gensim` and `scikit` randomized) and never said
9+
which to pick. It reports speed *and* fidelity in the terms the package
10+
actually consumes: agreement of the resulting cosine similarities and of the
11+
top-10 nearest neighbours with the exact backend, rather than raw singular
12+
vectors (which are only defined up to sign and rotation, so comparing them
13+
would be misleading).
14+
15+
The finding: the randomized backends are **not a free speedup**. On a
16+
5001x5001 PPMI matrix, `scikit` runs 2.4-3.7x faster but shares only 0.69-0.86
17+
of the exact backend's top-10 neighbours; `gensim` is 1.2-1.7x faster at
18+
0.59-0.82. `scipy` therefore stays the default, and `gensim` is *dominated* by
19+
`scikit`, which is both faster and more accurate at every dimension tested.
20+
`docs/usage.md` now says so under `impl`.
21+
522
## 0.2.0 - 2026-07-22
623

724
Modernization of the package for current Python and dependency versions, plus

bench/bench_svd.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""
2+
Which SVD backend should you use?
3+
4+
python bench/bench_svd.py [n_sentences] [vocab] [dims...]
5+
6+
`calc_svd` offers three backends and the package has never said which to pick:
7+
8+
scipy scipy.sparse.linalg.svds -- exact truncated SVD (ARPACK)
9+
gensim gensim stochastic_svd -- randomized
10+
scikit sklearn randomized_svd -- randomized, needs the `full` extra
11+
12+
Randomized SVD is an *approximation*, so the choice is not free: it trades
13+
accuracy for time. This script measures both halves of that trade on real PPMI
14+
matrices built by this package's own pipeline, so the spectrum is the one the
15+
backends actually see rather than a random matrix's.
16+
17+
WHAT IS MEASURED, AND WHY THESE MEASURES
18+
========================================
19+
20+
**Time** -- wall clock of `calc_svd` alone, best of several repeats.
21+
22+
**Fidelity, in the terms the package actually consumes.** Comparing raw
23+
singular vectors would be misleading: they are only defined up to sign (and, for
24+
tied singular values, up to a rotation within the tied subspace), so two correct
25+
factorizations can differ arbitrarily column by column. What `hyperhyper`
26+
*consumes* is the embedding built from them -- and after `SVDEmbedding`
27+
normalizes rows, everything the package reports is a cosine between rows. So
28+
fidelity is measured there:
29+
30+
sv_rel_err max relative error of the singular values vs the exact ones.
31+
A direct measure of how well the randomized range-finder
32+
captured the top-`dim` subspace.
33+
34+
cos_spearman Spearman correlation between this backend's cosine similarities
35+
and scipy's, over a fixed random sample of word pairs. This is
36+
exactly the quantity `eval_similarity` correlates against human
37+
scores, so a value near 1.0 means "this backend would report the
38+
same word-similarity score".
39+
40+
nn_overlap mean overlap of the top-10 nearest neighbours with scipy's, over
41+
a sample of words. `most_similar` is the other thing users read,
42+
and it is harsher than the correlation: a small perturbation
43+
reorders neighbours long before it moves a rank correlation.
44+
45+
`scipy` is the reference for the fidelity columns because it is the exact
46+
truncated SVD -- not because it is "the right answer" in some larger sense.
47+
48+
WHAT THIS DOES NOT MEASURE
49+
==========================
50+
Not whether the *approximation* is worse for a downstream task. A randomized
51+
backend could in principle score higher on word similarity than the exact one
52+
(truncation is itself a denoising step, and there is no law saying the exact
53+
top-`dim` subspace maximizes agreement with human judgement). Measuring that
54+
needs a real corpus with real vocabulary and a gold set -- this script uses
55+
synthetic Zipfian text, whose "words" have no meaning to correlate against. What
56+
it answers is narrower and still worth knowing: **how much does the backend
57+
change the answer, and what do you get for it.**
58+
"""
59+
60+
import sys
61+
import time
62+
from pathlib import Path
63+
64+
import numpy as np
65+
from scipy.stats import spearmanr
66+
67+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
68+
69+
from hyperhyper import pmi, svd
70+
from hyperhyper.pair_counts import make_count_closure
71+
72+
SEED = 20260722
73+
REPEATS = 3
74+
N_PAIR_SAMPLES = 20_000
75+
N_NEIGHBOUR_SAMPLES = 300
76+
TOP_N = 10
77+
78+
79+
class _Vocab:
80+
def __init__(self, size):
81+
self.size = size
82+
83+
84+
class _Corpus:
85+
"""The bit of a corpus `make_count_closure` needs."""
86+
87+
def __init__(self, size):
88+
self.vocab = _Vocab(size)
89+
90+
91+
def build_ppmi(n_sentences, vocab_size, window=5):
92+
"""A PPMI matrix from synthetic Zipfian text, via the package's own path."""
93+
rng = np.random.default_rng(SEED)
94+
ids = np.clip(rng.zipf(1.15, size=n_sentences * 15), 1, vocab_size) - 1
95+
texts, i = [], 0
96+
while i < len(ids):
97+
n = int(rng.integers(10, 25))
98+
texts.append([int(x) for x in ids[i : i + n]])
99+
i += n
100+
101+
closure = make_count_closure(
102+
_Corpus(vocab_size),
103+
window=window,
104+
dynamic_window="deter",
105+
decay_rate=0.25,
106+
delete_oov=True,
107+
subsample=None,
108+
subsampler_prob=None,
109+
seed=SEED,
110+
)
111+
counts = closure.count_texts(texts, None)
112+
return pmi.PPMIEmbedding(pmi.calc_pmi(counts, cds=0.75), neg=1, normalize=False)
113+
114+
115+
def embedding(ut, s):
116+
"""What the user ends up holding: row-normalized vectors at the default eig=0."""
117+
return svd.SVDEmbedding(ut, s, eig=0.0, normalize=True).m
118+
119+
120+
def fidelity(reference, candidate, ref_s, cand_s, rng):
121+
"""Compare a backend's embedding with the exact one; see the module docstring."""
122+
k = min(len(ref_s), len(cand_s))
123+
sv_rel_err = float(
124+
np.max(np.abs(cand_s[:k] - ref_s[:k]) / np.maximum(ref_s[:k], 1e-30))
125+
)
126+
127+
n_words = reference.shape[0]
128+
left = rng.integers(0, n_words, N_PAIR_SAMPLES)
129+
right = rng.integers(0, n_words, N_PAIR_SAMPLES)
130+
keep = left != right
131+
left, right = left[keep], right[keep]
132+
ref_cos = np.einsum("ij,ij->i", reference[left], reference[right])
133+
cand_cos = np.einsum("ij,ij->i", candidate[left], candidate[right])
134+
cos_spearman = float(spearmanr(ref_cos, cand_cos).statistic)
135+
136+
probes = rng.choice(n_words, size=min(N_NEIGHBOUR_SAMPLES, n_words), replace=False)
137+
overlaps = []
138+
for w in probes:
139+
ref_top = np.argpartition(-(reference @ reference[w]), TOP_N + 1)[: TOP_N + 1]
140+
cand_top = np.argpartition(-(candidate @ candidate[w]), TOP_N + 1)[: TOP_N + 1]
141+
ref_top = set(ref_top) - {w}
142+
cand_top = set(cand_top) - {w}
143+
overlaps.append(len(ref_top & cand_top) / max(len(ref_top), 1))
144+
return sv_rel_err, cos_spearman, float(np.mean(overlaps))
145+
146+
147+
def run(matrix, dim):
148+
results = {}
149+
for impl in ("scipy", "gensim", "scikit"):
150+
try:
151+
best, out = None, None
152+
for _ in range(REPEATS):
153+
start = time.perf_counter()
154+
out = svd.calc_svd(matrix, dim, impl, {})
155+
elapsed = time.perf_counter() - start
156+
best = elapsed if best is None else min(best, elapsed)
157+
results[impl] = (best, out)
158+
except ImportError as e:
159+
print(f" {impl}: skipped ({e})")
160+
if "scipy" not in results:
161+
return
162+
163+
ref_ut, ref_s, _ = results["scipy"][1]
164+
reference = embedding(ref_ut, ref_s)
165+
166+
print(
167+
f"{'backend':10s} {'time':>8s} {'speedup':>8s} {'components':>11s} "
168+
f"{'sv_rel_err':>11s} {'cos_spearman':>13s} {'nn_overlap@10':>14s}"
169+
)
170+
base = results["scipy"][0]
171+
for impl, (elapsed, (ut, s, _vt)) in results.items():
172+
if impl == "scipy":
173+
print(
174+
f"{impl:10s} {elapsed:7.3f}s {1.0:7.2f}x {len(s):11d} "
175+
f"{'(exact)':>11s} {'(reference)':>13s} {'(reference)':>14s}"
176+
)
177+
continue
178+
err, corr, overlap = fidelity(
179+
reference, embedding(ut, s), ref_s, s, np.random.default_rng(SEED)
180+
)
181+
print(
182+
f"{impl:10s} {elapsed:7.3f}s {base / elapsed:7.2f}x {len(s):11d} "
183+
f"{err:11.2e} {corr:13.4f} {overlap:14.3f}"
184+
)
185+
186+
187+
def main(argv):
188+
n_sentences = int(argv[0]) if len(argv) > 0 else 20_000
189+
vocab_size = int(argv[1]) if len(argv) > 1 else 5_000
190+
dims = [int(d) for d in argv[2:]] or [100, 300, 500]
191+
192+
print("hyperhyper SVD backend benchmark")
193+
print(f" seed {SEED}")
194+
print(f" sentences {n_sentences}")
195+
print(f" vocab {vocab_size}")
196+
print(f" repeats {REPEATS} (best of)")
197+
start = time.perf_counter()
198+
matrix = build_ppmi(n_sentences, vocab_size)
199+
print(f" PPMI matrix {matrix.m.shape}, nnz {matrix.m.nnz:,}")
200+
print(f" build {time.perf_counter() - start:.2f}s\n")
201+
202+
for dim in dims:
203+
print(f"dim = {dim}")
204+
run(matrix, dim)
205+
print()
206+
207+
208+
if __name__ == "__main__":
209+
main(sys.argv[1:])

docs/usage.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,29 @@ matrix. This is usually what you want.
220220
- **`impl`** (default `"scipy"`): the SVD backend. One of `"scipy"` (exact
221221
truncated SVD via `scipy.sparse.linalg.svds`), `"gensim"` (randomized), or
222222
`"scikit"` (randomized via scikit-learn, which needs the `full` extra).
223+
224+
The randomized backends are an **approximation, and it shows in the results**
225+
this is a real trade, not a free speedup. Measured on a 5001×5001 PPMI matrix
226+
(`bench/bench_svd.py`, which reports these columns for your own data):
227+
228+
| backend | speed vs `scipy` | top-10 neighbours shared with `scipy` | cosine rank corr. |
229+
|---|---:|---:|---:|
230+
| `scipy` | 1.0x | (exact) | (exact) |
231+
| `scikit` | 2.4–3.7x | 0.69–0.86 | 0.88–0.91 |
232+
| `gensim` | 1.2–1.7x | 0.59–0.82 | 0.78–0.87 |
233+
234+
So: **keep `"scipy"` unless the SVD is actually your bottleneck.** A third of
235+
the nearest neighbours changing is not a rounding difference — at `dim=100` the
236+
randomized backends agree with the exact one on barely two thirds of the top
237+
ten. Accuracy improves as `dim` grows (the randomized range-finder has more
238+
room), so the trade is least bad for large `dim`, which is also where the
239+
speedup is biggest.
240+
241+
If you do want the speed, **use `"scikit"`, not `"gensim"`**: it is faster
242+
*and* closer to the exact answer on every measure and every dimension tested,
243+
so `"gensim"` is dominated. It remains available because it needs no extra
244+
dependency, and because changing a default silently would move existing
245+
results.
223246
- **`impl_args`** (default `None`): a dict of extra keyword arguments passed
224247
straight to the chosen backend.
225248

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)