22
33Single-pass sweep over PCA_K values evaluating interpretability (cluster-based)
44and 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
717from __future__ import annotations
2838from 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
3142from 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
84120def 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