diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa1f06..ff84815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/plsdo/pipeline.py b/plsdo/pipeline.py index cf7a5a7..8f20ffa 100644 --- a/plsdo/pipeline.py +++ b/plsdo/pipeline.py @@ -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") @@ -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): @@ -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 @@ -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, @@ -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( diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a11e6bc..3e0826b 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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 ( @@ -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", @@ -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."""