Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plsdo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""PLS covariance analysis with statistical testing and visualisation."""

__version__ = "0.1.1"
__version__ = "0.2.0"
17 changes: 7 additions & 10 deletions plsdo/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Binary file modified tests/data/regression/correlational_x_bootstrap_ratios.npy
Binary file not shown.
Binary file modified tests/data/regression/correlational_x_loadings.npy
Binary file not shown.
Binary file modified tests/data/regression/correlational_y_bootstrap_ratios.npy
Binary file not shown.
Binary file modified tests/data/regression/correlational_y_loadings.npy
Binary file not shown.
Binary file modified tests/data/regression/discriminatory_x_bootstrap_ratios.npy
Binary file not shown.
Binary file modified tests/data/regression/discriminatory_x_loadings.npy
Binary file not shown.
Binary file modified tests/data/regression/discriminatory_y_bootstrap_ratios.npy
Binary file not shown.
Binary file modified tests/data/regression/discriminatory_y_loadings.npy
Binary file not shown.
17 changes: 9 additions & 8 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,20 @@ 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)
Y = zscore_columns(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
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
Loading