From a779e7bee6dc6791c1ff9666e781e4ec83e03c62 Mon Sep 17 00:00:00 2001 From: Donncha O'Toole Date: Tue, 8 Sep 2026 13:22:46 +0100 Subject: [PATCH 1/2] Apply Wald thresholds to the scalar contrast effect --- src/pydeseq2/glm.py | 12 +++++--- tests/test_wald_contrasts.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 tests/test_wald_contrasts.py diff --git a/src/pydeseq2/glm.py b/src/pydeseq2/glm.py index aa058dfe..05073f14 100644 --- a/src/pydeseq2/glm.py +++ b/src/pydeseq2/glm.py @@ -246,18 +246,22 @@ def wald_test( # Evaluate standard error and Wald statistic wald_se: float = np.sqrt(Hc.T @ M @ Hc) + # Apply the null and directional truncation to the tested scalar effect. + # Applying them coefficient-wise depends on the design parameterization. + contrast_lfc = contrast @ lfc + def greater(lfc_null): - stat = contrast @ np.fmax((lfc - lfc_null) / wald_se, 0) + stat = np.fmax((contrast_lfc - lfc_null) / wald_se, 0) pval = norm.sf(stat) return stat, pval def less(lfc_null): - stat = contrast @ np.fmin((lfc - lfc_null) / wald_se, 0) + stat = np.fmin((contrast_lfc - lfc_null) / wald_se, 0) pval = norm.sf(np.abs(stat)) return stat, pval def greater_abs(lfc_null): - stat = contrast @ (np.sign(lfc) * np.fmax((np.abs(lfc) - lfc_null) / wald_se, 0)) + stat = np.sign(contrast_lfc) * np.fmax((np.abs(contrast_lfc) - lfc_null) / wald_se, 0) pval = 2 * norm.sf(np.abs(stat)) # Only case where the test is two-tailed return stat, pval @@ -276,7 +280,7 @@ def less_abs(lfc_null): "less": less(lfc_null), }[alt_hypothesis] else: - wald_statistic = float(contrast @ (lfc - lfc_null) / wald_se) + wald_statistic = float((contrast_lfc - lfc_null) / wald_se) wald_p_value = 2 * norm.sf(np.abs(wald_statistic)) return wald_p_value, wald_statistic, wald_se diff --git a/tests/test_wald_contrasts.py b/tests/test_wald_contrasts.py new file mode 100644 index 00000000..e7321f39 --- /dev/null +++ b/tests/test_wald_contrasts.py @@ -0,0 +1,53 @@ +import numpy as np +import pytest +from scipy.stats import norm + +from pydeseq2.glm import wald_test + + +@pytest.mark.parametrize( + "alternative", [None, "greater", "less", "greaterAbs", "lessAbs"] +) +@pytest.mark.parametrize("null", [0.0, 1.5]) +@pytest.mark.parametrize( + "contrast", + [[0.0, -1.0, 1.0], [0.0, 1.0, -1.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]], +) +def test_wald_tests_the_scalar_contrast(alternative, null, contrast): + # All tests concern the scalar effect c @ beta, including multi-coefficient + # and reversed contrasts. Thresholding individual coefficients is not invariant + # to the chosen model parameterization. + design = np.column_stack([np.ones(12), np.tile([0, 1, 0], 4), np.tile([0, 0, 1], 4)]) + beta = np.array([4.0, 2.0, 3.0]) + contrast = np.asarray(contrast) + mu = np.exp(design @ beta) + dispersion = 0.1 + ridge = np.eye(3) * 1e-6 + pvalue, statistic, se = wald_test( + design, dispersion, beta, mu, ridge, contrast, null, alternative + ) + weight = mu / (1 + mu * dispersion) + information = design.T @ (weight[:, None] * design) + inverse = np.linalg.inv(information + ridge) + expected_se = np.sqrt(contrast @ inverse @ information @ inverse @ contrast) + effect = contrast @ beta + if alternative is None: + expected_stat = (effect - null) / expected_se + expected_p = 2 * norm.sf(abs(expected_stat)) + elif alternative == "greater": + expected_stat = max((effect - null) / expected_se, 0) + expected_p = norm.sf(expected_stat) + elif alternative == "less": + expected_stat = min((effect - null) / expected_se, 0) + expected_p = norm.sf(abs(expected_stat)) + elif alternative == "greaterAbs": + expected_stat = np.sign(effect) * max((abs(effect) - null) / expected_se, 0) + expected_p = 2 * norm.sf(abs(expected_stat)) + else: + above = max((effect + abs(null)) / expected_se, 0) + below = min((effect - abs(null)) / expected_se, 0) + expected_stat = min(above, below, key=abs) + expected_p = max(norm.sf(abs(above)), norm.sf(abs(below))) + np.testing.assert_allclose(se, expected_se) + np.testing.assert_allclose(statistic, expected_stat) + np.testing.assert_allclose(pvalue, expected_p, atol=0) From 68f16c538f4e93ce3f2880aa4e713a330496a898 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:23:48 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pydeseq2/glm.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pydeseq2/glm.py b/src/pydeseq2/glm.py index 05073f14..2e4175b6 100644 --- a/src/pydeseq2/glm.py +++ b/src/pydeseq2/glm.py @@ -261,7 +261,9 @@ def less(lfc_null): return stat, pval def greater_abs(lfc_null): - stat = np.sign(contrast_lfc) * np.fmax((np.abs(contrast_lfc) - lfc_null) / wald_se, 0) + stat = np.sign(contrast_lfc) * np.fmax( + (np.abs(contrast_lfc) - lfc_null) / wald_se, 0 + ) pval = 2 * norm.sf(np.abs(stat)) # Only case where the test is two-tailed return stat, pval