Skip to content

Commit fea15e9

Browse files
Ram efficient mode + PLSKit ful release
1 parent c686e4d commit fea15e9

25 files changed

Lines changed: 1546 additions & 718 deletions

docs/start_here.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ This folder holds four tiers of documentation plus a runnable demo. Start wherev
1616
| [`../examples/demo_api.py`](../examples/demo_api.py) | See the whole pipeline in ~50 lines — load embeddings, build a corpus, fit PLS / PCA+OLS / groups, print stats, export a report. |
1717
| [`../examples/demo_multipls.py`](../examples/demo_multipls.py) | Minimal runnable example for the in-development `fit_multipls` (rotated multi-component PLS). |
1818

19+
> **Migration (v1.x → next):** `SSD(emb, corpus, y, lexicon)` now requires
20+
> an L2-normalised embedding. Insert `.normalize(l2=True, abtt=1)` between
21+
> `Embeddings.load(...)` and `SSD(...)`. `.ssdembed` files saved by
22+
> previous runs of `normalize().save()` already carry `l2_normalized=True`
23+
> and need no change.
24+
1925
### For power users — making results do what you want
2026

2127
| Read this | When you want to… |
@@ -61,6 +67,31 @@ See [`../examples/demo_api.py`](../examples/demo_api.py) for a runnable end-to-e
6167

6268
---
6369

70+
## Low-RAM mode
71+
72+
For machines that cannot fit the full embedding matrix in RAM (Colab free
73+
tier, 8 GB laptops, etc.), pass `ram_efficient=True` to
74+
`Embeddings.load`. Only an uncompressed `.ssdembed` file works in this
75+
mode — convert other formats once with the snippet below.
76+
77+
```python
78+
# One-time: convert any format to .ssdembed and pre-normalise.
79+
emb = Embeddings.load("model.bin").normalize(l2=True, abtt=1)
80+
emb.save("model_norm") # → model_norm.ssdembed
81+
82+
# Each subsequent run:
83+
emb = Embeddings.load("model_norm.ssdembed", ram_efficient=True)
84+
emb.attach_corpus(corpus)
85+
ssd = SSD(emb, corpus, y, lexicon).fit_pls()
86+
```
87+
88+
RAM mode is read-only: `normalize`, `save`, and `SSD.fit_multipls` raise.
89+
For the full PLS / PCA+OLS / group-comparison pipeline this is enough —
90+
`fit_multipls` is the only fit method that needs the full vocabulary as a
91+
rotation target.
92+
93+
---
94+
6495
## Citing
6596

6697
> Plisiecki, H., Lenartowicz, P., Pokropek, A., Małyska, K., & Flakus, M. (2025).

examples/demo_multipls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
ssd = SSD(emb, corpus_full, ratings[dim], use_full_doc=True)
3030

3131
print("── varimax, k=auto ──")
32-
res_varimax = ssd.fit_multipls(n_components="auto", rotate="varimax", p_method=None)
32+
res_varimax = ssd.fit_multipls(k="auto", rotate="varimax")
3333
print(res_varimax.stats)
3434
print(res_varimax.words)
3535
# res_varimax.report(top_words=10).save(f"multipls_varimax_{dim}.md")

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ classifiers = [
3232
dependencies = [
3333
"numpy>=1.26.4",
3434
"spacy>=3.7.2",
35-
"plskit>=0.0.1",
35+
"plskit>=0.1.0",
3636
]
3737

3838
[project.optional-dependencies]
@@ -66,6 +66,9 @@ markers = [
6666
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
6767
"local: marks tests that need local files not in repo",
6868
]
69+
filterwarnings = [
70+
"ignore:Saving as .*normalization and ABTT metadata will be lost.*:UserWarning",
71+
]
6972

7073
[tool.ruff]
7174
target-version = "py310"

ssdiff/backends/pca_sweep.py

Lines changed: 168 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
33
Single-pass sweep over PCA_K values evaluating interpretability (cluster-based)
44
and beta stability, then selects the best K via a joint AUCK score.
5+
6+
The per-K cost is dominated by the cluster step (vocab GEMV + kmeans on 100×D
7+
points, twice per K). The sweep runs in three passes so the costly GEMV
8+
collapses into a single batched GEMM:
9+
10+
Pass 1 — slice the cached SVD, fit OLS, orient β, compute β-stability.
11+
Pass 2 — one (V, D) × (D, 2·N_K) GEMM yields all neighbor similarities.
12+
Pass 3 — argpartition / regex-filter / kmeans per (K, side); the cluster
13+
inputs come from indexing the cached normed vocab matrix, no
14+
per-word dict lookups.
515
"""
616

717
from __future__ import annotations
@@ -28,57 +38,83 @@
2838
from ssdiff.backends._sweep_math import (
2939
zscore_ignore_nan as _zscore_ignore_nan,
3040
)
41+
from ssdiff.lang_config import get_config as _get_lang_config
3142
from ssdiff.utils import _diagnostic
32-
from ssdiff.utils.math import unit_vector
33-
from ssdiff.utils.neighbors import cluster_top_neighbors
34-
35-
36-
def _cluster_both_sides(
37-
embeddings,
38-
beta: np.ndarray,
43+
from ssdiff.utils.math import kmeans_auto_k, unit_vector
44+
45+
# Restrict neighbor search to the top-N most-frequent vocab rows; matches the
46+
# cluster_top_neighbors default used on the public path.
47+
_RESTRICT_VOCAB = 50_000
48+
# Candidate pool size before regex filtering — must be >= cluster_topn.
49+
_NEIGHBOR_CAND = 2000
50+
# kmeans seed and minimum cluster size — match cluster_top_neighbors defaults.
51+
_KMEANS_SEED = 2137
52+
_MIN_CLUSTER_SIZE = 2
53+
54+
55+
def _top_indices_filtered(
56+
sim_col: np.ndarray,
57+
keys: list[str],
58+
bad_re,
59+
topn: int,
60+
cand: int,
61+
) -> np.ndarray:
62+
"""Return top-``topn`` filtered vocab indices from a similarity column.
63+
64+
Mirrors :func:`~ssdiff.utils.neighbors.filtered_neighbors` but takes a
65+
precomputed similarity column so a batched GEMM can fan out to many K's.
66+
"""
67+
V = sim_col.shape[0]
68+
cand = min(cand, V)
69+
if cand <= 0:
70+
return np.empty(0, dtype=np.intp)
71+
raw = np.argpartition(-sim_col, cand - 1)[:cand]
72+
raw = raw[np.argsort(-sim_col[raw])]
73+
out: list[int] = []
74+
for i in raw:
75+
if not bad_re.match(keys[int(i)]):
76+
out.append(int(i))
77+
if len(out) >= topn:
78+
break
79+
return np.asarray(out, dtype=np.intp)
80+
81+
82+
def _cluster_from_indices(
83+
emb_n: np.ndarray,
84+
indices: np.ndarray,
85+
beta_unit: np.ndarray,
3986
*,
40-
topn: int = 100,
41-
k_min: int = 2,
42-
k_max: int = 5,
43-
restrict_vocab: int = 50000,
44-
random_state: int = 2137,
45-
lang: str = "pl",
46-
min_cluster_size: int = 2,
87+
side: str,
88+
k_min: int,
89+
k_max: int,
4790
) -> list[dict]:
48-
"""Cluster top neighbors of both +beta and -beta poles.
91+
"""Cluster the rows of ``emb_n`` selected by ``indices``.
4992
50-
Wraps :func:`~ssdiff.utils.neighbors.cluster_top_neighbors` (pure numpy)
51-
for both poles and returns a combined list of cluster dicts compatible
52-
with :func:`~ssdiff.backends._sweep_math.overall_interpretability`.
93+
Returns the keys consumed by
94+
:func:`~ssdiff.backends._sweep_math.overall_interpretability` —
95+
``side``, ``size``, ``centroid_cos_beta``, ``coherence``. Drops
96+
``words`` because the sweep aggregates only.
5397
"""
54-
all_clusters: list[dict] = []
55-
56-
for side in ("pos", "neg"):
57-
try:
58-
clusters = cluster_top_neighbors(
59-
embeddings, beta,
60-
topn=topn,
61-
k=None,
62-
k_min=k_min,
63-
k_max=k_max,
64-
restrict_vocab=restrict_vocab,
65-
random_state=random_state,
66-
min_cluster_size=min_cluster_size,
67-
side=side,
68-
lang=lang,
69-
)
70-
except ValueError:
98+
if len(indices) < max(2, k_min):
99+
raise ValueError("Not enough neighbors to cluster.")
100+
W = emb_n[indices].astype(np.float64, copy=False)
101+
labels, _centers, _inertia, _k_use = kmeans_auto_k(
102+
W, k_min=k_min, k_max=min(k_max, len(W)), random_state=_KMEANS_SEED,
103+
)
104+
clusters: list[dict] = []
105+
for cid in sorted(set(labels)):
106+
idx = np.where(labels == cid)[0]
107+
if len(idx) < _MIN_CLUSTER_SIZE:
71108
continue
72-
73-
for c in clusters:
74-
all_clusters.append({
75-
"side": side,
76-
"size": c["size"],
77-
"centroid_cos_beta": c["centroid_cos_beta"],
78-
"coherence": c["coherence"],
79-
})
80-
81-
return all_clusters
109+
Wc = W[idx]
110+
centroid = unit_vector(Wc.mean(axis=0))
111+
clusters.append({
112+
"side": side,
113+
"size": int(len(idx)),
114+
"centroid_cos_beta": float(centroid @ beta_unit),
115+
"coherence": float(np.mean((Wc @ centroid).astype(float))),
116+
})
117+
return clusters
82118

83119

84120
def pca_sweep(
@@ -153,35 +189,34 @@ def pca_sweep(
153189
n, D = Xs.shape
154190
X_scale_safe = np.where(X_scale > 1e-12, X_scale, 1.0)
155191

156-
rows: list[dict] = []
157-
beta_prev: np.ndarray | None = None
158-
159192
# Precompute full SVD once — each K just slices the first K components.
160193
U_full, S_full, Vt_full = np.linalg.svd(Xs, full_matrices=False)
161194
explained_var_full = (S_full ** 2) / (n - 1)
162195
total_var_full = float(explained_var_full.sum())
163196

164-
from ssdiff.utils import _progress
165-
166-
for K in _progress(pca_k_values, verbose=verbose,
167-
total=len(pca_k_values), desc="PCA sweep"):
168-
197+
# ---- Pass 1: per-K linear algebra (cheap, no embedding lookups) -------
198+
# Each record is (ok, K, var_expl, gradient, beta_delta).
199+
# Failed K's keep ok=False and are skipped in Pass 2/3.
200+
records: list[tuple[bool, int, float, np.ndarray | None, float]] = []
201+
beta_prev: np.ndarray | None = None
202+
for K in pca_k_values:
169203
try:
170204
max_k = min(K, n - 1, D)
171205
if max_k < 1:
172206
raise ValueError(f"PCA_K={K} too large for data (n={n}, D={D})")
173207

174-
# Slice precomputed SVD
175-
components_k = Vt_full[:max_k] # (max_k, D)
176-
z = Xs @ components_k.T # (n, max_k)
177-
var_expl = float(explained_var_full[:max_k].sum() / total_var_full * 100) if total_var_full > 0 else 0.0
208+
components_k = Vt_full[:max_k] # (max_k, D)
209+
z = Xs @ components_k.T # (n, max_k)
210+
var_expl = (
211+
float(explained_var_full[:max_k].sum() / total_var_full * 100)
212+
if total_var_full > 0 else 0.0
213+
)
178214

179215
# OLS in PCA space (normal equations, matches official)
180216
w_reg = np.linalg.solve(z.T @ z, z.T @ ys)
181217

182218
# Back-project to document space
183-
beta_std = components_k.T @ w_reg
184-
beta = beta_std / X_scale_safe
219+
beta = (components_k.T @ w_reg) / X_scale_safe
185220

186221
# Orient beta so higher alignment → higher outcome
187222
yhat = (x @ beta).ravel()
@@ -196,51 +231,91 @@ def pca_sweep(
196231

197232
gradient = unit_vector(beta)
198233

199-
# Beta stability
200234
if beta_prev is not None:
201235
beta_delta = 1.0 - _cosine(beta_prev, gradient)
202236
else:
203-
beta_delta = np.nan
237+
beta_delta = float("nan")
204238
beta_prev = gradient
205239

206-
# Interpretability via clustering BOTH sides (matches official)
207-
clusters = _cluster_both_sides(
208-
embeddings, beta,
209-
topn=cluster_topn,
210-
k_min=cluster_k_min,
211-
k_max=cluster_k_max,
212-
lang=lang,
213-
)
214-
overall = _overall_interpretability(
215-
clusters, weight_by_size=weight_by_size,
216-
)
217-
218-
rows.append(dict(
219-
PCA_K=int(K),
220-
var_explained=var_expl,
221-
mean_coherence=overall["mean_coherence"],
222-
mean_abs_cosb=overall["mean_abs_cosb"],
223-
aggregate=overall["aggregate"],
224-
n_clusters=overall["n_clusters"],
225-
total_size=overall["total_size"],
226-
beta_delta_1_minus_cos=(
227-
float(beta_delta) if np.isfinite(beta_delta) else np.nan
228-
),
229-
))
230-
240+
records.append((True, int(K), var_expl, gradient, float(beta_delta)))
231241
except (np.linalg.LinAlgError, ValueError) as e:
232242
_diagnostic(verbose, f"[sweep] PCA_K={K} skipped: {type(e).__name__}: {e}")
243+
records.append((False, int(K), float("nan"), None, float("nan")))
244+
beta_prev = None
245+
246+
# ---- Pass 2: one batched GEMM for all valid K's, both sides -----------
247+
# emb_n is the cached, restricted, L2-normalized vocab matrix; the matmul
248+
# is float32 to match the per-K path's dtype (similar_by_vector casts to
249+
# float32), so argpartition produces identical rankings.
250+
emb_n_full = embeddings.vectors
251+
restrict = min(_RESTRICT_VOCAB, emb_n_full.shape[0])
252+
emb_n = emb_n_full[:restrict]
253+
keys = embeddings.index_to_key
254+
bad_re = _get_lang_config(lang).bad_token_re
255+
256+
valid_records = [r for r in records if r[0]]
257+
if valid_records:
258+
cols = []
259+
for _ok, _K, _var, gradient, _bd in valid_records:
260+
cols.append(gradient)
261+
cols.append(-gradient)
262+
# Stack as (D, 2 * N_valid) and cast once to embedding dtype.
263+
B = np.stack(cols, axis=1).astype(emb_n.dtype, copy=False)
264+
sims_all = emb_n @ B # (V, 2 * N_valid)
265+
else:
266+
sims_all = None
267+
268+
# ---- Pass 3: filter + cluster per (K, side); aggregate per K ----------
269+
from ssdiff.utils import _progress
270+
271+
rows: list[dict] = []
272+
col = 0
273+
for ok, K, var_expl, gradient, beta_delta in _progress(
274+
records, verbose=verbose, total=len(records), desc="PCA sweep",
275+
):
276+
if not ok:
233277
rows.append(dict(
234-
PCA_K=int(K),
235-
var_explained=np.nan,
236-
mean_coherence=np.nan,
237-
mean_abs_cosb=np.nan,
238-
aggregate=np.nan,
278+
PCA_K=K,
279+
var_explained=float("nan"),
280+
mean_coherence=float("nan"),
281+
mean_abs_cosb=float("nan"),
282+
aggregate=float("nan"),
239283
n_clusters=0,
240284
total_size=0,
241-
beta_delta_1_minus_cos=np.nan,
285+
beta_delta_1_minus_cos=float("nan"),
242286
))
243-
beta_prev = None
287+
continue
288+
289+
clusters: list[dict] = []
290+
for side, side_off in (("pos", 0), ("neg", 1)):
291+
sim_col = sims_all[:, col + side_off]
292+
try:
293+
indices = _top_indices_filtered(
294+
sim_col, keys, bad_re,
295+
topn=cluster_topn, cand=_NEIGHBOR_CAND,
296+
)
297+
clusters.extend(_cluster_from_indices(
298+
emb_n, indices, gradient,
299+
side=side,
300+
k_min=cluster_k_min, k_max=cluster_k_max,
301+
))
302+
except ValueError:
303+
continue
304+
col += 2
305+
306+
overall = _overall_interpretability(clusters, weight_by_size=weight_by_size)
307+
rows.append(dict(
308+
PCA_K=K,
309+
var_explained=var_expl,
310+
mean_coherence=overall["mean_coherence"],
311+
mean_abs_cosb=overall["mean_abs_cosb"],
312+
aggregate=overall["aggregate"],
313+
n_clusters=overall["n_clusters"],
314+
total_size=overall["total_size"],
315+
beta_delta_1_minus_cos=(
316+
float(beta_delta) if np.isfinite(beta_delta) else float("nan")
317+
),
318+
))
244319

245320
# Sort rows by PCA_K
246321
rows.sort(key=lambda r: r["PCA_K"])

0 commit comments

Comments
 (0)