From b0292ee7d7e4a61bd03d001f56dc41e7efd1db6b Mon Sep 17 00:00:00 2001 From: Eilidh MacNicol Date: Fri, 5 Jun 2026 16:05:11 +0100 Subject: [PATCH 1/2] fix: form bootstrap ratios from raw saliences, not S-scaled loadings --- plsdo/core.py | 17 +++++++---------- .../correlational_x_bootstrap_ratios.npy | Bin 288 -> 288 bytes .../regression/correlational_x_loadings.npy | Bin 288 -> 288 bytes .../correlational_y_bootstrap_ratios.npy | Bin 256 -> 256 bytes .../regression/correlational_y_loadings.npy | Bin 256 -> 256 bytes .../discriminatory_x_bootstrap_ratios.npy | Bin 200 -> 200 bytes .../regression/discriminatory_x_loadings.npy | Bin 200 -> 200 bytes .../discriminatory_y_bootstrap_ratios.npy | Bin 224 -> 224 bytes .../regression/discriminatory_y_loadings.npy | Bin 224 -> 224 bytes tests/test_core.py | 17 +++++++++-------- 10 files changed, 16 insertions(+), 18 deletions(-) diff --git a/plsdo/core.py b/plsdo/core.py index 83f21d8..f18e373 100644 --- a/plsdo/core.py +++ b/plsdo/core.py @@ -50,8 +50,8 @@ def fit(self): self.xcorr = self.X.T @ self.Y / (self.n_subjects - 1) self._decompose() self._fix_component_signs() - self.u_loadings = self.u * self.s[np.newaxis, :] - self.vt_loadings = self.s[:, np.newaxis] * self.vt + self.u_loadings = self.u + self.vt_loadings = self.vt self.x_scores = self.X @ self.u self.y_scores = self.Y @ self.vt.T self._fitted = True @@ -141,22 +141,19 @@ def bootstrap(self, n_bootstraps: int = 10000) -> None: y_boot = zscore_columns(self.Y[idx, :]) boot_xcorr = x_boot.T @ y_boot / (self.n_subjects - 1) - boot_u, boot_s, boot_vt = np.linalg.svd(boot_xcorr, full_matrices=False) + boot_u, _, boot_vt = np.linalg.svd(boot_xcorr, full_matrices=False) # Procrustes: rotate bootstrap Vt to align with reference Q, _ = orthogonal_procrustes(boot_vt.T, self.vt.T) - boot_u_load = boot_u * boot_s[np.newaxis, :] - boot_vt_load = boot_s[:, np.newaxis] * boot_vt - - aligned_u_load = boot_u_load @ Q - aligned_vt_load = Q.T @ boot_vt_load + aligned_u = boot_u @ Q + aligned_vt = Q.T @ boot_vt # No separate sign correction: orthogonal_procrustes returns an # unconstrained orthogonal matrix (reflections allowed), so the # alignment above already resolves each component's arbitrary sign. - u_distribution.append(aligned_u_load) - vt_distribution.append(aligned_vt_load) + u_distribution.append(aligned_u) + vt_distribution.append(aligned_vt) self.u_se = np.std(np.stack(u_distribution, axis=2), axis=2, ddof=1) self.vt_se = np.std(np.stack(vt_distribution, axis=2), axis=2, ddof=1) diff --git a/tests/data/regression/correlational_x_bootstrap_ratios.npy b/tests/data/regression/correlational_x_bootstrap_ratios.npy index f256075eb48176f21c37f91fce5e5f318e6340ee..608c873003f844b5ac77744ec734f6474f1b2780 100644 GIT binary patch delta 168 zcmV;Z09XH@0-yqrfPdKLOY5=qNI*LhNasvdSirj~o`^uZQ$V3V(v=8 z60D&O0zl&o$;RJb_5Zl=L?I{(LWiP!AoFH&_A&!sRu|%?mq!{VWhj0^C3K$7R~A2Z5X}tuM)+t W$HKf&%}UbJ7$iN&^gb@Jga|!1JW@db diff --git a/tests/data/regression/correlational_x_loadings.npy b/tests/data/regression/correlational_x_loadings.npy index 4e25fbbc7f2836911353c2a3048817cfa3b0f2de..ebd1c528a34676209e6c8635e507c8190cb5b490 100644 GIT binary patch delta 168 zcmV;Z09XH@0-yqrfPb^OLL$&G+&?efjvPd}E)n`R;5bU!bTlyWx|Tp>J4hgUfVVu8Ffkj-{w Wttz~+6BHb>sN^v~n6-w#= diff --git a/tests/data/regression/correlational_y_bootstrap_ratios.npy b/tests/data/regression/correlational_y_bootstrap_ratios.npy index ec133852d13d0cedbcdee4695bd6cd03299de912..999d0efaca66a27248f452fb6eab91a4480c8149 100644 GIT binary patch delta 136 zcmV;30C)d@0)PULfPXJbTp+?_UBY{}THecCT|(hiYK1mHT*B5<&v#Q}T|(VYPm{Hw zSi<9oOJhxZNJ2?rQB;oxX+pp(FAPY0MnY6+2oPTKWJ1KF!8|#tVnSk9)K+#dK|&m~ qGCE1VNkaN_nwn>KNy6U?fB=(NUqa^zekiYyD#CK)l32%|X92>s$vX-F delta 136 zcmV;30C)d@0)PULfPd}(%1y^FY`|AkpP6dpYe4E9o&kxfMZk?y8)qP>YCx73Ur+37 z*uPpC+d2M;$v@;KP4E~k`agbqFKabh$Uj3Z{iK48?LWdAg()h*=s!4jSmN-B$3L^Q q+0p-e(?89$EUxC60=?f7MW!_s7d>Y6c9DYp<-8arBCw%gApyP5IztBl diff --git a/tests/data/regression/correlational_y_loadings.npy b/tests/data/regression/correlational_y_loadings.npy index c328d242e001f695037f5550146e19a1b979cde5..31f530d6f72d1bafbc38cf525d2918c64d4257ff 100644 GIT binary patch delta 136 zcmV;30C)d@0)PULfPdghZc4)>p!5J;4T@t$UhY8fj|5Q=Rdx4FkiNXfl#!#~sj delta 136 zcmV;30C)d@0)PULfPeBux{{Mz1Hfw_af+-~13>D7**!j+0>Dq^CVTl>13;oqj#NO8 zgug&Zbw&qpbU!0No91ofls{4c9Ga)ZazDplVlJzbb3bR$<5IeDaX;bRFb=E`P(ScD qvI|HLSU*kAp!WftZoE!&X^Ak`f;@1}2$&-8PrTa+2UQSei~+p-(L0U+ diff --git a/tests/data/regression/discriminatory_x_bootstrap_ratios.npy b/tests/data/regression/discriminatory_x_bootstrap_ratios.npy index ec46f1233c1987c1dc6388df67896553a2bc7d26..ea3db93851d4b1245f72b738a02f3b3df423d469 100644 GIT binary patch delta 79 zcmV-V0I>ha0muQ6fJkJ1m!$J)8NkF7k{aCo7(fL5Hww{UbH9v0J7PAE_P^~Ved85r l_P@L)PJ0vH1VDKU^Lq=I@ITz$Jh4{4@ISsQ*TYK#?my{hCaC}b delta 79 zcmV-V0I>ha0muQ6fJiW|q_W{^6u{Ed#wx&>6hMBOBn44zbH8`H5!yG>|G)LO8LrD` l^S>fGiBFL5`ai*DwV(dE3q7f+7A?*T1U*ZYDFxN;`aDblC<*`o diff --git a/tests/data/regression/discriminatory_x_loadings.npy b/tests/data/regression/discriminatory_x_loadings.npy index c516cc4272c1ebc56f21d7107f33a8dae3575461..c8c925caea0ab160516354a28d24301b95de56d8 100644 GIT binary patch delta 79 zcmX@Xc!F_4gU7l~pPKGD&-RPRPnKK1@!iks&{L@;OBFui-m6O b7rEJ}y+`4dy}a=R*X5Ou>=mH2D3k^O{fi}( delta 79 zcmX@Xc!F_4gU6zUdkudadcD8*`SbK|XI|UCy3!qz^Cx)!Cg!DA|M|_?FMA^IeUrhQ c{okH;K008(#@@~2O61SW^K87Jv>B8J0BZRzu>b%7 diff --git a/tests/data/regression/discriminatory_y_bootstrap_ratios.npy b/tests/data/regression/discriminatory_y_bootstrap_ratios.npy index 247136ea8bcab316047fccf27a4b3ffcbc3b4a80..b9fb3d934c5e37de89c5c973aa91bff505280441 100644 GIT binary patch delta 103 zcmV-t0GR*a0pJ0UfM8t&s7;(9alpq(55U_nNkEOxlwR1IO~9epZ=gH|T|m**;Q}Ug zAwVRhw)J`)+&^0DcN`Dc6~LNEvViG}-oIfXouxJo2tXmyow0|}-ar0>faH#t0YFiv J(*|pA{6Fb&F}VN$ delta 103 zcmV-t0GR*a0pJ0UfM74fyf7;QCBW}VMAEFlB|xDD-eOdEA;4Ee@xRWfB|t3=;Mq%x z{y)v|`&gZy)<4)1Yq|3o^}lXRDOIz<)W2f`%~F)0{yZ{lWZ!(r`8;@0Nsw%0{5%6g JF8r6~7(H2UGpPUo diff --git a/tests/data/regression/discriminatory_y_loadings.npy b/tests/data/regression/discriminatory_y_loadings.npy index fa0a503788706a190546c745fa7066f6c273adc8..eaf43fafd2cc0e396aa9a5654fbbd7e4b4a29781 100644 GIT binary patch delta 103 zcmV-t0GR*a0pJ0UfMB=xxdE6z;J+5Qs@Y#m;6GxI`;w4V-oG}jPm1{?{1Co<<$iahmJHnBJ2mprW- Jap`(iqCCb9G^+pr diff --git a/tests/test_core.py b/tests/test_core.py index 364da5a..e1e5168 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -36,7 +36,11 @@ def test_cross_correlation_formula(self, x_array, y_array): expected = X.T @ Y / (X.shape[0] - 1) np.testing.assert_allclose(model.xcorr, expected) - def test_loadings_are_scaled_vectors(self, x_array, y_array): + def test_loadings_are_raw_saliences(self, x_array, y_array): + """Loadings are the singular vectors (saliences) themselves, not + scaled by the singular values. Bootstrap ratios are formed from these + raw saliences (McIntosh & Lobaugh 2004; Krishnan et al. 2011), so the + singular values must not enter and contaminate the bootstrap SE.""" from plsdo.io import zscore_columns X = zscore_columns(x_array) @@ -44,10 +48,8 @@ def test_loadings_are_scaled_vectors(self, x_array, y_array): model = PLS(X, Y) model.fit() - expected_u_load = model.u @ np.diag(model.s) - expected_vt_load = np.diag(model.s) @ model.vt - np.testing.assert_allclose(model.u_loadings, expected_u_load) - np.testing.assert_allclose(model.vt_loadings, expected_vt_load) + np.testing.assert_allclose(model.u_loadings, model.u) + np.testing.assert_allclose(model.vt_loadings, model.vt) def test_scores_are_projections(self, x_array, y_array): from plsdo.io import zscore_columns @@ -462,8 +464,8 @@ def test_single_x_feature(self): def test_additive_multifactor_trailing_lv_is_inert(self): """An additive K-factor dummy design is rank-deficient by K-1. The degenerate trailing latent variable must be harmless: ~zero singular - value, non-significant, dropped by filter_lvs, with ~zero loadings. - (Guards the design without switching to contrast coding.)""" + value, non-significant, dropped by filter_lvs. (Guards the design + without switching to contrast coding.)""" from plsdo.io import zscore_columns a = np.repeat(np.arange(3), 4) # factor A, 3 levels, 12 subjects @@ -480,7 +482,6 @@ def test_additive_multifactor_trailing_lv_is_inert(self): assert model.s[-1] < 1e-8 assert not model.significant_lvs[-1] assert not model.final_lvs[-1] - assert np.abs(model.u_loadings[:, -1]).max() < 1e-6 def test_many_group_discriminatory(self): """Dummy-coded X with five groups gives min(n_groups, n_y) components.""" From 16d1a0cd25af62ddcb1f0e2fc7128e8095dc9f45 Mon Sep 17 00:00:00 2001 From: Eilidh MacNicol Date: Fri, 5 Jun 2026 16:05:37 +0100 Subject: [PATCH 2/2] chore: bump version to v0.2.0 --- CITATION.cff | 4 ++-- plsdo/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 15098a7..f4337a7 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -8,8 +8,8 @@ authors: orcid: "https://orcid.org/0000-0003-3715-7012" license: BSD-3-Clause repository-code: "https://github.com/braincentrekcl/plsdo" -version: "0.1.1" -date-released: "2026-05-22" +version: "0.2.0" +date-released: "2026-06-05" doi: 10.5281/zenodo.20333467 identifiers: - type: doi diff --git a/plsdo/__init__.py b/plsdo/__init__.py index a38f72d..dd8ffe7 100644 --- a/plsdo/__init__.py +++ b/plsdo/__init__.py @@ -1,3 +1,3 @@ """PLS covariance analysis with statistical testing and visualisation.""" -__version__ = "0.1.1" +__version__ = "0.2.0"