Skip to content

Commit c686e4d

Browse files
Switched to PLSKit backend
1 parent 330180c commit c686e4d

19 files changed

Lines changed: 636 additions & 1996 deletions

README.md

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -277,42 +277,42 @@ New proposed algorithm. PLS regression operates directly in the full embedding s
277277

278278
```python
279279
result = ssd.fit_pls(
280-
n_components=1, # or "auto" for CV-based selection
280+
n_components=1, # or "sequence" / "postselection" for K-selection
281281
p_method="auto", # significance test (see below)
282282
verbose=False,
283283
)
284284
```
285285

286286
| Argument | Type | Default | Description |
287287
|----------|------|---------|-------------|
288-
| `n_components` | `int \| "auto"` | `1` | Number of PLS components. `"auto"` picks argmax CV R² over 10-fold CV |
289-
| `cv_folds` | `int` | `10` | CV folds for component selection |
290-
| `pca_preprocess` | `int \| str \| None` | `None` | Optional PCA preprocessing (e.g. `50` or `"var95"`) |
288+
| `n_components` | `int \| "sequence" \| "postselection"` | `1` | Number of PLS components. Pass a string to delegate K-selection to `plskit.pls1_find_k` |
289+
| `cv_folds` | `int` | `10` | CV folds for `selector="cv_q2"` under `"postselection"` |
290+
| `k_cap` | `int` | `5` | Maximum K considered when `n_components` is a string |
291+
| `selection_kwargs` | `dict \| None` | `None` | Forwarded to `plskit.pls1_find_k` (e.g. `test_method`, `selector`, `alpha`) |
291292
| `p_method` | `str \| None` | `"auto"` | Significance test method |
292293
| `n_perm` | `int` | `1000` | Permutation iterations |
293294
| `n_splits` | `int` | `50` | Split-half iterations |
294-
| `split_ratio` | `float` | `0.5` | Training fraction for split-based tests |
295295
| `random_state` | `int` | `2137` | Random seed |
296296
| `verbose` | `bool` | `False` | Print progress |
297297

298298
**`p_method` options**:
299299

300300
| Value | Description |
301301
|-------|-------------|
302-
| `"auto"` | `"split"` when `n_components=1`, `"perm"` otherwise |
303-
| `"perm"` | Permutation test on cross-validated R-squared |
304-
| `"split"` | Split-half test with overlap-corrected t-test |
305-
| `"split_cal"` | Permutation-calibrated split-half (exact FPR control, slower) |
302+
| `"auto"` | `"split_nb"` when `n_components=1`, `"raw_perm"` otherwise |
303+
| `"raw_perm"` | Permutation test on cross-validated R-squared |
304+
| `"split_nb"` | Split-half test with overlap-corrected t-test |
305+
| `"split_perm"` | Permutation-calibrated split-half (exact FPR control, slower) |
306306
| `None` | Skip significance testing (p-value = NaN) |
307307

308308
### Multi-component PLS (in development)
309309

310-
When you expect more than one interpretable semantic axis related to the outcome, `fit_multipls()` fits `k` PLS components, rotates the W-subspace (`"varimax"`, `"promax"`, or `"raw"`), and returns a container of per-dim leaves — one per rotated axis plus a `"combined"` leaf for the (rotation-invariant) unrotated prediction β.
310+
When you expect more than one interpretable semantic axis related to the outcome, `fit_multipls()` fits `k` PLS components, rotates the W-subspace (`"varimax"` or `"raw"`), and returns a container of per-dim leaves — one per rotated axis plus a `"combined"` leaf for the (rotation-invariant) unrotated prediction β.
311311

312312
```python
313313
result = ssd.fit_multipls(
314314
n_components=2, # required, no default
315-
rotate="varimax", # or "promax" / "raw"
315+
rotate="varimax", # or "raw"
316316
p_method="auto",
317317
verbose=False,
318318
)
@@ -327,9 +327,8 @@ result["combined"].words # zoom into unrotated prediction β
327327
| Argument | Type | Default | Description |
328328
|----------|------|---------|-------------|
329329
| `n_components` | `int` | — (required) | Number of PLS components to extract |
330-
| `rotate` | `"raw" \| "varimax" \| "promax"` | `"varimax"` | Rotation applied to the W-subspace |
331-
| `kappa` | `int \| float` | `4` | Promax exaggeration exponent (ignored for other rotations) |
332-
| `pca_preprocess`, `p_method`, `n_perm`, `n_splits`, `split_ratio`, `random_state`, `verbose` ||| Same meaning and defaults as `fit_pls` |
330+
| `rotate` | `"raw" \| "varimax"` | `"varimax"` | Rotation applied to the W-subspace |
331+
| `p_method`, `n_perm`, `n_splits`, `random_state`, `verbose` ||| Same meaning and defaults as `fit_pls` |
333332

334333
> **Status.** API is stable for research use but feature parity with `PLSResult` (per-leaf clusters, snippets, misdiagnosed docs, per-dim diagnostics) is still being rolled out. See [`examples/demo_multipls.py`](examples/demo_multipls.py) and [`docs/api_reference.md`](docs/api_reference.md#fit_multipls--rotated-multi-component-pls-in-development).
335334
@@ -604,7 +603,7 @@ Methods:
604603
- `.misdiagnosed(k=20, side="both")` -> `list[dict]`
605604
- `.snippets(pre_docs, top_per_side=200)` -> `dict`
606605
- `.snippets_extreme(pre_docs, k=50, by="predicted")` -> `dict`
607-
- `.split_test(n_splits=50, method="split")` — mutates in place: overwrites `pvalue`, `p_method`, `split_mean_r` (PLSResult only). Returns `self`.
606+
- `.split_test(n_splits=50, method="split_nb")` — mutates in place: overwrites `pvalue`, `p_method`, `split_mean_r` (PLSResult only). Returns `self`.
608607
- `.plot_sweep(path=None)` — PCA-K sweep plot (PCAOLSResult only)
609608

610609
### `GroupResult`

docs/api_reference.md

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -208,22 +208,23 @@ Returns a `PCAOLSResult`. Significance is reported as the OLS F-test. Sweep diag
208208
result = ssd.fit_pls(*,
209209
n_components=1,
210210
cv_folds=10,
211-
pca_preprocess=None,
211+
k_cap=5,
212+
selection_kwargs=None,
212213
p_method="auto",
213-
n_perm=1000, n_splits=50, split_ratio=0.5,
214+
n_perm=1000, n_splits=50,
214215
random_state=2137, verbose=False,
215216
)
216217
```
217218

218219
| Argument | Type | Default | Description |
219220
|---|---|---|---|
220-
| `n_components` | `int \| "auto"` | `1` | Number of PLS components. `"auto"` picks argmax mean CV R² over `cv_folds`-fold CV. |
221-
| `cv_folds` | `int` | `10` | CV folds for `"auto"` component selection. |
222-
| `pca_preprocess` | `int \| str \| None` | `None` | Pre-PLS PCA reduction. Int = component count; `"var95"` retains 95 % of variance. |
223-
| `p_method` | `str \| None` | `"auto"` | `"perm"`, `"split"`, `"split_cal"`, `"auto"` (= `"split"` for 1 component, `"perm"` otherwise), or `None` to skip. |
224-
| `n_perm` | `int` | `1000` | Permutations for `"perm"` / `"split_cal"`. |
225-
| `n_splits` | `int` | `50` | Splits for `"split"` / `"split_cal"`. |
226-
| `split_ratio` | `float` | `0.5` | Train fraction for split-based tests. |
221+
| `n_components` | `int \| "sequence" \| "postselection"` | `1` | Number of PLS components. Pass a string to delegate K-selection to `plskit.pls1_find_k`: `"sequence"` (sequential incremental tests) or `"postselection"` (split-based selection + calibrated certificate). |
222+
| `cv_folds` | `int` | `10` | CV folds for `selector="cv_q2"` under `n_components="postselection"`. Ignored otherwise. |
223+
| `k_cap` | `int` | `5` | Maximum K considered when `n_components` is a string (further capped at `n − 1` and `D`). |
224+
| `selection_kwargs` | `dict \| None` | `None` | Extra keyword arguments forwarded to `plskit.pls1_find_k` (e.g. `test_method`, `selector`, `alpha`). |
225+
| `p_method` | `str \| None` | `"auto"` | `"raw_perm"`, `"split_nb"`, `"split_perm"`, `"auto"` (= `"split_nb"` for 1 component, `"raw_perm"` otherwise), or `None` to skip. |
226+
| `n_perm` | `int` | `1000` | Permutations for `"raw_perm"` / `"split_perm"`. |
227+
| `n_splits` | `int` | `50` | Splits for `"split_nb"` / `"split_perm"`. |
227228
| `random_state` | `int` | `2137` | Seed. |
228229
| `verbose` | `bool` | `False` | Print progress. |
229230

@@ -261,21 +262,18 @@ Fits `n_components` PLS dimensions, rotates the W-subspace for interpretability,
261262
```python
262263
result = ssd.fit_multipls(*,
263264
n_components, # required — no default
264-
rotate="varimax", # "varimax" | "promax" | "raw"
265-
kappa=4, # promax exaggeration exponent
266-
pca_preprocess=None,
265+
rotate="varimax", # "varimax" | "raw"
267266
p_method="auto",
268-
n_perm=1000, n_splits=50, split_ratio=0.5,
267+
n_perm=1000, n_splits=50,
269268
random_state=2137, verbose=False,
270269
)
271270
```
272271

273272
| Argument | Type | Default | Description |
274273
|---|---|---|---|
275274
| `n_components` | `int` || Number of PLS components to rotate. Required. Raises if NIPALS deflation produces fewer (no silent truncation). |
276-
| `rotate` | `str` | `"varimax"` | `"varimax"` (orthogonal), `"promax"` (oblique, correlated factors), `"raw"` (no rotation — still reorders dims by `|corr(t_i, y)|` and sign-flips). |
277-
| `kappa` | `int \| float` | `4` | Promax exaggeration exponent. Ignored for other rotations. |
278-
| `pca_preprocess`, `p_method`, `n_perm`, `n_splits`, `split_ratio`, `random_state`, `verbose` ||| Same meaning and defaults as `fit_pls`. |
275+
| `rotate` | `str` | `"varimax"` | `"varimax"` (orthogonal) or `"raw"` (no rotation — still reorders dims by `|corr(t_i, y)|` and sign-flips). |
276+
| `p_method`, `n_perm`, `n_splits`, `random_state`, `verbose` ||| Same meaning and defaults as `fit_pls`. |
279277

280278
Returns a `MultiPLSResult` with:
281279

@@ -357,7 +355,7 @@ ols.plot_sweep("sweep.png")
357355
ols.report(top_words=10, clusters=50).save("report_ols.md")
358356

359357
# Continuous fit — PLS on the same SSD
360-
pls = ssd.fit_pls(n_components="auto", p_method="split")
358+
pls = ssd.fit_pls(n_components="postselection", p_method="split_nb")
361359
pls.report(top_words=10, clusters=50).save("report_pls.md")
362360

363361
# Group comparison

docs/architecture.md

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,10 @@ ssdiff/
4040
├── lang_config.py — language → spaCy model mapping (23 languages)
4141
├── py.typed — PEP 561 marker
4242
├── backends/
43-
│ ├── pls.py — PLS1 NIPALS, CV selection, perm / split / split_cal tests
43+
│ ├── pls.py — plskit orchestration: mpls_fit (PLS1 + W-rotation), run_signal_test (raw_perm / split_nb / split_perm)
4444
│ ├── pca_sweep.py — PCA + OLS; joint interpretability/stability sweep
4545
│ ├── _sweep_math.py — sweep scoring primitives
46-
│ ├── group.py — unified permutation test (omnibus + pairwise)
47-
│ └── multipls.py — varimax / promax rotation of the PLS W-subspace (in development)
46+
│ └── group.py — unified permutation test (omnibus + pairwise)
4847
├── results/
4948
│ ├── __init__.py — public result exports
5049
│ ├── core.py — Result ABC, View / ScalarView / TestView, save()/to_* helpers, parameter-keyed cache
@@ -100,24 +99,25 @@ No fitting runs at construction — just doc-vector preparation.
10099
ssd.x (n × D), ssd.y (n)
101100
102101
├─ standardize X (columns, ddof=0)
103-
├─ optional PCA preprocess (var95 or fixed k) → Z
104-
├─ optional auto-select n_components (K-fold CV, argmax R²)
105-
├─ NIPALS PLS1:
102+
├─ optional K-selection (plskit.pls1_find_k):
103+
│ "sequence" → sequential incremental tests, K* = first k
104+
│ that fails to reject
105+
│ "postselection" → split A picks K*, split B re-tests at K*
106+
├─ NIPALS PLS1 (plskit.pls1_fit):
106107
│ for each component:
107108
│ w = X'y / ‖X'y‖
108109
│ t = Xw (score)
109110
│ p = X't / t't (loading)
110111
│ q = y't / t't (y-loading)
111112
│ deflate X and y
112113
├─ β = W (P'W)⁻¹ Q
113-
├─ back-project through PCA preprocess (if any) → β in embedding space
114114
├─ unscale: β / X_scale
115115
├─ orient β: flip if corr(ŷ, y) < 0
116-
└─ p-value (optional):
117-
"perm" → full permutation on CV-R²
118-
"split" → repeated train/test split, overlap-corrected t
119-
"split_cal" → split procedure on permuted y → exact null
120-
"auto" → "split" for n_components=1, "perm" otherwise
116+
└─ p-value (optional, via plskit.pls1_signal_test):
117+
"raw_perm" → full permutation on CV-R²
118+
"split_nb" → repeated train/test split, overlap-corrected t
119+
"split_perm" → split procedure on permuted y → exact null
120+
"auto" → "split_nb" for n_components=1, "raw_perm" otherwise
121121
122122
123123
PLSResult
@@ -154,19 +154,18 @@ ssd.x (n × D), ssd.y (n)
154154
ssd.x (n × D), ssd.y (n), ssd.embeddings
155155
156156
├─ standardize X and y (caller-side — mpls_fit expects standardised input)
157-
├─ project vocabulary into the same column space → E_target
158-
├─ optional PCA preprocess → Z, E_target reduced to PCA space
159-
├─ backends.multipls.mpls_fit(Xs, ys, n_components, rotate, E_target, kappa):
160-
│ NIPALS PLS1 → W, P, Q (raise if returned k < n_components)
157+
├─ project vocabulary lazily via callable target L = ((E - X_mean) / X_scale) @ W
158+
├─ backends.pls.mpls_fit(Xs, ys, n_components, rotate, E_target):
159+
│ plskit.pls1_fit → W, P, Q (raise if returned k < n_components)
161160
│ β_combined = W(P'W)⁻¹Q ← unrotated, rotation-invariant
162161
│ L = E_target @ W ← full-vocab projection (rotation target)
163-
│ rotate("varimax" | "promax" | "raw") → W_pre
162+
plskit.rotate("varimax" | "raw") → W_pre
164163
│ recompute dim scores: T_pre[:, i] = Xs @ W_pre[:, i]
165164
│ reorder dims by |corr(T_pre_i, ys)| desc; sign-flip so corr > 0
166-
│ → W_rot, T_rot, rotation_meta (R, order, signs, sweeps, phi, pattern, …)
167-
├─ shared model-level p-value: perm / split / split_cal (same backends as fit_pls)
165+
│ → W_rot, T_rot, rotation_meta (R, order, signs, sweeps, V_converged)
166+
├─ shared model-level p-value: raw_perm / split_nb / split_perm (same helper as fit_pls)
168167
└─ wrap into MultiPLSResult with leaves:
169-
"dim-1", …, "dim-k" → β_i = W_rot[:, i] (pattern column for promax)
168+
"dim-1", …, "dim-k" → β_i = W_rot[:, i]
170169
"combined" → β = β_combined (unrotated prediction direction)
171170
172171
@@ -389,10 +388,10 @@ Analytic F-test from OLS in PCA space. Tests the null that all PCA-space regress
389388

390389
| name | idea | cost | control |
391390
|---|---|---|---|
392-
| `"perm"` | shuffle y, refit PLS with CV, compare observed CV-R² to null | `n_perm` PLS refits | exact under permutation |
393-
| `"split"` | repeated train/test split, correlate predictions, overlap-corrected t | `n_splits` PLS fits | asymptotic |
394-
| `"split_cal"` | run the full split procedure on permuted y → exact null | `n_splits × n_perm` PLS fits | exact |
395-
| `"auto"` | `"split"` for `n_components=1`, `"perm"` otherwise |||
391+
| `"raw_perm"` | shuffle y, refit PLS with CV, compare observed CV-R² to null | `n_perm` PLS refits | exact under permutation |
392+
| `"split_nb"` | repeated train/test split, correlate predictions, overlap-corrected t | `n_splits` PLS fits | asymptotic |
393+
| `"split_perm"` | run the full split procedure on permuted y → exact null | `n_splits × n_perm` PLS fits | exact |
394+
| `"auto"` | `"split_nb"` for `n_components=1`, `"raw_perm"` otherwise |||
396395

397396
All three are exposed as `result.test(name, **params)` for reruns; `result.stats.pvalue` is propagated by the `_on_rerun` hook.
398397

docs/results.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Every result exposes its data through **views** — small objects you can print,
5757
|---|---|---|
5858
| `View[T]` | Tabular. Iterates frozen dataclasses. | `result.words`, `result.docs` |
5959
| `ScalarView` | Single row. Field access via `.r2` or `["r2"]`. | `result.stats`, `result.fit_info` |
60-
| `TestView` | Callable ScalarView — calling it reruns the test. | `result.test("perm", n_perm=5000)` |
60+
| `TestView` | Callable ScalarView — calling it reruns the test. | `result.test("raw_perm", n_perm=5000)` |
6161

6262
All views share the same output methods:
6363

@@ -225,14 +225,14 @@ Leaf keys are `"dim-1"`, …, `"dim-k"`, `"combined"` (strings). `res.words` / `
225225
`res.test(...)` runs one whole-model test — CV-R² is a model-level quantity, rotation is free for prediction. Same three backends as `PLSResult`:
226226

227227
```python
228-
res.test("split", n_splits=100)
229-
res.test("perm", n_perm=2000)
230-
res.test("split_cal", n_splits=50, n_perm=2000)
228+
res.test("split_nb", n_splits=100)
229+
res.test("raw_perm", n_perm=2000)
230+
res.test("split_perm", n_splits=50, n_perm=2000)
231231
```
232232

233233
### Rotation diagnostics
234234

235-
`res.pls_info` exposes: `n_components`, `rotate`, `pca_k`, `order`, `signs`, `kaiser_normalized`, `sweeps`, `V_converged`, `kappa`, `pvalue_source`, `random_state`.
235+
`res.pls_info` exposes: `n_components`, `rotate`, `order`, `signs`, `kaiser_normalized`, `sweeps`, `V_converged`, `pvalue_source`, `random_state`.
236236

237237
### Minimal report (v1)
238238

@@ -328,12 +328,12 @@ Unsupported extensions raise `ValueError` with the list of supported ones.
328328

329329
**PLS:**
330330
```python
331-
result.test("perm", n_perm=5000, seed=0)
332-
result.test("split", n_splits=100, split_ratio=0.5)
333-
result.test("split_cal", n_splits=50, n_perm=2000)
331+
result.test("raw_perm", n_perm=5000, seed=0)
332+
result.test("split_nb", n_splits=100)
333+
result.test("split_perm", n_splits=50, n_perm=2000)
334334

335335
result.test.pvalue # current p-value
336-
result.test.name # "perm" | "split" | "split_cal"
336+
result.test.name # "raw_perm" | "split_nb" | "split_perm"
337337
```
338338

339339
**Groups:**
@@ -474,7 +474,7 @@ Missing deps raise `ImportError` with an install hint.
474474
| Doc + raw text | `result.docs.id(42)` |
475475
| Recompute snippets | `result.snippets(top_per_side=200, min_cosine=0.4)` |
476476
| Snippets inside cluster 3 | `result.clusters.pos.snippets(cluster_id=3)` |
477-
| Rerun PLS test | `result.test("perm", n_perm=5000)` |
477+
| Rerun PLS test | `result.test("raw_perm", n_perm=5000)` |
478478
| Rerun group test | `gr.test(n_perm=10000, correction="fdr_bh")` |
479479
| Zoom to one pair | `gr[('g1','g2')]` |
480480
| Per-pair top words | `gr[('g1','g2')].words.pos(20)` |

0 commit comments

Comments
 (0)