diff --git a/CHANGELOG.md b/CHANGELOG.md index 3754935..0ba8bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.7.0 + +- Extended `__getitem__` subscripting on `RangedSummarizedExperiment` to support direct slicing by `GenomicRanges` or `CompressedGenomicRangesList` objects. + ## Version 0.6.0 - 0.6.5 - Classes now extend `BiocObject` from biocutils, which provides a metadata field. diff --git a/src/summarizedexperiment/RangedSummarizedExperiment.py b/src/summarizedexperiment/RangedSummarizedExperiment.py index abb57cf..3414ea5 100644 --- a/src/summarizedexperiment/RangedSummarizedExperiment.py +++ b/src/summarizedexperiment/RangedSummarizedExperiment.py @@ -392,6 +392,17 @@ def seq_info(self) -> SeqInfo: # rest of them are inherited from BaseSE. + def _normalize_row_slice(self, rows: Union[str, int, bool, Sequence]): + + if isinstance(rows, (GenomicRanges, CompressedGenomicRangesList)): + hits = self.row_ranges.find_overlaps(query=rows) + rows = hits.get_column("self_hits") + elif hasattr(rows, "find_overlaps"): + hits = self.row_ranges.find_overlaps(query=rows) + rows = hits.get_column("self_hits") + + return super()._normalize_row_slice(rows) + def get_slice( self, rows: Optional[Union[str, int, bool, Sequence]], diff --git a/src/summarizedexperiment/base.py b/src/summarizedexperiment/base.py index 3da43a5..fcb2e34 100644 --- a/src/summarizedexperiment/base.py +++ b/src/summarizedexperiment/base.py @@ -33,7 +33,10 @@ def _guess_assay_shape(assays, rows, cols, row_names, col_names) -> tuple: _keys = list(assays.keys()) if len(_keys) > 0: _first = _keys[0] - return assays[_first].shape + mat = assays[_first] + if not hasattr(mat, "shape"): + raise TypeError(f"Assay: '{_first}' is not a supported matrix representation.") + return mat.shape _r = 0 if rows is not None: @@ -201,14 +204,14 @@ def __init__( self._cols = _sanitize_frame(column_data, self._shape[1]) if row_names is None: - row_names = self._rows.row_names + row_names = self._rows.row_names if hasattr(self._rows, "row_names") else None if row_names is not None and not isinstance(row_names, ut.Names): row_names = ut.Names(row_names) self._row_names = row_names if column_names is None: - column_names = self._cols.row_names + column_names = self._cols.row_names if hasattr(self._cols, "row_names") else None if column_names is not None and not isinstance(column_names, ut.Names): column_names = ut.Names(column_names) @@ -1125,7 +1128,7 @@ def __getitem__( Returns: Same type as caller with the sliced rows and columns. """ - if isinstance(args, (str, int)): + if not isinstance(args, tuple): return self.get_slice(args, slice(None)) if isinstance(args, tuple): @@ -1139,8 +1142,6 @@ def __getitem__( else: raise ValueError(f"`{type(self).__name__}` only supports 2-dimensional slicing.") - raise TypeError("args must be a sequence or a scalar integer or string or a tuple of atmost 2 values.") - ################################ ######>> AnnData interop <<##### ################################ diff --git a/tests/test_RSE_methods.py b/tests/test_RSE_methods.py index 9f40a87..6c168c8 100644 --- a/tests/test_RSE_methods.py +++ b/tests/test_RSE_methods.py @@ -4,6 +4,9 @@ import numpy as np import pandas as pd import pytest +from genomicranges import GenomicRanges +from iranges import IRanges + from summarizedexperiment.RangedSummarizedExperiment import RangedSummarizedExperiment from summarizedexperiment.SummarizedExperiment import SummarizedExperiment @@ -15,42 +18,36 @@ nrows = 200 ncols = 6 counts = np.random.rand(nrows, ncols) -df_gr = pd.DataFrame( - { - "seqnames": [ - "chr1", - "chr2", - "chr2", - "chr2", - "chr1", - "chr1", - "chr3", - "chr3", - "chr3", - "chr3", - ] - * 20, - "starts": range(100, 300), - "ends": range(110, 310), - "strand": ["-", "+", "+", "*", "*", "+", "+", "+", "-", "-"] * 20, - "score": range(0, 200), - "GC": [random() for _ in range(10)] * 20, - } -) +df_gr = pd.DataFrame({ + "seqnames": [ + "chr1", + "chr2", + "chr2", + "chr2", + "chr1", + "chr1", + "chr3", + "chr3", + "chr3", + "chr3", + ] + * 20, + "starts": range(100, 300), + "ends": range(110, 310), + "strand": ["-", "+", "+", "*", "*", "+", "+", "+", "-", "-"] * 20, + "score": range(0, 200), + "GC": [random() for _ in range(10)] * 20, +}) gr = genomicranges.GenomicRanges.from_pandas(df_gr) -col_data = pd.DataFrame( - { - "treatment": ["ChIP", "Input"] * 3, - } -) +col_data = pd.DataFrame({ + "treatment": ["ChIP", "Input"] * 3, +}) def test_RSE_props(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -78,9 +75,7 @@ def test_RSE_props(): def test_RSE_subset(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -96,9 +91,7 @@ def test_RSE_subset(): def test_RSE_subset_assays(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -112,9 +105,7 @@ def test_RSE_subset_assays(): def test_RSE_coverage(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -125,9 +116,7 @@ def test_RSE_coverage(): def test_RSE_distance_methods(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -143,9 +132,7 @@ def test_RSE_distance_methods(): def test_RSE_range_methods(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -170,9 +157,7 @@ def test_RSE_range_methods(): def test_RSE_search(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -185,9 +170,7 @@ def test_RSE_search(): def test_RSE_sort_order(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -200,10 +183,9 @@ def test_RSE_sort_order(): assert res is not None assert len(res.row_ranges) == len(tse.row_ranges) + def test_RSE_assay_getters_and_setters(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) assert tse is not None assert isinstance(tse, RangedSummarizedExperiment) @@ -221,10 +203,9 @@ def test_RSE_assay_getters_and_setters(): assert tse.get_assay("new_counts") is not None assert new_tse.get_assay("new_counts") is not None + def test_RSE_to_se(): - rse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + rse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) se = rse.to_summarizedexperiment() @@ -232,10 +213,9 @@ def test_RSE_to_se(): assert isinstance(se, SummarizedExperiment) assert rse.shape == se.shape + def test_RSE_to_se_with_ranges(): - tse = RangedSummarizedExperiment( - assays={"counts": counts}, row_ranges=gr, column_data=col_data - ) + tse = RangedSummarizedExperiment(assays={"counts": counts}, row_ranges=gr, column_data=col_data) se = tse.to_summarizedexperiment() @@ -247,3 +227,62 @@ def test_RSE_to_se_with_ranges(): assert "seqnames" in se.row_data.column_names assert "starts" in se.row_data.column_names assert "ends" in se.row_data.column_names + + +def test_RSE_combine(): + gr1 = GenomicRanges(seqnames=["chr1", "chr2"], ranges=IRanges([1, 100], [10, 50]), strand=["+", "-"]) + rse1 = RangedSummarizedExperiment( + assays={"counts": np.array([[1, 2], [3, 4]])}, row_ranges=gr1, column_names=["sample1", "sample2"] + ) + + gr2 = GenomicRanges(seqnames=["chr3"], ranges=IRanges([200], [20]), strand=["*"]) + rse2 = RangedSummarizedExperiment( + assays={"counts": np.array([[5, 6]])}, row_ranges=gr2, column_names=["sample1", "sample2"] + ) + + combined_rows = rse1.combine_rows(rse2) + assert combined_rows.shape == (3, 2) + assert list(combined_rows.row_ranges.get_seqnames()) == ["chr1", "chr2", "chr3"] + + combined_rows_rel = rse1.relaxed_combine_rows(rse2) + assert combined_rows_rel.shape == (3, 2) + + rse3 = RangedSummarizedExperiment(assays={"counts": np.array([[7], [8]])}, row_ranges=gr1, column_names=["sample3"]) + combined_cols = rse1.combine_columns(rse3) + assert combined_cols.shape == (2, 3) + assert list(combined_cols.column_names) == ["sample1", "sample2", "sample3"] + + combined_cols_rel = rse1.relaxed_combine_columns(rse3) + assert combined_cols_rel.shape == (2, 3) + + +def test_RSE_genomic_slice(): + df_gr = pd.DataFrame({ + "seqnames": ["chr1", "chr2", "chr3"], + "starts": [100, 200, 300], + "ends": [150, 250, 350], + "strand": ["+", "-", "*"], + }) + gr = genomicranges.GenomicRanges.from_pandas(df_gr) + + rse = RangedSummarizedExperiment( + assays={"counts": np.random.rand(3, 2)}, + row_ranges=gr, + ) + + query_df = pd.DataFrame({ + "seqnames": ["chr2"], + "starts": [220], + "ends": [230], + "strand": ["-"], + }) + query_gr = genomicranges.GenomicRanges.from_pandas(query_df) + + sliced = rse[query_gr, :] + assert sliced is not None + assert sliced.shape == (1, 2) + assert sliced.row_ranges.get_seqnames() == ["chr2"] + + sliced_1d = rse[query_gr] + assert sliced_1d.shape == (1, 2) + assert sliced_1d.row_ranges.get_seqnames() == ["chr2"] diff --git a/tests/test_SE_validation.py b/tests/test_SE_validation.py new file mode 100644 index 0000000..5f380c0 --- /dev/null +++ b/tests/test_SE_validation.py @@ -0,0 +1,128 @@ +from copy import deepcopy + +import numpy as np +import pytest +from biocframe import BiocFrame +from genomicranges import GenomicRanges +from iranges import IRanges + +from summarizedexperiment import RangedSummarizedExperiment, SummarizedExperiment + +__author__ = "jkanche" +__copyright__ = "jkanche" +__license__ = "MIT" + + +def test_assays_validation(): + with pytest.raises(Exception): + SummarizedExperiment(assays=123) + + with pytest.raises(TypeError): + SummarizedExperiment(assays={"counts": "invalid"}) + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2, 2)}) + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 3), "log": np.random.rand(2, 4)}) + + +def test_rows_cols_validation(): + with pytest.raises(TypeError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, row_data="invalid") + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, row_data=BiocFrame({}, number_of_rows=3)) + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, row_names=["a", "b", "c"]) + + with pytest.raises(TypeError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, column_data="invalid") + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, column_data=BiocFrame({}, number_of_rows=3)) + + with pytest.raises(ValueError): + SummarizedExperiment(assays={"counts": np.random.rand(2, 2)}, column_names=["a", "b", "c"]) + + +def test_deepcopy(): + se = SummarizedExperiment( + assays={"counts": np.random.rand(2, 2)}, + row_names=["gene1", "gene2"], + column_names=["sample1", "sample2"], + metadata={"study": "cancer"}, + ) + + se_copy = deepcopy(se) + assert se_copy is not se + assert se_copy.shape == se.shape + assert list(se_copy.row_names) == list(se.row_names) + assert list(se_copy.column_names) == list(se.column_names) + assert se_copy.metadata["study"] == "cancer" + + gr = GenomicRanges(seqnames=["chr1", "chr2"], ranges=IRanges([1, 100], [10, 50]), strand=["+", "-"]) + rse = RangedSummarizedExperiment( + assays={"counts": np.random.rand(2, 2)}, + row_ranges=gr, + column_names=["sample1", "sample2"], + metadata={"study": "cancer"}, + ) + rse_copy = deepcopy(rse) + assert rse_copy is not rse + assert rse_copy.shape == rse.shape + assert len(rse_copy.row_ranges) == len(rse.row_ranges) + + +def test_properties_get_set(): + se = SummarizedExperiment( + assays={"counts": np.random.rand(2, 2)}, row_names=["gene1", "gene2"], column_names=["sample1", "sample2"] + ) + + se.assays = {"counts2": np.random.rand(2, 2)} + assert "counts2" in se.assay_names + + row_df = BiocFrame({"feat": [1, 2]}) + se.row_data = row_df + assert "feat" in se.row_data.colnames + se.rowdata = row_df + assert "feat" in se.rowdata.colnames + + col_df = BiocFrame({"sample_id": [1, 2]}) + se.col_data = col_df + assert "sample_id" in se.col_data.colnames + se.coldata = col_df + assert "sample_id" in se.coldata.colnames + se.columndata = col_df + assert "sample_id" in se.columndata.colnames + + rdata = se.get_row_data(replace_row_names=False) + cdata = se.get_column_data(replace_row_names=False) + assert rdata.row_names is None + assert cdata.row_names is None + + new_r = BiocFrame({"feat": [3, 4]}, row_names=["g1", "g2"]) + se2 = se.set_row_data(new_r, replace_row_names=True) + assert list(se2.row_names) == ["g1", "g2"] + + new_c = BiocFrame({"sample_id": [3, 4]}, row_names=["s1", "s2"]) + se3 = se.set_column_data(new_c, replace_column_names=True) + assert list(se3.column_names) == ["s1", "s2"] + + +def test_hasattr_overlaps_slice(): + import unittest.mock as mock + + gr = GenomicRanges(seqnames=["chr1", "chr2"], ranges=IRanges([1, 100], [10, 50]), strand=["+", "-"]) + rse = RangedSummarizedExperiment( + assays={"counts": np.random.rand(2, 2)}, row_ranges=gr, column_names=["sample1", "sample2"] + ) + + class OverlapMock: + def find_overlaps(self, query): + pass + + with mock.patch.object(rse.row_ranges, "find_overlaps", return_value=BiocFrame({"self_hits": [0]})): + res = rse[OverlapMock(), :] + assert res.shape == (1, 2)