Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A loud warning when a run finds no latent variable that is both significant
and reliable, so the null result is not left implicit in an empty
`significant_lvs` list; in that case `subject_scores.csv` is not written.
- `cv` as a short alias for the `cross-validate` subcommand.
- Test coverage reporting (`pytest-cov`) with a 95% floor, enforced in CI.
- A CI guard that fails if `CITATION.cff` and the package version drift apart.
Expand Down
62 changes: 39 additions & 23 deletions plsdo/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
logger = logging.getLogger("plsdo")


def _write_log(output_dir: Path, params: dict) -> None:
"""Write a log.txt with run parameters."""
def _write_log(output_dir: Path, params: dict, notes: list[str] | None = None) -> None:
"""Write a log.txt with run parameters and optional trailing notes."""
log_path = output_dir / "log.txt"
with open(log_path, "w") as f:
f.write("PLS analysis log\n")
Expand All @@ -49,6 +49,10 @@ def _write_log(output_dir: Path, params: dict) -> None:
f.write("\nParameters:\n")
for k, v in params.items():
f.write(f" {k}: {v}\n")
if notes:
f.write("\nNotes:\n")
for note in notes:
f.write(f" {note}\n")


def _save_csv(data: np.ndarray, path: Path, columns=None, index=None):
Expand Down Expand Up @@ -251,31 +255,29 @@ def run_pipeline(
else:
subject_ids = list(y_aligned[sid].itertuples(index=False, name=None))
final_lv_names = [f"LV{i + 1}" for i, v in enumerate(model.final_lvs) if v]
scores_data = (
np.column_stack(
# With no final LVs there are no scores to write, so skip the file entirely.
if any(model.final_lvs):
scores_data = np.column_stack(
[
model.x_scores[:, model.final_lvs],
model.y_scores[:, model.final_lvs],
]
)
if any(model.final_lvs)
else np.empty((len(subject_ids), 0))
)
scores_cols = [f"X_{name}" for name in final_lv_names] + [
f"Y_{name}" for name in final_lv_names
]
if len(sid) == 1:
scores_df = pd.DataFrame(
scores_data, columns=scores_cols, index=subject_ids
)
scores_df.index.name = sid[0]
else:
scores_df = pd.DataFrame(
scores_data,
columns=scores_cols,
index=pd.MultiIndex.from_tuples(subject_ids, names=sid),
)
scores_df.to_csv(data_dir / "subject_scores.csv")
scores_cols = [f"X_{name}" for name in final_lv_names] + [
f"Y_{name}" for name in final_lv_names
]
if len(sid) == 1:
scores_df = pd.DataFrame(
scores_data, columns=scores_cols, index=subject_ids
)
scores_df.index.name = sid[0]
else:
scores_df = pd.DataFrame(
scores_data,
columns=scores_cols,
index=pd.MultiIndex.from_tuples(subject_ids, names=sid),
)
scores_df.to_csv(data_dir / "subject_scores.csv")

# --- Generate plots ---
ext = img_format
Expand Down Expand Up @@ -393,6 +395,16 @@ def run_pipeline(
verbose_feature_limit=verbose_feature_limit,
)

# Single source of truth for the null-result message, shared between the
# console warning and the durable log.txt note so they cannot drift.
null_result_message = (
"No latent variable was both significant (p < 0.05) and reliable "
"(|bootstrap ratio| > 1.96 on both the X and Y sides). The per-LV "
"score, loading, and bootstrap-ratio plots were skipped, and "
"subject_scores.csv was not written. (The loadings and bootstrap-ratio "
"CSVs are still written for every component.)"
)

# --- Write log ---
_write_log(
output_dir,
Expand All @@ -417,10 +429,14 @@ def run_pipeline(
"n_y_features": len(y_feature_names),
"significant_lvs": final_lv_names,
},
notes=None if final_lv_names else [null_result_message],
)

logger.info("PLS analysis complete. Results saved to: %s", output_dir)
logger.info("Significant and reliable LVs: %s", final_lv_names)
if final_lv_names:
logger.info("Significant and reliable LVs: %s", final_lv_names)
else:
logger.warning(null_result_message)


def cross_validate_pipeline(
Expand Down
106 changes: 105 additions & 1 deletion tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pandas as pd
import pytest

from plsdo import core as core_mod
from plsdo import pipeline as pipeline_mod
from plsdo.io import GroupConfig, GroupSpec
from plsdo.pipeline import (
Expand Down Expand Up @@ -144,7 +145,10 @@ def test_explicit_lower_limit_fires_guard(self, figures_dir, caplog):
class TestMultiIndexSubjectScores:
"""Integration: compound subject ID produces a two-level index in CSV."""

def test_multi_index_subject_scores_csv(self, tmp_path):
def test_multi_index_subject_scores_csv(self, tmp_path, monkeypatch):
# The synthetic multi-index data keeps no LV on its own, so force one
# to survive: the scores CSV is only written when there is a final LV.
_force_one_significant_lv(monkeypatch)
out = tmp_path / "output"
run_pipeline(
method="discriminatory",
Expand Down Expand Up @@ -281,6 +285,106 @@ def test_subject_scores_one_row_per_subject_with_paired_lvs(self, run_out):
assert len(x_cols) == len(y_cols)


def _force_no_significant_lvs(monkeypatch):
"""Make filter_lvs drop every LV, simulating a null result."""
original = core_mod.PLS.filter_lvs

def zero_filter(self, *args, **kwargs):
original(self, *args, **kwargs)
self.final_lvs = np.zeros(len(self.s), dtype=bool)

monkeypatch.setattr(core_mod.PLS, "filter_lvs", zero_filter)


def _force_one_significant_lv(monkeypatch):
"""Make filter_lvs keep exactly the first LV, simulating a real result."""
original = core_mod.PLS.filter_lvs

def one_filter(self, *args, **kwargs):
original(self, *args, **kwargs)
mask = np.zeros(len(self.s), dtype=bool)
mask[0] = True
self.final_lvs = mask

monkeypatch.setattr(core_mod.PLS, "filter_lvs", one_filter)


def _capture_final_lvs(monkeypatch):
"""Record model.final_lvs after filter_lvs runs, without altering it.

Returns a list the spy appends the surviving-LV mask to.
"""
captured = []
original = core_mod.PLS.filter_lvs

def capturing_filter(self, *args, **kwargs):
original(self, *args, **kwargs)
captured.append(self.final_lvs.copy())

monkeypatch.setattr(core_mod.PLS, "filter_lvs", capturing_filter)
return captured


def test_synthetic_data_yields_a_surviving_lv(tmp_path, monkeypatch):
"""Several CSV-reading tests (e.g. those reading subject_scores.csv) assume
a normal discriminatory run on the synthetic data keeps at least one LV.
Make that assumption explicit so it fails loudly if the data ever drifts."""
captured = _capture_final_lvs(monkeypatch)
_run("discriminatory", tmp_path / "out")
# Assert the assumption only — a surviving LV in the final mask — without
# coupling to how many times filter_lvs happens to be called.
assert captured and any(captured[-1])


def _warning_records(caplog):
return [r for r in caplog.records if r.levelname == "WARNING"]


class TestNullResultWarning:
"""A null result (no significant + reliable LV) must be announced loudly,
not left implicit in an empty `significant_lvs` list with the scores CSV
silently omitted."""

def test_warns_when_no_lvs_survive(self, tmp_path, monkeypatch, caplog):
_force_no_significant_lvs(monkeypatch)
with caplog.at_level(logging.WARNING, logger="plsdo"):
_run("discriminatory", tmp_path / "out")
assert any(
"no latent variable" in r.message.lower() for r in _warning_records(caplog)
)

def test_no_warning_when_lvs_survive(self, tmp_path, monkeypatch, caplog):
# A normal run on the synthetic data keeps at least one LV.
with caplog.at_level(logging.WARNING, logger="plsdo"):
_run("discriminatory", tmp_path / "out")
assert not any(
"no latent variable" in r.message.lower() for r in _warning_records(caplog)
)

def test_no_scores_csv_when_no_lvs_survive(self, tmp_path, monkeypatch):
_force_no_significant_lvs(monkeypatch)
out = tmp_path / "out"
_run("discriminatory", out)
assert not (out / "data" / "subject_scores.csv").exists()

def test_null_result_recorded_in_log(self, tmp_path, monkeypatch):
"""The null-result warning must also be persisted durably in log.txt,
not only emitted to the console."""
_force_no_significant_lvs(monkeypatch)
out = tmp_path / "out"
_run("discriminatory", out)
log = (out / "log.txt").read_text()
assert "no latent variable" in log.lower()

def test_normal_run_log_omits_null_message(self, tmp_path):
"""A normal run keeps at least one LV, so log.txt must not contain the
null-result message."""
out = tmp_path / "out"
_run("discriminatory", out)
log = (out / "log.txt").read_text()
assert "no latent variable" not in log.lower()


class TestCrossValidatePipelineOutputs:
"""End-to-end: cross_validate_pipeline writes its CSVs, figures, and log."""

Expand Down
Loading