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" 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 f256075..608c873 100644 Binary files a/tests/data/regression/correlational_x_bootstrap_ratios.npy and b/tests/data/regression/correlational_x_bootstrap_ratios.npy differ diff --git a/tests/data/regression/correlational_x_loadings.npy b/tests/data/regression/correlational_x_loadings.npy index 4e25fbb..ebd1c52 100644 Binary files a/tests/data/regression/correlational_x_loadings.npy and b/tests/data/regression/correlational_x_loadings.npy differ diff --git a/tests/data/regression/correlational_y_bootstrap_ratios.npy b/tests/data/regression/correlational_y_bootstrap_ratios.npy index ec13385..999d0ef 100644 Binary files a/tests/data/regression/correlational_y_bootstrap_ratios.npy and b/tests/data/regression/correlational_y_bootstrap_ratios.npy differ diff --git a/tests/data/regression/correlational_y_loadings.npy b/tests/data/regression/correlational_y_loadings.npy index c328d24..31f530d 100644 Binary files a/tests/data/regression/correlational_y_loadings.npy and b/tests/data/regression/correlational_y_loadings.npy differ diff --git a/tests/data/regression/discriminatory_x_bootstrap_ratios.npy b/tests/data/regression/discriminatory_x_bootstrap_ratios.npy index ec46f12..ea3db93 100644 Binary files a/tests/data/regression/discriminatory_x_bootstrap_ratios.npy and b/tests/data/regression/discriminatory_x_bootstrap_ratios.npy differ diff --git a/tests/data/regression/discriminatory_x_loadings.npy b/tests/data/regression/discriminatory_x_loadings.npy index c516cc4..c8c925c 100644 Binary files a/tests/data/regression/discriminatory_x_loadings.npy and b/tests/data/regression/discriminatory_x_loadings.npy differ diff --git a/tests/data/regression/discriminatory_y_bootstrap_ratios.npy b/tests/data/regression/discriminatory_y_bootstrap_ratios.npy index 247136e..b9fb3d9 100644 Binary files a/tests/data/regression/discriminatory_y_bootstrap_ratios.npy and b/tests/data/regression/discriminatory_y_bootstrap_ratios.npy differ diff --git a/tests/data/regression/discriminatory_y_loadings.npy b/tests/data/regression/discriminatory_y_loadings.npy index fa0a503..eaf43fa 100644 Binary files a/tests/data/regression/discriminatory_y_loadings.npy and b/tests/data/regression/discriminatory_y_loadings.npy differ 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."""