Repository: owkin/PyDESeq2
Pull request #23 (2022-12-23 13:21:34)

CI: implement tests for count matrices
This PR implements tests on the count matrix. More precisely:

- The `DeseqDataset` class now runs `test_valid_counts` at initialisation and throws a ValueError in case of unexpected inputs,
- Pytests now test that an error is thrown in case a `DeseqDataset` is initialised with NaN, non-integer or negative values in the count matrix,
- Pytests test the behaviour of  `DeseqDataset` and `DeseqStats` when a gene only has zero counts for all samples. 


Patch:
diff --git a/pydeseq2/DeseqDataSet.py b/pydeseq2/DeseqDataSet.py
index a83e71a..b3c3a54 100644
--- a/pydeseq2/DeseqDataSet.py
+++ b/pydeseq2/DeseqDataSet.py
@@ -22,6 +22,7 @@ from pydeseq2.utils import fit_rough_dispersions
 from pydeseq2.utils import get_num_processes
 from pydeseq2.utils import irls_solver
 from pydeseq2.utils import robust_method_of_moments_disp
+from pydeseq2.utils import test_valid_counts
 from pydeseq2.utils import trimmed_mean
 
 # Ignore DomainWarning raised by statsmodels when fitting a Gamma GLM with identity link.
@@ -160,6 +161,10 @@ class DeseqDataSet:
         """Initialize the DeseqDataSet instance, computing the design matrix and
         the number of multiprocessing threads.
         """
+
+        # Test counts before going further
+        test_valid_counts(counts)
+
         self.counts = counts
         self.clinical = clinical
         self.design_factor = design_factor
diff --git a/pydeseq2/utils.py b/pydeseq2/utils.py
index 04ed005..053e63f 100644
--- a/pydeseq2/utils.py
+++ b/pydeseq2/utils.py
@@ -115,6 +115,28 @@ def load_data(
     return df
 
 
+def test_valid_counts(counts_df):
+    """Test that the count matrix contains valid inputs.
+
+    More precisely, test that inputs are non-negative integers.
+
+    Parameters
+    ----------
+    counts_df : pandas.DataFrame
+        Raw counts. One column per gene, rows are indexed by sample barcodes.
+    """
+    if counts_df.isna().any().any():
+        raise ValueError("NaNs are not allowed in the count matrix.")
+    if ~counts_df.apply(
+        lambda s: pd.to_numeric(s, errors="coerce").notnull().all()
+    ).all():
+        raise ValueError("The count matrix should only contain numbers.")
+    if (counts_df % 1 != 0).any().any():
+        raise ValueError("The count matrix should only contain integers.")
+    if (counts_df < 0).any().any():
+        raise ValueError("The count matrix should only contain non-negative values.")
+
+
 def build_design_matrix(
     clinical_df, design="high_grade", ref=None, expanded=False, intercept=True
 ):


Test patch:
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
new file mode 100644
index 0000000..3878d66
--- /dev/null
+++ b/tests/test_edge_cases.py
@@ -0,0 +1,91 @@
+import os
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+import tests
+from pydeseq2.DeseqDataSet import DeseqDataSet
+from pydeseq2.DeseqStats import DeseqStats
+
+
+def test_zero_genes():
+    """Test that genes with only 0 counts are handled by DeseqDataSet et DeseqStats,
+    and that NaNs are returned.
+    """
+
+    test_path = str(Path(os.path.realpath(tests.__file__)).parent.resolve())
+
+    counts_df = pd.read_csv(
+        os.path.join(test_path, "data/test_counts.csv"), index_col=0
+    ).T
+    clinical_df = pd.read_csv(
+        os.path.join(test_path, "data/test_clinical.csv"), index_col=0
+    )
+
+    n, m = counts_df.shape
+
+    # Introduce a few genes with fully 0 counts
+    np.random.seed(42)
+    zero_genes = counts_df.columns[np.random.choice(m, size=m // 3, replace=False)]
+    counts_df[zero_genes] = 0
+
+    # Run analysis
+    dds = DeseqDataSet(counts_df, clinical_df, design_factor="condition")
+    dds.deseq2()
+
+    # check that the corresponding parameters are NaN
+    assert dds.dispersions.loc[zero_genes].isna().all()
+    assert dds.LFCs.loc[zero_genes].isna().all().all()
+
+    res = DeseqStats(dds)
+    res_df = res.summary()
+
+    # check that the corresponding stats are NaN
+    assert (res_df.loc[zero_genes].baseMean == 0).all()
+    assert res_df.loc[zero_genes].log2FoldChange.isna().all()
+    assert res_df.loc[zero_genes].lfcSE.isna().all()
+    assert res_df.loc[zero_genes].stat.isna().all()
+    assert res_df.loc[zero_genes].pvalue.isna().all()
+    assert res_df.loc[zero_genes].padj.isna().all()
+
+
+def test_nan_counts():
+    """Test that a ValueError is thrown when the count matrix contains NaNs."""
+    counts_df = pd.DataFrame({"gene1": [0, np.nan], "gene2": [4, 12]})
+    clinical_df = pd.DataFrame({"condition": [0, 1]})
+
+    with pytest.raises(ValueError):
+        DeseqDataSet(counts_df, clinical_df, design_factor="condition")
+
+
+def test_numeric_counts():
+    """Test that a ValueError is thrown when the count matrix contains
+    non-numeric values.
+    """
+    counts_df = pd.DataFrame({"gene1": [0, "a"], "gene2": [4, 12]})
+    clinical_df = pd.DataFrame({"condition": [0, 1]})
+
+    with pytest.raises(ValueError):
+        DeseqDataSet(counts_df, clinical_df, design_factor="condition")
+
+
+def test_integer_counts():
+    """Test that a ValueError is thrown when the count matrix contains
+    non-integer values."""
+    counts_df = pd.DataFrame({"gene1": [0, 1.5], "gene2": [4, 12]})
+    clinical_df = pd.DataFrame({"condition": [0, 1]})
+
+    with pytest.raises(ValueError):
+        DeseqDataSet(counts_df, clinical_df, design_factor="condition")
+
+
+def test_non_negative_counts():
+    """Test that a ValueError is thrown when the count matrix contains
+    negative values."""
+    counts_df = pd.DataFrame({"gene1": [0, -1], "gene2": [4, 12]})
+    clinical_df = pd.DataFrame({"condition": [0, 1]})
+
+    with pytest.raises(ValueError):
+        DeseqDataSet(counts_df, clinical_df, design_factor="condition")