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
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ jobs:
- name: Install uv
run: pip install uv

- name: Install ruff
run: uv pip install --system ruff
- name: Install package and dev dependencies
run: uv pip install --system -e ".[dev]"

- name: Run ruff check
run: ruff check plsdo/ tests/ scripts/

- name: Run ruff
run: ruff check plsdo/ tests/
- name: Run ruff format check
run: ruff format --check plsdo/ tests/ scripts/

- name: Check version consistency
run: python scripts/check_version.py
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ jobs:
uv venv
uv pip install -e ".[dev]"

- name: Run ruff
run: uv run ruff check plsdo/ tests/
- name: Run ruff check
run: uv run ruff check plsdo/ tests/ scripts/

- name: Run ruff format check
run: uv run ruff format --check plsdo/ tests/ scripts/

- name: Check version consistency
run: python scripts/check_version.py
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Run `pre-commit install` once to enable these hooks locally.
# Keep this rev in step with the ruff pin in pyproject.toml; bump both together.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.11
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ uv venv .venv && source .venv/bin/activate
uv pip install -e ".[dev]"
```

The `dev` extra includes the test runner, coverage, and the linter. Cross-
validation additionally requires the `cv` extra (`uv pip install -e ".[dev,cv]"`).
The `dev` extra includes the test runner, coverage, the linter, and
`pre-commit`. Cross-validation additionally requires the `cv` extra
(`uv pip install -e ".[dev,cv]"`).

Install the git hooks once so ruff runs automatically on every commit:

```bash
pre-commit install
```

## Running tests

Expand Down
4 changes: 1 addition & 3 deletions plsdo/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ def permutation_test(self, n_perms: int = 10000) -> None:
perm_s_list.append(perm_s)

self.permuted_singular_values = np.stack(perm_s_list, axis=1)
self.p_values = corrected_pvalue(
self.s, self.permuted_singular_values, axis=1
)
self.p_values = corrected_pvalue(self.s, self.permuted_singular_values, axis=1)
self.significant_lvs = self.p_values < 0.05
self._permuted = True

Expand Down
1 change: 1 addition & 0 deletions plsdo/cross_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import numpy as np
import pandas as pd

try:
from sklearn.cross_decomposition import PLSRegression
from sklearn.metrics import (
Expand Down
15 changes: 4 additions & 11 deletions plsdo/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,7 @@ def align_subjects(
col = sid[0]
id_sets = [set(df[col]) for df in dfs]
else:
id_sets = [
set(df[sid].itertuples(index=False, name=None)) for df in dfs
]
id_sets = [set(df[sid].itertuples(index=False, name=None)) for df in dfs]

shared_ids = id_sets[0]
for s in id_sets[1:]:
Expand Down Expand Up @@ -203,8 +201,7 @@ def align_subjects(
ordered_df = pd.DataFrame(ordered_tuples, columns=sid)

aligned = [
ordered_df.merge(df, on=sid, how="left").reset_index(drop=True)
for df in dfs
ordered_df.merge(df, on=sid, how="left").reset_index(drop=True) for df in dfs
]
return aligned

Expand Down Expand Up @@ -345,9 +342,7 @@ class GroupConfig:
groups: list[GroupSpec] = field(default_factory=list)

@classmethod
def from_group_col(
cls, group_col: str, subject_id: SubjectID | None = None
):
def from_group_col(cls, group_col: str, subject_id: SubjectID | None = None):
"""Create a config from a single --group-col string."""
return cls(
subject_id=subject_id,
Expand All @@ -368,9 +363,7 @@ def x_axis_group(self) -> Optional[GroupSpec]:

def hue_column(self) -> Optional[str]:
"""Column name of the group with role 'hue', or None if none."""
return next(
(g.column for g in self.active_groups() if g.role == "hue"), None
)
return next((g.column for g in self.active_groups() if g.role == "hue"), None)

def facet_rows_column(self) -> Optional[str]:
"""Column name of the group with role 'facet_rows', or None if none."""
Expand Down
2 changes: 1 addition & 1 deletion plsdo/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def run_pipeline(
if len(active) == 1:
prefix = active[0].column + "_"
x_display_names = [
name[len(prefix):] if name.startswith(prefix) else name
name[len(prefix) :] if name.startswith(prefix) else name
for name in x_feature_names
]
else:
Expand Down
32 changes: 21 additions & 11 deletions plsdo/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,15 @@ def plot_heatmap(
fmt=".2f" if annotate else "",
)
ax.set_xticklabels(
ax.get_xticklabels(), rotation=45, ha="right",
ax.get_xticklabels(),
rotation=45,
ha="right",
fontsize=tick_fontsize,
)
ax.set_yticklabels(
ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize,
ax.get_yticklabels(),
rotation=0,
fontsize=tick_fontsize,
)
if subtitle:
fig.suptitle(subtitle)
Expand Down Expand Up @@ -195,9 +199,7 @@ def _heatmap_with_colour_bars(
ax.set_xticklabels(
ax.get_xticklabels(), rotation=45, ha="right", fontsize=tick_fontsize
)
ax.set_yticklabels(
ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize
)
ax.set_yticklabels(ax.get_yticklabels(), rotation=0, fontsize=tick_fontsize)
if subtitle:
g.figure.suptitle(subtitle)
g.savefig(out_path, transparent=False, dpi=dpi)
Expand Down Expand Up @@ -352,16 +354,24 @@ def _box_strip_facet(
g = sns.FacetGrid(data=data, col=col, sharex=False, **grid_kwargs)
g.map_dataframe(
sns.boxplot,
x=x, y=y, hue=hue,
order=order, hue_order=hue_order,
palette=palette, dodge=box_dodge,
x=x,
y=y,
hue=hue,
order=order,
hue_order=hue_order,
palette=palette,
dodge=box_dodge,
**_BOXPLOT_STYLE,
)
g.map_dataframe(
sns.stripplot,
x=x, y=y, hue=hue,
order=order, hue_order=hue_order,
palette=palette, dodge=strip_dodge,
x=x,
y=y,
hue=hue,
order=order,
hue_order=hue_order,
palette=palette,
dodge=strip_dodge,
**_STRIPPLOT_STYLE,
)
if rotate_xticklabels:
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ cv = [
dev = [
"pytest",
"pytest-cov",
"ruff",
"ruff==0.15.11",
"pre-commit",
"scikit-learn",
]

Expand Down
3 changes: 2 additions & 1 deletion scripts/check_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def main() -> None:
ROOT / "plsdo" / "__init__.py", r'__version__\s*=\s*"([^"]+)"'
)
citation_version = _extract(
ROOT / "CITATION.cff", r'^version:\s*"?([^"\n]+)"?',
ROOT / "CITATION.cff",
r'^version:\s*"?([^"\n]+)"?',
)

error = compare(package_version, citation_version)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def test_sign_convention_largest_loading_positive(self, x_array, y_array):
col = model.u_loadings[:, i]
assert col[np.argmax(np.abs(col))] > 0


class TestPermutationTest:
def _fitted_model(self, x_array, y_array):
from plsdo.io import zscore_columns
Expand Down Expand Up @@ -198,6 +199,7 @@ def test_seed_reproducibility(self, x_array, y_array):

np.testing.assert_array_equal(m1.u_bootstrap_ratios, m2.u_bootstrap_ratios)


class TestBootstrapZscoreX:
def test_zscore_x_false_does_not_alter_dummy_x(self):
"""Bootstrap with zscore_x=False must leave integer dummy X unchanged."""
Expand Down
6 changes: 5 additions & 1 deletion tests/test_cross_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ def test_missing_sklearn_raises_helpful_error(self, monkeypatch):
import sys

for mod in list(sys.modules):
if mod == "sklearn" or mod.startswith("sklearn.") or mod == "plsdo.cross_validate":
if (
mod == "sklearn"
or mod.startswith("sklearn.")
or mod == "plsdo.cross_validate"
):
monkeypatch.delitem(sys.modules, mod, raising=False)

real_import = builtins.__import__
Expand Down
12 changes: 3 additions & 9 deletions tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,22 +211,16 @@ def test_multi_column_drops_non_shared(self, caplog):
assert "not present in all files" in caplog.text

def test_multi_column_empty_intersection_raises(self):
df1 = pd.DataFrame(
{"sid": ["a", "a"], "run": [1, 2], "v1": [10, 20]}
)
df2 = pd.DataFrame(
{"sid": ["b", "b"], "run": [1, 2], "v2": [30, 40]}
)
df1 = pd.DataFrame({"sid": ["a", "a"], "run": [1, 2], "v1": [10, 20]})
df2 = pd.DataFrame({"sid": ["b", "b"], "run": [1, 2], "v2": [30, 40]})
with pytest.raises(ValueError, match="No subjects shared"):
align_subjects([df1, df2], subject_id=["sid", "run"])

def test_single_element_list_matches_string(self):
df1 = pd.DataFrame({"id": ["a", "b", "c"], "v1": [1, 2, 3]})
df2 = pd.DataFrame({"id": ["c", "a", "b"], "v2": [30, 10, 20]})
aligned_str = align_subjects([df1.copy(), df2.copy()], subject_id="id")
aligned_list = align_subjects(
[df1.copy(), df2.copy()], subject_id=["id"]
)
aligned_list = align_subjects([df1.copy(), df2.copy()], subject_id=["id"])
for a, b in zip(aligned_str, aligned_list):
pd.testing.assert_frame_equal(a, b)

Expand Down
5 changes: 2 additions & 3 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def test_explicit_override_bypasses_guard(self, figures_dir, caplog):
assert len(produced) > 1
assert "scree.svg" in produced


class TestMultiIndexSubjectScores:
"""Integration: compound subject ID produces a two-level index in CSV."""

Expand Down Expand Up @@ -471,9 +472,7 @@ def test_facet_cols_moves_lv_to_rows(self, monkeypatch, tmp_path):
def test_default_layout_threads_col_wrap(self, monkeypatch, tmp_path):
"""In the default layout (LV on columns, no facet) facet_col_wrap is
passed through to control column wrapping."""
config = GroupConfig(
groups=[GroupSpec("group", "x_axis", facet_col_wrap=3)]
)
config = GroupConfig(groups=[GroupSpec("group", "x_axis", facet_col_wrap=3)])
calls = self._capture_boxstrip_calls(config, monkeypatch, tmp_path)
assert calls[0]["col_col"] == "LV"
assert calls[0]["row_col"] is None
Expand Down
32 changes: 19 additions & 13 deletions tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,21 @@ def test_box_and_strip_receive_same_palette_dict(self, tmp_output, monkeypatch):
real_stripplot = sns.stripplot

def spy_boxplot(*args, **kwargs):
captured["boxplot"].append({
"palette": kwargs.get("palette"),
"hue_order": kwargs.get("hue_order"),
})
captured["boxplot"].append(
{
"palette": kwargs.get("palette"),
"hue_order": kwargs.get("hue_order"),
}
)
return real_boxplot(*args, **kwargs)

def spy_stripplot(*args, **kwargs):
captured["stripplot"].append({
"palette": kwargs.get("palette"),
"hue_order": kwargs.get("hue_order"),
})
captured["stripplot"].append(
{
"palette": kwargs.get("palette"),
"hue_order": kwargs.get("hue_order"),
}
)
return real_stripplot(*args, **kwargs)

monkeypatch.setattr(plotting_mod.sns, "boxplot", spy_boxplot)
Expand Down Expand Up @@ -272,7 +276,9 @@ def test_non_alphabetical_order_colours_match(self, tmp_output, monkeypatch):
rows.append({"group": g, "score": rng.standard_normal(), "LV": "LV1"})
scores_df = pd.DataFrame(rows)
scores_df["group"] = pd.Categorical(
scores_df["group"], categories=cat_order, ordered=True,
scores_df["group"],
categories=cat_order,
ordered=True,
)

# Prevent plt.close so we can inspect the rendered figure
Expand Down Expand Up @@ -306,9 +312,7 @@ def test_non_alphabetical_order_colours_match(self, tmp_output, monkeypatch):
continue
for offset, fc in zip(offsets, fcs):
x_pos = round(offset[0])
strip_colours.setdefault(x_pos, set()).add(
tuple(fc[:3].round(4))
)
strip_colours.setdefault(x_pos, set()).add(tuple(fc[:3].round(4)))

# Each box patch colour should match the strip colour at the
# same position.
Expand Down Expand Up @@ -341,7 +345,9 @@ def _facet_df(self):
}
)
df = pd.DataFrame(rows)
df["group"] = pd.Categorical(df["group"], categories=["A", "B", "C"], ordered=True)
df["group"] = pd.Categorical(
df["group"], categories=["A", "B", "C"], ordered=True
)
return df

def test_row_col_produces_grid_rows(self, tmp_output, monkeypatch):
Expand Down
Loading