diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..585dcd6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: [master] + # No branch filter, so pull requests stacked on another branch are tested too. + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.2.0 + + - name: Install the package and test dependencies + run: | + uv venv --python ${{ matrix.python-version }} + uv pip install -e ".[test]" + + # addopts deselects the CLI tests, so select everything explicitly. + - name: Run the test suite + run: | + source .venv/bin/activate + python -m pytest -m "cmd or not cmd" diff --git a/.gitignore b/.gitignore index dae8aaf..cacec75 100644 --- a/.gitignore +++ b/.gitignore @@ -202,7 +202,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -tests/data - # Misc scratch/ \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index d94e3a9..7d3c113 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,13 @@ modisco - Unit tests Thanks @MuhammedHasan! + - Unit tests now cover every module, run on a committed set of real + attributions instead of downloaded data, and finish in under a minute. + CLI tests are deselected by default; run them with `pytest -m cmd`. + + - GitHub Actions runs the full test suite, including the CLI tests, on + Python 3.9 through 3.13 for every pull request and push to master. + Version 2.5.2 diff --git a/pyproject.toml b/pyproject.toml index 7471adc..104b6c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ dependencies = [ ] [project.optional-dependencies] -test = ["pytest", "pytest-cov"] +# The end-to-end regression tests expect the clusters leidenalg 0.11.0 finds. +test = ["pytest", "pytest-cov", "leidenalg==0.11.0"] [project.urls] Homepage = "https://github.com/kundajelab/tfmodisco" @@ -44,3 +45,10 @@ include = ["modiscolite"] [tool.setuptools.package-data] modiscolite = ["templates/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-m 'not cmd'" +markers = [ + "cmd: tests that invoke command-line entry points", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index f148b0d..fc8f6a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,20 +1,224 @@ -from pathlib import Path -from urllib.request import urlretrieve +# conftest.py +# Contact: Jacob Schreiber + +import os + +# Both must be set before numba and matplotlib are first imported. numba +# otherwise starts one thread per core, which oversubscribes shared machines +# and gains nothing on inputs this small. +os.environ.setdefault("NUMBA_NUM_THREADS", "4") +os.environ.setdefault("MPLBACKEND", "Agg") + +import copy +import pathlib + +import numpy import pytest +from modiscolite.core import Seqlet +from modiscolite.core import TrackSet +from modiscolite.extract_seqlets import extract_seqlets +from modiscolite.io import save_hdf5 +from modiscolite.meme_writer import MEMEWriter +from modiscolite.meme_writer import MEMEWriterMotif +from modiscolite.tfmodisco import TFMoDISco + + +DATA_DIR = pathlib.Path(__file__).parent / "data" + + +def pytest_sessionstart(session): + """Compile the lazily compiled numba kernels before any test is timed. + + The first call to each kernel compiles for 1-3 s, and every new dtype or + memory layout compiles again, so these calls cover each signature that the + library and the tests pass in. memelite caches its kernels on disk, so its + call is only slow on the first run in a fresh environment. + """ + + from memelite import tomtom + from modiscolite import affinitymat + from modiscolite import gapped_kmer + + for dtype in ('float32', 'float64'): + affinitymat.pairwise_jaccard(numpy.ones((2, 3), dtype=dtype), 1) + + X = numpy.array([[[0, 0, 1.0], [1, 1, 1.0]]]) + gapped_kmer._extract_gkmers(X, 2, 2, 1, 2, 1) + + data = numpy.ones(1) + indices = numpy.zeros(1, dtype='int64') + indptr = numpy.array([0, 1], dtype='int64') + affinitymat._sparse_mm_dot(data, indices, indptr, data, indices, indptr, 1) + + # tomtom compiles separately for C- and F-ordered queries; transposed + # PPMs are F-ordered unless they are a single position long. + pwm = numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=6).T + for Q in (numpy.ascontiguousarray(pwm), numpy.asfortranarray(pwm)): + tomtom([Q], [pwm, pwm], n_nearest=1) + + +## + + +@pytest.fixture +def meme_writer_default(): + """Clear the motif list that every default MEMEWriter shares. + + MEMEWriter's default `motifs=[]` is a single list shared by all instances, + and write_meme_from_h5 relies on that default, so without this each call + would also write the motifs from every earlier call in the session. + """ + + default = MEMEWriter.__init__.__defaults__[0] + default.clear() + yield default + default.clear() + + +## + + +@pytest.fixture(scope="session") +def _arrays(): + sequences = numpy.load(DATA_DIR / "sequences.npz")["arr_0"] + attributions = numpy.load(DATA_DIR / "attributions.npz")["arr_0"] + + one_hot = numpy.ascontiguousarray(sequences.transpose(0, 2, 1), + dtype='float32') + hypothetical_contribs = numpy.ascontiguousarray( + attributions.transpose(0, 2, 1), dtype='float32') + + one_hot.flags.writeable = False + hypothetical_contribs.flags.writeable = False + return one_hot, hypothetical_contribs + + +@pytest.fixture +def one_hot(_arrays): + return _arrays[0].copy() + + +@pytest.fixture +def hypothetical_contribs(_arrays): + return _arrays[1].copy() + + +@pytest.fixture +def contrib_scores(one_hot, hypothetical_contribs): + return one_hot * hypothetical_contribs + + +@pytest.fixture +def track_set(one_hot, hypothetical_contribs, contrib_scores): + return TrackSet(one_hot=one_hot, contrib_scores=contrib_scores, + hypothetical_contribs=hypothetical_contribs) -BASE_URL = "https://raw.githubusercontent.com/AvantiShri/model_storage/d53ee8e/modisco/gkmexplain_scores" + +## + + +@pytest.fixture(scope="session") +def _seqlet_coords(_arrays): + one_hot, hypothetical_contribs = _arrays + attributions = (one_hot * hypothetical_contribs).sum(axis=2) + + seqlets, threshold = extract_seqlets(attributions, window_size=20, + flank=5, suppress=15, target_fdr=0.05, min_passing_windows_frac=0.03, + max_passing_windows_frac=0.2, weak_threshold_for_counting_sign=0.8) + + coords = [(s.example_idx, s.start, s.end, s.is_revcomp) for s in seqlets] + return coords, threshold + + +@pytest.fixture +def seqlets(track_set, _seqlet_coords): + """All seqlets called on the fixture with a 20 bp window and 5 bp flanks.""" + + return track_set.create_seqlets([Seqlet(*c) for c in _seqlet_coords[0]]) + + +@pytest.fixture +def pos_seqlets(seqlets, _seqlet_coords): + """The first 150 seqlets whose core passes the threshold, as TFMoDISco.""" + + threshold = _seqlet_coords[1] + return [s for s in seqlets if s.contrib_scores[5:-5].sum() > threshold][:150] + + +## @pytest.fixture(scope="session") -def data_ohe_hyps(): - data_dir = Path(__file__).parent / "data" - data_dir.mkdir(parents=True, exist_ok=True) +def _signed_arrays(_arrays): + """The fixture with the attributions of the last 150 examples negated.""" + + one_hot, hypothetical_contribs = _arrays + hypothetical_contribs = hypothetical_contribs.copy() + hypothetical_contribs[150:] *= -1 + hypothetical_contribs.flags.writeable = False + return one_hot, hypothetical_contribs + + +@pytest.fixture +def signed_track_set(_signed_arrays): + one_hot, hypothetical_contribs = _signed_arrays + return TrackSet(one_hot=one_hot.copy(), + contrib_scores=one_hot*hypothetical_contribs, + hypothetical_contribs=hypothetical_contribs.copy()) + + +@pytest.fixture(scope="session") +def _patterns(_signed_arrays): + one_hot, hypothetical_contribs = _signed_arrays + + return TFMoDISco(one_hot=one_hot.copy(), + hypothetical_contribs=hypothetical_contribs.copy(), + max_seqlets_per_metacluster=150, sliding_window_size=20, + flank_size=5, trim_to_window_size=30, initial_flank_to_add=10, + target_seqlet_fdr=0.05, n_leiden_runs=2) + + +@pytest.fixture +def pos_patterns(_patterns): + """Positive patterns found on `signed_track_set`, copied per test.""" + + return copy.deepcopy(_patterns[0]) + + +@pytest.fixture +def neg_patterns(_patterns): + """Negative patterns found on `signed_track_set`, copied per test.""" + + return copy.deepcopy(_patterns[1]) + + +@pytest.fixture(scope="session") +def modisco_h5(_patterns, tmp_path_factory): + """A read-only results file holding both the positive and negative patterns.""" + + filename = tmp_path_factory.mktemp("results") / "modisco_results.h5" + save_hdf5(filename, _patterns[0], _patterns[1], window_size=300) + return filename + + +@pytest.fixture(scope="session") +def meme_db(_patterns, tmp_path_factory): + """A MEME database holding the cores of the two positive patterns followed + by three random motifs, named `db_motif_ NAME`. The negative + patterns carry the same two motifs as the positive ones.""" + + rng = numpy.random.RandomState(0) + ppms = [pattern.sequence[10:40] for pattern in _patterns[0]] + ppms += [rng.dirichlet(numpy.ones(4), size=12) for _ in range(3)] - if not (data_dir / "ohe1.npz").exists(): - urlretrieve(f"{BASE_URL}/ohe1.npz", data_dir / "ohe1.npz") + writer = MEMEWriter(memesuite_version='5', motifs=[], alphabet='ACGT', + background_frequencies='A 0.25 C 0.25 G 0.25 T 0.25') - if not (data_dir / "hypscores1.npz").exists(): - urlretrieve(f"{BASE_URL}/hypscores1.npz", data_dir / "hypscores1.npz") + for i, ppm in enumerate(ppms): + writer.add_motif(MEMEWriterMotif(name="db_motif_{} NAME{}".format(i, i), + probability_matrix=ppm, source_sites=1, alphabet='ACGT')) - return data_dir / "ohe1.npz", data_dir / "hypscores1.npz" \ No newline at end of file + filename = tmp_path_factory.mktemp("meme") / "motifs.meme" + writer.write(filename) + return filename diff --git a/tests/data/attributions.npz b/tests/data/attributions.npz new file mode 100644 index 0000000..82eec45 Binary files /dev/null and b/tests/data/attributions.npz differ diff --git a/tests/data/sequences.npz b/tests/data/sequences.npz new file mode 100644 index 0000000..5fe6f22 Binary files /dev/null and b/tests/data/sequences.npz differ diff --git a/tests/synthetic.py b/tests/synthetic.py new file mode 100644 index 0000000..1778b30 --- /dev/null +++ b/tests/synthetic.py @@ -0,0 +1,117 @@ +# synthetic.py +# Contact: Jacob Schreiber + +import numpy + +from modiscolite.core import Seqlet +from modiscolite.core import SeqletSet +from modiscolite.core import TrackSet + + +def random_one_hot(shape, random_state=0): + """Return a float64 one-hot array with shape (n, length, 4).""" + + n, length = shape + idxs = numpy.random.RandomState(random_state).randint(4, size=(n, length)) + return numpy.eye(4)[idxs] + + +def random_track_set(n=8, length=60, random_state=0): + """Return a TrackSet over random sequence with Gaussian attributions.""" + + one_hot = random_one_hot((n, length), random_state=random_state) + hypothetical_contribs = numpy.random.RandomState(random_state+1).randn( + n, length, 4) + + return TrackSet(one_hot=one_hot, contrib_scores=one_hot*hypothetical_contribs, + hypothetical_contribs=hypothetical_contribs) + + +def planted_track_set(n=12, length=80, motif_length=8, position=30, + strength=3.0, random_state=0): + """Return a TrackSet with one strong motif at `position` in every example. + + The motif is the same sequence in every example and carries attribution + `strength` per base, while the background carries small Gaussian noise. + """ + + rng = numpy.random.RandomState(random_state) + one_hot = random_one_hot((n, length), random_state=random_state) + motif = numpy.eye(4)[rng.randint(4, size=motif_length)] + one_hot[:, position:position+motif_length] = motif + + hypothetical_contribs = rng.randn(n, length, 4) * 0.05 + hypothetical_contribs[:, position:position+motif_length] += motif * strength + + return TrackSet(one_hot=one_hot, contrib_scores=one_hot*hypothetical_contribs, + hypothetical_contribs=hypothetical_contribs) + + +def two_motif_track_set(n=20, length=60, motif_length=10, position=20, + random_state=0): + """Return a TrackSet whose first n examples carry one motif and whose + last n examples carry a different one, both at `position`.""" + + ts0 = planted_track_set(n=n, length=length, motif_length=motif_length, + position=position, random_state=random_state) + ts1 = planted_track_set(n=n, length=length, motif_length=motif_length, + position=position, random_state=random_state+100) + + one_hot = numpy.concatenate([ts0.one_hot, ts1.one_hot]) + hypothetical_contribs = numpy.concatenate([ts0.hypothetical_contribs, + ts1.hypothetical_contribs]) + + return TrackSet(one_hot=one_hot, contrib_scores=one_hot*hypothetical_contribs, + hypothetical_contribs=hypothetical_contribs) + + +def make_seqlets(track_set, coords): + """Create seqlets from (example_idx, start, end, is_revcomp) tuples.""" + + return track_set.create_seqlets([Seqlet(*coord) for coord in coords]) + + +def make_pattern(track_set, coords): + """Create a SeqletSet from (example_idx, start, end, is_revcomp) tuples.""" + + return SeqletSet(make_seqlets(track_set, coords)) + + +def synthetic_patterns(track_set, n_patterns, n_seqlets, length, + random_state=0): + """Return `n_patterns` SeqletSets of `n_seqlets` random seqlets each.""" + + rng = numpy.random.RandomState(random_state) + n = len(track_set.one_hot) + + patterns = [] + for _ in range(n_patterns): + coords = [] + for _ in range(n_seqlets): + idx = int(rng.randint(n)) + start = int(rng.randint(track_set.length - length + 1)) + coords.append((idx, start, start+length, bool(rng.randint(2)))) + + patterns.append(make_pattern(track_set, coords)) + + return patterns + + +def write_peaks(filename, n, chroms=('chr1', 'chr2', 'chrX'), width=500): + """Write `n` narrowPeak rows cycling through `chroms`. + + Row i starts at 1000*i + 17 on chromosome chroms[i % len(chroms)] and has + score 100 + i. Returns the list of rows as written. + """ + + rows = [] + for i in range(n): + start = 1000*i + 17 + rows.append("\t".join(map(str, [chroms[i % len(chroms)], start, + start+width, "peak_{}".format(i), 100+i, ".", 1.5, 2.5, 3.5, + width // 2]))) + + with open(filename, "w") as outfile: + outfile.write("\n".join(rows) + "\n") + + return rows diff --git a/tests/test_affinitymat.py b/tests/test_affinitymat.py new file mode 100644 index 0000000..7af417e --- /dev/null +++ b/tests/test_affinitymat.py @@ -0,0 +1,860 @@ +# test_affinitymat.py +# Contact: Jacob Schreiber + +import numpy +import scipy.sparse +import sklearn.preprocessing +import pytest + +from modiscolite.affinitymat import _sparse_vv_dot +from modiscolite.affinitymat import _sparse_mm_dot +from modiscolite.affinitymat import cosine_similarity_from_seqlets +from modiscolite.affinitymat import jaccard_from_seqlets +from modiscolite.affinitymat import jaccard +from modiscolite.affinitymat import pairwise_jaccard +from modiscolite.affinitymat import _jaccard +from modiscolite.affinitymat import pearson_correlation +from modiscolite.affinitymat import NNTsneConditionalProbs + +from modiscolite.core import Seqlet +from modiscolite.gapped_kmer import _seqlet_to_gkmers +from modiscolite.util import get_2d_data_from_patterns + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +def _csr(n, d, density, random_state=0): + X = scipy.sparse.random(n, d, density=density, format='csr', + random_state=random_state) + X.sort_indices() + X.indices = X.indices.astype('int64') + X.indptr = X.indptr.astype('int64') + return X + + +def _cont_jaccard(x, y): + sign = numpy.sign(x) * numpy.sign(y) + ax, ay = numpy.abs(x), numpy.abs(y) + return (numpy.minimum(ax, ay) * sign).sum() / numpy.maximum(ax, ay).sum() + + +def _jaccard_reference(X, Y, min_overlap, seqlet_neighbors, func): + X, Y = X.astype('float64'), Y.astype('float64') + if seqlet_neighbors is None: + seqlet_neighbors = numpy.tile(numpy.arange(len(X)), (len(Y), 1)) + + n_pad = 0 if min_overlap is None else int(func(X.shape[1]*(1-min_overlap))) + Y = numpy.pad(Y, ((0, 0), (n_pad, n_pad), (0, 0))) + d = X.shape[1] + + results = numpy.zeros((len(Y), seqlet_neighbors.shape[1], 2)) + for l in range(len(Y)): + for i, neighbor in enumerate(seqlet_neighbors[l]): + scores = [_cont_jaccard(X[neighbor], Y[l, idx:idx+d]) + for idx in range(Y.shape[1] - d + 1)] + results[l, i] = numpy.max(scores), numpy.argmax(scores) - n_pad + + return results + + +def _pearson_reference(x, y, min_overlap, func): + n_pad = int(func(len(x) * (1 - min_overlap))) + y = numpy.pad(y, ((n_pad, n_pad), (0, 0))) + d = len(x) + + scores = [] + for idx in range(len(y) - d + 1): + y_ = y[idx:idx+d] + norm = numpy.linalg.norm(y_) + scores.append(0.0 if norm == 0 else (x * y_).sum() / + (numpy.linalg.norm(x) * norm)) + + return max(scores), numpy.argmax(scores) - n_pad + + +def _compact(X, Y): + """Dense copies of X and Y restricted to the columns either one uses. + + The gapped k-mer matrices have 5**15 columns, so transposing one or + converting it to CSC would allocate hundreds of gigabytes. + """ + + cols = numpy.union1d(X.indices, Y.indices) + dense = [] + for Z in (X, Y): + Z_ = numpy.zeros((Z.shape[0], len(cols))) + for i in range(Z.shape[0]): + idxs = slice(Z.indptr[i], Z.indptr[i+1]) + Z_[i, numpy.searchsorted(cols, Z.indices[idxs])] = Z.data[idxs] + + dense.append(Z_) + + return dense + + +def _mm_dot_reference(X, Y, k): + X, Y = _compact(X, Y) + dot = numpy.maximum(X @ X.T, X @ Y.T) + neighbors = numpy.argsort(-dot, axis=1, kind='mergesort')[:, :k] + return numpy.take_along_axis(dot, neighbors, axis=1), neighbors + + +def _pairwise_reference(X, k): + n = len(X) + J = numpy.array([[_cont_jaccard(X[i], X[j]) for j in range(n)] + for i in range(n)]) + neighbors = numpy.argsort(-J, axis=1, kind='mergesort')[:, :k] + return numpy.take_along_axis(J, neighbors, axis=1), neighbors + + +def _negated(seqlets): + """Copies of `seqlets` with the attributions negated.""" + + negated = [] + for seqlet in seqlets: + s = Seqlet(seqlet.example_idx, seqlet.start, seqlet.end, + seqlet.is_revcomp) + s.sequence = seqlet.sequence + s.contrib_scores = -seqlet.contrib_scores + s.hypothetical_contribs = -seqlet.hypothetical_contribs + negated.append(s) + + return negated + + +@pytest.fixture +def X3(): + return numpy.random.RandomState(0).randn(6, 12, 4) + + +## + + +@pytest.mark.parametrize("n,d,density", [(1, 10, 0.5), (5, 10, 0.3), + (5, 100, 0.05), (20, 50, 0.2), (8, 30, 1.0)]) +def test_sparse_vv_dot(n, d, density): + X = _csr(n, d, density, random_state=0) + Y = _csr(n, d, density, random_state=1) + dot = (X @ Y.T).toarray() + + for i in range(n): + for j in range(n): + y = _sparse_vv_dot(X.data, X.indices, X.indptr, Y.data, Y.indices, + Y.indptr, i, j) + assert abs(y - dot[i, j]) < 1e-10 + + +def test_sparse_vv_dot_values(): + X = scipy.sparse.csr_matrix(numpy.array([[1.0, 0, 2.0, 0, 3.0], + [0, 0, 0, 0, 0], [0, 4.0, 0, 5.0, 0]])) + indices, indptr = X.indices.astype('int64'), X.indptr.astype('int64') + + assert _sparse_vv_dot(X.data, indices, indptr, X.data, indices, indptr, + 0, 0) == 14.0 + assert _sparse_vv_dot(X.data, indices, indptr, X.data, indices, indptr, + 0, 2) == 0.0 + assert _sparse_vv_dot(X.data, indices, indptr, X.data, indices, indptr, + 1, 1) == 0.0 + assert _sparse_vv_dot(X.data, indices, indptr, X.data, indices, indptr, + 2, 2) == 41.0 + + +def test_sparse_vv_dot_raises(): + X = scipy.sparse.csr_matrix(numpy.eye(3)) + assert_raises(TypeError, _sparse_vv_dot, X.data, X.indices, X.indptr, + X.data, X.indices, X.indptr, 0, 0) + + +## + + +@pytest.mark.parametrize("n,d,density", [(1, 10, 0.5), (5, 10, 0.3), + (10, 100, 0.05), (30, 50, 0.2), (12, 30, 1.0)]) +@pytest.mark.parametrize("k", [1, 3, 100]) +def test_sparse_mm_dot(n, d, density, k): + X = _csr(n, d, density, random_state=0) + Y = _csr(n, d, density, random_state=1) + k = min(k, n) + + sims, neighbors = _sparse_mm_dot(X.data, X.indices, X.indptr, Y.data, + Y.indices, Y.indptr, k) + sims_ref, neighbors_ref = _mm_dot_reference(X, Y, k) + + assert sims.shape == (n, k) + assert neighbors.shape == (n, k) + assert sims.dtype == numpy.float64 + assert neighbors.dtype == numpy.int32 + assert_array_almost_equal(sims, sims_ref) + assert_array_equal(neighbors, neighbors_ref) + + +def test_sparse_mm_dot_max(): + X = scipy.sparse.csr_matrix(numpy.array([[1.0, 0.0], [0.0, 1.0]])) + Y = scipy.sparse.csr_matrix(numpy.array([[0.0, 2.0], [3.0, 0.0]])) + args = [a.astype('int64') if a.dtype.kind == 'i' else a for a in + (X.data, X.indices, X.indptr, Y.data, Y.indices, Y.indptr)] + + sims, neighbors = _sparse_mm_dot(*args, 2) + assert_array_almost_equal(sims, [[3.0, 1.0], [2.0, 1.0]]) + assert_array_equal(neighbors, [[1, 0], [0, 1]]) + + +def test_sparse_mm_dot_ties(): + # Ties keep their original order. + X = _csr(6, 4, 1.0) + X.data[:] = 1.0 + + sims, neighbors = _sparse_mm_dot(X.data, X.indices, X.indptr, X.data, + X.indices, X.indptr, 6) + assert_array_almost_equal(sims, numpy.full((6, 6), 4.0)) + assert_array_equal(neighbors, numpy.tile(numpy.arange(6), (6, 1))) + + +## + + +@pytest.mark.parametrize("n", [2, 10, 40]) +@pytest.mark.parametrize("n_neighbors", [1, 5, 20, 1000]) +def test_cosine_similarity_from_seqlets(seqlets, n, n_neighbors): + sims, neighbors = cosine_similarity_from_seqlets(seqlets[:n], + n_neighbors=n_neighbors, sign=1) + + k = min(n_neighbors + 1, n) + assert sims.shape == (n, k) + assert neighbors.shape == (n, k) + assert numpy.all(numpy.diff(sims, axis=1) <= 1e-12) + assert numpy.all(sims <= 1 + 1e-6) + assert numpy.all(neighbors < n) + + +@pytest.mark.parametrize("sign", [1, -1]) +def test_cosine_similarity_from_seqlets_reference(seqlets, sign): + seqlets = seqlets[:30] + sims, neighbors = cosine_similarity_from_seqlets(seqlets, n_neighbors=9, + sign=sign) + + X = sklearn.preprocessing.normalize(_seqlet_to_gkmers(seqlets, 20, 4, 6, + 15, 15, 500, True, sign)) + Y = sklearn.preprocessing.normalize(_seqlet_to_gkmers(seqlets, 20, 4, 6, + 15, 15, 500, False, sign)) + sims_ref, neighbors_ref = _mm_dot_reference(X, Y, 10) + + assert_array_almost_equal(sims, sims_ref) + assert_array_equal(neighbors, neighbors_ref) + + +def test_cosine_similarity_from_seqlets_self(seqlets): + sims, neighbors = cosine_similarity_from_seqlets(seqlets[:50], + n_neighbors=10, sign=1) + + assert_array_almost_equal(sims[:, 0], numpy.ones(50)) + self_sims = [sims[i][neighbors[i] == i] for i in range(50)] + assert all(len(s) == 0 or abs(s[0] - 1) < 1e-6 for s in self_sims) + + +def test_cosine_similarity_from_seqlets_sign(seqlets): + sims0, neighbors0 = cosine_similarity_from_seqlets(seqlets[:30], + n_neighbors=5, sign=1) + sims1, neighbors1 = cosine_similarity_from_seqlets(_negated(seqlets[:30]), + n_neighbors=5, sign=-1) + + assert_array_almost_equal(sims0, sims1) + assert_array_equal(neighbors0, neighbors1) + + +@pytest.mark.parametrize("kwargs", [{'topn': 10}, {'topn': 30}, + {'min_k': 3, 'max_k': 5}, {'min_k': 2, 'max_k': 3}, {'max_gap': 5}, + {'max_len': 14}, {'max_entries': 50}, {'alphabet_size': 8}]) +def test_cosine_similarity_from_seqlets_kwargs(seqlets, kwargs): + params = dict(topn=20, min_k=4, max_k=6, max_gap=15, max_len=15, + max_entries=500) + params.update(kwargs) + + sims, neighbors = cosine_similarity_from_seqlets(seqlets[:25], + n_neighbors=6, sign=1, **kwargs) + + X = sklearn.preprocessing.normalize(_seqlet_to_gkmers(seqlets[:25], + params['topn'], params['min_k'], params['max_k'], params['max_gap'], + params['max_len'], params['max_entries'], True, 1)) + Y = sklearn.preprocessing.normalize(_seqlet_to_gkmers(seqlets[:25], + params['topn'], params['min_k'], params['max_k'], params['max_gap'], + params['max_len'], params['max_entries'], False, 1)) + sims_ref, neighbors_ref = _mm_dot_reference(X, Y, 7) + + assert_array_almost_equal(sims, sims_ref) + assert_array_equal(neighbors, neighbors_ref) + + +@pytest.mark.skip(reason="bug: for max_len <= 13, 5**max_len fits in int32 " + "so scipy builds the gapped k-mer matrix with int32 indices, which " + "_sparse_vv_dot's int64-only signature rejects with a TypingError") +@pytest.mark.parametrize("max_len", [8, 10, 13]) +def test_cosine_similarity_from_seqlets_max_len(seqlets, max_len): + sims, neighbors = cosine_similarity_from_seqlets(seqlets[:25], + n_neighbors=6, sign=1, max_gap=5, max_len=max_len) + assert sims.shape == (25, 7) + + +def test_cosine_similarity_from_seqlets_revcomp(seqlets): + # A seqlet and its reverse complement are near-perfect matches through + # the backward representation. + seqlets = seqlets[:10] + [s.revcomp() for s in seqlets[:10]] + sims, neighbors = cosine_similarity_from_seqlets(seqlets, n_neighbors=19, + sign=1) + + for i in range(10): + partner = numpy.where(neighbors[i] == i + 10)[0][0] + assert sims[i, partner] > 0.99 + + +def test_cosine_similarity_from_seqlets_real(seqlets): + sims, neighbors = cosine_similarity_from_seqlets(seqlets[:100], + n_neighbors=3, sign=1) + + assert_array_almost_equal(sims[:3], [ + [1. , 0.4929, 0.4917, 0.4892], + [1. , 0.478 , 0.4511, 0.416 ], + [1. , 0.4639, 0.4507, 0.4474]], 4) + assert_array_equal(neighbors[:3], [[0, 75, 41, 89], [1, 71, 39, 68], + [2, 34, 0, 45]]) + + +## + + +def _jaccard_from_seqlets_reference(seqlets, filter_seqlets, neighbors, + min_overlap): + Y = get_2d_data_from_patterns(seqlets)[0] + X_fwd, X_rev = get_2d_data_from_patterns(filter_seqlets) + + fwd = _jaccard_reference(X_fwd, Y, min_overlap, neighbors, int)[:, :, 0] + rev = _jaccard_reference(X_rev, Y, min_overlap, neighbors, int)[:, :, 0] + return numpy.maximum(fwd, rev) + + +@pytest.mark.parametrize("n,k", [(5, 1), (10, 4), (20, 20)]) +@pytest.mark.parametrize("min_overlap", [0.5, 0.7, 1.0]) +def test_jaccard_from_seqlets(seqlets, n, k, min_overlap): + seqlets = seqlets[:n] + _, neighbors = cosine_similarity_from_seqlets(seqlets, n_neighbors=k-1, + sign=1) + + affmat = jaccard_from_seqlets(seqlets, min_overlap, + seqlet_neighbors=neighbors) + expected = _jaccard_from_seqlets_reference(seqlets, seqlets, neighbors, + min_overlap) + + assert affmat.shape == (n, k) + assert affmat.dtype == numpy.float32 + assert_array_almost_equal(affmat, expected, 5) + + +def test_jaccard_from_seqlets_self(seqlets): + seqlets = seqlets[:15] + neighbors = numpy.tile(numpy.arange(15), (15, 1)) + affmat = jaccard_from_seqlets(seqlets, 0.7, seqlet_neighbors=neighbors) + + assert_array_almost_equal(numpy.diag(affmat), numpy.ones(15)) + assert numpy.all(affmat <= 1 + 1e-6) + + +def test_jaccard_from_seqlets_revcomp(seqlets): + # A seqlet matches its reverse complement perfectly through the reverse + # strand of the filters. + seqlets = seqlets[:8] + [s.revcomp() for s in seqlets[:8]] + neighbors = numpy.array([[i, (i + 8) % 16] for i in range(16)]) + affmat = jaccard_from_seqlets(seqlets, 0.7, seqlet_neighbors=neighbors) + + assert_array_almost_equal(affmat, numpy.ones((16, 2))) + + +def test_jaccard_from_seqlets_filter_seqlets(seqlets): + filters = seqlets[20:26] + neighbors = numpy.array([[0, 3, 5], [1, 1, 2], [4, 0, 2], [5, 4, 3]]) + affmat = jaccard_from_seqlets(seqlets[:4], 0.7, filter_seqlets=filters, + seqlet_neighbors=neighbors) + expected = _jaccard_from_seqlets_reference(seqlets[:4], filters, + neighbors, 0.7) + + assert affmat.shape == (4, 3) + assert_array_almost_equal(affmat, expected, 5) + + +def test_jaccard_from_seqlets_neighbor_dtype(seqlets): + neighbors = numpy.tile(numpy.arange(6), (6, 1)) + affmat0 = jaccard_from_seqlets(seqlets[:6], 0.7, + seqlet_neighbors=neighbors.astype('int32')) + affmat1 = jaccard_from_seqlets(seqlets[:6], 0.7, + seqlet_neighbors=neighbors.astype('int64')) + assert_array_equal(affmat0, affmat1) + + +def test_jaccard_from_seqlets_real(seqlets): + _, neighbors = cosine_similarity_from_seqlets(seqlets[:50], n_neighbors=3, + sign=1) + affmat = jaccard_from_seqlets(seqlets[:50], 0.7, seqlet_neighbors=neighbors) + + assert_array_almost_equal(affmat[:2], [[1.0, 0.6953, 0.5454, 0.7445], + [1.0, 0.6897, 0.6896, 0.6064]], 4) + + +@pytest.mark.skip(reason="bug: with seqlet_neighbors=None, " + "jaccard_from_seqlets builds a list of lists, which jaccard then calls " + ".astype on, raising AttributeError") +def test_jaccard_from_seqlets_default_neighbors(seqlets): + affmat = jaccard_from_seqlets(seqlets[:6], 0.7) + assert affmat.shape == (6, 6) + + +@pytest.mark.parametrize("nx,ny,d,m", [(1, 1, 5, 1), (1, 3, 5, 4), + (3, 1, 10, 4), (4, 4, 10, 8), (10, 2, 20, 8), (2, 6, 7, 2)]) +@pytest.mark.parametrize("min_overlap", [None, 0.5, 0.7, 1.0]) +def test_jaccard(nx, ny, d, m, min_overlap): + rng = numpy.random.RandomState(nx*ny) + X = rng.randn(nx, d, m) + Y = rng.randn(ny, d, m) + + results = jaccard(X, Y, min_overlap=min_overlap) + expected = _jaccard_reference(X, Y, min_overlap, None, numpy.ceil) + + assert results.shape == (ny, nx, 2) + assert_array_almost_equal(results, expected, 5) + + +@pytest.mark.parametrize("nx,ny,d,m", [(1, 1, 5, 1), (3, 1, 10, 4), + (4, 4, 10, 8), (2, 6, 7, 2)]) +@pytest.mark.parametrize("min_overlap", [None, 0.5, 0.7, 1.0]) +def test_jaccard_sparse(nx, ny, d, m, min_overlap): + rng = numpy.random.RandomState(nx*ny) + X = rng.randn(nx, d, m) + Y = rng.randn(ny, d, m) + + scores = jaccard(X, Y, min_overlap=min_overlap, return_sparse=True) + expected = _jaccard_reference(X, Y, min_overlap, None, numpy.ceil) + + assert scores.shape == (ny, nx) + assert scores.dtype == numpy.float32 + assert_array_almost_equal(scores, expected[:, :, 0], 5) + + +@pytest.mark.parametrize("func", [numpy.ceil, numpy.floor, int, round]) +@pytest.mark.parametrize("min_overlap", [0.3, 0.55, 0.7, 0.9]) +def test_jaccard_func(X3, func, min_overlap): + Y = numpy.random.RandomState(1).randn(3, 12, 4) + results = jaccard(X3, Y, min_overlap=min_overlap, func=func) + expected = _jaccard_reference(X3, Y, min_overlap, None, func) + + n_pad = int(func(12 * (1 - min_overlap))) + assert numpy.all(results[:, :, 1] >= -n_pad) + assert numpy.all(results[:, :, 1] <= n_pad) + assert_array_almost_equal(results, expected, 5) + + +@pytest.mark.parametrize("dy", [12, 15, 30]) +def test_jaccard_longer_y(X3, dy): + Y = numpy.random.RandomState(1).randn(2, dy, 4) + results = jaccard(X3, Y, min_overlap=0.5) + expected = _jaccard_reference(X3, Y, 0.5, None, numpy.ceil) + + assert_array_almost_equal(results, expected, 5) + assert numpy.all(results[:, :, 1] <= dy - 12 + 6) + + +def test_jaccard_neighbors(X3): + Y = numpy.random.RandomState(1).randn(4, 12, 4) + neighbors = numpy.array([[0, 5], [1, 1], [3, 2], [4, 0]]) + + results = jaccard(X3, Y, min_overlap=0.7, seqlet_neighbors=neighbors) + expected = _jaccard_reference(X3, Y, 0.7, neighbors, numpy.ceil) + + assert results.shape == (4, 2, 2) + assert_array_almost_equal(results, expected, 5) + + +def test_jaccard_neighbors_default(X3): + neighbors = numpy.tile(numpy.arange(6), (6, 1)) + results0 = jaccard(X3, X3, min_overlap=0.7) + results1 = jaccard(X3, X3, min_overlap=0.7, seqlet_neighbors=neighbors) + assert_array_equal(results0, results1) + + +@pytest.mark.parametrize("min_overlap", [None, 0.5, 0.9]) +def test_jaccard_self(X3, min_overlap): + results = jaccard(X3, X3, min_overlap=min_overlap) + + assert_array_almost_equal(numpy.diagonal(results[:, :, 0]), numpy.ones(6)) + assert_array_equal(numpy.diagonal(results[:, :, 1]), numpy.zeros(6)) + assert numpy.all(results[:, :, 0] <= 1 + 1e-6) + assert numpy.all(results[:, :, 0] >= -1 - 1e-6) + + +@pytest.mark.parametrize("shift", [-3, -1, 1, 2, 4]) +def test_jaccard_shift(shift): + x = numpy.zeros((1, 16, 4)) + x[0, 4:10] = numpy.random.RandomState(0).uniform(0.5, 1, size=(6, 4)) + y = numpy.roll(x, shift, axis=1) + + results = jaccard(x, y, min_overlap=0.5) + assert abs(results[0, 0, 0] - 1) < 1e-6 + assert results[0, 0, 1] == shift + + +def test_jaccard_values(): + X = numpy.array([[[1.0], [2.0]]]) + Y = numpy.array([[[2.0], [-1.0]]]) + + # (1 - 1) / (2 + 2) at zero offset. + results = jaccard(X, Y) + assert_array_almost_equal(results, [[[0.0, 0.0]]]) + + # Offsets -1, 0, 1 give 2/3, 0 and -1/3. + results = jaccard(X, Y, min_overlap=0.5) + assert_array_almost_equal(results, [[[2/3, -1]]]) + + +@pytest.mark.parametrize("scale", [0.001, 3.0, 1000.0]) +def test_jaccard_scale(X3, scale): + results0 = jaccard(X3, X3[::-1], min_overlap=0.7) + results1 = jaccard(X3 * scale, X3[::-1] * scale, min_overlap=0.7) + assert_array_almost_equal(results0, results1, 5) + + +def test_jaccard_symmetric(X3): + results = jaccard(X3, X3) + assert_array_almost_equal(results[:, :, 0], results[:, :, 0].T, 6) + + +@pytest.mark.parametrize("dtype", ['float16', 'float32', 'float64']) +def test_jaccard_dtypes(X3, dtype): + results = jaccard(X3.astype(dtype), X3.astype(dtype), min_overlap=0.7) + assert_array_almost_equal(numpy.diagonal(results[:, :, 0]), numpy.ones(6)) + + +def test_jaccard_does_not_modify(X3): + X = X3.copy() + jaccard(X3, X3, min_overlap=0.5) + assert_array_equal(X3, X) + + +def test_jaccard_raises(): + X = numpy.ones((2, 5, 4)) + assert_raises(ValueError, jaccard, X, numpy.ones((2, 3, 4))) + + +## + + +def _raw_jaccard(X, Y, neighbors): + X = X.astype('float32') + Y = Y.astype('float32') + scores = numpy.zeros((len(Y), neighbors.shape[1], Y.shape[1]-X.shape[1]+1), + dtype='float32') + _jaccard(X, Y, neighbors.astype('int32'), scores) + return scores + + +@pytest.mark.parametrize("nx,ny,d,dy,m", [(1, 1, 4, 4, 1), (3, 2, 5, 9, 4), + (6, 6, 10, 10, 8), (2, 5, 3, 20, 2)]) +def test__jaccard(nx, ny, d, dy, m): + rng = numpy.random.RandomState(0) + X, Y = rng.randn(nx, d, m), rng.randn(ny, dy, m) + neighbors = numpy.tile(numpy.arange(nx), (ny, 1)) + + scores = _raw_jaccard(X, Y, neighbors) + for l in range(ny): + for i in range(nx): + for idx in range(dy - d + 1): + expected = _cont_jaccard(X[i], Y[l, idx:idx+d]) + assert abs(scores[l, i, idx] - expected) < 1e-5 + + +def test__jaccard_raises(): + X = numpy.ones((2, 5, 4)) + scores = numpy.zeros((2, 2, 1), dtype='float32') + neighbors = numpy.zeros((2, 2), dtype='int32') + + assert_raises(TypeError, _jaccard, X, X, neighbors, scores) + assert_raises(TypeError, _jaccard, X.astype('float32'), + X.astype('float32'), neighbors.astype('int64'), scores) + + +## + + +@pytest.mark.parametrize("n,m", [(1, 1), (2, 4), (10, 4), (10, 40), (50, 8), + (31, 120)]) +@pytest.mark.parametrize("k", [1, 2, 10, 50]) +def test_pairwise_jaccard(n, m, k): + X = numpy.random.RandomState(n).randn(n, m) + k = min(k, n) + + jaccards, neighbors = pairwise_jaccard(X, k) + jaccards_ref, neighbors_ref = _pairwise_reference(X, k) + + assert jaccards.shape == (n, k) + assert neighbors.shape == (n, k) + assert jaccards.dtype == numpy.float64 + assert neighbors.dtype == numpy.int32 + assert_array_almost_equal(jaccards, jaccards_ref) + assert_array_equal(neighbors, neighbors_ref) + + +@pytest.mark.parametrize("dtype", ['float32', 'float64']) +def test_pairwise_jaccard_dtypes(dtype): + X = numpy.random.RandomState(0).randn(20, 16).astype(dtype) + jaccards, neighbors = pairwise_jaccard(X, 5) + jaccards_ref, neighbors_ref = _pairwise_reference(X.astype('float64'), 5) + + assert_array_almost_equal(jaccards, jaccards_ref, 5) + assert_array_equal(neighbors, neighbors_ref) + + +def test_pairwise_jaccard_self(): + X = numpy.random.RandomState(0).randn(25, 10) + jaccards, neighbors = pairwise_jaccard(X, 5) + + assert_array_equal(neighbors[:, 0], numpy.arange(25)) + assert_array_almost_equal(jaccards[:, 0], numpy.ones(25)) + assert numpy.all(numpy.diff(jaccards, axis=1) <= 0) + + +def test_pairwise_jaccard_symmetric(): + X = numpy.random.RandomState(0).randn(15, 10) + jaccards, neighbors = pairwise_jaccard(X, 15) + + J = numpy.zeros((15, 15)) + for i in range(15): + J[i, neighbors[i]] = jaccards[i] + + assert_array_almost_equal(J, J.T) + + +def test_pairwise_jaccard_values(): + X = numpy.array([[1.0, 2.0], [2.0, -1.0], [2.0, 4.0]]) + jaccards, neighbors = pairwise_jaccard(X, 3) + + assert_array_almost_equal(jaccards, [[1.0, 0.5, 0.0], [1.0, 1/6, 0.0], + [1.0, 0.5, 1/6]]) + assert_array_equal(neighbors, [[0, 2, 1], [1, 2, 0], [2, 0, 1]]) + + +def test_pairwise_jaccard_real(seqlets): + X = get_2d_data_from_patterns(seqlets[:40])[0].reshape(40, -1) + jaccards, neighbors = pairwise_jaccard(X, 4) + + assert_array_almost_equal(jaccards[:2], [[1., 0.5714, 0.1347, 0.1147], + [1., 0.5426, 0.26 , 0.2453]], 4) + assert_array_equal(neighbors[:2], [[0, 10, 4, 33], [1, 16, 15, 23]]) + + +## + + +@pytest.mark.parametrize("d", [5, 12, 30]) +@pytest.mark.parametrize("min_overlap", [0.3, 0.5, 0.7, 1.0]) +def test_pearson_correlation(d, min_overlap): + rng = numpy.random.RandomState(d) + x, y = rng.randn(d, 8), rng.randn(d, 8) + + result = pearson_correlation(x[None], y[None], min_overlap=min_overlap) + score, offset = _pearson_reference(x, y, min_overlap, numpy.ceil) + + assert result.shape == (1, 1, 2) + assert abs(result[0, 0, 0] - score) < 1e-8 + assert result[0, 0, 1] == offset + + +@pytest.mark.parametrize("func", [numpy.ceil, numpy.floor, int]) +def test_pearson_correlation_func(func): + rng = numpy.random.RandomState(0) + x, y = rng.randn(15, 4), rng.randn(15, 4) + + result = pearson_correlation(x[None], y[None], min_overlap=0.55, func=func) + score, offset = _pearson_reference(x, y, 0.55, func) + + assert abs(result[0, 0, 0] - score) < 1e-8 + assert result[0, 0, 1] == offset + + +def test_pearson_correlation_2d(): + rng = numpy.random.RandomState(0) + x, y = rng.randn(12, 4), rng.randn(12, 4) + + result0 = pearson_correlation(x, y, min_overlap=0.5) + result1 = pearson_correlation(x[None], y[None], min_overlap=0.5) + result2 = pearson_correlation(x, y[None], min_overlap=0.5) + + assert_array_equal(result0, result1) + assert_array_equal(result0, result2) + + +@pytest.mark.parametrize("min_overlap", [0.5, 0.7, 1.0]) +def test_pearson_correlation_self(min_overlap): + x = numpy.random.RandomState(0).randn(1, 20, 8) + result = pearson_correlation(x, x, min_overlap=min_overlap) + assert_array_almost_equal(result, [[[1.0, 0.0]]]) + + +@pytest.mark.filterwarnings("ignore:invalid value encountered in divide") +@pytest.mark.parametrize("shift", [-4, -2, 1, 3]) +def test_pearson_correlation_shift(shift): + x = numpy.zeros((1, 20, 4)) + x[0, 6:14] = numpy.random.RandomState(0).randn(8, 4) + y = numpy.roll(x, shift, axis=1) + + result = pearson_correlation(x, y, min_overlap=0.5) + assert abs(result[0, 0, 0] - 1) < 1e-8 + assert result[0, 0, 1] == shift + + +@pytest.mark.parametrize("scale", [0.01, 5.0, -1.0]) +def test_pearson_correlation_scale(scale): + rng = numpy.random.RandomState(0) + x, y = rng.randn(1, 10, 4), rng.randn(1, 10, 4) + + result0 = pearson_correlation(x, y, min_overlap=0.7) + result1 = pearson_correlation(x, y * abs(scale), min_overlap=0.7) + assert_array_almost_equal(result0, result1) + + +def test_pearson_correlation_negated(): + x = numpy.random.RandomState(0).randn(1, 10, 4) + result = pearson_correlation(x, -x, min_overlap=1.0) + assert_array_almost_equal(result, [[[-1.0, 0.0]]]) + + +@pytest.mark.filterwarnings("ignore:invalid value encountered in divide") +def test_pearson_correlation_zeros(): + x = numpy.random.RandomState(0).randn(1, 10, 4) + result = pearson_correlation(x, numpy.zeros((1, 10, 4)), min_overlap=0.5) + assert_array_almost_equal(result, [[[0.0, -5.0]]]) + + +def test_pearson_correlation_longer_y(): + rng = numpy.random.RandomState(0) + x, y = rng.randn(1, 10, 4), rng.randn(1, 25, 4) + x[0] = y[0, 12:22] + + result = pearson_correlation(x, y, min_overlap=1.0) + assert_array_almost_equal(result, [[[1.0, 12.0]]]) + + +@pytest.mark.skip(reason="bug: pearson_correlation raises UnboundLocalError " + "when min_overlap is None because n_pad is only set inside the " + "min_overlap branch") +def test_pearson_correlation_no_overlap(): + x = numpy.random.RandomState(0).randn(1, 10, 4) + result = pearson_correlation(x, x) + assert_array_almost_equal(result, [[[1.0, 0.0]]]) + + +@pytest.mark.skip(reason="bug: pearson_correlation normalizes and correlates " + "the whole batch at once, so every row receives the same score when " + "X holds more than one example") +def test_pearson_correlation_batch(): + rng = numpy.random.RandomState(0) + X, Y = rng.randn(3, 10, 4), rng.randn(3, 10, 4) + Y[1] = X[1] + + result = pearson_correlation(X, Y, min_overlap=1.0) + assert abs(result[0, 1, 0] - 1) < 1e-8 + assert result[0, 0, 0] < 0.9 + + +## + + +def _nn_affinity(n, k, random_state=0): + X = numpy.random.RandomState(random_state).randn(n, 16) + return pairwise_jaccard(X, k) + + +@pytest.mark.parametrize("n,k", [(10, 10), (30, 20), (60, 31), (40, 40)]) +@pytest.mark.parametrize("perplexity", [2, 5, 10]) +def test_nn_tsne_conditional_probs(n, k, perplexity): + affmat, neighbors = _nn_affinity(n, k) + P = NNTsneConditionalProbs(perplexity)(affmat, neighbors) + + n_used = min(n - 1, int(3 * perplexity + 1), k - 1) + assert isinstance(P, scipy.sparse.coo_matrix) + assert P.shape == (n, n) + assert P.nnz == n * n_used + assert_array_almost_equal(numpy.asarray(P.sum(axis=1)).ravel(), + numpy.ones(n)) + + +def test_nn_tsne_conditional_probs_structure(): + affmat, neighbors = _nn_affinity(30, 12) + P = NNTsneConditionalProbs(3)(affmat, neighbors) + + for i in range(30): + assert_array_equal(P.col[P.row == i], neighbors[i, 1:11]) + assert i not in P.col[P.row == i] + + +def test_nn_tsne_conditional_probs_order(): + # Closer neighbors receive at least as much probability. + affmat, neighbors = _nn_affinity(40, 20) + P = NNTsneConditionalProbs(5)(affmat, neighbors).toarray() + + for i in range(40): + probs = P[i, neighbors[i, 1:17]] + assert numpy.all(numpy.diff(probs) <= 1e-6) + + +def test_nn_tsne_conditional_probs_perplexity(): + # The probabilities spread over more neighbors as perplexity grows. + affmat, neighbors = _nn_affinity(50, 40) + P2 = NNTsneConditionalProbs(2)(affmat, neighbors).toarray() + P10 = NNTsneConditionalProbs(10)(affmat, neighbors).toarray() + + assert P2.max(axis=1).mean() > P10.max(axis=1).mean() + + +def test_nn_tsne_conditional_probs_small(): + affmat, neighbors = _nn_affinity(4, 4) + P = NNTsneConditionalProbs(10)(affmat, neighbors) + + assert P.nnz == 12 + assert_array_almost_equal(numpy.asarray(P.sum(axis=1)).ravel(), + numpy.ones(4)) + + +def test_nn_tsne_conditional_probs_tsne_probs_calc(): + distances = numpy.array([[0.1, 0.5, 2.0], [0.2, 0.3, 1.0], [0.5, 1.0, 1.5], + [0.05, 0.1, 0.8]]) + neighbors = [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]] + P = NNTsneConditionalProbs(2).tsne_probs_calc(distances, neighbors) + + assert isinstance(P, scipy.sparse.coo_matrix) + assert P.shape == (4, 4) + + P = P.toarray() + assert_array_almost_equal(P.sum(axis=1), numpy.ones(4)) + assert_array_equal(numpy.diag(P), numpy.zeros(4)) + for i, row in enumerate(neighbors): + assert P[i, row[0]] > P[i, row[1]] > P[i, row[2]] > 0 + + +def test_nn_tsne_conditional_probs_tsne_probs_calc_uniform(): + # Equal distances reach a perplexity equal to the number of neighbors + # only through uniform probabilities. + distances = numpy.array([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [0.5, 0.5, 0.5], + [3.0, 3.0, 3.0]]) + neighbors = [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]] + P = NNTsneConditionalProbs(3).tsne_probs_calc(distances, neighbors) + + assert_array_almost_equal(P.toarray(), (1 - numpy.eye(4)) / 3) + + +def test_nn_tsne_conditional_probs_real(seqlets): + X = get_2d_data_from_patterns(seqlets[:60])[0].reshape(60, -1) + affmat, neighbors = pairwise_jaccard(X, 32) + P = NNTsneConditionalProbs(10)(affmat, neighbors) + + assert P.shape == (60, 60) + assert P.nnz == 60 * 31 + assert_array_almost_equal(P.toarray()[0, neighbors[0, 1:4]], + [0.4112, 0.1398, 0.0499], 4) diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py new file mode 100644 index 0000000..3ec3d66 --- /dev/null +++ b/tests/test_aggregator.py @@ -0,0 +1,682 @@ +# test_aggregator.py +# Contact: Jacob Schreiber + +import numpy +import pytest + +from modiscolite.aggregator import polish_pattern +from modiscolite.aggregator import _expand_seqlets_to_fill_pattern +from modiscolite.aggregator import _align_patterns +from modiscolite.aggregator import merge_in_seqlets_filledges +from modiscolite.aggregator import PatternMergeHierarchy +from modiscolite.aggregator import PatternMergeHierarchyNode +from modiscolite.aggregator import _detect_spurious_merging +from modiscolite.aggregator import SimilarPatternsCollapser + +from modiscolite.affinitymat import jaccard +from modiscolite.affinitymat import pearson_correlation +from modiscolite.core import SeqletSet +from modiscolite.core import TrackSet + +from .synthetic import planted_track_set +from .synthetic import two_motif_track_set +from .synthetic import make_seqlets +from .synthetic import make_pattern + +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +BG = numpy.full(4, 0.25) + +MERGE = [(0.8, 0.8), (0.5, 0.85), (0.2, 0.9)] +DEALBREAKER = [(0.4, 0.75), (0.2, 0.8), (0.1, 0.85), (0.0, 0.9)] + +# SimilarPatternsCollapser assumes that every pattern is window_size + +# 2*flank_to_add long, which the patterns it receives in the pipeline always +# are. COLLAPSE fits 18 bp patterns around the 8 bp motif of motif_ts and +# COLLAPSE_TWO fits 20 bp patterns around the 10 bp motifs of +# two_motif_track_set. +COLLAPSE = dict(min_overlap=0.7, prob_and_pertrack_sim_merge_thresholds=MERGE, + prob_and_pertrack_sim_dealbreaker_thresholds=DEALBREAKER, min_frac=0.2, + min_num=30, flank_to_add=5, window_size=8, bg_freq=BG) + +COLLAPSE_TWO = dict(COLLAPSE, window_size=10) + + +def _coords(pattern): + return [(s.example_idx, s.start, s.end, s.is_revcomp) + for s in pattern.seqlets] + + +def _assert_consistent(pattern, track_set): + """Every seqlet's data matches the track set at its coordinates.""" + + for seqlet in pattern.seqlets: + expected = make_seqlets(track_set, [(seqlet.example_idx, seqlet.start, + seqlet.end, seqlet.is_revcomp)])[0] + + assert_array_equal(seqlet.sequence, expected.sequence) + assert_array_equal(seqlet.contrib_scores, expected.contrib_scores) + assert_array_equal(seqlet.hypothetical_contribs, + expected.hypothetical_contribs) + + +@pytest.fixture +def motif_ts(): + """30 examples of length 60 with an 8 bp motif at positions 26-34.""" + + return planted_track_set(n=30, length=60, motif_length=8, position=26) + + +@pytest.fixture +def motif_pattern(motif_ts): + coords = [(i, 16, 44, bool(i % 3 == 0)) for i in range(15)] + return make_pattern(motif_ts, coords) + + +@pytest.fixture +def edge_ts(motif_ts): + """motif_ts with the motif of example 29 moved to positions 50-58.""" + + one_hot = motif_ts.one_hot.copy() + hyp = motif_ts.hypothetical_contribs.copy() + one_hot[29] = numpy.roll(one_hot[29], 24, axis=0) + hyp[29] = numpy.roll(hyp[29], 24, axis=0) + return TrackSet(one_hot, one_hot*hyp, hyp) + + +## + + +def test_polish_pattern_planted(motif_ts, motif_pattern): + pattern = polish_pattern(motif_pattern, min_frac=0.2, min_num=30, + track_set=motif_ts, flank=5, window_size=8, bg_freq=BG) + + assert isinstance(pattern, SeqletSet) + assert len(pattern) == 18 + assert _coords(pattern) == [(i, 21, 39, bool(i % 3 == 0)) + for i in range(15)] + _assert_consistent(pattern, motif_ts) + + +@pytest.mark.parametrize("window_size", [4, 6, 8, 12]) +@pytest.mark.parametrize("flank", [0, 2, 5, 10]) +def test_polish_pattern(motif_ts, motif_pattern, window_size, flank): + pattern = polish_pattern(motif_pattern, min_frac=0.2, min_num=30, + track_set=motif_ts, flank=flank, window_size=window_size, bg_freq=BG) + + assert len(pattern) == window_size + 2*flank + assert len(pattern.seqlets) == 15 + _assert_consistent(pattern, motif_ts) + + core = [(s.start + flank, s.end - flank) for s in pattern.seqlets] + for start, end in core: + assert end - start == window_size + assert max(start, 26) < min(end, 34) + if window_size >= 8: + assert start <= 26 and end >= 34 + + +def test_polish_pattern_real(pos_patterns, signed_track_set): + for pattern in pos_patterns: + polished = polish_pattern(pattern, min_frac=0.2, min_num=30, + track_set=signed_track_set, flank=10, window_size=30, + bg_freq=BG) + + assert len(polished) == 50 + assert len(polished.seqlets) <= len(pattern.seqlets) + assert len(polished.seqlets) >= 0.9 * len(pattern.seqlets) + _assert_consistent(polished, signed_track_set) + + +def test_polish_pattern_background(motif_ts, motif_pattern): + # The background changes the information content but not the location + # of a motif this strong. + bg = numpy.array([0.4, 0.1, 0.1, 0.4]) + pattern0 = polish_pattern(motif_pattern, 0.2, 30, motif_ts, 5, 8, BG) + pattern1 = polish_pattern(motif_pattern, 0.2, 30, motif_ts, 5, 8, bg) + assert _coords(pattern0) == _coords(pattern1) + + +def test_polish_pattern_drops_edge(motif_ts): + coords = [(0, 16, 44, False), (1, 16, 44, False), (2, 0, 28, False)] + pattern = make_pattern(motif_ts, coords) + polished = polish_pattern(pattern, 0.2, 30, motif_ts, 5, 8, BG) + + assert [s.example_idx for s in polished.seqlets] == [0, 1] + + +def test_polish_pattern_none(motif_ts): + pattern = make_pattern(motif_ts, [(0, 0, 30, False), (1, 30, 60, True)]) + assert polish_pattern(pattern, 0.2, 30, motif_ts, 5, 8, BG) is None + + +def test_polish_pattern_does_not_modify(motif_ts, motif_pattern): + coords = _coords(motif_pattern) + polish_pattern(motif_pattern, 0.2, 30, motif_ts, 5, 8, BG) + assert _coords(motif_pattern) == coords + + +## + + +@pytest.mark.parametrize("left", [0, 3, 10]) +@pytest.mark.parametrize("right", [0, 3, 10]) +def test_expand_seqlets_to_fill_pattern(motif_ts, motif_pattern, left, right): + pattern = _expand_seqlets_to_fill_pattern(motif_pattern, motif_ts, + left_flank_to_add=left, right_flank_to_add=right) + + assert len(pattern) == 28 + left + right + assert len(pattern.seqlets) == 15 + for seqlet in pattern.seqlets: + if seqlet.is_revcomp: + assert (seqlet.start, seqlet.end) == (16 - right, 44 + left) + else: + assert (seqlet.start, seqlet.end) == (16 - left, 44 + right) + + _assert_consistent(pattern, motif_ts) + + +def test_expand_seqlets_to_fill_pattern_uneven(motif_ts): + coords = [(0, 10, 30, False), (1, 10, 25, False), (2, 20, 30, True), + (3, 10, 20, True)] + pattern = make_pattern(motif_ts, coords) + expanded = _expand_seqlets_to_fill_pattern(pattern, motif_ts, 2, 3) + + # Shorter seqlets grow on the right to fill the pattern, which is the left + # of the genome for the reverse strand, so the last seqlet falls off. + assert _coords(expanded) == [(0, 8, 33, False), (1, 8, 33, False), + (2, 7, 32, True)] + assert_array_equal(expanded.per_position_counts, numpy.full(25, 3)) + _assert_consistent(expanded, motif_ts) + + +def test_expand_seqlets_to_fill_pattern_zero(motif_ts, motif_pattern): + pattern = _expand_seqlets_to_fill_pattern(motif_pattern, motif_ts, 0, 0) + + assert pattern is not motif_pattern + assert _coords(pattern) == _coords(motif_pattern) + assert_array_almost_equal(pattern.sequence, motif_pattern.sequence) + + +@pytest.mark.parametrize("left,right,expected", [(10, 10, 15), (11, 0, 5), + (0, 11, 10), (22, 0, 5), (0, 22, 10), (23, 0, 0), (11, 11, 0)]) +def test_expand_seqlets_to_fill_pattern_bounds(motif_ts, left, right, + expected): + # Seqlets span 10-38 of a length 60 track and one in three is on the + # reverse strand. Forward seqlets have 10 bp of room on the left and 22 bp + # on the right; reverse strand seqlets the opposite. + coords = [(i, 10, 38, bool(i % 3 == 0)) for i in range(15)] + pattern = make_pattern(motif_ts, coords) + expanded = _expand_seqlets_to_fill_pattern(pattern, motif_ts, left, right) + + if expected == 0: + assert expanded is None + else: + assert len(expanded.seqlets) == expected + assert all(s.start >= 0 and s.end <= 60 for s in expanded.seqlets) + + +def test_expand_seqlets_to_fill_pattern_real(pos_patterns, signed_track_set): + pattern = pos_patterns[0] + expanded = _expand_seqlets_to_fill_pattern(pattern, signed_track_set, 5, 5) + + assert len(expanded) == len(pattern) + 10 + assert len(expanded.seqlets) <= len(pattern.seqlets) + _assert_consistent(expanded, signed_track_set) + + +## + + +@pytest.mark.parametrize("metric,transformer,include_hypothetical", [ + (jaccard, 'l1', True), (jaccard, 'l1', False), + (pearson_correlation, 'magnitude', False), + (pearson_correlation, 'magnitude', True)]) +def test_align_patterns_self(motif_pattern, metric, transformer, + include_hypothetical): + offset, rc, score = _align_patterns(motif_pattern, motif_pattern, metric, + 0.7, transformer, include_hypothetical) + + assert offset == 0 + assert rc is False + assert abs(score - 1) < 1e-5 + + +@pytest.mark.parametrize("metric,transformer", [(jaccard, 'l1'), + (pearson_correlation, 'magnitude')]) +def test_align_patterns_revcomp(motif_ts, metric, transformer): + parent = make_pattern(motif_ts, [(i, 16, 44, False) for i in range(10)]) + child = make_pattern(motif_ts, [(i, 16, 44, True) for i in range(10)]) + + offset, rc, score = _align_patterns(parent, child, metric, 0.7, + transformer, False) + + assert offset == 0 + assert rc is True + assert abs(score - 1) < 1e-5 + + +@pytest.mark.parametrize("metric,transformer", [(jaccard, 'l1'), + (pearson_correlation, 'magnitude')]) +@pytest.mark.parametrize("start", [16, 18, 20, 22, 24]) +def test_align_patterns_offset(motif_ts, metric, transformer, start): + parent = make_pattern(motif_ts, [(i, 16, 44, False) for i in range(10)]) + child = make_pattern(motif_ts, [(i, start, start+20, False) + for i in range(10, 20)]) + + offset, rc, score = _align_patterns(parent, child, metric, 0.7, + transformer, False) + + assert offset == start - 16 + assert rc is False + + +@pytest.mark.parametrize("start", [16, 20, 24]) +def test_align_patterns_offset_revcomp(motif_ts, start): + parent = make_pattern(motif_ts, [(i, 16, 44, False) for i in range(10)]) + child = make_pattern(motif_ts, [(i, start, start+20, True) + for i in range(10, 20)]) + + offset, rc, score = _align_patterns(parent, child, jaccard, 0.7, 'l1', + False) + + # The flipped child is the forward strand of its window. + assert rc is True + assert offset == start - 16 + + +def test_align_patterns_types(motif_pattern): + offset, rc, score = _align_patterns(motif_pattern, motif_pattern, jaccard, + 0.7, 'l1', True) + + assert isinstance(offset, int) + assert isinstance(rc, bool) + assert numpy.isscalar(score) + + +def test_align_patterns_seqlet(motif_ts, motif_pattern): + # A single seqlet can stand in for the child pattern. + seqlet = make_seqlets(motif_ts, [(20, 20, 40, False)])[0] + offset, rc, score = _align_patterns(motif_pattern, seqlet, jaccard, 0.7, + 'l1', True) + assert offset == 4 + + +def test_align_patterns_real(pos_patterns): + offset, rc, score = _align_patterns(pos_patterns[0], pos_patterns[0], + pearson_correlation, 0.7, 'magnitude', False) + assert (offset, rc) == (0, False) + assert abs(score - 1) < 1e-6 + + offset, rc, score = _align_patterns(pos_patterns[0], pos_patterns[1], + pearson_correlation, 0.7, 'magnitude', False) + assert (offset, rc) == (11, True) + assert abs(score - 0.7721) < 1e-4 + + +## + + +def test_merge_in_seqlets_filledges(motif_ts, motif_pattern): + seqlets = make_seqlets(motif_ts, [(i, 16, 44, bool(i % 2)) + for i in range(15, 25)]) + merged = merge_in_seqlets_filledges(motif_pattern, seqlets, motif_ts, + jaccard, 0.7) + + assert merged is not motif_pattern + assert len(merged.seqlets) == 25 + assert len(merged) == 28 + _assert_consistent(merged, motif_ts) + + +def test_merge_in_seqlets_filledges_strand(motif_ts): + parent = make_pattern(motif_ts, [(i, 16, 44, False) for i in range(5)]) + seqlets = make_seqlets(motif_ts, [(i, 16, 44, True) for i in range(5, 10)]) + merged = merge_in_seqlets_filledges(parent, seqlets, motif_ts, jaccard, 0.7) + + # The merged seqlets were flipped to match the parent's orientation. + assert _coords(merged)[5:] == [(i, 16, 44, False) for i in range(5, 10)] + + +def test_merge_in_seqlets_filledges_shifted(motif_ts): + parent = make_pattern(motif_ts, [(i, 16, 44, False) for i in range(5)]) + seqlets = make_seqlets(motif_ts, [(5, 20, 40, False), (6, 18, 38, True), + (7, 22, 42, False)]) + merged = merge_in_seqlets_filledges(parent, seqlets, motif_ts, jaccard, 0.7) + + # Each seqlet is expanded to cover the whole parent. + assert _coords(merged)[5:] == [(5, 16, 44, False), (6, 16, 44, False), + (7, 16, 44, False)] + _assert_consistent(merged, motif_ts) + + +def test_merge_in_seqlets_filledges_expands_parent(motif_ts): + parent = make_pattern(motif_ts, [(i, 26, 54, False) for i in range(5)]) + seqlets = make_seqlets(motif_ts, [(5, 20, 40, False)]) + merged = merge_in_seqlets_filledges(parent, seqlets, motif_ts, jaccard, 0.7) + + # The seqlet starts 6 bp before the parent, so the parent grows by 6 bp. + assert len(merged) == 34 + assert _coords(merged) == [(i, 20, 54, False) for i in range(6)] + _assert_consistent(merged, motif_ts) + + +def test_merge_in_seqlets_filledges_skips_edge(edge_ts): + parent = make_pattern(edge_ts, [(i, 16, 44, False) for i in range(5)]) + seqlets = make_seqlets(edge_ts, [(29, 46, 60, False), (5, 16, 44, False)]) + merged = merge_in_seqlets_filledges(parent, seqlets, edge_ts, jaccard, 0.7) + + assert [s.example_idx for s in merged.seqlets] == [0, 1, 2, 3, 4, 5] + + +def test_merge_in_seqlets_filledges_duplicates(motif_ts, motif_pattern): + merged = merge_in_seqlets_filledges(motif_pattern, motif_pattern.seqlets, + motif_ts, jaccard, 0.7) + + assert _coords(merged) == _coords(motif_pattern) + assert_array_almost_equal(merged.sequence, motif_pattern.sequence) + + +def test_merge_in_seqlets_filledges_empty(motif_ts, motif_pattern): + merged = merge_in_seqlets_filledges(motif_pattern, [], motif_ts, jaccard, + 0.7) + + assert merged is not motif_pattern + assert _coords(merged) == _coords(motif_pattern) + + +def test_merge_in_seqlets_filledges_does_not_modify(motif_ts, motif_pattern): + coords = _coords(motif_pattern) + seqlets = make_seqlets(motif_ts, [(20, 16, 44, False)]) + merge_in_seqlets_filledges(motif_pattern, seqlets, motif_ts, jaccard, 0.7) + + assert _coords(motif_pattern) == coords + assert len(motif_pattern.unique_seqlets) == 15 + + +@pytest.mark.parametrize("metric,transformer,include_hypothetical", [ + (jaccard, 'l1', True), (jaccard, 'magnitude', False), + (pearson_correlation, 'magnitude', False)]) +def test_merge_in_seqlets_filledges_metrics(motif_ts, motif_pattern, metric, + transformer, include_hypothetical): + seqlets = make_seqlets(motif_ts, [(i, 18, 38, bool(i % 2)) + for i in range(15, 25)]) + merged = merge_in_seqlets_filledges(motif_pattern, seqlets, motif_ts, + metric, 0.7, transformer=transformer, + include_hypothetical=include_hypothetical) + + assert len(merged.seqlets) == 25 + assert all((s.start, s.end) == (16, 44) for s in merged.seqlets) + + +@pytest.mark.parametrize("min_overlap", [0.3, 0.5, 0.9]) +def test_merge_in_seqlets_filledges_min_overlap(motif_ts, motif_pattern, + min_overlap): + seqlets = make_seqlets(motif_ts, [(i, 16, 44, False) for i in range(15, 20)]) + merged = merge_in_seqlets_filledges(motif_pattern, seqlets, motif_ts, + jaccard, min_overlap) + assert len(merged.seqlets) == 20 + + +def test_merge_in_seqlets_filledges_real(pos_patterns, signed_track_set): + parent, child = pos_patterns + merged = merge_in_seqlets_filledges(parent, child.seqlets, + signed_track_set, jaccard, 0.7) + + assert len(merged.seqlets) > len(parent.seqlets) + assert len(merged.seqlets) <= len(parent.seqlets) + len(child.seqlets) + _assert_consistent(merged, signed_track_set) + + +## + + +def test_pattern_merge_hierarchy_node(): + node = PatternMergeHierarchyNode(pattern="p") + + assert node.pattern == "p" + assert node.child_nodes == [] + assert node.parent_node is None + assert node.indices_merged is None + assert node.submat_crosscontam is None + assert node.submat_alignersim is None + + +def test_pattern_merge_hierarchy_node_children(): + children = [PatternMergeHierarchyNode("a"), PatternMergeHierarchyNode("b")] + node = PatternMergeHierarchyNode("p", child_nodes=children, + indices_merged=(0, 1), submat_crosscontam=numpy.eye(2), + submat_alignersim=numpy.ones((2, 2))) + + assert node.child_nodes is children + assert node.indices_merged == (0, 1) + assert_array_equal(node.submat_crosscontam, numpy.eye(2)) + + +def test_pattern_merge_hierarchy_node_independent_children(): + node0 = PatternMergeHierarchyNode("a") + node1 = PatternMergeHierarchyNode("b") + node0.child_nodes.append("x") + assert node1.child_nodes == [] + + +def test_pattern_merge_hierarchy(): + nodes = [PatternMergeHierarchyNode("a"), PatternMergeHierarchyNode("b")] + hierarchy = PatternMergeHierarchy(root_nodes=nodes) + assert hierarchy.root_nodes is nodes + + +@pytest.mark.skip(reason="bug: PatternMergeHierarchy.add_level appends to " + "self.levels, which is never initialized, so it raises AttributeError") +def test_pattern_merge_hierarchy_add_level(): + hierarchy = PatternMergeHierarchy(root_nodes=[]) + hierarchy.add_level(["a"]) + assert hierarchy.levels == [["a"]] + + +## + + +def test_similar_patterns_collapser_same(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, True) for i in range(15, 30)])] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + **COLLAPSE) + + assert len(collapsed) == 1 + assert len(collapsed[0].seqlets) == 30 + assert len(collapsed[0]) == 18 + _assert_consistent(collapsed[0], motif_ts) + + assert isinstance(hierarchy, PatternMergeHierarchy) + assert len(hierarchy.root_nodes) == 1 + root = hierarchy.root_nodes[0] + assert root.pattern is collapsed[0] + assert root.indices_merged == (0, 1) + assert len(root.child_nodes) == 2 + assert all(child.parent_node is root for child in root.child_nodes) + assert root.submat_crosscontam.shape == (2, 2) + assert root.submat_alignersim.shape == (2, 2) + + +def test_similar_patterns_collapser_different(): + track_set = two_motif_track_set(n=20) + patterns = [make_pattern(track_set, [(i, 15, 35, False) for i in range(20)]), + make_pattern(track_set, [(i, 15, 35, False) for i in range(20, 40)])] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, track_set, + **COLLAPSE_TWO) + + assert len(collapsed) == 2 + assert _coords(collapsed[0]) == _coords(patterns[0]) + assert _coords(collapsed[1]) == _coords(patterns[1]) + assert len(hierarchy.root_nodes) == 2 + assert all(node.child_nodes == [] for node in hierarchy.root_nodes) + + +def test_similar_patterns_collapser_three(motif_ts): + # Three copies of one motif collapse into a single pattern. + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) + for i in range(j, 30, 3)]) for j in range(3)] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + **COLLAPSE) + + assert len(collapsed) == 1 + assert len(collapsed[0].seqlets) == 30 + + +def test_similar_patterns_collapser_mixed(): + track_set = two_motif_track_set(n=20) + patterns = [ + make_pattern(track_set, [(i, 15, 35, False) for i in range(10)]), + make_pattern(track_set, [(i, 15, 35, False) for i in range(20, 30)]), + make_pattern(track_set, [(i, 15, 35, True) for i in range(10, 20)]), + ] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, track_set, + **COLLAPSE_TWO) + + assert len(collapsed) == 2 + assert len(collapsed[0].seqlets) == 20 + assert set(s.example_idx for s in collapsed[0].seqlets) == set(range(20)) + assert set(s.example_idx for s in collapsed[1].seqlets) == set(range(20, 30)) + + +def test_similar_patterns_collapser_single(motif_pattern, motif_ts): + collapsed, hierarchy = SimilarPatternsCollapser([motif_pattern], motif_ts, + **COLLAPSE) + + assert len(collapsed) == 1 + assert collapsed[0] is not motif_pattern + assert _coords(collapsed[0]) == _coords(motif_pattern) + assert len(hierarchy.root_nodes) == 1 + + +def test_similar_patterns_collapser_no_thresholds(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15, 30)])] + kwargs = dict(COLLAPSE, prob_and_pertrack_sim_merge_thresholds=[]) + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + **kwargs) + assert len(collapsed) == 2 + + +def test_similar_patterns_collapser_dealbreaker(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15, 30)])] + kwargs = dict(COLLAPSE, + prob_and_pertrack_sim_dealbreaker_thresholds=[(1.0, 1.0)]) + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + **kwargs) + assert len(collapsed) == 2 + + +@pytest.mark.parametrize("max_seqlets_subsample", [2, 5, 14, 1000]) +def test_similar_patterns_collapser_subsample(motif_ts, max_seqlets_subsample): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, True) for i in range(15, 30)])] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + max_seqlets_subsample=max_seqlets_subsample, **COLLAPSE) + + assert len(collapsed) == 1 + assert len(collapsed[0].seqlets) == 30 + + +@pytest.mark.parametrize("window_size,flank_to_add", [(6, 0), (8, 2), + (10, 5)]) +def test_similar_patterns_collapser_polish(motif_ts, window_size, + flank_to_add): + patterns = [make_pattern(motif_ts, [(i, 16, 44, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 16, 44, False) for i in range(15, 30)])] + kwargs = dict(COLLAPSE, window_size=window_size, flank_to_add=flank_to_add) + collapsed, hierarchy = SimilarPatternsCollapser(patterns, motif_ts, + **kwargs) + + assert len(collapsed) == 1 + assert len(collapsed[0]) == window_size + 2*flank_to_add + + +def test_similar_patterns_collapser_sorted(motif_ts): + track_set = two_motif_track_set(n=20) + patterns = [ + make_pattern(track_set, [(i, 15, 35, False) for i in range(20, 25)]), + make_pattern(track_set, [(i, 15, 35, False) for i in range(8)]), + make_pattern(track_set, [(i, 15, 35, False) for i in range(8, 20)]), + ] + collapsed, hierarchy = SimilarPatternsCollapser(patterns, track_set, + **COLLAPSE_TWO) + + assert [len(p.seqlets) for p in collapsed] == [20, 5] + + +def test_similar_patterns_collapser_does_not_modify(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15, 30)])] + coords = [_coords(p) for p in patterns] + SimilarPatternsCollapser(patterns, motif_ts, **COLLAPSE) + + assert [_coords(p) for p in patterns] == coords + + +def test_similar_patterns_collapser_real(pos_patterns, signed_track_set): + kwargs = dict(COLLAPSE, min_num=30, flank_to_add=10, window_size=30) + collapsed, hierarchy = SimilarPatternsCollapser(pos_patterns, + signed_track_set, **kwargs) + + assert len(collapsed) == 2 + assert [len(p.seqlets) for p in collapsed] == [74, 26] + + +## + + +def test_detect_spurious_merging_passthrough(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15)]), + make_pattern(motif_ts, [(i, 21, 39, False) for i in range(15, 30)])] + merged, hierarchy = _detect_spurious_merging(patterns, motif_ts, + perplexity=10, min_in_subcluster=1000, n_seeds=1, **COLLAPSE) + expected, _ = SimilarPatternsCollapser(patterns, motif_ts, **COLLAPSE) + + assert [_coords(p) for p in merged] == [_coords(p) for p in expected] + assert isinstance(hierarchy, PatternMergeHierarchy) + + +def test_detect_spurious_merging_split(): + # A pattern mixing two motifs is split by subclustering. + track_set = two_motif_track_set(n=20) + pattern = make_pattern(track_set, [(i, 15, 35, False) for i in range(40)]) + merged, hierarchy = _detect_spurious_merging([pattern], track_set, + perplexity=10, min_in_subcluster=10, n_seeds=2, **COLLAPSE_TWO) + + assert len(merged) == 2 + assert sorted(len(p.seqlets) for p in merged) == [20, 20] + for p in merged: + assert len(set(s.example_idx < 20 for s in p.seqlets)) == 1 + + +@pytest.mark.parametrize("n_seeds", [1, 2, 3]) +def test_detect_spurious_merging_n_seeds(n_seeds): + track_set = two_motif_track_set(n=20) + pattern = make_pattern(track_set, [(i, 15, 35, False) for i in range(40)]) + merged, hierarchy = _detect_spurious_merging([pattern], track_set, + perplexity=10, min_in_subcluster=10, n_seeds=n_seeds, **COLLAPSE_TWO) + assert len(merged) == 2 + + +def test_detect_spurious_merging_computes_subpatterns(): + track_set = two_motif_track_set(n=20) + pattern = make_pattern(track_set, [(i, 15, 35, False) for i in range(40)]) + _detect_spurious_merging([pattern], track_set, perplexity=10, + min_in_subcluster=10, n_seeds=2, **COLLAPSE_TWO) + + assert pattern.subclusters is not None + assert len(pattern.subcluster_to_subpattern) >= 2 + + +def test_detect_spurious_merging_real(pos_patterns, signed_track_set): + kwargs = dict(COLLAPSE, flank_to_add=10, window_size=30) + merged, hierarchy = _detect_spurious_merging(pos_patterns, + signed_track_set, perplexity=50, min_in_subcluster=50, n_seeds=2, + **kwargs) + + assert [len(p.seqlets) for p in merged] == [74, 26] diff --git a/tests/test_bed_writer.py b/tests/test_bed_writer.py new file mode 100644 index 0000000..f456dc2 --- /dev/null +++ b/tests/test_bed_writer.py @@ -0,0 +1,225 @@ +# test_bed_writer.py +# Contact: Jacob Schreiber + +from collections import OrderedDict + +import pytest + +from modiscolite.bed_writer import BEDTrackLine +from modiscolite.bed_writer import BEDRow +from modiscolite.bed_writer import BEDTrack +from modiscolite.bed_writer import BEDWriter + +from numpy.testing import assert_raises + + +OPTIONAL = [("name", "row1"), ("score", 100), ("strand", "+"), + ("thick_start", 110), ("thick_end", 190), ("item_rgb", "0,0,255"), + ("block_count", 2), ("block_sizes", "10,20"), ("block_starts", "0,80")] + + +@pytest.fixture +def track_line(): + return BEDTrackLine(OrderedDict([("name", "pattern_0"), + ("description", "TF-MoDISco pattern")])) + + +@pytest.fixture +def rows(): + return [BEDRow("chr1", 100, 200, name="a", score=5, strand="+"), + BEDRow("chr2", 0, 10, name="b", score=7, strand="-")] + + +## + + +@pytest.mark.parametrize("arguments,expected", [ + (OrderedDict(), 'track'), + (OrderedDict([("name", "x")]), 'track name="x"'), + (OrderedDict([("name", "x"), ("description", "a b c")]), + 'track name="x" description="a b c"'), + (OrderedDict([("visibility", 2), ("color", "0,0,255")]), + 'track visibility="2" color="0,0,255"'), +]) +def test_bed_track_line_str(arguments, expected): + assert str(BEDTrackLine(arguments)) == expected + + +def test_bed_track_line_order(): + arguments = OrderedDict([("b", 1), ("a", 2), ("c", 3)]) + assert str(BEDTrackLine(arguments)) == 'track b="1" a="2" c="3"' + + +def test_bed_track_line_init(track_line): + assert list(track_line.arguments.keys()) == ["name", "description"] + + +def test_bed_track_line_repr(): + line = BEDTrackLine(OrderedDict([("name", "x")])) + assert repr(line) == "BEDTrackLine(arguments={})".format( + OrderedDict([("name", "x")])) + + +def test_bed_track_line_dict(): + assert str(BEDTrackLine({"name": "x", "useScore": 1})) == \ + 'track name="x" useScore="1"' + + +## + + +@pytest.mark.parametrize("chrom,start,end", [("chr1", 0, 10), + ("chrX", 12345, 12400), ("1", 5, 6), ("chr2_random", 100, 100)]) +def test_bed_row_required(chrom, start, end): + row = BEDRow(chrom, start, end) + assert str(row) == "{}\t{}\t{}".format(chrom, start, end) + + +def test_bed_row_init(): + row = BEDRow("chr1", 100, 200) + + assert (row.chrom, row.chrom_start, row.chrom_end) == ("chr1", 100, 200) + for field, _ in OPTIONAL: + assert getattr(row, field) is None + + +@pytest.mark.parametrize("n", range(1, len(OPTIONAL) + 1)) +def test_bed_row_optional(n): + kwargs = dict(OPTIONAL[:n]) + row = BEDRow("chr1", 100, 200, **kwargs) + + expected = ["chr1", "100", "200"] + [str(v) for _, v in OPTIONAL[:n]] + assert str(row) == "\t".join(expected) + + +@pytest.mark.parametrize("field,value", OPTIONAL) +def test_bed_row_single_optional(field, value): + row = BEDRow("chr1", 100, 200, **{field: value}) + + assert getattr(row, field) == value + assert str(row) == "chr1\t100\t200\t{}".format(value) + + +def test_bed_row_dots(): + row = BEDRow("chr1", 100, 200, name=".", score=".", strand=".") + assert str(row) == "chr1\t100\t200\t.\t.\t." + + +def test_bed_row_all(): + row = BEDRow("chr1", 100, 200, **dict(OPTIONAL)) + assert str(row) == "chr1\t100\t200\trow1\t100\t+\t110\t190\t0,0,255\t2" \ + "\t10,20\t0,80" + assert len(str(row).split("\t")) == 12 + + +def test_bed_row_numpy_types(): + import numpy + row = BEDRow("chr1", numpy.int64(5), numpy.int64(15), score="3.5") + assert str(row) == "chr1\t5\t15\t3.5" + + +## + + +def test_bed_track_init(): + track = BEDTrack() + assert track.track_line is None + assert track.rows == [] + assert str(track) == "" + + +def test_bed_track_independent_rows(): + track0 = BEDTrack() + track0.add_row(BEDRow("chr1", 0, 1)) + assert BEDTrack().rows == [] + + +def test_bed_track_rows(rows): + track = BEDTrack(rows=rows) + assert track.rows is rows + assert str(track) == "chr1\t100\t200\ta\t5\t+\nchr2\t0\t10\tb\t7\t-\n" + + +def test_bed_track_track_line(track_line, rows): + track = BEDTrack(track_line=track_line, rows=rows) + assert str(track) == ('track name="pattern_0" description="TF-MoDISco ' + 'pattern"\nchr1\t100\t200\ta\t5\t+\nchr2\t0\t10\tb\t7\t-\n') + + +def test_bed_track_track_line_only(track_line): + track = BEDTrack(track_line=track_line) + assert str(track) == str(track_line) + "\n" + + +@pytest.mark.parametrize("n", [1, 2, 10]) +def test_bed_track_add_row(n): + track = BEDTrack() + for i in range(n): + track.add_row(BEDRow("chr1", i, i+1)) + + assert len(track.rows) == n + assert str(track).count("\n") == n + assert str(track).split("\n")[n-1] == "chr1\t{}\t{}".format(n-1, n) + + +def test_bed_track_repr(rows): + track = BEDTrack(rows=rows[:1]) + assert repr(track).startswith("BEDTrack(track_line=None, rows=[") + + +## + + +def test_bed_writer_init(): + writer = BEDWriter() + assert writer.tracks == [] + assert writer.get_output() == "" + + +def test_bed_writer_independent(): + writer0 = BEDWriter() + writer0.add_track(BEDTrack()) + assert BEDWriter().tracks == [] + + +def test_bed_writer_get_output(track_line, rows): + writer = BEDWriter() + writer.add_track(BEDTrack(track_line=track_line, rows=rows[:1])) + writer.add_track(BEDTrack(rows=rows[1:])) + + assert writer.get_output() == ('track name="pattern_0" description=' + '"TF-MoDISco pattern"\nchr1\t100\t200\ta\t5\t+\n\n' + 'chr2\t0\t10\tb\t7\t-\n') + + +@pytest.mark.parametrize("n", [1, 2, 5]) +def test_bed_writer_add_track(rows, n): + writer = BEDWriter() + for _ in range(n): + writer.add_track(BEDTrack(rows=rows)) + + assert len(writer.tracks) == n + assert writer.get_output().count("chr1\t100\t200") == n + + +def test_bed_writer_write(track_line, rows, tmp_path): + writer = BEDWriter() + writer.add_track(BEDTrack(track_line=track_line, rows=rows)) + + filename = tmp_path / "seqlets.bed" + writer.write(filename) + assert open(filename).read() == writer.get_output() + + +def test_bed_writer_write_str_path(rows, tmp_path): + writer = BEDWriter() + writer.add_track(BEDTrack(rows=rows)) + + filename = str(tmp_path / "seqlets.bed") + writer.write(filename) + assert open(filename).read() == writer.get_output() + + +def test_bed_writer_write_raises(tmp_path): + filename = tmp_path / "missing" / "seqlets.bed" + assert_raises(IOError, BEDWriter().write, filename) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a2ed0bd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,560 @@ +# test_cli.py +# Contact: Jacob Schreiber + +import os + +import h5py +import numpy +import pytest + +from click.testing import CliRunner + +from modiscolite.cli import cli +from modiscolite.cli import _split_chroms + +from modiscolite.io import save_hdf5 +from modiscolite.util import MemeDataType + +from memelite.io import read_meme + +from .conftest import DATA_DIR +from .synthetic import random_track_set +from .synthetic import make_pattern +from .synthetic import write_peaks + +from numpy.testing import assert_array_equal + + +pytestmark = pytest.mark.usefixtures("meme_writer_default") + +SEQUENCES = str(DATA_DIR / "sequences.npz") +ATTRIBUTIONS = str(DATA_DIR / "attributions.npz") + +COMMANDS = ['convert', 'convert-backward', 'meme', 'motifs', 'report', + 'report-simple', 'seqlet-bed', 'seqlet-fasta'] + + +def _invoke(args): + result = CliRunner().invoke(cli, [str(arg) for arg in args]) + return result + + +def _motifs(tmp_path, *args, n=60): + output = tmp_path / "modisco_results.h5" + result = _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-n", n, + "-o", output] + list(args)) + return result, output + + +def _patterns(filename): + with h5py.File(filename, "r") as f: + return {"{}/{}".format(g, p): f[g][p]["sequence"][:] + for g in f.keys() for p in f[g].keys()} + + +@pytest.fixture +def report_h5(tmp_path): + """A single 6 bp pattern, which keeps the rendered reports fast.""" + + track_set = random_track_set(n=6, length=40) + pattern = make_pattern(track_set, [(0, 2, 8, False), (3, 20, 26, True)]) + + filename = tmp_path / "report.h5" + save_hdf5(filename, [pattern], None, window_size=40) + return filename + + +@pytest.fixture +def small_h5(tmp_path): + track_set = random_track_set(n=6, length=40) + pos = make_pattern(track_set, [(0, 2, 8, False), (3, 20, 26, True)]) + neg = make_pattern(track_set, [(5, 30, 36, False)]) + + filename = tmp_path / "small.h5" + save_hdf5(filename, [pos], [neg], window_size=40) + return filename + + +## + + +@pytest.mark.parametrize("chroms,expected", [("*", "*"), ("chr1", ["chr1"]), + ("chr1,chr2,chrX", ["chr1", "chr2", "chrX"]), ("1,2,X", ["1", "2", "X"]), + ("", [""]), ("chr1,", ["chr1", ""])]) +def test_split_chroms(chroms, expected): + assert _split_chroms(chroms) == expected + + +## + + +@pytest.mark.cmd +def test_cli_help(): + result = _invoke(["--help"]) + + assert result.exit_code == 0 + assert "TF-MoDISco is a motif detection algorithm" in result.output + for command in COMMANDS: + assert command in result.output + + +@pytest.mark.cmd +def test_cli_commands(): + assert sorted(cli.commands.keys()) == COMMANDS + + +@pytest.mark.cmd +@pytest.mark.parametrize("command", COMMANDS) +def test_cli_command_help(command): + result = _invoke([command, "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +@pytest.mark.cmd +def test_cli_unknown_command(): + assert _invoke(["not-a-command"]).exit_code == 2 + + +## + + +@pytest.mark.cmd +def test_motifs(tmp_path): + result, output = _motifs(tmp_path, "-w", 300, n=100) + + assert result.exit_code == 0, result.output + assert result.output == "" + with h5py.File(output, "r") as f: + assert f.attrs["window_size"] == 300 + assert list(f.keys()) == ["pos_patterns"] + assert len(f["pos_patterns"]) > 0 + for pattern in f["pos_patterns"].values(): + assert pattern["sequence"].shape == (50, 4) + + +@pytest.mark.cmd +@pytest.mark.parametrize("window", [100, 200, 300]) +def test_motifs_window(tmp_path, window): + result, output = _motifs(tmp_path, "-w", window) + + assert result.exit_code == 0, result.output + with h5py.File(output, "r") as f: + assert f.attrs["window_size"] == window + for pattern in f["pos_patterns"].values(): + assert numpy.all(pattern["seqlets/end"][:] <= window) + + +@pytest.mark.cmd +def test_motifs_default_window(tmp_path): + # The default window of 400 is longer than the 300 bp fixture. + result, output = _motifs(tmp_path) + + assert result.exit_code == 1 + assert isinstance(result.exception, ValueError) + assert "Window (400) cannot be longer than the sequence length" in \ + str(result.exception) + assert not output.exists() + + +@pytest.mark.cmd +@pytest.mark.skip(reason="bug: a window longer than the sequences gives a " + "negative start offset, which wraps around, so the error reports a " + "sequence length of 50 instead of 300") +def test_motifs_default_window_message(tmp_path): + result, output = _motifs(tmp_path) + assert "sequence length (300)" in str(result.exception) + + +@pytest.mark.cmd +@pytest.mark.skip(reason="bug: calculate_window_offsets returns a window one " + "bp short when the window is odd, so an odd -w always fails with a " + "misleading 'Window cannot be longer than the sequence length' error") +def test_motifs_odd_window(tmp_path): + result, output = _motifs(tmp_path, "-w", 201) + assert result.exit_code == 0, result.output + + +@pytest.mark.cmd +@pytest.mark.parametrize("n", [40, 60, 100]) +def test_motifs_max_seqlets(tmp_path, n): + result, output = _motifs(tmp_path, "-w", 300, "-v", n=n) + + assert result.exit_code == 0, result.output + assert result.output == "Using {} positive seqlets\n".format(n) + with h5py.File(output, "r") as f: + total = sum(p["seqlets/n_seqlets"][0] for p in f["pos_patterns"].values()) + assert total <= n + + +@pytest.mark.cmd +def test_motifs_max_seqlets_required(tmp_path): + result = _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-o", + tmp_path / "out.h5"]) + assert result.exit_code == 2 + assert "Missing option '-n'" in result.output + + +@pytest.mark.cmd +@pytest.mark.parametrize("args,length", [([], 50), (["-t", 20], 40), + (["-g", 5], 40), (["-j", 3], 56), (["-t", 16, "-g", 2, "-j", 1], 22)]) +def test_motifs_pattern_length(tmp_path, args, length): + result, output = _motifs(tmp_path, "-w", 300, *args) + + assert result.exit_code == 0, result.output + with h5py.File(output, "r") as f: + for pattern in f["pos_patterns"].values(): + assert pattern["sequence"].shape == (length, 4) + + +@pytest.mark.cmd +@pytest.mark.parametrize("args", [["-z", 15], ["-z", 12, "-f", 3], ["-l", 1], + ["-l", 3]]) +def test_motifs_options(tmp_path, args): + result, output = _motifs(tmp_path, "-w", 300, *args) + + assert result.exit_code == 0, result.output + assert len(_patterns(output)) > 0 + + +@pytest.mark.cmd +def test_motifs_npy(tmp_path): + sequences = tmp_path / "sequences.npy" + attributions = tmp_path / "attributions.npy" + numpy.save(sequences, numpy.load(SEQUENCES)["arr_0"]) + numpy.save(attributions, numpy.load(ATTRIBUTIONS)["arr_0"]) + + output0 = tmp_path / "npz.h5" + output1 = tmp_path / "npy.h5" + _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-n", 60, "-w", 300, + "-o", output0]) + result = _invoke(["motifs", "-s", sequences, "-a", attributions, "-n", 60, + "-w", 300, "-o", output1]) + + assert result.exit_code == 0, result.output + patterns0, patterns1 = _patterns(output0), _patterns(output1) + assert patterns0.keys() == patterns1.keys() + for key in patterns0: + assert_array_equal(patterns0[key], patterns1[key]) + + +@pytest.mark.cmd +def test_motifs_h5_hyp_scores(tmp_path): + sequences = numpy.load(SEQUENCES)["arr_0"].transpose(0, 2, 1) + attributions = numpy.load(ATTRIBUTIONS)["arr_0"].transpose(0, 2, 1) + + h5 = tmp_path / "scores.h5" + with h5py.File(h5, "w") as f: + f.create_dataset("hyp_scores", data=attributions) + f.create_dataset("input_seqs", data=sequences) + + output0 = tmp_path / "npz.h5" + output1 = tmp_path / "h5.h5" + _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-n", 60, "-w", 200, + "-o", output0]) + result = _invoke(["motifs", "-i", h5, "-n", 60, "-w", 200, "-o", output1]) + + assert result.exit_code == 0, result.output + patterns0, patterns1 = _patterns(output0), _patterns(output1) + assert patterns0.keys() == patterns1.keys() + for key in patterns0: + assert_array_equal(patterns0[key], patterns1[key]) + + +@pytest.mark.cmd +def test_motifs_h5_shap(tmp_path): + h5 = tmp_path / "scores.h5" + with h5py.File(h5, "w") as f: + f.create_dataset("shap/seq", data=numpy.load(ATTRIBUTIONS)["arr_0"]) + f.create_dataset("raw/seq", data=numpy.load(SEQUENCES)["arr_0"]) + + output0 = tmp_path / "npz.h5" + output1 = tmp_path / "h5.h5" + _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-n", 60, "-w", 200, + "-o", output0]) + result = _invoke(["motifs", "-i", h5, "-n", 60, "-w", 200, "-o", output1]) + + assert result.exit_code == 0, result.output + patterns0, patterns1 = _patterns(output0), _patterns(output1) + assert patterns0.keys() == patterns1.keys() + for key in patterns0: + assert_array_equal(patterns0[key], patterns1[key]) + + +@pytest.mark.cmd +def test_motifs_negative(tmp_path): + attributions = tmp_path / "attributions.npz" + numpy.savez(attributions, -numpy.load(ATTRIBUTIONS)["arr_0"]) + + output = tmp_path / "out.h5" + result = _invoke(["motifs", "-s", SEQUENCES, "-a", attributions, "-n", 60, + "-w", 300, "-o", output, "-v"]) + + assert result.exit_code == 0, result.output + assert result.output == "Extracted 60 negative seqlets\n" + with h5py.File(output, "r") as f: + assert list(f.keys()) == ["neg_patterns"] + + +@pytest.mark.cmd +def test_motifs_default_output(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = _invoke(["motifs", "-s", SEQUENCES, "-a", ATTRIBUTIONS, "-n", 60, + "-w", 300]) + + assert result.exit_code == 0, result.output + assert (tmp_path / "modisco_results.h5").exists() + + +@pytest.mark.cmd +def test_motifs_missing_file(tmp_path): + result = _invoke(["motifs", "-s", tmp_path / "missing.npz", "-a", + ATTRIBUTIONS, "-n", 60]) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +## + + +@pytest.mark.cmd +@pytest.mark.parametrize("datatype", [str(d) for d in MemeDataType]) +def test_meme(modisco_h5, tmp_path, datatype): + output = tmp_path / "motifs.meme" + result = _invoke(["meme", "-i", modisco_h5, "-t", datatype, "-o", output, + "-q"]) + + assert result.exit_code == 0, result.output + assert result.output == "" + assert list(read_meme(str(output)).keys()) == ["pos_patterns.pattern_0", + "pos_patterns.pattern_1", "neg_patterns.pattern_0", + "neg_patterns.pattern_1"] + + +@pytest.mark.cmd +def test_meme_stdout(modisco_h5): + result = _invoke(["meme", "-i", modisco_h5, "-t", "PFM"]) + + assert result.exit_code == 0, result.output + assert result.output.startswith("MEME version 5\n") + assert result.output.count("MOTIF ") == 4 + + +@pytest.mark.cmd +def test_meme_repeated(modisco_h5, meme_writer_default): + result0 = _invoke(["meme", "-i", modisco_h5, "-t", "CWM"]) + meme_writer_default.clear() + result1 = _invoke(["meme", "-i", modisco_h5, "-t", "CWM"]) + assert result0.output == result1.output + + +@pytest.mark.cmd +@pytest.mark.parametrize("datatype", ["pfm", "PWM", "CWM_PFM", ""]) +def test_meme_bad_datatype(modisco_h5, datatype): + result = _invoke(["meme", "-i", modisco_h5, "-t", datatype]) + assert result.exit_code == 2 + + +@pytest.mark.cmd +def test_meme_datatype_required(modisco_h5): + result = _invoke(["meme", "-i", modisco_h5]) + assert result.exit_code == 2 + assert "Missing option '-t'" in result.output + + +## + + +@pytest.mark.cmd +def test_seqlet_bed(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + output = tmp_path / "seqlets.bed" + + result = _invoke(["seqlet-bed", "-i", small_h5, "-p", peaks, "-c", "*", + "-o", output, "-q"]) + + assert result.exit_code == 0, result.output + assert result.output == "" + lines = [l for l in open(output).read().split("\n") + if l and not l.startswith("track")] + assert lines == ["chr1\t250\t255\tpattern_0.0\t100\t+", + "chr1\t3268\t3273\tpattern_0.1\t103\t-", + "chrX\t5278\t5283\tpattern_0.0\t105\t+"] + + +@pytest.mark.cmd +def test_seqlet_bed_stdout(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + result = _invoke(["seqlet-bed", "-i", small_h5, "-p", peaks, "-c", "*"]) + assert result.exit_code == 0, result.output + assert result.output.count("pattern_0.") == 3 + + +@pytest.mark.cmd +def test_seqlet_bed_chroms(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 12, chroms=('chr1', 'chr2')) + output = tmp_path / "seqlets.bed" + + result = _invoke(["seqlet-bed", "-i", small_h5, "-p", peaks, "-c", "chr2", + "-o", output, "-q"]) + assert result.exit_code == 0, result.output + + chroms = [l.split("\t")[0] for l in open(output).read().split("\n") + if l and not l.startswith("track")] + assert chroms == ["chr2", "chr2", "chr2"] + + +@pytest.mark.cmd +def test_seqlet_bed_windowsize(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + output = tmp_path / "seqlets.bed" + + result = _invoke(["seqlet-bed", "-i", small_h5, "-p", peaks, "-c", "*", + "-o", output, "-q", "-w", 100]) + assert result.exit_code == 0, result.output + assert "chr1\t220\t225\tpattern_0.0" in open(output).read() + + +@pytest.mark.cmd +@pytest.mark.parametrize("missing", ["-i", "-p", "-c"]) +def test_seqlet_bed_required(small_h5, tmp_path, missing): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + args = {"-i": small_h5, "-p": peaks, "-c": "*"} + del args[missing] + result = _invoke(["seqlet-bed"] + [x for kv in args.items() for x in kv]) + assert result.exit_code == 2 + + +## + + +@pytest.mark.cmd +def test_seqlet_fasta(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, random_track_set(n=6, length=40).one_hot.transpose( + 0, 2, 1)) + output = tmp_path / "seqlets.fa" + + result = _invoke(["seqlet-fasta", "-i", small_h5, "-p", peaks, "-s", + sequences, "-c", "*", "-o", output, "-q"]) + + assert result.exit_code == 0, result.output + headers = open(output).read().split("\n")[::2] + assert headers == [">chr1:250-255 dir=+ pattern_0.0", + ">chr1:3268-3273 dir=- pattern_0.1", ">chrX:5278-5283 dir=+ pattern_0.0"] + + +@pytest.mark.cmd +def test_seqlet_fasta_real(modisco_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 300) + output = tmp_path / "seqlets.fa" + + result = _invoke(["seqlet-fasta", "-i", modisco_h5, "-p", peaks, "-s", + SEQUENCES, "-c", "*", "-o", output, "-q"]) + + assert result.exit_code == 0, result.output + lines = open(output).read().split("\n") + assert len(lines) == 2 * (74 + 26 + 77 + 27) + + +@pytest.mark.cmd +def test_seqlet_fasta_mismatch(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + result = _invoke(["seqlet-fasta", "-i", small_h5, "-p", peaks, "-s", + SEQUENCES, "-c", "*", "-q"]) + assert result.exit_code == 1 + assert "does not match the number of peaks" in result.output + + +## + + +@pytest.mark.cmd +def test_convert_backward(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + + result0 = _invoke(["convert-backward", "-i", modisco_h5, "-o", old]) + result1 = _invoke(["convert", "-i", old, "-o", new]) + + assert result0.exit_code == 0, result0.output + assert result1.exit_code == 0, result1.output + + patterns0, patterns1 = _patterns(modisco_h5), _patterns(new) + assert patterns0.keys() == patterns1.keys() + for key in patterns0: + assert_array_equal(patterns0[key], patterns1[key]) + + +@pytest.mark.cmd +@pytest.mark.parametrize("command", ["convert", "convert-backward"]) +def test_convert_required(command, tmp_path): + result = _invoke([command, "-o", tmp_path / "out.h5"]) + assert result.exit_code == 2 + assert "Missing option '-i'" in result.output + + +## + + +@pytest.mark.cmd +def test_report(report_h5, tmp_path): + output = tmp_path / "report" + result = _invoke(["report", "-i", report_h5, "-o", output, "--n_examples", + 1]) + + assert result.exit_code == 0, result.output + assert result.output == "Report generated: {}\n".format(os.path.join( + str(output), "report.html")) + assert (output / "report.html").exists() + + +@pytest.mark.cmd +def test_report_lite(report_h5, tmp_path, meme_db): + output = tmp_path / "report" + result = _invoke(["report", "-i", report_h5, "-o", output, "-m", meme_db, + "-l", "-n", 1, "--n_examples", 1, "--trim_threshold", 0.2]) + + assert result.exit_code == 0, result.output + assert "P-value" in open(output / "report.html").read() + + +@pytest.mark.cmd +def test_report_simple(report_h5, tmp_path): + output = tmp_path / "report" + result = _invoke(["report-simple", "-i", report_h5, "-o", output, "-s", + "report/"]) + + assert result.exit_code == 0, result.output + html = open(output / "motifs.html").read() + assert "report/trimmed_logos/pos_patterns.pattern_0.cwm.fwd.png" in html + assert len(os.listdir(output / "trimmed_logos")) == 2 + + +@pytest.mark.cmd +def test_report_simple_lite(report_h5, tmp_path, meme_db): + output = tmp_path / "report" + result = _invoke(["report-simple", "-i", report_h5, "-o", output, "-m", + meme_db, "-l", "-n", 1]) + + assert result.exit_code == 0, result.output + assert "match0" in open(output / "motifs.html").read() + + +@pytest.mark.cmd +@pytest.mark.parametrize("command", ["report", "report-simple"]) +def test_report_required(command, tmp_path): + result = _invoke([command, "-o", tmp_path / "report"]) + assert result.exit_code == 2 diff --git a/tests/test_cluster.py b/tests/test_cluster.py new file mode 100644 index 0000000..9b927e2 --- /dev/null +++ b/tests/test_cluster.py @@ -0,0 +1,176 @@ +# test_cluster.py +# Contact: Jacob Schreiber + +import numpy +import scipy.linalg +import scipy.sparse +import pytest + +import igraph +import leidenalg + +from modiscolite.cluster import LeidenCluster + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal + + +def _block_affinity(sizes, within=1.0, between=0.01, noise=0.0, + random_state=0): + """Return a dense block-diagonal affinity matrix and the true labels.""" + + labels = numpy.concatenate([numpy.full(size, i) for i, size in + enumerate(sizes)]) + same = labels[:, None] == labels[None, :] + X = numpy.where(same, within, between) + + if noise > 0: + rng = numpy.random.RandomState(random_state) + X = X + rng.uniform(0, noise, size=X.shape) + X = (X + X.T) / 2 + + numpy.fill_diagonal(X, 0) + return X, labels + + +def _same_partition(y, labels): + """Whether two labelings define the same partition.""" + + pairs = set(zip(y.tolist(), labels.tolist())) + return len(pairs) == len(set(y.tolist())) == len(set(labels.tolist())) + + +def _reference(affinity_mat, n_seeds, n_leiden_iterations): + n = affinity_mat.shape[0] + sources = numpy.repeat(numpy.arange(n), numpy.diff(affinity_mat.indptr)) + g = igraph.Graph() + g.add_vertices(n) + g.add_edges(zip(sources, affinity_mat.indices)) + + best, best_quality = None, None + for seed in range(1, n_seeds+1): + partition = leidenalg.find_partition(g, + leidenalg.ModularityVertexPartition, weights=affinity_mat.data, + n_iterations=n_leiden_iterations, seed=seed*100) + + if best_quality is None or partition.quality() > best_quality: + best, best_quality = partition.membership, partition.quality() + + return numpy.array(best) + + +## + + +@pytest.mark.parametrize("k", [2, 3, 4, 6]) +@pytest.mark.parametrize("size", [5, 10, 20]) +def test_leiden_cluster_blocks(k, size): + X, labels = _block_affinity([size] * k) + y = LeidenCluster(scipy.sparse.csr_matrix(X)) + + assert y.shape == (k * size,) + assert _same_partition(y, labels) + + +@pytest.mark.parametrize("sizes", [[3, 30], [5, 10, 15], [2, 2, 2, 2], + [25, 5, 12, 8]]) +def test_leiden_cluster_uneven_blocks(sizes): + X, labels = _block_affinity(sizes) + y = LeidenCluster(scipy.sparse.csr_matrix(X)) + assert _same_partition(y, labels) + + +@pytest.mark.parametrize("n_seeds", [1, 2, 5, 10]) +def test_leiden_cluster_n_seeds(n_seeds): + X, labels = _block_affinity([8, 12, 10], noise=0.2) + y = LeidenCluster(scipy.sparse.csr_matrix(X), n_seeds=n_seeds) + assert _same_partition(y, labels) + + +@pytest.mark.parametrize("n_leiden_iterations", [-1, 1, 2, 10]) +def test_leiden_cluster_iterations(n_leiden_iterations): + X, labels = _block_affinity([8, 12, 10], noise=0.2) + y = LeidenCluster(scipy.sparse.csr_matrix(X), + n_leiden_iterations=n_leiden_iterations) + assert _same_partition(y, labels) + + +@pytest.mark.parametrize("n_seeds", [1, 2, 3, 7]) +@pytest.mark.parametrize("n_leiden_iterations", [-1, 2]) +def test_leiden_cluster_reference(n_seeds, n_leiden_iterations): + rng = numpy.random.RandomState(n_seeds) + X = scipy.sparse.random(60, 60, density=0.2, random_state=rng) + X = scipy.sparse.csr_matrix(X + X.T) + + y = LeidenCluster(X, n_seeds=n_seeds, + n_leiden_iterations=n_leiden_iterations) + assert_array_equal(y, _reference(X, n_seeds, n_leiden_iterations)) + + +def test_leiden_cluster_labels(): + X, labels = _block_affinity([10, 10, 10, 10]) + y = LeidenCluster(scipy.sparse.csr_matrix(X)) + + assert isinstance(y, numpy.ndarray) + assert y.dtype.kind == 'i' + assert_array_equal(numpy.unique(y), numpy.arange(4)) + + +def test_leiden_cluster_largest_first(): + # Leiden numbers communities from largest to smallest. + X, labels = _block_affinity([4, 20, 9]) + y = LeidenCluster(scipy.sparse.csr_matrix(X)) + assert_array_equal(numpy.bincount(y), [20, 9, 4]) + + +def test_leiden_cluster_deterministic(): + rng = numpy.random.RandomState(0) + X = scipy.sparse.random(80, 80, density=0.1, random_state=rng) + X = scipy.sparse.csr_matrix(X + X.T) + + y0 = LeidenCluster(X, n_seeds=3) + y1 = LeidenCluster(X, n_seeds=3) + assert_array_equal(y0, y1) + + +def test_leiden_cluster_weights(): + # Two groups of five, fully connected. The within-group edges carry the + # weight in the first matrix and the between-group edges in the second. + labels = numpy.repeat([0, 1], 5) + same = labels[:, None] == labels[None, :] + X0 = numpy.where(same, 1.0, 0.01) + X1 = numpy.where(same, 0.01, 1.0) + numpy.fill_diagonal(X0, 0) + numpy.fill_diagonal(X1, 0) + + y0 = LeidenCluster(scipy.sparse.csr_matrix(X0)) + y1 = LeidenCluster(scipy.sparse.csr_matrix(X1)) + + assert _same_partition(y0, labels) + assert not _same_partition(y1, labels) + + +def test_leiden_cluster_disconnected(): + X = scipy.sparse.csr_matrix(scipy.linalg.block_diag(numpy.ones((6, 6)), + numpy.ones((4, 4)), numpy.ones((3, 3)))) + y = LeidenCluster(X) + + assert _same_partition(y, numpy.repeat([0, 1, 2], [6, 4, 3])) + + +def test_leiden_cluster_asymmetric(): + # Only the upper triangle is stored; the graph is still undirected. + X, labels = _block_affinity([6, 6, 6]) + y = LeidenCluster(scipy.sparse.csr_matrix(numpy.triu(X))) + assert _same_partition(y, labels) + + +def test_leiden_cluster_float32(): + X, labels = _block_affinity([7, 9]) + y = LeidenCluster(scipy.sparse.csr_matrix(X.astype('float32'))) + assert _same_partition(y, labels) + + +def test_leiden_cluster_raises(): + X, _ = _block_affinity([5, 5]) + assert_raises(AttributeError, LeidenCluster, scipy.sparse.coo_matrix(X)) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..aa08b54 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,657 @@ +# test_core.py +# Contact: Jacob Schreiber + +import numpy +import pytest + +from modiscolite.core import TrackSet +from modiscolite.core import Seqlet +from modiscolite.core import SeqletSet + +from .synthetic import random_one_hot +from .synthetic import random_track_set +from .synthetic import two_motif_track_set +from .synthetic import make_seqlets +from .synthetic import make_pattern + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +@pytest.fixture +def random_ts(): + return random_track_set(n=8, length=60) + + +@pytest.fixture +def pattern(random_ts): + coords = [(0, 5, 25, False), (1, 10, 30, True), (2, 0, 20, False), + (3, 40, 60, True), (4, 17, 37, False)] + return make_pattern(random_ts, coords) + + +def _two_motif_pattern(n=40): + """A pattern whose seqlets are drawn half from each of two motifs.""" + + track_set = two_motif_track_set(n=n, length=60, motif_length=10, + position=20) + coords = [(i, 15, 35, False) for i in range(2*n)] + return make_pattern(track_set, coords) + + +## + + +@pytest.mark.parametrize("n,length", [(1, 1), (1, 20), (5, 60), (64, 100), + (3, 1000)]) +def test_track_set_init(n, length): + one_hot = random_one_hot((n, length)) + hyp = numpy.random.RandomState(0).randn(n, length, 4) + track_set = TrackSet(one_hot, one_hot*hyp, hyp) + + assert track_set.length == length + assert track_set.one_hot is one_hot + assert track_set.hypothetical_contribs is hyp + assert_array_equal(track_set.contrib_scores, one_hot*hyp) + + +def test_track_set_init_real(track_set, one_hot, hypothetical_contribs): + assert track_set.length == 300 + assert track_set.one_hot.shape == (300, 300, 4) + assert track_set.contrib_scores.dtype == numpy.float32 + assert_array_equal(track_set.contrib_scores, one_hot*hypothetical_contribs) + + +@pytest.mark.parametrize("coord", [(0, 0, 10), (0, 50, 60), (3, 10, 40), + (7, 0, 60), (5, 20, 21), (2, 33, 45)]) +def test_track_set_create_seqlets_fwd(random_ts, coord): + idx, start, end = coord + seqlet = random_ts.create_seqlets([Seqlet(idx, start, end, False)])[0] + + assert_array_equal(seqlet.sequence, random_ts.one_hot[idx, start:end]) + assert_array_equal(seqlet.contrib_scores, + random_ts.contrib_scores[idx, start:end]) + assert_array_equal(seqlet.hypothetical_contribs, + random_ts.hypothetical_contribs[idx, start:end]) + + +@pytest.mark.parametrize("coord", [(0, 0, 10), (0, 50, 60), (3, 10, 40), + (7, 0, 60), (5, 20, 21), (2, 33, 45)]) +def test_track_set_create_seqlets_rc(random_ts, coord): + idx, start, end = coord + seqlet = random_ts.create_seqlets([Seqlet(idx, start, end, True)])[0] + + assert_array_equal(seqlet.sequence, + random_ts.one_hot[idx, start:end][::-1, ::-1]) + assert_array_equal(seqlet.contrib_scores, + random_ts.contrib_scores[idx, start:end][::-1, ::-1]) + assert_array_equal(seqlet.hypothetical_contribs, + random_ts.hypothetical_contribs[idx, start:end][::-1, ::-1]) + + +def test_track_set_create_seqlets_in_place(random_ts): + seqlets = [Seqlet(0, 0, 10, False), Seqlet(1, 5, 20, True)] + returned = random_ts.create_seqlets(seqlets) + + assert returned is seqlets + assert returned[0] is seqlets[0] + assert seqlets[1].sequence.shape == (15, 4) + + +def test_track_set_create_seqlets_empty(random_ts): + assert random_ts.create_seqlets([]) == [] + + +def test_track_set_create_seqlets_views(random_ts): + seqlet = random_ts.create_seqlets([Seqlet(2, 5, 15, True)])[0] + + assert numpy.shares_memory(seqlet.sequence, random_ts.one_hot) + assert numpy.shares_memory(seqlet.contrib_scores, random_ts.contrib_scores) + + +def test_track_set_create_seqlets_real(track_set, seqlets): + assert len(seqlets) == 551 + for seqlet in seqlets: + assert seqlet.sequence.shape == (30, 4) + assert seqlet.contrib_scores.shape == (30, 4) + assert seqlet.hypothetical_contribs.shape == (30, 4) + assert_array_equal(seqlet.sequence.sum(axis=1), numpy.ones(30)) + + +def test_track_set_create_seqlets_rc_complement(track_set): + fwd = track_set.create_seqlets([Seqlet(10, 100, 130, False)])[0] + rc = track_set.create_seqlets([Seqlet(10, 100, 130, True)])[0] + + assert_array_equal(rc.sequence, fwd.sequence[::-1, ::-1]) + assert abs(rc.contrib_scores.sum() - fwd.contrib_scores.sum()) < 1e-5 + + +## + + +@pytest.mark.parametrize("coord", [(0, 0, 10, False), (5, 17, 40, True), + (1000, 5, 6, False), (3, -2, 8, True)]) +def test_seqlet_init(coord): + seqlet = Seqlet(*coord) + + assert seqlet.example_idx == coord[0] + assert seqlet.start == coord[1] + assert seqlet.end == coord[2] + assert seqlet.is_revcomp == coord[3] + assert seqlet.sequence is None + assert seqlet.contrib_scores is None + assert seqlet.hypothetical_contribs is None + + +def test_seqlet_init_keywords(): + seqlet = Seqlet(example_idx=4, start=10, end=31, is_revcomp=True) + assert (seqlet.example_idx, seqlet.start, seqlet.end) == (4, 10, 31) + assert seqlet.is_revcomp is True + + +@pytest.mark.parametrize("coord,expected", [ + ((0, 0, 10, False), "example:0,start:0,end:10,rc:False"), + ((5, 17, 40, True), "example:5,start:17,end:40,rc:True"), + ((1234, 200, 230, False), "example:1234,start:200,end:230,rc:False"), +]) +def test_seqlet_str(coord, expected): + assert str(Seqlet(*coord)) == expected + + +@pytest.mark.parametrize("start,end", [(0, 1), (0, 10), (5, 35), (100, 400), + (7, 7)]) +def test_seqlet_len(start, end): + assert len(Seqlet(0, start, end, False)) == end - start + assert len(Seqlet(0, start, end, True)) == end - start + + +@pytest.mark.parametrize("coord,expected", [((0, 0, 10, False), "0_0_10"), + ((5, 17, 40, True), "5_17_40"), ((12, 3, 4, False), "12_3_4")]) +def test_seqlet_string(coord, expected): + assert Seqlet(*coord).string == expected + + +def test_seqlet_string_ignores_strand(): + assert Seqlet(3, 10, 20, False).string == Seqlet(3, 10, 20, True).string + + +@pytest.mark.parametrize("is_revcomp", [False, True]) +def test_seqlet_revcomp(random_ts, is_revcomp): + seqlet = make_seqlets(random_ts, [(2, 10, 30, is_revcomp)])[0] + rc = seqlet.revcomp() + + assert rc is not seqlet + assert rc.is_revcomp == (not is_revcomp) + assert (rc.example_idx, rc.start, rc.end) == (2, 10, 30) + assert_array_equal(rc.sequence, seqlet.sequence[::-1, ::-1]) + assert_array_equal(rc.contrib_scores, seqlet.contrib_scores[::-1, ::-1]) + assert_array_equal(rc.hypothetical_contribs, + seqlet.hypothetical_contribs[::-1, ::-1]) + + +@pytest.mark.parametrize("is_revcomp", [False, True]) +def test_seqlet_revcomp_matches_track_set(random_ts, is_revcomp): + seqlet = make_seqlets(random_ts, [(4, 7, 29, is_revcomp)])[0] + expected = make_seqlets(random_ts, [(4, 7, 29, not is_revcomp)])[0] + rc = seqlet.revcomp() + + assert_array_equal(rc.sequence, expected.sequence) + assert_array_equal(rc.contrib_scores, expected.contrib_scores) + assert_array_equal(rc.hypothetical_contribs, expected.hypothetical_contribs) + + +def test_seqlet_revcomp_twice(random_ts): + seqlet = make_seqlets(random_ts, [(1, 0, 25, False)])[0] + rc2 = seqlet.revcomp().revcomp() + + assert rc2.is_revcomp is False + assert_array_equal(rc2.sequence, seqlet.sequence) + assert_array_equal(rc2.contrib_scores, seqlet.contrib_scores) + + +def test_seqlet_revcomp_does_not_modify(random_ts): + seqlet = make_seqlets(random_ts, [(1, 0, 25, False)])[0] + sequence = seqlet.sequence.copy() + seqlet.revcomp() + + assert seqlet.is_revcomp is False + assert_array_equal(seqlet.sequence, sequence) + + +def test_seqlet_revcomp_raises(): + assert_raises(TypeError, Seqlet(0, 0, 10, False).revcomp) + + +@pytest.mark.parametrize("shift", [-10, -1, 0, 1, 7, 100]) +@pytest.mark.parametrize("is_revcomp", [False, True]) +def test_seqlet_shift(shift, is_revcomp): + seqlet = Seqlet(3, 20, 45, is_revcomp) + shifted = seqlet.shift(shift) + + assert shifted is not seqlet + assert shifted.example_idx == 3 + assert shifted.start == 20 + shift + assert shifted.end == 45 + shift + assert shifted.is_revcomp == is_revcomp + assert len(shifted) == len(seqlet) + assert shifted.sequence is None + assert (seqlet.start, seqlet.end) == (20, 45) + + +@pytest.mark.parametrize("start_idx,end_idx", [(0, 20), (0, 5), (5, 20), + (3, 17), (10, 11), (19, 20)]) +def test_seqlet_trim_fwd(random_ts, start_idx, end_idx): + seqlet = make_seqlets(random_ts, [(3, 10, 30, False)])[0] + trimmed = seqlet.trim(start_idx, end_idx) + + assert trimmed.is_revcomp is False + assert trimmed.start == 10 + start_idx + assert trimmed.end == 10 + end_idx + assert_array_equal(trimmed.sequence, seqlet.sequence[start_idx:end_idx]) + assert_array_equal(trimmed.contrib_scores, + seqlet.contrib_scores[start_idx:end_idx]) + assert_array_equal(trimmed.hypothetical_contribs, + seqlet.hypothetical_contribs[start_idx:end_idx]) + + +@pytest.mark.parametrize("start_idx,end_idx", [(0, 20), (0, 5), (5, 20), + (3, 17), (10, 11), (19, 20)]) +def test_seqlet_trim_rc(random_ts, start_idx, end_idx): + seqlet = make_seqlets(random_ts, [(3, 10, 30, True)])[0] + trimmed = seqlet.trim(start_idx, end_idx) + + assert trimmed.is_revcomp is True + assert trimmed.start == 30 - end_idx + assert trimmed.end == 30 - start_idx + assert_array_equal(trimmed.sequence, seqlet.sequence[start_idx:end_idx]) + + +@pytest.mark.parametrize("is_revcomp", [False, True]) +@pytest.mark.parametrize("start_idx,end_idx", [(0, 20), (2, 9), (7, 20), + (0, 1)]) +def test_seqlet_trim_matches_track_set(random_ts, is_revcomp, start_idx, + end_idx): + seqlet = make_seqlets(random_ts, [(6, 25, 45, is_revcomp)])[0] + trimmed = seqlet.trim(start_idx, end_idx) + expected = make_seqlets(random_ts, [(6, trimmed.start, trimmed.end, + is_revcomp)])[0] + + assert_array_equal(trimmed.sequence, expected.sequence) + assert_array_equal(trimmed.contrib_scores, expected.contrib_scores) + assert_array_equal(trimmed.hypothetical_contribs, + expected.hypothetical_contribs) + + +def test_seqlet_trim_does_not_modify(random_ts): + seqlet = make_seqlets(random_ts, [(3, 10, 30, False)])[0] + seqlet.trim(2, 8) + + assert (seqlet.start, seqlet.end) == (10, 30) + assert seqlet.sequence.shape == (20, 4) + + +## + + +def test_seqlet_set_single(random_ts): + seqlet = make_seqlets(random_ts, [(2, 10, 30, False)])[0] + pattern = SeqletSet([seqlet]) + + assert pattern.seqlets == [seqlet] + assert pattern.unique_seqlets == {"2_10_30": seqlet} + assert pattern.length == 20 + assert len(pattern) == 20 + assert pattern.subclusters is None + assert pattern.subcluster_to_subpattern is None + assert_array_equal(pattern.per_position_counts, numpy.ones(20)) + assert_array_almost_equal(pattern.sequence, seqlet.sequence) + assert_array_almost_equal(pattern.contrib_scores, seqlet.contrib_scores) + assert_array_almost_equal(pattern.hypothetical_contribs, + seqlet.hypothetical_contribs) + + +@pytest.mark.parametrize("n", [2, 3, 5, 8]) +@pytest.mark.parametrize("length", [1, 10, 25]) +def test_seqlet_set_mean(random_ts, n, length): + coords = [(i, i, i+length, bool(i % 2)) for i in range(n)] + seqlets = make_seqlets(random_ts, coords) + pattern = SeqletSet(seqlets) + + assert len(pattern.seqlets) == n + assert pattern.length == length + assert_array_equal(pattern.per_position_counts, numpy.full(length, n)) + assert_array_almost_equal(pattern.sequence, + numpy.mean([s.sequence for s in seqlets], axis=0)) + assert_array_almost_equal(pattern.contrib_scores, + numpy.mean([s.contrib_scores for s in seqlets], axis=0)) + assert_array_almost_equal(pattern.hypothetical_contribs, + numpy.mean([s.hypothetical_contribs for s in seqlets], axis=0)) + + +def test_seqlet_set_ppm(pattern): + assert_array_almost_equal(pattern.sequence.sum(axis=1), numpy.ones(20)) + assert numpy.all(pattern.sequence >= 0) + + +def test_seqlet_set_uneven_lengths(random_ts): + seqlets = make_seqlets(random_ts, [(0, 0, 10, False), (1, 0, 6, False), + (2, 0, 3, True)]) + pattern = SeqletSet(seqlets) + + assert pattern.length == 10 + assert_array_equal(pattern.per_position_counts, + [3, 3, 3, 2, 2, 2, 1, 1, 1, 1]) + + assert_array_almost_equal(pattern.sequence[:3], + numpy.mean([s.sequence[:3] for s in seqlets], axis=0)) + assert_array_almost_equal(pattern.sequence[3:6], + numpy.mean([s.sequence[3:6] for s in seqlets[:2]], axis=0)) + assert_array_almost_equal(pattern.contrib_scores[6:], + seqlets[0].contrib_scores[6:]) + + +def test_seqlet_set_longest_not_first(random_ts): + seqlets = make_seqlets(random_ts, [(0, 0, 4, False), (1, 0, 12, False)]) + pattern = SeqletSet(seqlets) + + assert pattern.length == 12 + assert_array_equal(pattern.per_position_counts, [2]*4 + [1]*8) + assert_array_almost_equal(pattern.sequence[4:], seqlets[1].sequence[4:]) + + +def test_seqlet_set_duplicates(random_ts): + seqlets = make_seqlets(random_ts, [(0, 0, 10, False), (0, 0, 10, False), + (1, 0, 10, False)]) + pattern = SeqletSet(seqlets) + + assert len(pattern.seqlets) == 2 + assert pattern.seqlets == [seqlets[0], seqlets[2]] + assert_array_equal(pattern.per_position_counts, numpy.full(10, 2)) + assert_array_almost_equal(pattern.sequence, + (seqlets[0].sequence + seqlets[2].sequence) / 2) + + +def test_seqlet_set_duplicates_strand(random_ts): + # Seqlets on opposite strands of the same coordinates share a string, so + # only the first one is kept. + seqlets = make_seqlets(random_ts, [(3, 5, 15, False), (3, 5, 15, True)]) + pattern = SeqletSet(seqlets) + + assert pattern.seqlets == [seqlets[0]] + assert_array_almost_equal(pattern.sequence, seqlets[0].sequence) + + +def test_seqlet_set_real(seqlets): + pattern = SeqletSet(seqlets) + + assert len(pattern.seqlets) == 551 + assert pattern.length == 30 + assert pattern.sequence.shape == (30, 4) + assert_array_almost_equal(pattern.sequence.sum(axis=1), numpy.ones(30)) + assert_array_almost_equal(pattern.contrib_scores, + numpy.mean([s.contrib_scores for s in seqlets], axis=0)) + + +def test_seqlet_set_dtype(seqlets): + pattern = SeqletSet(seqlets[:10]) + assert pattern.sequence.dtype == numpy.float64 + assert pattern.contrib_scores.dtype == numpy.float64 + + +def test_seqlet_set_raises(): + assert_raises(ValueError, SeqletSet, []) + + +## + + +def test_seqlet_set_copy(pattern, random_ts): + pattern_copy = pattern.copy() + + assert pattern_copy is not pattern + assert pattern_copy.seqlets == pattern.seqlets + assert pattern_copy.seqlets is not pattern.seqlets + assert_array_almost_equal(pattern_copy.sequence, pattern.sequence) + assert_array_almost_equal(pattern_copy.contrib_scores, + pattern.contrib_scores) + + new_seqlet = make_seqlets(random_ts, [(7, 0, 20, False)])[0] + pattern_copy._add_seqlet(new_seqlet) + + assert len(pattern.seqlets) == 5 + assert len(pattern_copy.seqlets) == 6 + assert "7_0_20" not in pattern.unique_seqlets + + +def test_seqlet_set_copy_subclusters(pos_patterns): + pattern = pos_patterns[0] + assert pattern.subclusters is not None + + pattern_copy = pattern.copy() + assert pattern_copy.subclusters is None + assert pattern_copy.subcluster_to_subpattern is None + + +@pytest.mark.parametrize("start_idx,end_idx", [(0, 20), (0, 10), (5, 20), + (4, 16), (19, 20)]) +def test_seqlet_set_trim_to_idx(pattern, start_idx, end_idx): + trimmed = pattern.trim_to_idx(start_idx, end_idx) + + assert trimmed is not pattern + assert len(trimmed) == end_idx - start_idx + assert len(trimmed.seqlets) == 5 + assert_array_almost_equal(trimmed.sequence, + pattern.sequence[start_idx:end_idx]) + assert_array_almost_equal(trimmed.contrib_scores, + pattern.contrib_scores[start_idx:end_idx]) + assert_array_almost_equal(trimmed.hypothetical_contribs, + pattern.hypothetical_contribs[start_idx:end_idx]) + + +def test_seqlet_set_trim_to_idx_coords(pattern): + trimmed = pattern.trim_to_idx(3, 13) + + for seqlet, trimmed_seqlet in zip(pattern.seqlets, trimmed.seqlets): + assert trimmed_seqlet.is_revcomp == seqlet.is_revcomp + if seqlet.is_revcomp: + assert trimmed_seqlet.start == seqlet.end - 13 + assert trimmed_seqlet.end == seqlet.end - 3 + else: + assert trimmed_seqlet.start == seqlet.start + 3 + assert trimmed_seqlet.end == seqlet.start + 13 + + +def test_seqlet_set_trim_to_idx_does_not_modify(pattern): + sequence = pattern.sequence.copy() + pattern.trim_to_idx(2, 8) + + assert len(pattern) == 20 + assert_array_almost_equal(pattern.sequence, sequence) + + +@pytest.fixture +def uneven_pattern(random_ts): + seqlets = make_seqlets(random_ts, [(0, 0, 10, False), (1, 0, 10, False), + (2, 0, 8, False), (3, 0, 6, False), (4, 0, 4, False)]) + return SeqletSet(seqlets) + + +def test_seqlet_set_trim_to_support_counts(uneven_pattern): + assert_array_equal(uneven_pattern.per_position_counts, + [5, 5, 5, 5, 4, 4, 3, 3, 2, 2]) + + +@pytest.mark.parametrize("min_frac,min_num", [(0.9, 100), (1.0, 5), + (0.95, 30), (1.0, 4.5)]) +def test_seqlet_set_trim_to_support(uneven_pattern, min_frac, min_num): + # Every threshold above 4 keeps only the positions all 5 seqlets cover. + trimmed = uneven_pattern.trim_to_support(min_frac, min_num) + + assert len(trimmed) == 4 + assert len(trimmed.seqlets) == 5 + assert_array_equal(trimmed.per_position_counts, numpy.full(4, 5)) + assert_array_almost_equal(trimmed.sequence, uneven_pattern.sequence[:4]) + + +@pytest.mark.skip(reason="bug: Seqlet.trim does not clip end_idx to the " + "seqlet's own length, so trim_to_support raises a broadcasting ValueError " + "whenever it keeps positions beyond the shortest seqlet") +@pytest.mark.parametrize("min_frac,min_num,expected", [(0.5, 100, 8), + (0.2, 100, 10), (1.0, 3, 8), (1.0, 4, 6), (0.0, 100, 10)]) +def test_seqlet_set_trim_to_support_uneven(uneven_pattern, min_frac, min_num, + expected): + trimmed = uneven_pattern.trim_to_support(min_frac, min_num) + + assert len(trimmed) == expected + assert len(trimmed.seqlets) == 5 + + +def test_seqlet_set_trim_to_support_full(pattern): + trimmed = pattern.trim_to_support(min_frac=0.2, min_num=30) + + assert len(trimmed) == 20 + assert_array_almost_equal(trimmed.sequence, pattern.sequence) + + +def test_seqlet_set_trim_to_support_real(pos_patterns): + for pattern in pos_patterns: + trimmed = pattern.trim_to_support(min_frac=0.2, min_num=30) + assert len(trimmed) == len(pattern) + assert len(trimmed.seqlets) == len(pattern.seqlets) + + +## + + +def test_seqlet_set_add_seqlet(pattern, random_ts): + seqlet = make_seqlets(random_ts, [(7, 30, 50, False)])[0] + expected = numpy.mean([s.contrib_scores for s in pattern.seqlets + + [seqlet]], axis=0) + + pattern._add_seqlet(seqlet) + + assert len(pattern.seqlets) == 6 + assert pattern.seqlets[-1] is seqlet + assert pattern.unique_seqlets["7_30_50"] is seqlet + assert_array_equal(pattern.per_position_counts, numpy.full(20, 6)) + assert_array_almost_equal(pattern.contrib_scores, expected) + + +def test_seqlet_set_add_seqlet_shorter(pattern, random_ts): + seqlet = make_seqlets(random_ts, [(7, 30, 40, False)])[0] + pattern._add_seqlet(seqlet) + + assert len(pattern) == 20 + assert_array_equal(pattern.per_position_counts, [6]*10 + [5]*10) + + +def test_seqlet_set_add_seqlet_no_dedup(pattern): + # _add_seqlet does not check for duplicates; its callers do. + pattern._add_seqlet(pattern.seqlets[0]) + + assert len(pattern.seqlets) == 6 + assert len(pattern.unique_seqlets) == 5 + assert_array_equal(pattern.per_position_counts, numpy.full(20, 6)) + + +def test_seqlet_set_add_seqlet_longer_raises(pattern, random_ts): + seqlet = make_seqlets(random_ts, [(7, 20, 50, False)])[0] + assert_raises(ValueError, pattern._add_seqlet, seqlet) + + +## + + +def test_seqlet_set_save_seqlets(random_ts, tmp_path): + seqlets = make_seqlets(random_ts, [(0, 5, 10, False), (3, 20, 26, True)]) + pattern = SeqletSet(seqlets) + + filename = tmp_path / "seqlets.fa" + pattern.save_seqlets(filename) + + bases = numpy.array(list("ACGT")) + lines = open(filename).read().split("\n") + + assert len(lines) == 5 + assert lines[0] == ">example0:5-10" + assert lines[1] == "".join(bases[random_ts.one_hot[0, 5:10].argmax(axis=1)]) + assert lines[2] == ">example3:20-26" + assert lines[3] == "".join(bases[ + random_ts.one_hot[3, 20:26][::-1, ::-1].argmax(axis=1)]) + assert lines[4] == "" + + +def test_seqlet_set_save_seqlets_real(seqlets, tmp_path): + pattern = SeqletSet(seqlets[:3]) + + filename = tmp_path / "seqlets.fa" + pattern.save_seqlets(filename) + + assert open(filename).read() == ( + ">example0:117-147\nATCCAGCAGGGAGGAGAGAGGCTCTCCACG\n" + ">example1:130-160\nGGACAGCAGAGAGAGGAACAGGCAGGCCGA\n" + ">example2:144-174\nCGGCCTAGGCCAGCCCCTCTCCCCAGTCAT\n") + + +## + + +@pytest.mark.parametrize("perplexity", [5, 10, 50]) +@pytest.mark.parametrize("n_seeds", [1, 2]) +def test_seqlet_set_compute_subpatterns(pos_patterns, perplexity, n_seeds): + pattern = pos_patterns[0].copy() + pattern.compute_subpatterns(perplexity=perplexity, n_seeds=n_seeds) + + n = len(pattern.seqlets) + assert pattern.subclusters.shape == (n,) + assert pattern.subclusters.min() == 0 + + sizes = [len(p.seqlets) for p in pattern.subcluster_to_subpattern.values()] + assert sum(sizes) == n + assert sizes == sorted(sizes, reverse=True) + assert set(pattern.subcluster_to_subpattern.keys()) == set( + pattern.subclusters) + + for subcluster, subpattern in pattern.subcluster_to_subpattern.items(): + assert isinstance(subpattern, SeqletSet) + members = [s for s, c in zip(pattern.seqlets, pattern.subclusters) + if c == subcluster] + assert subpattern.seqlets == members + + +def test_seqlet_set_compute_subpatterns_deterministic(pos_patterns): + pattern0 = pos_patterns[0].copy() + pattern1 = pos_patterns[0].copy() + + pattern0.compute_subpatterns(perplexity=10, n_seeds=2) + pattern1.compute_subpatterns(perplexity=10, n_seeds=2) + assert_array_equal(pattern0.subclusters, pattern1.subclusters) + + +@pytest.mark.parametrize("n_iterations", [-1, 1, 2, 5]) +def test_seqlet_set_compute_subpatterns_iterations(pos_patterns, n_iterations): + pattern = pos_patterns[1].copy() + pattern.compute_subpatterns(perplexity=10, n_seeds=1, + n_iterations=n_iterations) + + assert pattern.subclusters.shape == (len(pattern.seqlets),) + + +def test_seqlet_set_compute_subpatterns_two_motifs(): + pattern = _two_motif_pattern(n=40) + pattern.compute_subpatterns(perplexity=10, n_seeds=2) + + assert len(pattern.subcluster_to_subpattern) >= 2 + # No subcluster mixes seqlets from the two motifs. + for subpattern in pattern.subcluster_to_subpattern.values(): + sources = set(s.example_idx < 40 for s in subpattern.seqlets) + assert len(sources) == 1 + + +def test_seqlet_set_compute_subpatterns_real(pos_patterns): + pattern = pos_patterns[0] + sizes = [len(p.seqlets) for p in pattern.subcluster_to_subpattern.values()] + + assert len(pattern.seqlets) == 74 + assert sizes == [35, 22, 17] diff --git a/tests/test_descriptive_report.py b/tests/test_descriptive_report.py new file mode 100644 index 0000000..da8e887 --- /dev/null +++ b/tests/test_descriptive_report.py @@ -0,0 +1,750 @@ +# test_descriptive_report.py +# Contact: Jacob Schreiber + +import base64 +import os +import shutil + +import h5py +import numpy +import pytest + +import modiscolite.descriptive_report + +from modiscolite.descriptive_report import plot_to_base64 +from modiscolite.descriptive_report import plot_histogram_to_base64 +from modiscolite.descriptive_report import extract_seqlet_data +from modiscolite.descriptive_report import compute_global_region_size_and_distances +from modiscolite.descriptive_report import create_logos +from modiscolite.descriptive_report import create_distribution_plots +from modiscolite.descriptive_report import create_seqlet_example_logos +from modiscolite.descriptive_report import create_tomtom_match_logos +from modiscolite.descriptive_report import create_descriptive_names +from modiscolite.descriptive_report import generate_descriptive_report + +from modiscolite.io import convert +from modiscolite.io import convert_new_to_old +from modiscolite.io import save_hdf5 +from modiscolite.report import compute_per_position_ic + +from .synthetic import random_track_set +from .synthetic import make_pattern + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +requires_tomtom = pytest.mark.skipif(shutil.which("tomtom") is None, + reason="the MEME suite tomtom executable is not installed") + +TAGS = ["pos_patterns.pattern_0", "pos_patterns.pattern_1", + "neg_patterns.pattern_0", "neg_patterns.pattern_1"] + +GROUPS = ['pos_patterns', 'neg_patterns'] + +STUB = "data:image/png;base64,STUB" + + +def _is_png(data): + prefix = "data:image/png;base64," + assert data.startswith(prefix) + return base64.b64decode(data[len(prefix):])[:8] == b"\x89PNG\r\n\x1a\n" + + +@pytest.fixture +def plots(monkeypatch): + """Replace the plotting functions with fast recorders. + + Rendering a 50 bp logo takes about 0.25 s, so tests of the logic around + the plots record what would be drawn instead. The recorders write a small + file wherever a file would be written. + """ + + calls = {'weights': [], 'base64': [], 'histogram': []} + + def _plot_weights(array, path, figsize=(10, 3), clamp=True): + calls['weights'].append((numpy.array(array), path, figsize, clamp)) + with open(path, "wb") as f: + f.write(b"png") + + def _plot_to_base64(array, figsize=(10, 3), clamp=True): + calls['base64'].append((numpy.array(array), figsize, clamp)) + return STUB + + def _histogram(data, **kwargs): + calls['histogram'].append((numpy.array(data), kwargs)) + return STUB + + module = modiscolite.descriptive_report + monkeypatch.setattr(module, "_plot_weights", _plot_weights) + monkeypatch.setattr(module, "plot_to_base64", _plot_to_base64) + monkeypatch.setattr(module, "plot_histogram_to_base64", _histogram) + return calls + + +@pytest.fixture +def small_h5(tmp_path): + """A results file with one 6 bp positive pattern of 5 seqlets, which is + quick to render.""" + + track_set = random_track_set(n=8, length=40) + pattern = make_pattern(track_set, [(i, 3*i, 3*i+6, bool(i % 2)) + for i in range(5)]) + + filename = tmp_path / "small.h5" + save_hdf5(filename, [pattern], None, window_size=40) + return filename + + +@pytest.fixture +def patterns_data(modisco_h5): + return compute_global_region_size_and_distances( + extract_seqlet_data(modisco_h5, GROUPS)) + + +@pytest.fixture +def small_data(small_h5): + return compute_global_region_size_and_distances( + extract_seqlet_data(small_h5, GROUPS)) + + +## + + +@pytest.mark.parametrize("length", [1, 5, 12]) +@pytest.mark.parametrize("clamp", [True, False]) +def test_plot_to_base64(length, clamp): + array = numpy.abs(numpy.random.RandomState(length).randn(length, 4)) + assert _is_png(plot_to_base64(array, clamp=clamp)) + + +@pytest.mark.parametrize("figsize", [(4, 1), (8, 1.2), (12, 3)]) +def test_plot_to_base64_figsize(figsize): + array = numpy.random.RandomState(0).randn(6, 4) + assert _is_png(plot_to_base64(array, figsize=figsize)) + + +def test_plot_to_base64_deterministic(): + array = numpy.random.RandomState(0).randn(6, 4) + assert plot_to_base64(array) == plot_to_base64(array) + + +def test_plot_to_base64_closes_figure(): + import matplotlib.pyplot as plt + n = len(plt.get_fignums()) + plot_to_base64(numpy.ones((5, 4))) + assert len(plt.get_fignums()) == n + + +def test_plot_to_base64_raises(): + assert_raises(ValueError, plot_to_base64, numpy.ones((5, 3))) + + +## + + +@pytest.mark.parametrize("bins", [5, 30]) +@pytest.mark.parametrize("xlim", [None, (-5, 5)]) +def test_plot_histogram_to_base64(bins, xlim): + data = numpy.random.RandomState(0).randn(200) + image = plot_histogram_to_base64(data, bins=bins, xlim=xlim) + assert _is_png(image) + + +def test_plot_histogram_to_base64_labels(): + data = numpy.random.RandomState(0).randn(50) + image0 = plot_histogram_to_base64(data) + image1 = plot_histogram_to_base64(data, xlabel="x", ylabel="y", + title="title", color="lightcoral", figsize=(6, 3)) + + assert _is_png(image1) + assert image0 != image1 + + +def test_plot_histogram_to_base64_closes_figure(): + import matplotlib.pyplot as plt + n = len(plt.get_fignums()) + plot_histogram_to_base64(numpy.arange(10)) + assert len(plt.get_fignums()) == n + + +## + + +def test_extract_seqlet_data(modisco_h5): + data = extract_seqlet_data(modisco_h5, GROUPS) + assert list(data.keys()) == TAGS + + with h5py.File(modisco_h5, "r") as f: + for tag, pattern_data in data.items(): + grp = f[tag.replace(".", "/")] + seqlets = grp["seqlets"] + contribs = seqlets["contrib_scores"][:] + importance = numpy.abs(contribs).sum(axis=(1, 2)) + + assert_array_equal(pattern_data['ppm'], grp["sequence"][:]) + assert_array_equal(pattern_data['cwm'], grp["contrib_scores"][:]) + assert_array_equal(pattern_data['hcwm'], + grp["hypothetical_contribs"][:]) + assert pattern_data['n_seqlets'] == seqlets["n_seqlets"][0] + assert_array_equal(pattern_data['seqlet_starts'], seqlets["start"][:]) + assert_array_equal(pattern_data['seqlet_ends'], seqlets["end"][:]) + assert_array_equal(pattern_data['seqlet_example_idx'], + seqlets["example_idx"][:]) + assert_array_equal(pattern_data['seqlet_contribs'], contribs) + assert_array_almost_equal(pattern_data['seqlet_importance'], + importance) + + cwm = grp["contrib_scores"][:] + assert abs(pattern_data['avg_importance'] - + numpy.abs(cwm).sum(axis=1).mean()) < 1e-12 + assert abs(pattern_data['std_importance'] - importance.std()) < 1e-6 + assert numpy.isnan(pattern_data['median_abs_distance_from_center']) + assert numpy.isnan(pattern_data['std_distance_from_center']) + + +def test_extract_seqlet_data_values(modisco_h5): + data = extract_seqlet_data(modisco_h5, GROUPS) + + assert [data[tag]['n_seqlets'] for tag in TAGS] == [74, 26, 77, 27] + assert_array_almost_equal([data[tag]['avg_importance'] for tag in TAGS], + [numpy.abs(data[tag]["cwm"]).sum(axis=1).mean() for tag in TAGS]) + + +@pytest.mark.skip(reason="bug: extract_seqlet_data computes gc_content as " + "the mean over the C and G columns of the PPM, which is half of the GC " + "fraction") +def test_extract_seqlet_data_gc_content(modisco_h5): + data = extract_seqlet_data(modisco_h5, GROUPS) + for pattern_data in data.values(): + ppm = pattern_data['ppm'] + assert abs(pattern_data['gc_content'] - ppm[:, 1:3].sum(axis=1).mean() + ) < 1e-12 + + +@pytest.mark.parametrize("groups,expected", [(['pos_patterns'], TAGS[:2]), + (['neg_patterns'], TAGS[2:]), (['neg_patterns', 'pos_patterns'], + TAGS[2:] + TAGS[:2]), (['missing'], []), ([], [])]) +def test_extract_seqlet_data_groups(modisco_h5, groups, expected): + assert list(extract_seqlet_data(modisco_h5, groups).keys()) == expected + + +def test_extract_seqlet_data_order(tmp_path): + track_set = random_track_set(n=6, length=40) + patterns = [make_pattern(track_set, [(0, i, i+6, False)]) + for i in range(12)] + + filename = tmp_path / "results.h5" + save_hdf5(filename, patterns, None, 40) + + data = extract_seqlet_data(filename, GROUPS) + assert list(data.keys()) == ["pos_patterns.pattern_{}".format(i) + for i in range(12)] + assert [d['seqlet_starts'][0] for d in data.values()] == list(range(12)) + + +def test_extract_seqlet_data_no_seqlet_contribs(modisco_h5, tmp_path): + # Files converted from the original format have no per-seqlet scores. + old, new = tmp_path / "old.h5", tmp_path / "new.h5" + convert_new_to_old(modisco_h5, old) + convert(old, new) + + data = extract_seqlet_data(new, GROUPS) + assert list(data.keys()) == TAGS + for pattern_data in data.values(): + assert len(pattern_data['seqlet_importance']) == 0 + assert len(pattern_data['seqlet_contribs']) == 0 + assert numpy.isnan(pattern_data['std_importance']) + assert len(pattern_data['seqlet_starts']) == pattern_data['n_seqlets'] + + +## + + +def _data(starts, ends): + return {'seqlet_starts': numpy.array(starts), + 'seqlet_ends': numpy.array(ends)} + + +def test_compute_global_region_size_and_distances(): + data = {'a': _data([10, 20], [30, 40]), 'b': _data([0], [20])} + updated = compute_global_region_size_and_distances(data) + + assert list(updated.keys()) == ['a', 'b'] + for d in updated.values(): + assert d['global_region_size'] == 40 + assert d['global_center'] == 20 + + # Centers are 20 and 30 for a, and 10 for b. + assert updated['a']['median_abs_distance_from_center'] == 5 + assert updated['a']['std_distance_from_center'] == 5 + assert updated['b']['median_abs_distance_from_center'] == 10 + assert updated['b']['std_distance_from_center'] == 0 + + +def test_compute_global_region_size_and_distances_empty(): + data = {'a': _data([], []), 'b': _data([], [])} + updated = compute_global_region_size_and_distances(data) + + for d in updated.values(): + assert d['global_region_size'] == 400 + assert d['global_center'] == 200 + assert numpy.isnan(d['median_abs_distance_from_center']) + assert numpy.isnan(d['std_distance_from_center']) + + +def test_compute_global_region_size_and_distances_mixed(): + data = {'a': _data([], []), 'b': _data([100, 150], [120, 190])} + updated = compute_global_region_size_and_distances(data) + + # Centers of 110 and 170 lie 35 and 25 bp from the global center of 145. + assert updated['a']['global_region_size'] == 90 + assert updated['a']['global_center'] == 145 + assert numpy.isnan(updated['a']['median_abs_distance_from_center']) + assert updated['b']['median_abs_distance_from_center'] == 30 + assert updated['b']['std_distance_from_center'] == 5 + + +def test_compute_global_region_size_and_distances_no_patterns(): + assert compute_global_region_size_and_distances({}) == {} + + +@pytest.mark.parametrize("offset", [0, 17, 1000]) +def test_compute_global_region_size_and_distances_translation(offset): + data0 = {'a': _data([10, 20, 5], [30, 40, 25])} + data1 = {'a': _data([10+offset, 20+offset, 5+offset], + [30+offset, 40+offset, 25+offset])} + + d0 = compute_global_region_size_and_distances(data0)['a'] + d1 = compute_global_region_size_and_distances(data1)['a'] + + assert d0['global_region_size'] == d1['global_region_size'] + assert d1['global_center'] == d0['global_center'] + offset + assert d0['median_abs_distance_from_center'] == \ + d1['median_abs_distance_from_center'] + + +def test_compute_global_region_size_and_distances_real(patterns_data): + for data in patterns_data.values(): + assert data['global_region_size'] == 275 + assert data['global_center'] == 149.5 + + assert_array_almost_equal([patterns_data[t][ + 'median_abs_distance_from_center'] for t in TAGS], + [9.5, 31.5, 16.5, 47.5], 4) + assert_array_almost_equal([patterns_data[t][ + 'std_distance_from_center'] for t in TAGS], + [11.7567, 22.1886, 20.0074, 25.3212], 4) + + +## + + +LOGO_KEYS = ['cwm', 'cwm_path', 'hcwm', 'hcwm_path', 'ic_ppm', 'ic_ppm_path', + 'pwm', 'pwm_path', 'trimmed_cwm_fwd', 'trimmed_cwm_fwd_path', + 'trimmed_cwm_rev', 'trimmed_cwm_rev_path', 'trimmed_cwm', + 'trimmed_cwm_path'] + + +def test_create_logos(patterns_data, tmp_path, plots): + logos = create_logos(patterns_data, str(tmp_path), 0.3) + + assert list(logos.keys()) == TAGS + for tag in TAGS: + assert sorted(logos[tag].keys()) == sorted(LOGO_KEYS) + assert logos[tag]['cwm'] == STUB + assert logos[tag]['pwm'] == logos[tag]['ic_ppm'] + assert logos[tag]['trimmed_cwm'] == logos[tag]['trimmed_cwm_fwd'] + assert logos[tag]['cwm_path'] == os.path.join(str(tmp_path), "logos", + tag, "cwm_logo.png") + + assert sorted(os.listdir(tmp_path / "logos" / tag)) == [ + "cwm_logo.png", "hcwm_logo.png", "ic_ppm_logo.png", + "trimmed_cwm_fwd_logo.png", "trimmed_cwm_rev_logo.png"] + + assert len(plots['weights']) == 5 * len(TAGS) + assert len(plots['base64']) == 5 * len(TAGS) + + +def test_create_logos_arrays(patterns_data, tmp_path, plots): + create_logos({TAGS[0]: patterns_data[TAGS[0]]}, str(tmp_path), 0.3) + data = patterns_data[TAGS[0]] + (cwm, _, _, clamp0), (hcwm, _, _, clamp1), (ic_ppm, _, _, _) = \ + plots['weights'][:3] + + assert_array_equal(cwm, data['cwm']) + assert_array_equal(hcwm, data['hcwm']) + assert (clamp0, clamp1) == (True, False) + + ic = compute_per_position_ic(data['ppm'], numpy.full(4, 0.25), 0.001) + assert_array_almost_equal(ic_ppm, data['ppm'] * ic[:, None]) + + for (a0, _, _, c0), (a1, _, c1) in zip(plots['weights'], plots['base64']): + assert_array_equal(a0, a1) + assert c0 == c1 + + +@pytest.mark.parametrize("trim_threshold", [0.0, 0.3, 0.6, 1.0]) +def test_create_logos_trim(tmp_path, plots, trim_threshold): + cwm = numpy.zeros((20, 4)) + cwm[:, 0] = [0.1, 0, 0, 0, 0, 0.2, 0.5, 1, 0.5, 0.2, 0, 0, 0.9, 0, 0, 0, 0, + 0, 0, 0.05] + data = {'p': {'cwm': cwm, 'hcwm': cwm, 'ppm': numpy.full((20, 4), 0.25)}} + create_logos(data, str(tmp_path), trim_threshold) + + fwd, rev = plots['weights'][3][0], plots['weights'][4][0] + score = numpy.abs(cwm).sum(axis=1) + passing = numpy.where(score >= score.max() * trim_threshold)[0] + start, end = max(passing.min() - 2, 0), min(passing.max() + 3, 20) + assert_array_equal(fwd, cwm[start:end]) + + score = score[::-1] + passing = numpy.where(score >= score.max() * trim_threshold)[0] + start, end = max(passing.min() - 2, 0), min(passing.max() + 3, 20) + assert_array_equal(rev, cwm[::-1, ::-1][start:end]) + + +def test_create_logos_trim_values(tmp_path, plots): + cwm = numpy.zeros((20, 4)) + cwm[8:11, 2] = 1.0 + data = {'p': {'cwm': cwm, 'hcwm': cwm, 'ppm': numpy.full((20, 4), 0.25)}} + create_logos(data, str(tmp_path), 0.3) + + # Two positions of padding on the left and two on the right. + assert_array_equal(plots['weights'][3][0], cwm[6:13]) + assert_array_equal(plots['weights'][4][0], cwm[::-1, ::-1][7:14]) + + +def test_create_logos_figsize(patterns_data, tmp_path, plots): + create_logos({TAGS[1]: patterns_data[TAGS[1]]}, str(tmp_path), 0.3) + sizes = [figsize for _, _, figsize, _ in plots['weights']] + assert sizes == [(12, 3), (12, 3), (12, 3), (10, 3), (10, 3)] + + +def test_create_logos_render(small_data, tmp_path): + logos = create_logos(small_data, str(tmp_path), 0.3) + + tag = "pos_patterns.pattern_0" + assert list(logos.keys()) == [tag] + for key in ['cwm', 'hcwm', 'ic_ppm', 'trimmed_cwm_fwd', 'trimmed_cwm_rev']: + assert _is_png(logos[tag][key]) + with open(logos[tag][key + '_path'], "rb") as f: + assert f.read(8) == b"\x89PNG\r\n\x1a\n" + + +def test_create_logos_existing_dir(small_data, tmp_path, plots): + (tmp_path / "logos" / "pos_patterns.pattern_0").mkdir(parents=True) + logos = create_logos(small_data, str(tmp_path), 0.3) + assert list(logos.keys()) == ["pos_patterns.pattern_0"] + + +def test_create_logos_empty(tmp_path, plots): + assert create_logos({}, str(tmp_path), 0.3) == {} + assert os.listdir(tmp_path / "logos") == [] + + +## + + +def test_create_distribution_plots(patterns_data, tmp_path, plots): + distributions = create_distribution_plots(patterns_data, str(tmp_path)) + + assert list(distributions.keys()) == TAGS + for tag in TAGS: + assert distributions[tag] == {'importance': STUB, 'spatial': STUB} + + assert len(plots['histogram']) == 2 * len(TAGS) + + +def test_create_distribution_plots_data(patterns_data, tmp_path, plots): + data = patterns_data[TAGS[0]] + create_distribution_plots({TAGS[0]: data}, str(tmp_path)) + (importance, kw0), (centers, kw1) = plots['histogram'] + + assert_array_equal(importance, data['seqlet_importance']) + assert kw0['xlabel'] == 'Seqlet Total Contribution Score' + assert kw0['title'] == ('Seqlet Contribution Score Distribution - ' + 'pos_patterns.pattern_0') + + expected = (data['seqlet_starts'] + data['seqlet_ends']) / 2 - 149.5 + assert_array_equal(centers, expected) + assert kw1['xlim'] == (-137.5, 137.5) + + +def test_create_distribution_plots_no_global(tmp_path, plots): + # Without a global region the centers are not shifted. + data = {'p': {'seqlet_importance': numpy.array([]), + 'seqlet_starts': numpy.array([10, 20]), + 'seqlet_ends': numpy.array([30, 50])}} + distributions = create_distribution_plots(data, str(tmp_path)) + + assert distributions == {'p': {'spatial': STUB}} + assert_array_equal(plots['histogram'][0][0], [20, 35]) + assert plots['histogram'][0][1]['xlim'] is None + + +def test_create_distribution_plots_no_seqlets(tmp_path, plots): + data = {'p': {'seqlet_importance': numpy.array([]), + 'seqlet_starts': numpy.array([]), 'seqlet_ends': numpy.array([])}} + + assert create_distribution_plots(data, str(tmp_path)) == {'p': {}} + assert plots['histogram'] == [] + + +def test_create_distribution_plots_render(small_data, tmp_path): + distributions = create_distribution_plots(small_data, str(tmp_path)) + plots = distributions["pos_patterns.pattern_0"] + + assert _is_png(plots['importance']) + assert _is_png(plots['spatial']) + + +## + + +@pytest.mark.parametrize("n_examples,expected", [(1, 1), (3, 3), (10, 10), + (100, 26)]) +def test_create_seqlet_example_logos(patterns_data, tmp_path, plots, + n_examples, expected): + data = {TAGS[1]: patterns_data[TAGS[1]]} + examples = create_seqlet_example_logos(data, str(tmp_path), n_examples) + + logos = examples[TAGS[1]] + assert len(logos) == expected + assert [logo['rank'] for logo in logos] == list(range(expected, 0, -1)) + assert all(logo['base64'] == STUB for logo in logos) + + quantiles = numpy.linspace(10, 100, expected)[::-1].astype(int) + assert [logo['quantile'] for logo in logos] == list(quantiles) + assert all(os.path.exists(logo['path']) for logo in logos) + + +def test_create_seqlet_example_logos_selection(tmp_path, plots): + importance = numpy.array([5.0, 1.0, 3.0, 2.0, 4.0]) + contribs = numpy.arange(5)[:, None, None] * numpy.ones((5, 6, 4)) + data = {'p': {'seqlet_importance': importance, 'seqlet_contribs': contribs}} + + examples = create_seqlet_example_logos(data, str(tmp_path), 5)['p'] + + # Quantiles 10, 32.5, 55, 77.5 and 100 are closest to 1.4, 2.3, 3.2, 4.1 + # and 5, which are seqlets 1, 3, 2, 4 and 0. + assert [e['importance'] for e in examples] == [5.0, 4.0, 3.0, 2.0, 1.0] + assert [e['quantile'] for e in examples] == [100, 77, 55, 32, 10] + assert [int(a[0, 0]) for a, _, _, _ in plots['weights']] == [1, 3, 2, + 4, 0] + + +def test_create_seqlet_example_logos_unique(tmp_path, plots): + # Seqlets are not reused even when several quantiles share a value. + importance = numpy.array([1.0, 1.0, 1.0, 9.0]) + contribs = numpy.arange(4)[:, None, None] * numpy.ones((4, 5, 4)) + data = {'p': {'seqlet_importance': importance, 'seqlet_contribs': contribs}} + + create_seqlet_example_logos(data, str(tmp_path), 4) + used = [int(a[0, 0]) for a, _, _, _ in plots['weights']] + assert sorted(used) == [0, 1, 2, 3] + + +def test_create_seqlet_example_logos_skips(patterns_data, tmp_path, plots): + data = {'empty': {'seqlet_importance': numpy.array([]), + 'seqlet_contribs': []}, TAGS[0]: patterns_data[TAGS[0]]} + examples = create_seqlet_example_logos(data, str(tmp_path), 2) + + assert list(examples.keys()) == [TAGS[0]] + assert not os.path.exists(tmp_path / "seqlet_examples" / "empty") + + +def test_create_seqlet_example_logos_paths(patterns_data, tmp_path, plots): + data = {TAGS[0]: patterns_data[TAGS[0]]} + examples = create_seqlet_example_logos(data, str(tmp_path), 2) + + assert [e['path'] for e in examples[TAGS[0]]] == [ + os.path.join(str(tmp_path), "seqlet_examples", TAGS[0], + "quantile_{}.png".format(q)) for q in [100, 10]] + assert all(figsize == (8, 1.2) for _, _, figsize, _ in plots['weights']) + + +def test_create_seqlet_example_logos_render(small_data, tmp_path): + examples = create_seqlet_example_logos(small_data, str(tmp_path), 2) + logos = examples["pos_patterns.pattern_0"] + + assert len(logos) == 2 + assert all(_is_png(logo['base64']) for logo in logos) + assert all(isinstance(logo['importance'], float) for logo in logos) + + +## + + +def test_create_tomtom_match_logos(meme_db, tmp_path, plots): + tomtom_data = {'a': {'match_0': 'db_motif_2', 'pval_0': 0.01, + 'match_1': 'db_motif_3', 'pval_1': 0.02}, + 'b': {'match_0': ' db_motif_4 ', 'pval_0': 0.5}} + + logos = create_tomtom_match_logos(tomtom_data, str(tmp_path), meme_db, 2) + + assert sorted(logos.keys()) == ['a', 'b'] + assert logos['a'] == { + 'match_0_logo': os.path.join(str(tmp_path), "tomtom_logos", + "a_match_0.png"), 'match_0_base64': STUB, + 'match_1_logo': os.path.join(str(tmp_path), "tomtom_logos", + "a_match_1.png"), 'match_1_base64': STUB} + assert sorted(logos['b'].keys()) == ['match_0_base64', 'match_0_logo'] + + +def test_create_tomtom_match_logos_ic(meme_db, tmp_path, plots): + from memelite.io import read_meme + ppm = read_meme(str(meme_db))["db_motif_3 NAME3"].T + + create_tomtom_match_logos({'a': {'match_0': 'db_motif_3'}}, str(tmp_path), + meme_db, 1) + + ic = compute_per_position_ic(ppm, numpy.full(4, 0.25), 0.001) + assert_array_almost_equal(plots['weights'][0][0], ppm * ic[:, None]) + assert plots['weights'][0][2] == (8, 2) + + +@pytest.mark.parametrize("top_n_matches,expected", [(1, 1), (2, 2), (3, 2)]) +def test_create_tomtom_match_logos_top_n(meme_db, tmp_path, plots, + top_n_matches, expected): + tomtom_data = {'a': {'match_0': 'db_motif_0', 'match_1': 'db_motif_1'}} + logos = create_tomtom_match_logos(tomtom_data, str(tmp_path), meme_db, + top_n_matches) + assert len(logos['a']) == 2 * expected + + +@pytest.mark.parametrize("matches", [{}, {'match_0': None}, {'match_0': ''}, + {'match_0': 'not_in_db'}, {'match_1': 'db_motif_0'}]) +def test_create_tomtom_match_logos_skips(meme_db, tmp_path, plots, matches): + logos = create_tomtom_match_logos({'a': matches}, str(tmp_path), meme_db, 1) + assert logos == {'a': {}} + assert plots['weights'] == [] + + +def test_create_tomtom_match_logos_render(meme_db, tmp_path): + logos = create_tomtom_match_logos({'a': {'match_0': 'db_motif_2'}}, + str(tmp_path), meme_db, 1) + + assert _is_png(logos['a']['match_0_base64']) + with open(logos['a']['match_0_logo'], "rb") as f: + assert f.read(8) == b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.skip(reason="bug: create_tomtom_match_logos keys the database by " + "the first word of each MOTIF line, but tomtom-lite reports the whole " + "line, so --lite reports never draw match logos for databases with " + "alternate names such as JASPAR") +def test_create_tomtom_match_logos_alt_name(meme_db, tmp_path, plots): + logos = create_tomtom_match_logos({'a': {'match_0': 'db_motif_2 NAME2'}}, + str(tmp_path), meme_db, 1) + assert 'match_0_logo' in logos['a'] + + +## + + +@pytest.mark.parametrize("matches,expected", [ + ({'match_0': 'CTCF', 'match_1': 'CTCFL', 'match_2': 'ZNF143'}, + "CTCF;CTCFL;ZNF143"), + ({'match_0': 'MA0139.1 CTCF', 'match_1': None}, "MA0139.1 C"), + ({'match_0': ' JUND ', 'match_1': ' FOSL2'}, "JUND;FOSL2"), + ({'match_0': 'A'*25}, "A"*10), + ({'match_1': 'SP1'}, "SP1"), + ({'match_0': '', 'match_1': 'KLF4'}, "KLF4"), + ({'match_0': None, 'match_1': None, 'match_2': None}, "tag"), + ({}, "tag"), +]) +def test_create_descriptive_names(matches, expected): + assert create_descriptive_names({'tag': matches}) == {'tag': expected} + + +@pytest.mark.parametrize("top_n_matches,expected", [(1, "A"), (2, "A;B"), + (3, "A;B;C"), (5, "A;B;C")]) +def test_create_descriptive_names_top_n(top_n_matches, expected): + matches = {'match_{}'.format(i): name for i, name in enumerate("ABCDE")} + names = create_descriptive_names({'tag': matches}, top_n_matches) + assert names == {'tag': expected} + + +def test_create_descriptive_names_multiple(): + tomtom_data = {'p0': {'match_0': 'X'}, 'p1': {}, 'p2': {'match_0': 'Y', + 'match_1': 'Z'}} + assert create_descriptive_names(tomtom_data) == {'p0': 'X', 'p1': 'p1', + 'p2': 'Y;Z'} + + +def test_create_descriptive_names_empty(): + assert create_descriptive_names({}) == {} + + +## + + +def test_generate_descriptive_report(modisco_h5, tmp_path, plots, capsys): + output_dir = tmp_path / "report" + path = generate_descriptive_report(str(modisco_h5), str(output_dir), + n_examples=2) + + assert path == os.path.join(str(output_dir), "report.html") + assert capsys.readouterr().out == "Report generated: {}\n".format(path) + assert sorted(os.listdir(output_dir)) == ["logos", "report.html", + "seqlet_examples"] + + html = open(path).read() + for tag in TAGS: + assert 'id="pattern-{}"'.format(tag.replace(".", "-")) in html + assert "tomtom_logos" not in os.listdir(output_dir) + + +def test_generate_descriptive_report_nested_dir(small_h5, tmp_path, plots): + output_dir = tmp_path / "a" / "b" / "c" + generate_descriptive_report(str(small_h5), str(output_dir), n_examples=1) + assert (output_dir / "report.html").exists() + + +@pytest.mark.parametrize("n_examples", [1, 3]) +def test_generate_descriptive_report_n_examples(small_h5, tmp_path, plots, + n_examples): + generate_descriptive_report(str(small_h5), str(tmp_path), + n_examples=n_examples) + files = os.listdir(tmp_path / "seqlet_examples" / "pos_patterns.pattern_0") + assert len(files) == n_examples + + +def test_generate_descriptive_report_render(small_h5, tmp_path, capsys): + path = generate_descriptive_report(str(small_h5), str(tmp_path), + n_examples=1) + + html = open(path).read() + assert 'id="pattern-pos_patterns-pattern_0"' in html + assert html.count("data:image/png;base64,") >= 6 + + +def test_generate_descriptive_report_ttl(modisco_h5, tmp_path, plots, + meme_db): + path = generate_descriptive_report(str(modisco_h5), str(tmp_path), + meme_motif_db=str(meme_db), top_n_matches=2, ttl=True, n_examples=1) + + html = open(path).read() + assert "db_motif_0 NAME0" in html + assert "db_motif_1 NAME1" in html + assert "P-value" in html + assert os.path.isdir(tmp_path / "tomtom_logos") + + +@requires_tomtom +def test_generate_descriptive_report_tomtom(small_h5, tmp_path, plots, + meme_db): + path = generate_descriptive_report(str(small_h5), str(tmp_path), + meme_motif_db=str(meme_db), top_n_matches=1, ttl=False, n_examples=1) + + html = open(path).read() + assert "Q-value" in html + + +@pytest.mark.parametrize("suffix", ["./", "report/"]) +def test_generate_descriptive_report_suffix(small_h5, tmp_path, plots, suffix): + path = generate_descriptive_report(str(small_h5), str(tmp_path), + img_path_suffix=suffix, n_examples=1) + assert os.path.exists(path) diff --git a/tests/test_extract_seqlets.py b/tests/test_extract_seqlets.py new file mode 100644 index 0000000..aef4ba4 --- /dev/null +++ b/tests/test_extract_seqlets.py @@ -0,0 +1,605 @@ +# test_extract_seqlets.py +# Contact: Jacob Schreiber + +import numpy +import pytest + +from modiscolite.extract_seqlets import _bin_mode +from modiscolite.extract_seqlets import _laplacian_null +from modiscolite.extract_seqlets import _iterative_extract_seqlets +from modiscolite.extract_seqlets import _smooth_and_split +from modiscolite.extract_seqlets import _isotonic_thresholds +from modiscolite.extract_seqlets import _refine_thresholds +from modiscolite.extract_seqlets import extract_seqlets + +from modiscolite.core import Seqlet + +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +KWARGS = dict(target_fdr=0.05, min_passing_windows_frac=0.03, + max_passing_windows_frac=0.2, weak_threshold_for_counting_sign=0.8) + + +def _coords(seqlets): + return [(s.example_idx, s.start, s.end, s.is_revcomp) for s in seqlets] + + +def _track(d, peaks, n=1): + """Return an (n, d) track of -inf with the given {position: value} peaks.""" + + track = numpy.full((n, d), -numpy.inf) + for (i, position), value in peaks.items(): + track[i, position] = value + + return track + + +@pytest.fixture +def attributions(contrib_scores): + return contrib_scores.sum(axis=2) + + +@pytest.fixture +def smoothed(attributions): + return _smooth_and_split(attributions, 20)[2] + + +## + + +@pytest.mark.parametrize("bins", [10, 100, 1000]) +@pytest.mark.parametrize("loc", [-3.0, 0.0, 2.5]) +def test_bin_mode(bins, loc): + values = numpy.random.RandomState(0).laplace(loc, 0.5, size=50000) + l_edge, r_edge, top = _bin_mode(values, bins=bins) + + hist, edges = numpy.histogram(values, bins=bins) + peak = numpy.argmax(hist) + + assert l_edge == edges[peak] + assert r_edge == edges[peak+1] + assert abs((r_edge - l_edge) - (values.max() - values.min()) / bins) < 1e-9 + assert numpy.all(top > l_edge) and numpy.all(top < r_edge) + assert_array_equal(top, values[(values > l_edge) & (values < r_edge)]) + + +@pytest.mark.parametrize("loc", [-3.0, 0.0, 2.5]) +def test_bin_mode_location(loc): + values = numpy.random.RandomState(0).laplace(loc, 0.5, size=100000) + l_edge, r_edge, top = _bin_mode(values, bins=100) + assert abs((l_edge + r_edge) / 2 - loc) < 0.2 + + +def test_bin_mode_spike(): + values = numpy.concatenate([numpy.random.RandomState(0).uniform(-10, 10, + size=1000), numpy.full(500, 3.3)]) + l_edge, r_edge, top = _bin_mode(values, bins=50) + + assert l_edge < 3.3 < r_edge + assert numpy.sum(top == 3.3) == 500 + + +def test_bin_mode_two_level(): + # The second pass on the first pass's values narrows the estimate. + values = numpy.random.RandomState(0).laplace(0.7, 0.3, size=200000) + l0, r0, top = _bin_mode(values) + l1, r1, _ = _bin_mode(top) + + assert l0 <= l1 < r1 <= r0 + assert (r1 - l1) < (r0 - l0) + + +def test_bin_mode_default_bins(): + values = numpy.random.RandomState(0).randn(10000) + l_edge, r_edge, _ = _bin_mode(values) + assert abs((r_edge - l_edge) - (values.max() - values.min()) / 1000) < 1e-9 + + +## + + +@pytest.mark.parametrize("num_to_samp", [10, 100, 1000, 10000]) +def test_laplacian_null(smoothed, num_to_samp): + pos, neg = _laplacian_null(smoothed, 20, num_to_samp) + + assert len(pos) + len(neg) == num_to_samp + assert numpy.all(pos >= 0) + assert numpy.all(neg < 0) + + +def test_laplacian_null_deterministic(smoothed): + pos0, neg0 = _laplacian_null(smoothed, 20, 1000) + pos1, neg1 = _laplacian_null(smoothed, 20, 1000) + + assert_array_equal(pos0, pos1) + assert_array_equal(neg0, neg1) + + +@pytest.mark.parametrize("random_seed", [0, 1, 99]) +def test_laplacian_null_seed(smoothed, random_seed): + pos0, neg0 = _laplacian_null(smoothed, 20, 1000) + pos1, neg1 = _laplacian_null(smoothed, 20, 1000, random_seed=random_seed) + + assert len(pos1) + len(neg1) == 1000 + assert not numpy.array_equal(numpy.sort(numpy.concatenate([pos0, neg0])), + numpy.sort(numpy.concatenate([pos1, neg1]))) + + +@pytest.mark.parametrize("window_size", [1, 10, 50]) +def test_laplacian_null_window_size_unused(smoothed, window_size): + pos0, neg0 = _laplacian_null(smoothed, 20, 500) + pos1, neg1 = _laplacian_null(smoothed, window_size, 500) + + assert_array_equal(pos0, pos1) + assert_array_equal(neg0, neg1) + + +def test_laplacian_null_symmetric(): + # A symmetric Laplace track gives a symmetric null centered at zero. + track = numpy.random.RandomState(0).laplace(0, 1, size=(200, 500)) + pos, neg = _laplacian_null(track, 20, 10000) + + assert abs(len(pos) / 10000 - 0.5) < 0.03 + assert abs(numpy.median(pos) + numpy.median(neg)) < 0.1 + + +def test_laplacian_null_scale(): + track = numpy.random.RandomState(0).laplace(0, 1, size=(100, 500)) + pos0, neg0 = _laplacian_null(track, 20, 5000) + pos1, neg1 = _laplacian_null(track * 10, 20, 5000) + + assert abs(numpy.median(pos1) / numpy.median(pos0) - 10) < 1.0 + + +def test_laplacian_null_real(smoothed): + pos, neg = _laplacian_null(smoothed, 20, 10000) + + assert len(pos) == 5822 + assert len(neg) == 4178 + assert_array_almost_equal(pos[:4], [0.2991, 0.4741, 0.4988, 0.2131], 4) + assert_array_almost_equal(neg[:4], [-0.0656, -0.4195, -0.2515, -0.4306], 4) + + +## + + +def test_iterative_extract_seqlets_values(): + track = _track(100, {(0, 30): 5.0, (0, 70): 3.0}) + seqlets = _iterative_extract_seqlets(track, window_size=10, flank=5, + suppress=10) + + assert _coords(seqlets) == [(0, 25, 45, False), (0, 65, 85, False)] + assert all(isinstance(s, Seqlet) for s in seqlets) + + +def test_iterative_extract_seqlets_order(): + track = _track(200, {(0, 30): 1.0, (0, 100): 3.0, (0, 150): 2.0}) + seqlets = _iterative_extract_seqlets(track, window_size=10, flank=5, + suppress=10) + + assert [s.start for s in seqlets] == [95, 145, 25] + + +@pytest.mark.parametrize("gap,expected", [(10, 1), (11, 2), (5, 1), (30, 2)]) +def test_iterative_extract_seqlets_suppress(gap, expected): + track = _track(100, {(0, 40): 2.0, (0, 40+gap): 1.0}) + seqlets = _iterative_extract_seqlets(track, window_size=5, flank=2, + suppress=10) + + assert len(seqlets) == expected + assert seqlets[0].start == 38 + + +@pytest.mark.parametrize("position,expected", [(0, 0), (4, 0), (5, 1), + (94, 1), (95, 0), (99, 0)]) +def test_iterative_extract_seqlets_edges(position, expected): + track = _track(100, {(0, position): 1.0}) + seqlets = _iterative_extract_seqlets(track, window_size=10, flank=5, + suppress=10) + assert len(seqlets) == expected + + +def test_iterative_extract_seqlets_edge_suppresses(): + # A peak too close to the edge is dropped but still suppresses neighbors. + track = _track(100, {(0, 2): 5.0, (0, 9): 1.0}) + seqlets = _iterative_extract_seqlets(track, window_size=10, flank=5, + suppress=10) + assert seqlets == [] + + +def test_iterative_extract_seqlets_examples(): + track = _track(100, {(0, 50): 1.0, (1, 20): 2.0, (1, 80): 1.5, + (3, 60): 0.5}, n=4) + seqlets = _iterative_extract_seqlets(track, window_size=6, flank=3, + suppress=8) + + assert _coords(seqlets) == [(0, 47, 59, False), (1, 17, 29, False), + (1, 77, 89, False), (3, 57, 69, False)] + + +def test_iterative_extract_seqlets_empty(): + track = numpy.full((3, 50), -numpy.inf) + assert _iterative_extract_seqlets(track, 5, 2, 5) == [] + + +def test_iterative_extract_seqlets_in_place(): + track = _track(100, {(0, 30): 5.0}) + _iterative_extract_seqlets(track, window_size=10, flank=5, suppress=10) + assert numpy.all(track == -numpy.inf) + + +@pytest.mark.parametrize("suppress", [3, 7, 15]) +def test_iterative_extract_seqlets_dense(suppress): + track = numpy.random.RandomState(suppress).uniform(0, 1, size=(2, 200)) + seqlets = _iterative_extract_seqlets(track, window_size=4, flank=2, + suppress=suppress) + + for i in range(2): + starts = sorted(s.start for s in seqlets if s.example_idx == i) + assert numpy.all(numpy.diff(starts) > suppress) + + +@pytest.mark.parametrize("window_size,flank", [(1, 0), (10, 0), (5, 3), + (20, 5)]) +def test_iterative_extract_seqlets_length(window_size, flank): + track = numpy.random.RandomState(0).uniform(0, 1, size=(3, 150)) + seqlets = _iterative_extract_seqlets(track, window_size, flank, 10) + + assert len(seqlets) > 0 + assert all(len(s) == window_size + 2*flank for s in seqlets) + assert all(s.start >= 0 for s in seqlets) + + +## + + +@pytest.mark.parametrize("n,length", [(1, 10), (5, 50), (20, 300), (3, 1000)]) +@pytest.mark.parametrize("window_size", [1, 5, 10]) +def test_smooth_and_split(n, length, window_size): + tracks = numpy.random.RandomState(n).randn(n, length) + pos, neg, smoothed = _smooth_and_split(tracks, window_size) + + assert smoothed.shape == (n, length - window_size + 1) + for i in range(n): + assert_array_almost_equal(smoothed[i], numpy.convolve(tracks[i], + numpy.ones(window_size), 'valid')) + + assert len(pos) + len(neg) == smoothed.size + assert numpy.all(pos >= 0) + assert numpy.all(neg < 0) + assert numpy.all(numpy.diff(pos) >= 0) + assert numpy.all(numpy.diff(neg) <= 0) + + +def test_smooth_and_split_values(): + tracks = numpy.array([[1.0, -2.0, 3.0, -4.0, 0.0]]) + pos, neg, smoothed = _smooth_and_split(tracks, 2) + + assert_array_almost_equal(smoothed, [[-1.0, 1.0, -1.0, -4.0]]) + assert_array_almost_equal(pos, [1.0]) + assert_array_almost_equal(neg, [-1.0, -1.0, -4.0]) + + +def test_smooth_and_split_zero(): + # Zero counts as positive. + pos, neg, smoothed = _smooth_and_split(numpy.zeros((2, 5)), 3) + assert_array_equal(pos, numpy.zeros(6)) + assert len(neg) == 0 + + +@pytest.mark.parametrize("subsample_cap", [10, 100, 1000]) +def test_smooth_and_split_subsample(subsample_cap): + tracks = numpy.random.RandomState(0).randn(10, 200) + pos, neg, smoothed = _smooth_and_split(tracks, 5, + subsample_cap=subsample_cap) + + assert smoothed.shape == (10, 196) + assert len(pos) + len(neg) == subsample_cap + assert numpy.isin(pos, smoothed).all() + assert numpy.isin(neg, smoothed).all() + + +def test_smooth_and_split_subsample_deterministic(): + tracks = numpy.random.RandomState(0).randn(10, 200) + pos0, neg0, _ = _smooth_and_split(tracks, 5, subsample_cap=500) + pos1, neg1, _ = _smooth_and_split(tracks, 5, subsample_cap=500) + + assert_array_equal(pos0, pos1) + assert_array_equal(neg0, neg1) + + +def test_smooth_and_split_does_not_modify(): + tracks = numpy.random.RandomState(0).randn(4, 30) + tracks0 = tracks.copy() + _smooth_and_split(tracks, 5) + assert_array_equal(tracks, tracks0) + + +def test_smooth_and_split_real(attributions): + pos, neg, smoothed = _smooth_and_split(attributions, 20) + + assert smoothed.shape == (300, 281) + assert len(pos) == 49556 + assert len(neg) == 34744 + assert_array_almost_equal([pos[-1], neg[-1]], [14.3691, -2.3010], 4) + + +## + + +@pytest.mark.parametrize("target_fdr", [0.01, 0.05, 0.2, 0.5]) +def test_isotonic_thresholds_increasing(target_fdr): + rng = numpy.random.RandomState(0) + values = numpy.sort(numpy.concatenate([rng.exponential(1, 5000), + rng.exponential(1, 500) + 6])) + null_values = rng.exponential(1, 5000) + + threshold = _isotonic_thresholds(values, null_values, increasing=True, + target_fdr=target_fdr) + + assert threshold in values + assert threshold > 2 + + +@pytest.mark.parametrize("target_fdr", [0.01, 0.05, 0.2, 0.5]) +def test_isotonic_thresholds_decreasing(target_fdr): + rng = numpy.random.RandomState(0) + values = numpy.sort(-numpy.concatenate([rng.exponential(1, 5000), + rng.exponential(1, 500) + 6]))[::-1] + null_values = -rng.exponential(1, 5000) + + threshold = _isotonic_thresholds(values, null_values, increasing=False, + target_fdr=target_fdr) + + assert threshold in values + assert threshold < -2 + + +def test_isotonic_thresholds_fdr_monotonic(): + rng = numpy.random.RandomState(0) + values = numpy.sort(numpy.concatenate([rng.exponential(1, 5000), + rng.exponential(2, 2000) + 2])) + null_values = rng.exponential(1, 5000) + + thresholds = [_isotonic_thresholds(values, null_values, True, fdr) + for fdr in [0.4, 0.2, 0.1, 0.05, 0.01]] + assert numpy.all(numpy.diff(thresholds) >= 0) + + +@pytest.mark.parametrize("min_frac_neg", [0.5, 0.95, 1.0]) +def test_isotonic_thresholds_min_frac_neg(min_frac_neg): + rng = numpy.random.RandomState(0) + values = numpy.sort(numpy.concatenate([rng.exponential(1, 5000), + rng.exponential(1, 500) + 6])) + null_values = rng.exponential(1, 5000) + + threshold = _isotonic_thresholds(values, null_values, True, 0.05, + min_frac_neg=min_frac_neg) + assert threshold in values + + +def test_isotonic_thresholds_min_frac_neg_monotonic(): + # Assuming more of the values are null can only raise the threshold. + rng = numpy.random.RandomState(1) + values = numpy.sort(numpy.concatenate([rng.exponential(1, 3000), + rng.exponential(1, 3000) + 3])) + null_values = rng.exponential(1, 3000) + + thresholds = [_isotonic_thresholds(values, null_values, True, 0.1, + min_frac_neg=frac) for frac in [0.0, 0.5, 0.9, 1.0]] + assert numpy.all(numpy.diff(thresholds) >= 0) + + +def test_isotonic_thresholds_indistinguishable(): + # With no signal only the most extreme value is forced to pass. + rng = numpy.random.RandomState(0) + values = numpy.sort(rng.exponential(1, 2000)) + null_values = rng.exponential(1, 2000) + + threshold = _isotonic_thresholds(values, null_values, True, 0.01) + assert threshold >= numpy.percentile(values, 99) + + +def test_isotonic_thresholds_real(attributions): + pos, neg, smoothed = _smooth_and_split(attributions, 20) + pos_null, neg_null = _laplacian_null(smoothed, 20, 10000) + + pos_threshold = _isotonic_thresholds(pos, pos_null, True, 0.05) + neg_threshold = _isotonic_thresholds(neg, neg_null, False, 0.05) + assert_array_almost_equal([pos_threshold, neg_threshold], [2.1326, -2.3010], + 4) + + +## + + +def test_refine_thresholds_unchanged(): + vals = numpy.linspace(-1, 1, 101) + pos, neg = _refine_thresholds(vals, 0.9, -0.9, 0.05, 0.5) + assert (pos, neg) == (0.9, -0.9) + + +@pytest.mark.parametrize("min_frac", [0.1, 0.25, 0.5]) +def test_refine_thresholds_too_few(min_frac): + vals = numpy.random.RandomState(0).randn(10000) + pos, neg = _refine_thresholds(vals, 100.0, -100.0, min_frac, 0.9) + + assert pos == numpy.percentile(numpy.abs(vals), 100*(1-min_frac)) + assert neg == -pos + frac = (numpy.sum(vals >= pos) + numpy.sum(vals <= neg)) / len(vals) + assert abs(frac - min_frac) < 0.01 + + +@pytest.mark.parametrize("max_frac", [0.1, 0.25, 0.5]) +def test_refine_thresholds_too_many(max_frac): + vals = numpy.random.RandomState(0).randn(10000) + pos, neg = _refine_thresholds(vals, 0.0, 0.0, 0.01, max_frac) + + assert pos == numpy.percentile(numpy.abs(vals), 100*(1-max_frac)) + assert neg == -pos + frac = (numpy.sum(vals >= pos) + numpy.sum(vals <= neg)) / len(vals) + assert abs(frac - max_frac) < 0.01 + + +def test_refine_thresholds_asymmetric(): + # Refinement replaces asymmetric thresholds with symmetric ones. + vals = numpy.random.RandomState(0).randn(10000) + pos, neg = _refine_thresholds(vals, 0.1, -50.0, 0.01, 0.2) + assert neg == -pos + assert pos > 0.1 + + +def test_refine_thresholds_boundaries(): + vals = numpy.array([-2.0, -1.0, 0.0, 1.0, 2.0]) + assert _refine_thresholds(vals, 2.0, -2.0, 0.4, 0.4) == (2.0, -2.0) + + +## + + +def test_extract_seqlets_real(attributions): + seqlets, threshold = extract_seqlets(attributions, window_size=20, + flank=5, suppress=15, **KWARGS) + + assert len(seqlets) == 551 + assert abs(threshold - 0.72265625) < 1e-6 + assert _coords(seqlets[:4]) == [(0, 117, 147, False), (1, 130, 160, False), + (2, 144, 174, False), (3, 163, 193, False)] + + +@pytest.mark.parametrize("window_size", [6, 10, 15, 20, 21]) +@pytest.mark.parametrize("flank", [1, 5, 10]) +def test_extract_seqlets(attributions, window_size, flank): + suppress = int(0.5*window_size) + flank + seqlets, threshold = extract_seqlets(attributions, window_size, flank, + suppress, **KWARGS) + + assert len(seqlets) > 100 + assert threshold > 0 + for seqlet in seqlets: + assert len(seqlet) == window_size + 2*flank + assert seqlet.start >= 0 + assert seqlet.end <= 300 + assert seqlet.is_revcomp is False + assert seqlet.sequence is None + + idxs = [s.example_idx for s in seqlets] + assert idxs == sorted(idxs) + + +@pytest.mark.parametrize("suppress", [5, 15, 30]) +def test_extract_seqlets_suppress(attributions, suppress): + seqlets, _ = extract_seqlets(attributions, 10, 5, suppress, **KWARGS) + + for i in range(300): + starts = sorted(s.start for s in seqlets if s.example_idx == i) + assert numpy.all(numpy.diff(starts) > suppress) + + +def test_extract_seqlets_suppress_count(attributions): + counts = [len(extract_seqlets(attributions, 10, 5, suppress, **KWARGS)[0]) + for suppress in [5, 10, 20, 40]] + assert numpy.all(numpy.diff(counts) < 0) + + +@pytest.mark.parametrize("target_fdr,expected", [(0.01, 496), (0.05, 551), + (0.2, 698)]) +def test_extract_seqlets_target_fdr(attributions, target_fdr, expected): + # The returned threshold is capped by weak_threshold_for_counting_sign, + # so only the number of seqlets depends on the target FDR here. + kwargs = dict(KWARGS, target_fdr=target_fdr) + seqlets, threshold = extract_seqlets(attributions, 20, 5, 15, **kwargs) + + assert len(seqlets) == expected + assert abs(threshold - 0.72265625) < 1e-6 + + +@pytest.mark.parametrize("weak", [0.2, 0.5, 0.8, 0.95]) +def test_extract_seqlets_weak_threshold(attributions, weak): + kwargs = dict(KWARGS, weak_threshold_for_counting_sign=weak) + seqlets, threshold = extract_seqlets(attributions, 20, 5, 15, **kwargs) + + distribution = numpy.sort(numpy.abs(_smooth_and_split(attributions, + 20)[2].ravel())) + assert threshold <= distribution[int(weak * len(distribution))] + assert len(seqlets) == 551 + + +def test_extract_seqlets_weak_threshold_monotonic(attributions): + thresholds = [extract_seqlets(attributions, 20, 5, 15, **dict(KWARGS, + weak_threshold_for_counting_sign=weak))[1] for weak in [0.1, 0.4, 0.7]] + assert numpy.all(numpy.diff(thresholds) > 0) + + +@pytest.mark.parametrize("min_frac", [0.05, 0.1, 0.2]) +def test_extract_seqlets_min_passing(attributions, min_frac): + kwargs = dict(KWARGS, min_passing_windows_frac=min_frac, + max_passing_windows_frac=0.5) + seqlets0, _ = extract_seqlets(attributions, 20, 5, 15, **KWARGS) + seqlets1, _ = extract_seqlets(attributions, 20, 5, 15, **kwargs) + assert len(seqlets1) >= len(seqlets0) + + +@pytest.mark.parametrize("max_frac", [0.005, 0.01, 0.02]) +def test_extract_seqlets_max_passing(attributions, max_frac): + kwargs = dict(KWARGS, min_passing_windows_frac=0.001, + max_passing_windows_frac=max_frac) + seqlets0, _ = extract_seqlets(attributions, 20, 5, 15, **KWARGS) + seqlets1, _ = extract_seqlets(attributions, 20, 5, 15, **kwargs) + assert len(seqlets1) <= len(seqlets0) + + +def test_extract_seqlets_negative(attributions): + # The absolute value of passing windows is used, so negating every + # attribution calls seqlets at the same positions. + seqlets0, _ = extract_seqlets(attributions, 20, 5, 15, **KWARGS) + seqlets1, _ = extract_seqlets(-attributions, 20, 5, 15, **KWARGS) + + overlap = set(_coords(seqlets0)) & set(_coords(seqlets1)) + assert len(overlap) > 0.9 * len(seqlets0) + + +def test_extract_seqlets_planted(): + rng = numpy.random.RandomState(0) + attributions = rng.laplace(0, 0.05, size=(200, 150)) + attributions[:, 70:80] += rng.uniform(0.5, 1.5, size=(200, 1)) + + seqlets, threshold = extract_seqlets(attributions, 10, 3, 8, **KWARGS) + first = {} + for s in seqlets: + first.setdefault(s.example_idx, s) + + assert len(first) == 200 + assert all(abs(s.start + 3 - 70) <= 1 for s in first.values()) + + +def test_extract_seqlets_does_not_modify(attributions): + attributions0 = attributions.copy() + extract_seqlets(attributions, 20, 5, 15, **KWARGS) + assert_array_equal(attributions, attributions0) + + +def test_extract_seqlets_float64(attributions): + seqlets0, threshold0 = extract_seqlets(attributions, 20, 5, 15, **KWARGS) + seqlets1, threshold1 = extract_seqlets(attributions.astype('float64'), 20, + 5, 15, **KWARGS) + + assert _coords(seqlets0) == _coords(seqlets1) + assert abs(threshold0 - threshold1) < 1e-4 + + +def test_extract_seqlets_subset(attributions): + seqlets, threshold = extract_seqlets(attributions[:50], 20, 5, 15, + **KWARGS) + assert all(s.example_idx < 50 for s in seqlets) + assert len(seqlets) > 50 + + +@pytest.mark.skip(reason="bug: extract_seqlets with flank=0 sets " + "smoothed_tracks[:, -0:], which is every column, to -inf, so no seqlets " + "are ever returned") +def test_extract_seqlets_no_flank(attributions): + seqlets, threshold = extract_seqlets(attributions, 20, 0, 10, **KWARGS) + assert len(seqlets) > 0 diff --git a/tests/test_fasta_writer.py b/tests/test_fasta_writer.py new file mode 100644 index 0000000..f52f8a6 --- /dev/null +++ b/tests/test_fasta_writer.py @@ -0,0 +1,131 @@ +# test_fasta_writer.py +# Contact: Jacob Schreiber + +import pytest + +from modiscolite.fasta_writer import FASTAEntry +from modiscolite.fasta_writer import FASTAWriter + +from numpy.testing import assert_raises + + +@pytest.fixture +def entries(): + return [FASTAEntry("chr1:10-20 dir=+ pattern_0.0", "ACGTACGTAC"), + FASTAEntry("chr2:5-9 dir=- pattern_0.1", "TTGA"), + FASTAEntry("seq3", "N")] + + +## + + +@pytest.mark.parametrize("header,sequence", [("seq", "ACGT"), + ("chr1:10-20 dir=+ pattern_0.0", "ACGTACGTAC"), ("", ""), ("x y z", "N"*50)]) +def test_fasta_entry(header, sequence): + entry = FASTAEntry(header, sequence) + + assert entry.header == header + assert entry.sequence == sequence + assert str(entry) == ">{}\n{}".format(header, sequence) + assert repr(entry) == "FASTAEntry(header={}, sequence={})".format(header, + sequence) + + +def test_fasta_entry_keywords(): + entry = FASTAEntry(sequence="ACGT", header="seq") + assert str(entry) == ">seq\nACGT" + + +## + + +def test_fasta_writer_init(): + writer = FASTAWriter() + assert writer.entries == [] + assert writer.get_output() == "" + assert str(writer) == "" + + +def test_fasta_writer_independent(): + writer0 = FASTAWriter() + writer0.add_pair(FASTAEntry("a", "A")) + assert FASTAWriter().entries == [] + + +@pytest.mark.skip(reason="bug: FASTAWriter.__init__ ignores its entries " + "argument and always starts empty") +def test_fasta_writer_init_entries(entries): + writer = FASTAWriter(entries=entries) + assert writer.entries == entries + + +@pytest.mark.parametrize("n", [1, 2, 3]) +def test_fasta_writer_add_pair(entries, n): + writer = FASTAWriter() + for entry in entries[:n]: + writer.add_pair(entry) + + assert writer.entries == entries[:n] + + +def test_fasta_writer_get_output(entries): + writer = FASTAWriter() + for entry in entries: + writer.add_pair(entry) + + assert writer.get_output() == (">chr1:10-20 dir=+ pattern_0.0\nACGTACGTAC\n" + ">chr2:5-9 dir=- pattern_0.1\nTTGA\n>seq3\nN") + assert str(writer) == writer.get_output() + + +def test_fasta_writer_get_output_single(): + writer = FASTAWriter() + writer.add_pair(FASTAEntry("a", "ACGT")) + assert writer.get_output() == ">a\nACGT" + + +def test_fasta_writer_repr(entries): + writer = FASTAWriter() + writer.add_pair(entries[2]) + assert repr(writer) == "FASTAWriter(entries=[FASTAEntry(header=seq3, " \ + "sequence=N)])" + + +def test_fasta_writer_write(entries, tmp_path): + writer = FASTAWriter() + for entry in entries: + writer.add_pair(entry) + + filename = tmp_path / "seqlets.fa" + writer.write(filename) + assert open(filename).read() == writer.get_output() + + +def test_fasta_writer_write_str_path(entries, tmp_path): + writer = FASTAWriter() + writer.add_pair(entries[0]) + + filename = str(tmp_path / "seqlets.fa") + writer.write(filename) + assert open(filename).read() == ">chr1:10-20 dir=+ pattern_0.0\nACGTACGTAC" + + +def test_fasta_writer_write_empty(tmp_path): + filename = tmp_path / "seqlets.fa" + FASTAWriter().write(filename) + assert open(filename).read() == "" + + +def test_fasta_writer_write_overwrites(entries, tmp_path): + filename = tmp_path / "seqlets.fa" + filename.write_text("old contents") + + writer = FASTAWriter() + writer.add_pair(entries[1]) + writer.write(filename) + assert open(filename).read() == ">chr2:5-9 dir=- pattern_0.1\nTTGA" + + +def test_fasta_writer_write_raises(tmp_path): + filename = tmp_path / "missing" / "seqlets.fa" + assert_raises(IOError, FASTAWriter().write, filename) diff --git a/tests/test_gapped_kmer.py b/tests/test_gapped_kmer.py new file mode 100644 index 0000000..359a058 --- /dev/null +++ b/tests/test_gapped_kmer.py @@ -0,0 +1,356 @@ +# test_gapped_kmer.py +# Contact: Jacob Schreiber + +import numpy +import scipy.sparse +import pytest + +from modiscolite.gapped_kmer import _extract_gkmers +from modiscolite.gapped_kmer import _seqlet_to_gkmers + +from modiscolite.core import Seqlet + +from .synthetic import random_track_set +from .synthetic import make_seqlets + +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +# The matrices returned by _seqlet_to_gkmers have 5**max_len columns, so +# these tests never densify, transpose or convert them to CSC. + + +def _random_X(nx, n, span=30, random_state=0): + """Return (nx, n, 3) rows of (position, base, attribution).""" + + rng = numpy.random.RandomState(random_state) + X = numpy.zeros((nx, n, 3)) + for i in range(nx): + X[i, :, 0] = numpy.sort(rng.choice(span, n, replace=False)) + X[i, :, 1] = rng.randint(4, size=n) + X[i, :, 2] = rng.randn(n) + + return X + + +def _gkmer_reference(x, min_k, max_k, max_gap, max_len): + """Enumerate every gapped k-mer in x and sum attribution / k per key.""" + + scores = {} + n = len(x) + + def extend(idxs): + k = len(idxs) + if k >= max(min_k, 2): + start = x[idxs[0], 0] + key = sum((int(x[i, 1]) + 1) * 5 ** int(x[i, 0] - start) + for i in idxs) + scores[key] = scores.get(key, 0) + sum(x[i, 2] for i in idxs) / k + + if k == max_k: + return + + for i in range(idxs[-1] + 1, n): + if x[i, 0] - x[idxs[0], 0] >= max_len: + break + if x[i, 0] - x[idxs[-1], 0] > max_gap: + continue + extend(idxs + [i]) + + for j in range(n): + extend([j]) + + return scores + + +def _as_dict(keys, scores): + return {int(k): s for k, s in zip(keys, scores) if s != 0} + + +def _row_dict(csr, i): + idxs = slice(csr.indptr[i], csr.indptr[i+1]) + return dict(zip(csr.indices[idxs].tolist(), csr.data[idxs].tolist())) + + +def _seqlet_X(seqlet, topn, take_fwd, sign): + """The (position, base, attribution) rows _seqlet_to_gkmers builds.""" + + onehot = seqlet.sequence + contrib = seqlet.hypothetical_contribs * onehot * sign + if not take_fwd: + onehot, contrib = onehot[::-1, ::-1], contrib[::-1, ::-1] + + imp = contrib.sum(axis=-1) + positions = numpy.sort(numpy.argsort(-imp)[:topn]) + return numpy.array([[p, numpy.argmax(onehot[p]), imp[p]] + for p in positions]) + + +@pytest.fixture +def random_seqlets(): + track_set = random_track_set(n=10, length=60) + return make_seqlets(track_set, [(i, 3*i, 3*i+25, bool(i % 3 == 0)) + for i in range(10)]) + + +## + + +@pytest.mark.parametrize("min_k,max_k", [(2, 2), (2, 3), (3, 4), (4, 6), + (3, 3)]) +@pytest.mark.parametrize("max_gap", [1, 3, 15]) +@pytest.mark.parametrize("max_len", [5, 10, 15]) +def test_extract_gkmers(min_k, max_k, max_gap, max_len): + X = _random_X(3, 12, span=20, random_state=max_gap*max_len) + keys, scores = _extract_gkmers(X, min_k, max_k, max_gap, max_len, 10000) + + assert keys.shape == (3, 10000) + assert scores.shape == (3, 10000) + assert keys.dtype == numpy.int64 + assert scores.dtype == numpy.float64 + + for i in range(3): + expected = _gkmer_reference(X[i], min_k, max_k, max_gap, max_len) + observed = _as_dict(keys[i], scores[i]) + + assert observed.keys() == expected.keys() + for key in expected: + assert abs(observed[key] - expected[key]) < 1e-10 + + +@pytest.mark.parametrize("nx,n", [(1, 3), (1, 20), (5, 8), (20, 10), + (64, 6)]) +def test_extract_gkmers_shapes(nx, n): + X = _random_X(nx, n, span=25, random_state=n) + keys, scores = _extract_gkmers(X, 2, 4, 5, 10, 3000) + + assert keys.shape == (nx, 3000) + for i in range(nx): + expected = _gkmer_reference(X[i], 2, 4, 5, 10) + assert _as_dict(keys[i], scores[i]).keys() == expected.keys() + + +@pytest.mark.parametrize("max_entries", [1, 5, 20, 100]) +def test_extract_gkmers_max_entries(max_entries): + X = _random_X(4, 15, span=20, random_state=0) + keys, scores = _extract_gkmers(X, 2, 4, 15, 15, max_entries) + + assert keys.shape == (4, max_entries) + for i in range(4): + expected = _gkmer_reference(X[i], 2, 4, 15, 15) + top = sorted(expected.items(), key=lambda kv: -abs(kv[1])) + top = dict(top[:max_entries]) + + observed = _as_dict(keys[i], scores[i]) + assert observed.keys() == top.keys() + assert numpy.all(numpy.diff(numpy.abs(scores[i])) <= 0) + + +def test_extract_gkmers_sorted(): + X = _random_X(6, 12, span=20, random_state=1) + keys, scores = _extract_gkmers(X, 2, 5, 15, 15, 2000) + + for i in range(6): + nonzero = scores[i][scores[i] != 0] + assert numpy.all(numpy.diff(numpy.abs(nonzero)) <= 0) + assert numpy.all(scores[i][len(nonzero):] == 0) + assert numpy.all(keys[i][len(nonzero):] == 0) + + +def test_extract_gkmers_values(): + # Bases A, C, G at positions 0, 1, 3 with attributions 1, 2, 3. + X = numpy.array([[[0, 0, 1.0], [1, 1, 2.0], [3, 2, 3.0]]]) + keys, scores = _extract_gkmers(X, 2, 3, 3, 10, 10) + observed = _as_dict(keys[0], scores[0]) + + assert observed == { + 1 + 2*5: 1.5, # AC + 1 + 3*125: 2.0, # A_G + 2 + 3*25: 2.5, # C_G + 1 + 2*5 + 3*125: 2.0 # AC_G + } + + +def test_extract_gkmers_max_gap(): + X = numpy.array([[[0, 0, 1.0], [1, 1, 2.0], [3, 2, 3.0]]]) + keys, scores = _extract_gkmers(X, 2, 3, 1, 10, 10) + assert _as_dict(keys[0], scores[0]) == {1 + 2*5: 1.5} + + +def test_extract_gkmers_max_len(): + X = numpy.array([[[0, 0, 1.0], [1, 1, 2.0], [3, 2, 3.0]]]) + keys, scores = _extract_gkmers(X, 2, 3, 3, 3, 10) + assert _as_dict(keys[0], scores[0]) == {1 + 2*5: 1.5, 2 + 3*25: 2.5} + + +def test_extract_gkmers_repeated(): + # The same gapped k-mer at two offsets sums both contributions. + X = numpy.array([[[0, 0, 1.0], [1, 1, 1.0], [5, 0, 2.0], [6, 1, 4.0]]]) + keys, scores = _extract_gkmers(X, 2, 2, 1, 10, 10) + assert _as_dict(keys[0], scores[0]) == {1 + 2*5: 1.0 + 3.0} + + +def test_extract_gkmers_min_k_above_max_k(): + X = _random_X(2, 8) + keys, scores = _extract_gkmers(X, 5, 4, 5, 10, 50) + assert numpy.all(scores == 0) + assert numpy.all(keys == 0) + + +def test_extract_gkmers_rows_independent(): + X = _random_X(5, 10, random_state=3) + keys, scores = _extract_gkmers(X, 2, 4, 5, 12, 400) + + for i in range(5): + keys_i, scores_i = _extract_gkmers(X[i:i+1].copy(), 2, 4, 5, 12, 400) + assert_array_equal(keys[i], keys_i[0]) + assert_array_equal(scores[i], scores_i[0]) + + +def test_extract_gkmers_sign(): + X = _random_X(3, 10, random_state=4) + Y = X.copy() + Y[:, :, 2] *= -1 + + keys0, scores0 = _extract_gkmers(X, 2, 4, 5, 12, 400) + keys1, scores1 = _extract_gkmers(Y, 2, 4, 5, 12, 400) + + for i in range(3): + assert _as_dict(keys0[i], scores0[i]) == {k: -v for k, v in + _as_dict(keys1[i], scores1[i]).items()} + + +def test_extract_gkmers_translation(): + # Keys are relative to the first position, so shifting every position + # leaves the output unchanged. + X = _random_X(2, 10, random_state=5) + Y = X.copy() + Y[:, :, 0] += 17 + + keys0, scores0 = _extract_gkmers(X, 2, 4, 5, 12, 400) + keys1, scores1 = _extract_gkmers(Y, 2, 4, 5, 12, 400) + + assert_array_equal(keys0, keys1) + assert_array_almost_equal(scores0, scores1) + + +def test_extract_gkmers_keys_in_range(): + X = _random_X(4, 15, span=40, random_state=6) + keys, scores = _extract_gkmers(X, 2, 6, 15, 15, 5000) + + assert numpy.all(keys >= 0) + assert numpy.all(keys < 5**15) + + +## + + +@pytest.mark.parametrize("take_fwd", [True, False]) +@pytest.mark.parametrize("sign", [1, -1]) +@pytest.mark.parametrize("topn", [5, 10, 25]) +def test_seqlet_to_gkmers(random_seqlets, take_fwd, sign, topn): + X = _seqlet_to_gkmers(random_seqlets, topn, 2, 4, 5, 15, 500, take_fwd, + sign) + + assert isinstance(X, scipy.sparse.csr_matrix) + assert X.shape == (10, 5**15) + + for i, seqlet in enumerate(random_seqlets): + x = _seqlet_X(seqlet, topn, take_fwd, sign) + expected = _gkmer_reference(x, 2, 4, 5, 15) + expected = dict(sorted(expected.items(), + key=lambda kv: -abs(kv[1]))[:500]) + + observed = _row_dict(X, i) + assert observed.keys() == expected.keys() + for key in expected: + assert abs(observed[key] - expected[key]) < 1e-8 + + +@pytest.mark.parametrize("max_entries", [1, 10, 50, 500]) +def test_seqlet_to_gkmers_max_entries(random_seqlets, max_entries): + X = _seqlet_to_gkmers(random_seqlets, 20, 4, 6, 15, 15, max_entries, + True, 1) + assert numpy.all(numpy.diff(X.indptr) <= max_entries) + assert numpy.all(X.data != 0) + + +@pytest.mark.parametrize("max_len", [14, 15, 16]) +def test_seqlet_to_gkmers_max_len(random_seqlets, max_len): + X = _seqlet_to_gkmers(random_seqlets, 20, 2, 4, 5, max_len, 500, True, 1) + + assert X.shape == (10, 5**max_len) + assert X.indices.dtype == numpy.int64 + assert numpy.all(X.indices < 5**max_len) + + +def test_seqlet_to_gkmers_revcomp(random_seqlets): + X0 = _seqlet_to_gkmers(random_seqlets, 15, 2, 4, 5, 15, 500, False, 1) + X1 = _seqlet_to_gkmers([s.revcomp() for s in random_seqlets], 15, 2, 4, 5, + 15, 500, True, 1) + + for i in range(10): + assert _row_dict(X0, i) == _row_dict(X1, i) + + +def test_seqlet_to_gkmers_sign(random_seqlets): + negated = [] + for seqlet in random_seqlets: + s = Seqlet(seqlet.example_idx, seqlet.start, seqlet.end, + seqlet.is_revcomp) + s.sequence = seqlet.sequence + s.hypothetical_contribs = -seqlet.hypothetical_contribs + negated.append(s) + + X0 = _seqlet_to_gkmers(random_seqlets, 15, 2, 4, 5, 15, 500, True, 1) + X1 = _seqlet_to_gkmers(negated, 15, 2, 4, 5, 15, 500, True, -1) + + for i in range(10): + assert _row_dict(X0, i) == _row_dict(X1, i) + + +def test_seqlet_to_gkmers_topn_all(random_seqlets): + # A topn of at least the seqlet length uses every position. + X0 = _seqlet_to_gkmers(random_seqlets, 25, 2, 3, 3, 15, 5000, True, 1) + X1 = _seqlet_to_gkmers(random_seqlets, 100, 2, 3, 3, 15, 5000, True, 1) + + for i in range(10): + assert _row_dict(X0, i) == _row_dict(X1, i) + + +def test_seqlet_to_gkmers_uses_contribs_not_hypothetical(random_seqlets): + # Only the observed base carries attribution, so changing the attribution + # of the unobserved bases leaves the output unchanged. + modified = [] + for seqlet in random_seqlets: + s = Seqlet(seqlet.example_idx, seqlet.start, seqlet.end, + seqlet.is_revcomp) + s.sequence = seqlet.sequence + s.hypothetical_contribs = seqlet.hypothetical_contribs + 5 * ( + 1 - seqlet.sequence) + modified.append(s) + + X0 = _seqlet_to_gkmers(random_seqlets, 15, 2, 4, 5, 15, 500, True, 1) + X1 = _seqlet_to_gkmers(modified, 15, 2, 4, 5, 15, 500, True, 1) + + for i in range(10): + assert _row_dict(X0, i) == _row_dict(X1, i) + + +def test_seqlet_to_gkmers_single(random_seqlets): + X = _seqlet_to_gkmers(random_seqlets[:1], 10, 2, 4, 5, 15, 500, True, 1) + assert X.shape == (1, 5**15) + assert X.nnz > 0 + + +def test_seqlet_to_gkmers_real(seqlets): + X = _seqlet_to_gkmers(seqlets[:50], 20, 4, 6, 15, 15, 500, True, 1) + + assert X.shape == (50, 5**15) + assert X.nnz == 50 * 500 + + top = [numpy.sort(numpy.abs(list(_row_dict(X, i).values())))[::-1][:4] + for i in range(2)] + assert_array_almost_equal(top, [[2.5596, 2.0713, 2.0573, 2.0525], + [1.6976, 1.567, 1.5533, 1.5262]], 4) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..572a049 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,979 @@ +# test_io.py +# Contact: Jacob Schreiber + +import h5py +import numpy +import scipy.special +import pytest + +from modiscolite.io import save_pattern +from modiscolite.io import save_hdf5 +from modiscolite.io import write_meme_from_h5 +from modiscolite.io import write_bed_from_h5 +from modiscolite.io import write_fasta_from_h5 +from modiscolite.io import convert +from modiscolite.io import convert_new_to_old + +from modiscolite.util import MemeDataType + +from memelite.io import read_meme + +from .conftest import DATA_DIR +from .synthetic import random_track_set +from .synthetic import make_pattern +from .synthetic import synthetic_patterns +from .synthetic import write_peaks + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +pytestmark = pytest.mark.usefixtures("meme_writer_default") + + +@pytest.fixture +def small_ts(): + return random_track_set(n=6, length=40) + + +@pytest.fixture +def small_h5(small_ts, tmp_path): + """A results file with one positive and one negative pattern of known + seqlets and a window size of 40.""" + + pos = make_pattern(small_ts, [(0, 2, 12, False), (3, 20, 30, True)]) + neg = make_pattern(small_ts, [(5, 30, 40, False)]) + + filename = tmp_path / "small.h5" + save_hdf5(filename, [pos], [neg], window_size=40) + return filename + + +@pytest.fixture +def peaks(tmp_path): + filename = tmp_path / "peaks.bed" + rows = write_peaks(filename, 300) + return filename, rows + + +def _meme_matrices(filename): + return {name: pwm.T for name, pwm in read_meme(str(filename)).items()} + + +def _parse_bed(output): + tracks = {} + for line in output.strip().split("\n"): + if line.startswith("track"): + name = line.split('"')[1] + tracks.setdefault(name, []) + elif line: + tracks[name].append(line.split("\t")) + + return tracks + + +def _parse_fasta(output): + lines = output.split("\n") + return list(zip(lines[::2], lines[1::2])) + + +## + + +def test_save_pattern(small_ts, tmp_path): + pattern = make_pattern(small_ts, [(0, 2, 12, False), (3, 20, 30, True), + (4, 0, 10, False)]) + + with h5py.File(tmp_path / "p.h5", "w") as f: + save_pattern(pattern, f.create_group("pattern_0")) + + with h5py.File(tmp_path / "p.h5", "r") as f: + grp = f["pattern_0"] + assert_array_almost_equal(grp["sequence"][:], pattern.sequence) + assert_array_almost_equal(grp["contrib_scores"][:], + pattern.contrib_scores) + assert_array_almost_equal(grp["hypothetical_contribs"][:], + pattern.hypothetical_contribs) + + seqlets = grp["seqlets"] + assert_array_equal(seqlets["n_seqlets"][:], [3]) + assert_array_equal(seqlets["start"][:], [2, 20, 0]) + assert_array_equal(seqlets["end"][:], [12, 30, 10]) + assert_array_equal(seqlets["example_idx"][:], [0, 3, 4]) + assert_array_equal(seqlets["is_revcomp"][:], [False, True, False]) + assert seqlets["sequence"].shape == (3, 10, 4) + assert_array_almost_equal(seqlets["contrib_scores"][1], + pattern.seqlets[1].contrib_scores) + assert_array_almost_equal(seqlets["hypothetical_contribs"][2], + pattern.seqlets[2].hypothetical_contribs) + + +def test_save_pattern_no_subpatterns(small_ts, tmp_path): + pattern = make_pattern(small_ts, [(0, 2, 12, False)]) + + with h5py.File(tmp_path / "p.h5", "w") as f: + save_pattern(pattern, f.create_group("pattern_0")) + assert sorted(f["pattern_0"].keys()) == ["contrib_scores", + "hypothetical_contribs", "seqlets", "sequence"] + + +def test_save_pattern_subpatterns(pos_patterns, tmp_path): + pattern = pos_patterns[0] + + with h5py.File(tmp_path / "p.h5", "w") as f: + save_pattern(pattern, f.create_group("pattern_0")) + + with h5py.File(tmp_path / "p.h5", "r") as f: + grp = f["pattern_0"] + names = sorted(k for k in grp.keys() if k.startswith("subpattern_")) + assert names == ["subpattern_{}".format(k) for k in + sorted(pattern.subcluster_to_subpattern.keys())] + + for k, subpattern in pattern.subcluster_to_subpattern.items(): + sub = grp["subpattern_{}".format(k)] + assert sub["seqlets"]["n_seqlets"][0] == len(subpattern.seqlets) + assert_array_almost_equal(sub["sequence"][:], subpattern.sequence) + assert_array_equal(sub["seqlets"]["start"][:], + [s.start for s in subpattern.seqlets]) + + +## + + +@pytest.mark.parametrize("n_pos,n_neg", [(1, 1), (3, 0), (0, 2), (4, 5)]) +def test_save_hdf5(small_ts, tmp_path, n_pos, n_neg): + pos = synthetic_patterns(small_ts, n_pos, 3, 10, random_state=0) + neg = synthetic_patterns(small_ts, n_neg, 2, 8, random_state=1) + + filename = tmp_path / "results.h5" + save_hdf5(filename, pos, neg, window_size=40) + + with h5py.File(filename, "r") as f: + assert f.attrs["window_size"] == 40 + assert sorted(f["pos_patterns"].keys()) == sorted("pattern_{}".format(i) + for i in range(n_pos)) + assert sorted(f["neg_patterns"].keys()) == sorted("pattern_{}".format(i) + for i in range(n_neg)) + + for i, pattern in enumerate(pos): + assert_array_almost_equal(f["pos_patterns/pattern_{}/sequence" + .format(i)][:], pattern.sequence) + for i, pattern in enumerate(neg): + assert_array_almost_equal(f["neg_patterns/pattern_{}/contrib_scores" + .format(i)][:], pattern.contrib_scores) + + +@pytest.mark.parametrize("pos,neg,groups", [(None, None, []), + ("pos", None, ["pos_patterns"]), (None, "neg", ["neg_patterns"])]) +def test_save_hdf5_none(small_ts, tmp_path, pos, neg, groups): + patterns = synthetic_patterns(small_ts, 1, 2, 10) + filename = tmp_path / "results.h5" + save_hdf5(filename, patterns if pos else None, patterns if neg else None, + window_size=40) + + with h5py.File(filename, "r") as f: + assert sorted(f.keys()) == groups + + +@pytest.mark.parametrize("window_size", [20, 400, 1000]) +def test_save_hdf5_window_size(small_ts, tmp_path, window_size): + filename = tmp_path / "results.h5" + save_hdf5(filename, None, None, window_size=window_size) + + with h5py.File(filename, "r") as f: + assert f.attrs["window_size"] == window_size + + +def test_save_hdf5_real(modisco_h5, pos_patterns, neg_patterns): + with h5py.File(modisco_h5, "r") as f: + assert f.attrs["window_size"] == 300 + assert sorted(f["pos_patterns"].keys()) == ["pattern_0", "pattern_1"] + assert sorted(f["neg_patterns"].keys()) == ["pattern_0", "pattern_1"] + + for name, patterns in [("pos_patterns", pos_patterns), + ("neg_patterns", neg_patterns)]: + for i, pattern in enumerate(patterns): + grp = f[name]["pattern_{}".format(i)] + assert grp["seqlets/n_seqlets"][0] == len(pattern.seqlets) + assert grp["seqlets/sequence"].shape == (len(pattern.seqlets), + 50, 4) + assert_array_almost_equal(grp["contrib_scores"][:], + pattern.contrib_scores) + + +def test_save_hdf5_overwrites(small_ts, tmp_path): + filename = tmp_path / "results.h5" + save_hdf5(filename, synthetic_patterns(small_ts, 3, 2, 10), None, 40) + save_hdf5(filename, synthetic_patterns(small_ts, 1, 2, 10), None, 20) + + with h5py.File(filename, "r") as f: + assert list(f["pos_patterns"].keys()) == ["pattern_0"] + assert f.attrs["window_size"] == 20 + + +## + + +@pytest.mark.parametrize("datatype", list(MemeDataType)) +def test_write_meme_from_h5(modisco_h5, tmp_path, datatype): + filename = tmp_path / "motifs.meme" + write_meme_from_h5(modisco_h5, datatype, filename, is_quiet=True) + + matrices = _meme_matrices(filename) + assert list(matrices.keys()) == ["pos_patterns.pattern_0", + "pos_patterns.pattern_1", "neg_patterns.pattern_0", + "neg_patterns.pattern_1"] + + with h5py.File(modisco_h5, "r") as f: + for name, matrix in matrices.items(): + grp = f[name.replace(".", "/")] + sequence = grp["sequence"][:] + contrib = grp["contrib_scores"][:] + hyp = grp["hypothetical_contribs"][:] + + expected = { + MemeDataType.PFM: sequence / sequence.sum(axis=1, keepdims=True), + MemeDataType.CWM: contrib, + MemeDataType.hCWM: hyp, + MemeDataType.CWM_PFM: scipy.special.softmax(contrib, axis=1), + MemeDataType.hCWM_PFM: scipy.special.softmax(hyp, axis=1) + }[datatype] + + assert matrix.shape == (50, 4) + assert_array_almost_equal(matrix, expected, 6) + + +@pytest.mark.parametrize("datatype", [MemeDataType.PFM, MemeDataType.CWM_PFM, + MemeDataType.hCWM_PFM]) +def test_write_meme_from_h5_probabilities(modisco_h5, tmp_path, datatype): + filename = tmp_path / "motifs.meme" + write_meme_from_h5(modisco_h5, datatype, filename, is_quiet=True) + + for matrix in _meme_matrices(filename).values(): + assert_array_almost_equal(matrix.sum(axis=1), numpy.ones(50), 5) + assert numpy.all(matrix >= 0) + + +def test_write_meme_from_h5_header(modisco_h5, tmp_path): + filename = tmp_path / "motifs.meme" + write_meme_from_h5(modisco_h5, MemeDataType.PFM, filename, is_quiet=True) + + lines = open(filename).read().split("\n") + assert lines[:7] == ["MEME version 5", "", "ALPHABET= ACGT", "", + "Background letter frequencies", "A 0.25 C 0.25 G 0.25 T 0.25", ""] + assert lines[7] == "MOTIF pos_patterns.pattern_0" + assert lines[8] == "letter-probability matrix: alength= 4 w= 50 nsites= 1" + + +def test_write_meme_from_h5_stdout(modisco_h5, tmp_path, capsys): + filename = tmp_path / "motifs.meme" + write_meme_from_h5(modisco_h5, MemeDataType.CWM, filename, is_quiet=False) + + assert capsys.readouterr().out == open(filename).read() + "\n" + + +def test_write_meme_from_h5_stdout_only(modisco_h5, tmp_path, capsys): + write_meme_from_h5(modisco_h5, MemeDataType.PFM, None, is_quiet=False) + + output = capsys.readouterr().out + assert output.startswith("MEME version 5\n") + assert output.count("MOTIF ") == 4 + assert list(tmp_path.iterdir()) == [] + + +def test_write_meme_from_h5_quiet(modisco_h5, tmp_path, capsys): + write_meme_from_h5(modisco_h5, MemeDataType.PFM, tmp_path / "m.meme", + is_quiet=True) + assert capsys.readouterr().out == "" + + +def test_write_meme_from_h5_neither(modisco_h5, tmp_path, capsys): + write_meme_from_h5(modisco_h5, MemeDataType.PFM, None, is_quiet=True) + assert capsys.readouterr().out == "" + assert list(tmp_path.iterdir()) == [] + + +def test_write_meme_from_h5_small(small_h5, tmp_path): + filename = tmp_path / "motifs.meme" + write_meme_from_h5(small_h5, MemeDataType.hCWM, filename, is_quiet=True) + + matrices = _meme_matrices(filename) + assert list(matrices.keys()) == ["pos_patterns.pattern_0", + "neg_patterns.pattern_0"] + assert all(m.shape == (10, 4) for m in matrices.values()) + + +def test_write_meme_from_h5_missing_group(small_ts, tmp_path): + filename = tmp_path / "results.h5" + save_hdf5(filename, synthetic_patterns(small_ts, 2, 3, 10), None, 40) + + write_meme_from_h5(filename, MemeDataType.PFM, tmp_path / "m.meme", True) + assert list(_meme_matrices(tmp_path / "m.meme").keys()) == [ + "pos_patterns.pattern_0", "pos_patterns.pattern_1"] + + +def test_write_meme_from_h5_skips_datasets(small_h5, tmp_path): + with h5py.File(small_h5, "a") as f: + f["pos_patterns"].create_dataset("not_a_pattern", data=numpy.ones(3)) + + write_meme_from_h5(small_h5, MemeDataType.PFM, tmp_path / "m.meme", True) + assert list(_meme_matrices(tmp_path / "m.meme").keys()) == [ + "pos_patterns.pattern_0", "neg_patterns.pattern_0"] + + +def test_write_meme_from_h5_order(small_ts, tmp_path): + # h5py lists groups in name order, so pattern_10 precedes pattern_2. + filename = tmp_path / "results.h5" + save_hdf5(filename, synthetic_patterns(small_ts, 12, 2, 10), None, 40) + + write_meme_from_h5(filename, MemeDataType.PFM, tmp_path / "m.meme", True) + names = list(_meme_matrices(tmp_path / "m.meme").keys()) + assert names[:4] == ["pos_patterns.pattern_0", "pos_patterns.pattern_1", + "pos_patterns.pattern_10", "pos_patterns.pattern_11"] + assert len(names) == 12 + + +def test_write_meme_from_h5_repeated(modisco_h5, tmp_path, meme_writer_default): + write_meme_from_h5(modisco_h5, MemeDataType.PFM, tmp_path / "a.meme", True) + meme_writer_default.clear() + write_meme_from_h5(modisco_h5, MemeDataType.PFM, tmp_path / "b.meme", True) + + assert open(tmp_path / "a.meme").read() == open(tmp_path / "b.meme").read() + + +@pytest.mark.parametrize("datatype", ["PFM", "CWM", None, 3]) +def test_write_meme_from_h5_raises(modisco_h5, datatype): + assert_raises(ValueError, write_meme_from_h5, modisco_h5, datatype, None, + True) + + +## + + +def test_write_bed_from_h5_small(small_h5, tmp_path, capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + filename = tmp_path / "seqlets.bed" + + write_bed_from_h5(small_h5, peaks, filename, '*', None, is_quiet=True) + + # Peak i lies on chroms[i % 3], spans 1000*i + 17 to 1000*i + 517 with its + # center at 1000*i + 267, and the window of 40 starts 20 bp before the + # center. + assert open(filename).read() == ( + 'track name="pattern_0" description="TF-MoDISco pattern \'pattern_0\' ' + 'on the positive strand."\n' + 'chr1\t250\t259\tpattern_0.0\t100\t+\n' + 'chr1\t3268\t3277\tpattern_0.1\t103\t-\n' + '\n' + 'track name="pattern_0" description="TF-MoDISco pattern \'pattern_0\' ' + 'on the positive strand."\n' + 'chrX\t5278\t5287\tpattern_0.0\t105\t+\n') + assert capsys.readouterr().out == "" + + +def test_write_bed_from_h5(modisco_h5, peaks, tmp_path): + peaks_file, rows = peaks + filename = tmp_path / "seqlets.bed" + write_bed_from_h5(modisco_h5, peaks_file, filename, '*', None, True) + + tracks = _parse_bed(open(filename).read()) + assert list(tracks.keys()) == ["pattern_0", "pattern_1"] + + with h5py.File(modisco_h5, "r") as f: + n = sum(f[g][p]["seqlets/n_seqlets"][0] for g in f.keys() + for p in f[g].keys()) + lines = [l for l in open(filename).read().split("\n") + if l and not l.startswith("track")] + assert len(lines) == n + + seqlets = f["pos_patterns/pattern_0/seqlets"] + for i, line in enumerate(lines[:len(seqlets["start"])]): + chrom, start, end, name, score, strand = line.split("\t") + idx = seqlets["example_idx"][i] + peak = rows[idx].split("\t") + center = (int(peak[1]) + int(peak[2])) // 2 + + assert chrom == peak[0] + assert score == peak[4] + assert name == "pattern_0.{}".format(i) + assert int(start) == center - 150 + seqlets["start"][i] + 1 + assert int(end) == center - 150 + seqlets["end"][i] + assert strand == ("-" if seqlets["is_revcomp"][i] else "+") + + +@pytest.mark.parametrize("window_size", [40, 100, 400]) +def test_write_bed_from_h5_window_size(small_h5, tmp_path, window_size): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + filename = tmp_path / "seqlets.bed" + + write_bed_from_h5(small_h5, peaks, filename, '*', window_size, True) + first = open(filename).read().split("\n")[1].split("\t") + + assert int(first[1]) == 267 - window_size // 2 + 2 + 1 + assert int(first[2]) == 267 - window_size // 2 + 12 + + +def test_write_bed_from_h5_chroms(small_ts, tmp_path): + # Seqlet example indices refer to the peaks on the chosen chromosomes. + peaks = tmp_path / "peaks.bed" + rows = write_peaks(peaks, 18, chroms=('chr1', 'chr2', 'chr3')) + pattern = make_pattern(small_ts, [(i, 2, 12, False) for i in range(6)]) + + h5 = tmp_path / "results.h5" + save_hdf5(h5, [pattern], None, window_size=40) + + filename = tmp_path / "seqlets.bed" + write_bed_from_h5(h5, peaks, filename, ['chr2'], None, True) + lines = open(filename).read().strip().split("\n")[1:] + + chr2 = [r for r in rows if r.startswith("chr2\t")] + assert len(lines) == 6 + for line, peak in zip(lines, chr2): + fields, peak = line.split("\t"), peak.split("\t") + center = (int(peak[1]) + int(peak[2])) // 2 + + assert fields[0] == "chr2" + assert int(fields[1]) == center - 20 + 3 + assert fields[4] == peak[4] + + +def test_write_bed_from_h5_chroms_multiple(small_ts, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 12, chroms=('chr1', 'chr2', 'chr3', 'chr4')) + pattern = make_pattern(small_ts, [(i, 2, 12, False) for i in range(6)]) + + h5 = tmp_path / "results.h5" + save_hdf5(h5, [pattern], None, window_size=40) + + filename = tmp_path / "seqlets.bed" + write_bed_from_h5(h5, peaks, filename, ['chr4', 'chr1'], None, True) + chroms = [l.split("\t")[0] for l in open(filename).read().strip().split( + "\n")[1:]] + assert chroms == ["chr1", "chr4", "chr1", "chr4", "chr1", "chr4"] + + +def test_write_bed_from_h5_stdout(small_h5, tmp_path, capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + filename = tmp_path / "seqlets.bed" + + write_bed_from_h5(small_h5, peaks, filename, '*', None, is_quiet=False) + assert capsys.readouterr().out == open(filename).read() + "\n" + + +def test_write_bed_from_h5_stdout_only(small_h5, tmp_path, capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + write_bed_from_h5(small_h5, peaks, None, '*', None, is_quiet=False) + assert capsys.readouterr().out.count("pattern_0.") == 3 + assert sorted(p.name for p in tmp_path.iterdir()) == ["peaks.bed", + "small.h5"] + + +def test_write_bed_from_h5_no_window(small_ts, tmp_path, capsys): + h5 = tmp_path / "results.h5" + with h5py.File(h5, "w") as f: + save_pattern(make_pattern(small_ts, [(0, 2, 12, False)]), + f.create_group("pos_patterns").create_group("pattern_0")) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + assert_raises(SystemExit, write_bed_from_h5, h5, peaks, None, '*', None, + True) + assert "window_size must be specified" in capsys.readouterr().out + + +def test_write_bed_from_h5_no_window_explicit(small_ts, tmp_path): + h5 = tmp_path / "results.h5" + with h5py.File(h5, "w") as f: + save_pattern(make_pattern(small_ts, [(0, 2, 12, False)]), + f.create_group("pos_patterns").create_group("pattern_0")) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + + filename = tmp_path / "seqlets.bed" + write_bed_from_h5(h5, peaks, filename, '*', 40, True) + assert "chr1\t250\t259\tpattern_0.0\t100\t+" in open(filename).read() + + +def test_write_bed_from_h5_raises_index(small_h5, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 3) + assert_raises(IndexError, write_bed_from_h5, small_h5, peaks, None, '*', + None, True) + + +## + + +def test_write_fasta_from_h5_small(small_ts, tmp_path): + h5 = tmp_path / "results.h5" + pattern = make_pattern(small_ts, [(0, 2, 12, False), (3, 20, 30, True)]) + save_hdf5(h5, [pattern], None, window_size=40) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(h5, peaks, sequences, filename, '*', None, True) + + bases = numpy.array(list("ACGT")) + seq0 = "".join(bases[small_ts.one_hot[0, 3:12].argmax(axis=1)]) + seq1 = "".join(bases[small_ts.one_hot[3, 21:30].argmax(axis=1)]) + + assert open(filename).read() == (">chr1:250-259 dir=+ pattern_0.0\n{}\n" + ">chr1:3268-3277 dir=- pattern_0.1\n{}".format(seq0, seq1)) + + +def test_write_fasta_from_h5(modisco_h5, peaks, tmp_path): + peaks_file, rows = peaks + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(modisco_h5, peaks_file, DATA_DIR / "sequences.npz", + filename, '*', None, True) + + entries = _parse_fasta(open(filename).read()) + sequences = numpy.load(DATA_DIR / "sequences.npz")["arr_0"] + bases = numpy.array(list("ACGT")) + + with h5py.File(modisco_h5, "r") as f: + n = sum(f[g][p]["seqlets/n_seqlets"][0] for g in f.keys() + for p in f[g].keys()) + assert len(entries) == n + + seqlets = f["pos_patterns/pattern_0/seqlets"] + for i, (header, sequence) in enumerate(entries[:len(seqlets["start"])]): + idx, start, end = (seqlets["example_idx"][i], seqlets["start"][i], + seqlets["end"][i]) + peak = rows[idx].split("\t") + center = (int(peak[1]) + int(peak[2])) // 2 + strand = "-" if seqlets["is_revcomp"][i] else "+" + + assert header == ">{}:{}-{} dir={} pattern_0.{}".format(peak[0], + center - 150 + start + 1, center - 150 + end, strand, i) + assert sequence == "".join(bases[sequences[idx, :, start+1:end] + .argmax(axis=0)]) + + +def test_write_fasta_from_h5_forward_strand(modisco_h5, peaks, tmp_path): + # Sequences are always read from the forward strand. + peaks_file, _ = peaks + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(modisco_h5, peaks_file, DATA_DIR / "sequences.npz", + filename, '*', None, True) + + entries = _parse_fasta(open(filename).read()) + with h5py.File(modisco_h5, "r") as f: + seqlets = f["pos_patterns/pattern_0/seqlets"] + rc = numpy.where(seqlets["is_revcomp"][:])[0] + assert len(rc) > 0 + + comp = str.maketrans("ACGT", "TGCA") + for i in rc[:5]: + sequence = entries[i][1] + stored = seqlets["sequence"][i].argmax(axis=1) + stored = "".join(numpy.array(list("ACGT"))[stored]) + assert sequence == stored[::-1].translate(comp)[1:] + + +def test_write_fasta_from_h5_chroms(small_ts, tmp_path): + h5 = tmp_path / "results.h5" + pattern = make_pattern(small_ts, [(i, 2, 12, False) for i in range(6)]) + save_hdf5(h5, [pattern], None, window_size=40) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 18, chroms=('chr1', 'chr2', 'chr3')) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(h5, peaks, sequences, filename, ['chr3'], None, True) + headers = [h for h, _ in _parse_fasta(open(filename).read())] + + assert headers[0] == ">chr3:2250-2259 dir=+ pattern_0.0" + assert all(h.startswith(">chr3:") for h in headers) + + +@pytest.mark.parametrize("window_size", [40, 60]) +def test_write_fasta_from_h5_window_size(small_h5, small_ts, tmp_path, + window_size): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(small_h5, peaks, sequences, filename, '*', window_size, + True) + header = open(filename).read().split("\n")[0] + assert header == ">chr1:{}-{} dir=+ pattern_0.0".format( + 267 - window_size // 2 + 3, 267 - window_size // 2 + 12) + + +def test_write_fasta_from_h5_stdout(small_h5, small_ts, tmp_path, capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(small_h5, peaks, sequences, filename, '*', None, False) + assert capsys.readouterr().out == open(filename).read() + "\n" + + +def test_write_fasta_from_h5_quiet(small_h5, small_ts, tmp_path, capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + write_fasta_from_h5(small_h5, peaks, sequences, None, '*', None, True) + assert capsys.readouterr().out == "" + + +def test_write_fasta_from_h5_row_mismatch(small_h5, small_ts, tmp_path, + capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 7) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + assert_raises(SystemExit, write_fasta_from_h5, small_h5, peaks, sequences, + None, '*', None, True) + assert "does not match the number of peaks" in capsys.readouterr().out + + +def test_write_fasta_from_h5_missing_key(small_h5, small_ts, tmp_path, + capsys): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, seqs=small_ts.one_hot.transpose(0, 2, 1)) + + assert_raises(SystemExit, write_fasta_from_h5, small_h5, peaks, sequences, + None, '*', None, True) + assert "does not\ncontain an 'arr_0' key" in capsys.readouterr().out + + +def test_write_fasta_from_h5_no_window(small_ts, tmp_path): + h5 = tmp_path / "results.h5" + with h5py.File(h5, "w") as f: + save_pattern(make_pattern(small_ts, [(0, 2, 12, False)]), + f.create_group("pos_patterns").create_group("pattern_0")) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + assert_raises(ValueError, write_fasta_from_h5, h5, peaks, sequences, None, + '*', None, True) + + +@pytest.mark.skip(reason="bug: write_fasta_from_h5 reads positions " + "start+1 through end-1, one base fewer than the start+1 to end span its " + "header reports") +def test_write_fasta_from_h5_length(small_h5, small_ts, tmp_path): + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(small_h5, peaks, sequences, filename, '*', None, True) + for header, sequence in _parse_fasta(open(filename).read()): + start, end = header.split(":")[1].split(" ")[0].split("-") + assert len(sequence) == int(end) - int(start) + 1 + + +@pytest.mark.skip(reason="bug: write_fasta_from_h5 indexes the sequences " + "with window-relative seqlet positions, so sequences longer than the " + "window, such as the full-length input given to `modisco motifs`, " + "return bases from the wrong offset") +def test_write_fasta_from_h5_long_sequences(small_ts, tmp_path): + pattern = make_pattern(small_ts, [(0, 12, 22, False)]) + h5 = tmp_path / "results.h5" + save_hdf5(h5, [pattern], None, window_size=20) + + peaks = tmp_path / "peaks.bed" + write_peaks(peaks, 6) + sequences = tmp_path / "sequences.npz" + numpy.savez(sequences, small_ts.one_hot.transpose(0, 2, 1)) + + filename = tmp_path / "seqlets.fa" + write_fasta_from_h5(h5, peaks, sequences, filename, '*', None, True) + + # The 20 bp window is the center of each 40 bp sequence. + bases = numpy.array(list("ACGT")) + expected = "".join(bases[small_ts.one_hot[0, 10+13:10+22].argmax(axis=1)]) + assert open(filename).read().split("\n")[1] == expected + + +## + + +def _read_new(filename): + """Return {group/pattern: (sequence, contrib, hyp, start, end, idx, rc)}.""" + + data = {} + with h5py.File(filename, "r") as f: + for group in f.keys(): + for name, pattern in f[group].items(): + seqlets = pattern["seqlets"] + data["{}/{}".format(group, name)] = (pattern["sequence"][:], + pattern["contrib_scores"][:], + pattern["hypothetical_contribs"][:], seqlets["start"][:], + seqlets["end"][:], seqlets["example_idx"][:], + seqlets["is_revcomp"][:]) + + return data + + +def test_convert_new_to_old(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + convert_new_to_old(modisco_h5, old) + + with h5py.File(old, "r") as f: + assert [x.decode() for x in f["task_names"][:]] == ["task0"] + grp = f["metacluster_idx_to_submetacluster_results"] + assert sorted(grp.keys()) == ["metacluster_0", "metacluster_1"] + + result = grp["metacluster_0/seqlets_to_patterns_result"] + assert result.attrs["success"] + assert result.attrs["total_time_taken"] == 1.0 + + patterns = result["patterns"] + assert [x.decode() for x in patterns["all_pattern_names"][:]] == [ + "pattern_0", "pattern_1"] + + with h5py.File(modisco_h5, "r") as g: + new = g["pos_patterns/pattern_0"] + assert_array_equal(patterns["pattern_0/sequence/fwd"][:], + new["sequence"][:]) + assert_array_equal(patterns["pattern_0/task0_contrib_scores/fwd"][:], + new["contrib_scores"][:]) + assert_array_equal( + patterns["pattern_0/task0_hypothetical_contribs/fwd"][:], + new["hypothetical_contribs"][:]) + + seqlets = [x.decode() for x in + patterns["pattern_0/seqlets_and_alnmts/seqlets"][:]] + n = new["seqlets/n_seqlets"][0] + assert len(seqlets) == n + assert seqlets[0] == "example:{},start:{},end:{},rc:{}".format( + new["seqlets/example_idx"][0], new["seqlets/start"][0], + new["seqlets/end"][0], new["seqlets/is_revcomp"][0]) + + alnmts = patterns["pattern_0/seqlets_and_alnmts/alnmts"] + assert alnmts.dtype == numpy.int32 + assert_array_equal(alnmts[:], numpy.zeros(n)) + + +def test_convert_new_to_old_metacluster_seqlets(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + convert_new_to_old(modisco_h5, old) + + with h5py.File(old, "r") as f, h5py.File(modisco_h5, "r") as g: + for new_name, old_name in [("pos_patterns", "metacluster_0"), + ("neg_patterns", "metacluster_1")]: + seqlets = f["metacluster_idx_to_submetacluster_results"][old_name][ + "seqlets"] + n = sum(g[new_name][p]["seqlets/n_seqlets"][0] + for p in g[new_name].keys()) + assert len(seqlets) == n + + +def test_convert_new_to_old_numeric_order(small_ts, tmp_path): + new = tmp_path / "new.h5" + save_hdf5(new, synthetic_patterns(small_ts, 12, 2, 10), None, 40) + + old = tmp_path / "old.h5" + convert_new_to_old(new, old) + + with h5py.File(old, "r") as f: + names = f["metacluster_idx_to_submetacluster_results/metacluster_0/" + "seqlets_to_patterns_result/patterns/all_pattern_names"][:] + assert [x.decode() for x in names] == ["pattern_{}".format(i) + for i in range(12)] + + +def test_convert_new_to_old_pos_only(small_ts, tmp_path): + new = tmp_path / "new.h5" + save_hdf5(new, synthetic_patterns(small_ts, 2, 2, 10), None, 40) + + old = tmp_path / "old.h5" + convert_new_to_old(new, old) + + with h5py.File(old, "r") as f: + assert list(f["metacluster_idx_to_submetacluster_results"].keys()) == [ + "metacluster_0"] + + +def test_convert_new_to_old_empty_group(small_ts, tmp_path): + new = tmp_path / "new.h5" + save_hdf5(new, [], None, 40) + + old = tmp_path / "old.h5" + convert_new_to_old(new, old) + + with h5py.File(old, "r") as f: + mc = f["metacluster_idx_to_submetacluster_results/metacluster_0"] + assert "patterns" not in mc["seqlets_to_patterns_result"] + assert len(mc["seqlets"]) == 0 + + +@pytest.mark.skip(reason="bug: convert_new_to_old looks for subpatterns " + "named subcluster_*, but save_pattern names them subpattern_*, so " + "subpatterns are never converted") +def test_convert_new_to_old_subpatterns(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + convert_new_to_old(modisco_h5, old) + + with h5py.File(old, "r") as f: + pattern = f["metacluster_idx_to_submetacluster_results/metacluster_0/" + "seqlets_to_patterns_result/patterns/pattern_0"] + assert "subcluster_to_subpattern" in pattern + + +def test_convert_round_trip(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + convert_new_to_old(modisco_h5, old) + convert(old, new) + + expected = _read_new(modisco_h5) + observed = _read_new(new) + + assert observed.keys() == expected.keys() + for key in expected: + for x, y in zip(observed[key], expected[key]): + assert_array_equal(x, y) + + +@pytest.mark.parametrize("n_pos,n_neg", [(1, 0), (0, 1), (3, 2), (11, 1)]) +def test_convert_round_trip_synthetic(small_ts, tmp_path, n_pos, n_neg): + original = tmp_path / "original.h5" + save_hdf5(original, synthetic_patterns(small_ts, n_pos, 3, 10) or None, + synthetic_patterns(small_ts, n_neg, 2, 12, random_state=5) or None, 40) + + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + convert_new_to_old(original, old) + convert(old, new) + + expected = _read_new(original) + observed = _read_new(new) + + assert observed.keys() == expected.keys() + for key in expected: + for x, y in zip(observed[key], expected[key]): + assert_array_equal(x, y) + + +def test_convert_n_seqlets(modisco_h5, tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + convert_new_to_old(modisco_h5, old) + convert(old, new) + + with h5py.File(new, "r") as f, h5py.File(modisco_h5, "r") as g: + for group in g.keys(): + for name in g[group].keys(): + assert f[group][name]["seqlets/n_seqlets"][0] == g[group][name][ + "seqlets/n_seqlets"][0] + + +def _write_old(filename, subpatterns=True, metaclusters=(0, 1)): + """Write a file in the original TF-MoDISco format by hand.""" + + rng = numpy.random.RandomState(0) + with h5py.File(filename, "w") as f: + root = f.create_group("metacluster_idx_to_submetacluster_results") + for mc in metaclusters: + result = root.create_group("metacluster_{}".format(mc)).create_group( + "seqlets_to_patterns_result") + patterns = result.create_group("patterns") + patterns.create_dataset("all_pattern_names", data=["pattern_0"]) + + pattern = patterns.create_group("pattern_0") + for name in ["sequence", "task0_contrib_scores", + "task0_hypothetical_contribs"]: + pattern.create_dataset(name + "/fwd", data=rng.randn(8, 4)) + + pattern.create_dataset("seqlets_and_alnmts/seqlets", data=[ + "example:3,start:10,end:18,rc:False", + "example:7,start:2,end:10,rc:True"]) + + if subpatterns: + sub = pattern.create_group("subcluster_to_subpattern") + sub.create_dataset("subcluster_names", data=["subcluster_0"]) + s0 = sub.create_group("subcluster_0") + for name in ["sequence", "task0_contrib_scores", + "task0_hypothetical_contribs"]: + s0.create_dataset(name + "/fwd", data=rng.randn(8, 4)) + s0.create_dataset("seqlets_and_alnmts/seqlets", data=[ + "example:3,start:10,end:18,rc:False"]) + + +def test_convert(tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + _write_old(old) + convert(old, new) + + with h5py.File(new, "r") as f, h5py.File(old, "r") as g: + assert sorted(f.keys()) == ["neg_patterns", "pos_patterns"] + pattern = f["pos_patterns/pattern_0"] + old_pattern = g["metacluster_idx_to_submetacluster_results/" + "metacluster_0/seqlets_to_patterns_result/patterns/pattern_0"] + + assert_array_equal(pattern["sequence"][:], + old_pattern["sequence/fwd"][:]) + assert_array_equal(pattern["seqlets/start"][:], [10, 2]) + assert_array_equal(pattern["seqlets/end"][:], [18, 10]) + assert_array_equal(pattern["seqlets/example_idx"][:], [3, 7]) + assert_array_equal(pattern["seqlets/is_revcomp"][:], [False, True]) + assert_array_equal(pattern["seqlets/n_seqlets"][:], [2]) + + +def test_convert_subpatterns(tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + _write_old(old) + convert(old, new) + + with h5py.File(new, "r") as f: + for group in ["pos_patterns", "neg_patterns"]: + sub = f[group]["pattern_0/subcluster_0"] + assert sub["sequence"].shape == (8, 4) + assert_array_equal(sub["seqlets/n_seqlets"][:], [1]) + + +def test_convert_no_subpatterns(tmp_path): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + _write_old(old, subpatterns=False) + convert(old, new) + + with h5py.File(new, "r") as f: + assert sorted(f["pos_patterns/pattern_0"].keys()) == ["contrib_scores", + "hypothetical_contribs", "seqlets", "sequence"] + + +@pytest.mark.parametrize("metaclusters,groups", [((0,), ["pos_patterns"]), + ((1,), ["neg_patterns"]), ((0, 1), ["neg_patterns", "pos_patterns"])]) +def test_convert_metaclusters(tmp_path, metaclusters, groups): + old = tmp_path / "old.h5" + new = tmp_path / "new.h5" + _write_old(old, metaclusters=metaclusters) + convert(old, new) + + with h5py.File(new, "r") as f: + assert sorted(f.keys()) == groups diff --git a/tests/test_meme_writer.py b/tests/test_meme_writer.py new file mode 100644 index 0000000..a3024f5 --- /dev/null +++ b/tests/test_meme_writer.py @@ -0,0 +1,334 @@ +# test_meme_writer.py +# Contact: Jacob Schreiber + +import numpy +import pytest + +from modiscolite.meme_writer import MEMEWriterMotif +from modiscolite.meme_writer import MEMEWriter +from modiscolite.meme_writer import array_to_string + +from memelite.io import read_meme + +from numpy.testing import assert_raises +from numpy.testing import assert_array_almost_equal + + +@pytest.fixture +def ppm(): + return numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=6) + + +@pytest.fixture +def motif(ppm): + return MEMEWriterMotif(name="motif_a", probability_matrix=ppm, + source_sites=12, alphabet="ACGT") + + +def _writer(**kwargs): + # Always pass motifs explicitly; the default list is shared between + # instances. + return MEMEWriter(memesuite_version='5', motifs=[], **kwargs) + + +## + + +@pytest.mark.parametrize("alphabet,expected", [("ACGT", 4), ("ACGU", 4), + ("AC", 2), ("ACDEFGHIKLMNPQRSTVWY", 20)]) +def test_meme_writer_motif_alphabet_length(ppm, alphabet, expected): + motif = MEMEWriterMotif("m", ppm, 1, alphabet) + assert motif.alphabet_length == expected + + +@pytest.mark.parametrize("alphabet_length", [1, 4, 7]) +def test_meme_writer_motif_alphabet_length_explicit(ppm, alphabet_length): + motif = MEMEWriterMotif("m", ppm, 1, "ACGT", alphabet_length=alphabet_length) + assert motif.alphabet_length == alphabet_length + + +def test_meme_writer_motif_init(motif, ppm): + assert motif.name == "motif_a" + assert motif.probability_matrix is ppm + assert motif.source_sites == 12 + assert motif.e_value is None + assert motif.url is None + + +@pytest.mark.parametrize("name", ["motif_a", "pos_patterns.pattern_0", + "MA0139.1 CTCF", ""]) +def test_meme_writer_motif_repr(ppm, name): + motif = MEMEWriterMotif(name, ppm, 1, "ACGT") + assert repr(motif) == "MEMEWriterMotif(name={})".format(name) + + +def test_meme_writer_motif_str(motif, ppm): + lines = str(motif).split("\n") + + assert lines[0] == "MOTIF motif_a" + assert lines[1] == "letter-probability matrix: alength= 4 w= 6 nsites= 12" + assert len(lines) == 8 + for line, row in zip(lines[2:], ppm): + assert line == " ".join("{:.6f}".format(x) for x in row) + + +@pytest.mark.parametrize("width", [1, 5, 30, 100]) +def test_meme_writer_motif_str_width(width): + ppm = numpy.random.RandomState(width).dirichlet(numpy.ones(4), size=width) + lines = str(MEMEWriterMotif("m", ppm, 3, "ACGT")).split("\n") + + assert "w= {}".format(width) in lines[1] + assert len(lines) == width + 2 + + +@pytest.mark.parametrize("source_sites", [0, 1, 17, 20000]) +def test_meme_writer_motif_str_source_sites(ppm, source_sites): + motif = MEMEWriterMotif("m", ppm, source_sites, "ACGT") + assert str(motif).split("\n")[1].endswith("nsites= {}".format(source_sites)) + + +def test_meme_writer_motif_str_values(): + ppm = numpy.array([[0.1, 0.2, 0.3, 0.4], [1.0, 0.0, 0.0, 0.0]]) + motif = MEMEWriterMotif("x", ppm, 2, "ACGT") + + assert str(motif) == ("MOTIF x\n" + "letter-probability matrix: alength= 4 w= 2 nsites= 2\n" + "0.100000 0.200000 0.300000 0.400000\n" + "1.000000 0.000000 0.000000 0.000000") + + +def test_meme_writer_motif_str_alphabet_length(ppm): + motif = MEMEWriterMotif("m", ppm, 1, "ACGT", alphabet_length=5) + assert "alength= 5 " in str(motif) + + +def test_meme_writer_motif_str_e_value(ppm): + motif = MEMEWriterMotif("m", ppm, 1, "ACGT", e_value="1.2e-05") + assert "E= 1.2e-05" in str(motif).split("\n")[1] + + +def test_meme_writer_motif_str_url(ppm): + motif = MEMEWriterMotif("m", ppm, 1, "ACGT", url="http://x.org/m") + assert "URL http://x.org/m" in str(motif) + + +@pytest.mark.skip(reason="bug: MEMEWriterMotif writes 'nsites= 12E= 0.01' " + "with no space before E=, and appends 'URL ...' to the last matrix row " + "without a newline") +def test_meme_writer_motif_str_e_value_url_format(ppm): + motif = MEMEWriterMotif("m", ppm, 12, "ACGT", e_value="0.01", + url="http://x.org/m") + lines = str(motif).split("\n") + + assert lines[1].endswith("nsites= 12 E= 0.01") + assert lines[-1] == "URL http://x.org/m" + + +## + + +def test_meme_writer_init(): + writer = MEMEWriter(memesuite_version='5', motifs=[]) + + assert writer.memesuite_version == '5' + assert writer.motifs == [] + assert writer.alphabet is None + assert writer.background_frequencies is None + assert writer.background_frequencies_source is None + assert writer.strands is None + + +def test_meme_writer_init_motifs(motif): + motifs = [motif] + writer = MEMEWriter('5', motifs=motifs) + assert writer.motifs is motifs + + +def test_meme_writer_init_none(): + writer = MEMEWriter('5', motifs=None) + assert writer.motifs == [] + + +@pytest.mark.skip(reason="bug: MEMEWriter's default motifs=[] is one list " + "shared by every instance, so motifs added to one writer appear in all " + "later writers, including every write_meme_from_h5 call in a process") +def test_meme_writer_default_motifs_independent(motif): + writer0 = MEMEWriter('5') + writer0.add_motif(motif) + + writer1 = MEMEWriter('5') + assert writer1.motifs == [] + + +@pytest.mark.parametrize("n", [1, 2, 5]) +def test_meme_writer_add_motif(ppm, n): + writer = _writer() + motifs = [MEMEWriterMotif("m{}".format(i), ppm, 1, "ACGT") + for i in range(n)] + + for motif in motifs: + writer.add_motif(motif) + + assert writer.motifs == motifs + + +def test_meme_writer_get_output_minimal(): + assert _writer().get_output() == "MEME version 5\n\n" + + +@pytest.mark.parametrize("version", ['4', '5', '5.5.0']) +def test_meme_writer_get_output_version(version): + writer = MEMEWriter(memesuite_version=version, motifs=[]) + assert writer.get_output() == "MEME version {}\n\n".format(version) + + +def test_meme_writer_get_output_alphabet(): + output = _writer(alphabet="ACGT").get_output() + assert output == "MEME version 5\n\nALPHABET= ACGT\n\n" + + +def test_meme_writer_get_output_strands(): + output = _writer(strands="+ -").get_output() + assert output == "MEME version 5\n\nstrands: + -\n\n" + + +def test_meme_writer_get_output_background(): + output = _writer(background_frequencies="A 0.25 C 0.25 G 0.25 T 0.25" + ).get_output() + assert output == ("MEME version 5\n\nBackground letter frequencies\n" + "A 0.25 C 0.25 G 0.25 T 0.25\n\n") + + +def test_meme_writer_get_output_background_source(): + output = _writer(background_frequencies="A 0.3 C 0.2 G 0.2 T 0.3", + background_frequencies_source="genome").get_output() + assert ("Background letter frequencies (from genome):\n" + "A 0.3 C 0.2 G 0.2 T 0.3\n\n") in output + + +def test_meme_writer_get_output_background_source_only(): + # A source without frequencies writes nothing. + output = _writer(background_frequencies_source="genome").get_output() + assert output == "MEME version 5\n\n" + + +def test_meme_writer_get_output_order(motif): + writer = _writer(alphabet="ACGT", strands="+ -", + background_frequencies="A 0.25 C 0.25 G 0.25 T 0.25") + writer.add_motif(motif) + output = writer.get_output() + + positions = [output.index(s) for s in ["MEME version", "ALPHABET", + "strands", "Background", "MOTIF"]] + assert positions == sorted(positions) + assert output.endswith(str(motif) + "\n\n") + + +def test_meme_writer_get_output_motifs(ppm): + writer = _writer() + for i in range(3): + writer.add_motif(MEMEWriterMotif("m{}".format(i), ppm, 1, "ACGT")) + + output = writer.get_output() + assert output.count("MOTIF ") == 3 + assert output.count("letter-probability matrix") == 3 + assert output.index("MOTIF m0") < output.index("MOTIF m1") < output.index( + "MOTIF m2") + + +def test_meme_writer_write(motif, tmp_path): + writer = _writer(alphabet="ACGT") + writer.add_motif(motif) + + filename = tmp_path / "motifs.meme" + writer.write(filename) + assert open(filename).read() == writer.get_output() + + +def test_meme_writer_write_str_path(motif, tmp_path): + writer = _writer() + writer.add_motif(motif) + + filename = str(tmp_path / "motifs.meme") + writer.write(filename) + assert open(filename).read() == writer.get_output() + + +def test_meme_writer_write_overwrites(motif, tmp_path): + filename = tmp_path / "motifs.meme" + filename.write_text("old contents") + + _writer().write(filename) + assert open(filename).read() == "MEME version 5\n\n" + + +def test_meme_writer_write_raises(tmp_path): + filename = tmp_path / "missing" / "motifs.meme" + assert_raises(IOError, _writer().write, filename) + + +@pytest.mark.parametrize("widths", [[5], [6, 12], [3, 8, 20, 1]]) +def test_meme_writer_read_meme(tmp_path, widths): + rng = numpy.random.RandomState(0) + ppms = [rng.dirichlet(numpy.ones(4), size=w) for w in widths] + + writer = _writer(alphabet="ACGT", + background_frequencies="A 0.25 C 0.25 G 0.25 T 0.25") + for i, ppm in enumerate(ppms): + writer.add_motif(MEMEWriterMotif("m{}".format(i), ppm, 1, "ACGT")) + + filename = tmp_path / "motifs.meme" + writer.write(filename) + motifs = read_meme(str(filename)) + + assert list(motifs.keys()) == ["m{}".format(i) for i in range(len(ppms))] + for pwm, ppm in zip(motifs.values(), ppms): + assert_array_almost_equal(pwm.T, ppm, 6) + + +def test_meme_writer_repr(motif): + writer = _writer(alphabet="ACGT") + writer.add_motif(motif) + + assert repr(writer) == ("MEMEWriter(memesuite_version=5, " + "motifs=[MEMEWriterMotif(name=motif_a)], alphabet=ACGT, " + "background_frequencies=None, strands=None)") + + +## + + +@pytest.mark.parametrize("precision", [0, 1, 3, 6, 10]) +def test_array_to_string_precision(precision): + array = numpy.array([[0.123456789, 1.5], [-2.25, 3.0]]) + string = array_to_string(array, precision) + fmt = "{:." + str(precision) + "f}" + + assert string == "\n".join(" ".join(fmt.format(x) for x in row) + for row in array) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 4), (5, 4), (3, 20)]) +def test_array_to_string_shape(shape): + array = numpy.random.RandomState(0).randn(*shape) + lines = array_to_string(array, 4).split("\n") + + assert len(lines) == shape[0] + assert all(len(line.split(" ")) == shape[1] for line in lines) + + +def test_array_to_string_values(): + array = numpy.array([[0.5, -0.25], [1, 0]]) + assert array_to_string(array, 2) == "0.50 -0.25\n1.00 0.00" + + +def test_array_to_string_rounding(): + assert array_to_string(numpy.array([[0.125, 0.9999]]), 2) == "0.12 1.00" + + +def test_array_to_string_empty(): + assert array_to_string(numpy.zeros((0, 4)), 6) == "" + + +def test_array_to_string_list(): + assert array_to_string([[1, 2], [3, 4]], 1) == "1.0 2.0\n3.0 4.0" diff --git a/tests/test_motif.py b/tests/test_motif.py index 40176df..f0e5fe9 100644 --- a/tests/test_motif.py +++ b/tests/test_motif.py @@ -1,180 +1,96 @@ -import random +# test_motif.py +# Contact: Jacob Schreiber + import h5py -import numpy as np +import pytest + from click.testing import CliRunner + from modiscolite.cli import motifs -from conftest import data_ohe_hyps + +from .conftest import DATA_DIR + +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal -def test_modisco_motif(data_ohe_hyps, tmp_path): - random.seed(42) - np.random.seed(42) +@pytest.mark.cmd +def test_modisco_motif(tmp_path): + output = tmp_path / "modisco_results.h5" - ohe, hyps = data_ohe_hyps - output = tmp_path / "modisco_results.h5" + runner = CliRunner() + result = runner.invoke(motifs, ["-s", str(DATA_DIR / "sequences.npz"), + "-a", str(DATA_DIR / "attributions.npz"), "-n", "150", "-o", + str(output), "-w", "300", "-v"]) - runner = CliRunner() - result = runner.invoke(motifs, ["-s", ohe, "-a", hyps, "-n", 2000, "-o", output, '-w', 200, '-v']) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output + assert result.output == "Using 150 positive seqlets\n" - with h5py.File(output, "r") as f: - assert list(f['pos_patterns'].keys()) == ['pattern_0', 'pattern_1', 'pattern_2'] + with h5py.File(output, "r") as f: + assert f.attrs["window_size"] == 300 + assert list(f.keys()) == ['pos_patterns'] + assert list(f['pos_patterns'].keys()) == ['pattern_0', 'pattern_1'] - np.testing.assert_allclose(f['pos_patterns']['pattern_0']['sequence'][:], np.array([ - [0.29230769, 0.32307692, 0.16923077, 0.21538462], - [0.26153846, 0.21538462, 0.21538462, 0.30769231], - [0.24615385, 0.29230769, 0.15384615, 0.30769231], - [0.24615385, 0.27692308, 0.29230769, 0.18461538], - [0.23076923, 0.24615385, 0.2 , 0.32307692], - [0.26153846, 0.24615385, 0.30769231, 0.18461538], - [0.26153846, 0.27692308, 0.23076923, 0.23076923], - [0.35384615, 0.18461538, 0.23076923, 0.23076923], - [0.30769231, 0.21538462, 0.26153846, 0.21538462], - [0.32307692, 0.27692308, 0.21538462, 0.18461538], - [0.24615385, 0.09230769, 0.4 , 0.26153846], - [0.10769231, 0.87692308, 0.01538462, 0. ], - [0.4 , 0.24615385, 0.07692308, 0.27692308], - [0. , 1. , 0. , 0. ], - [0.92307692, 0.04615385, 0. , 0.03076923], - [0.01538462, 0.03076923, 0.95384615, 0. ], - [0. , 0.93846154, 0. , 0.06153846], - [0.72307692, 0.01538462, 0.09230769, 0.16923077], - [0.01538462, 0. , 0.89230769, 0.09230769], - [0. , 0.01538462, 0.96923077, 0.01538462], - [0.44615385, 0.12307692, 0.23076923, 0.2 ], - [0.33846154, 0.09230769, 0.41538462, 0.15384615], - [0.29230769, 0.2 , 0.33846154, 0.16923077], - [0.09230769, 0.33846154, 0.26153846, 0.30769231], - [0.29230769, 0.27692308, 0.15384615, 0.27692308], - [0.30769231, 0.24615385, 0.26153846, 0.18461538], - [0.32307692, 0.16923077, 0.30769231, 0.2 ], - [0.23076923, 0.18461538, 0.27692308, 0.30769231], - [0.35384615, 0.21538462, 0.26153846, 0.16923077], - [0.29230769, 0.15384615, 0.33846154, 0.21538462], - [0.24615385, 0.18461538, 0.33846154, 0.23076923], - [0.15384615, 0.30769231, 0.29230769, 0.24615385], - [0.27692308, 0.30769231, 0.24615385, 0.16923077], - [0.29230769, 0.21538462, 0.23076923, 0.26153846], - [0.30769231, 0.13846154, 0.24615385, 0.30769231], - [0.38461538, 0.18461538, 0.26153846, 0.16923077], - [0.38461538, 0.15384615, 0.2 , 0.26153846], - [0.26153846, 0.21538462, 0.24615385, 0.27692308], - [0.23076923, 0.23076923, 0.26153846, 0.27692308], - [0.12307692, 0.26153846, 0.41538462, 0.2 ], - [0.36923077, 0.18461538, 0.29230769, 0.15384615], - [0.2 , 0.18461538, 0.30769231, 0.30769231], - [0.29230769, 0.24615385, 0.26153846, 0.2 ], - [0.27692308, 0.26153846, 0.33846154, 0.12307692], - [0.38461538, 0.16923077, 0.24615385, 0.2 ], - [0.26153846, 0.33846154, 0.18461538, 0.21538462], - [0.47692308, 0.16923077, 0.10769231, 0.24615385], - [0.23076923, 0.27692308, 0.24615385, 0.24615385], - [0.21538462, 0.15384615, 0.32307692, 0.30769231], - [0.21538462, 0.21538462, 0.36923077, 0.2 ] - ]), atol=1e-3, rtol=1e-3) + pattern = f['pos_patterns']['pattern_0'] + assert sorted(pattern.keys()) == ['contrib_scores', + 'hypothetical_contribs', 'seqlets', 'sequence', 'subpattern_0', + 'subpattern_1', 'subpattern_2'] + assert_array_equal(pattern['seqlets']['n_seqlets'][:], [77]) - np.testing.assert_allclose(f['pos_patterns']['pattern_1']['sequence'][:], np.array([ - [0.29268293, 0.31707317, 0.17073171, 0.2195122 ], - [0.2195122 , 0.17073171, 0.2195122 , 0.3902439 ], - [0.17073171, 0.31707317, 0.17073171, 0.34146341], - [0.24390244, 0.31707317, 0.14634146, 0.29268293], - [0.29268293, 0.17073171, 0.2195122 , 0.31707317], - [0.2195122 , 0.2195122 , 0.36585366, 0.19512195], - [0.24390244, 0.2195122 , 0.29268293, 0.24390244], - [0.34146341, 0.17073171, 0.2195122 , 0.26829268], - [0.34146341, 0.19512195, 0.2195122 , 0.24390244], - [0.24390244, 0.24390244, 0.31707317, 0.19512195], - [0.19512195, 0.26829268, 0.26829268, 0.26829268], - [0.14634146, 0.41463415, 0.26829268, 0.17073171], - [0.29268293, 0.34146341, 0.12195122, 0.24390244], - [0.34146341, 0.2195122 , 0.19512195, 0.24390244], - [0.29268293, 0.2195122 , 0.19512195, 0.29268293], - [0.17073171, 0.2195122 , 0.17073171, 0.43902439], - [0.14634146, 0.31707317, 0.2195122 , 0.31707317], - [0.14634146, 0.09756098, 0.17073171, 0.58536585], - [0.19512195, 0.19512195, 0.53658537, 0.07317073], - [0.41463415, 0.29268293, 0.2195122 , 0.07317073], - [0.68292683, 0.07317073, 0.14634146, 0.09756098], - [0.29268293, 0.02439024, 0.2195122 , 0.46341463], - [0.73170732, 0.09756098, 0.17073171, 0. ], - [0.97560976, 0. , 0. , 0.02439024], - [0. , 0.92682927, 0.04878049, 0.02439024], - [1. , 0. , 0. , 0. ], - [1. , 0. , 0. , 0. ], - [0.56097561, 0. , 0. , 0.43902439], - [0.2195122 , 0. , 0.75609756, 0.02439024], - [0.19512195, 0.14634146, 0.65853659, 0. ], - [0.3902439 , 0.24390244, 0.31707317, 0.04878049], - [0.31707317, 0.36585366, 0.12195122, 0.19512195], - [0.19512195, 0.29268293, 0.19512195, 0.31707317], - [0.24390244, 0.36585366, 0.14634146, 0.24390244], - [0.24390244, 0.41463415, 0.17073171, 0.17073171], - [0.36585366, 0.26829268, 0.14634146, 0.2195122 ], - [0.2195122 , 0.19512195, 0.17073171, 0.41463415], - [0.29268293, 0.12195122, 0.24390244, 0.34146341], - [0.26829268, 0.31707317, 0.24390244, 0.17073171], - [0.26829268, 0.31707317, 0.2195122 , 0.19512195], - [0.2195122 , 0.19512195, 0.29268293, 0.29268293], - [0.14634146, 0.36585366, 0.2195122 , 0.26829268], - [0.26829268, 0.26829268, 0.24390244, 0.2195122 ], - [0.17073171, 0.2195122 , 0.26829268, 0.34146341], - [0.17073171, 0.17073171, 0.29268293, 0.36585366], - [0.19512195, 0.36585366, 0.2195122 , 0.2195122 ], - [0.34146341, 0.09756098, 0.2195122 , 0.34146341], - [0.41463415, 0.2195122 , 0.14634146, 0.2195122 ], - [0.24390244, 0.31707317, 0.26829268, 0.17073171], - [0.19512195, 0.19512195, 0.2195122 , 0.3902439 ] - ]), atol=1e-3, rtol=1e-3) + assert_array_almost_equal(pattern['sequence'][:], [ + [0.2468, 0.1818, 0.3247, 0.2468], + [0.2727, 0.2338, 0.2987, 0.1948], + [0.2987, 0.2078, 0.3377, 0.1558], + [0.2078, 0.2857, 0.3117, 0.1948], + [0.2468, 0.2987, 0.2468, 0.2078], + [0.2597, 0.2078, 0.3117, 0.2208], + [0.2468, 0.1948, 0.3766, 0.1818], + [0.2597, 0.1818, 0.3117, 0.2468], + [0.2597, 0.2468, 0.1948, 0.2987], + [0.1818, 0.1948, 0.3247, 0.2987], + [0.1429, 0.2338, 0.4026, 0.2208], + [0.2078, 0.3247, 0.3247, 0.1429], + [0.4156, 0.1558, 0.2468, 0.1818], + [0.1429, 0.0519, 0.6753, 0.1299], + [0.0130, 0.0130, 0.9740, 0.0000], + [0.9870, 0.0000, 0.0130, 0.0000], + [0.0000, 0.0000, 1.0000, 0.0000], + [1.0000, 0.0000, 0.0000, 0.0000], + [0.0000, 0.0000, 1.0000, 0.0000], + [0.4286, 0.1169, 0.2468, 0.2078], + [0.0000, 0.0000, 1.0000, 0.0000], + [0.0000, 0.0000, 1.0000, 0.0000], + [0.4026, 0.2597, 0.1688, 0.1688], + [0.3636, 0.1039, 0.3506, 0.1818], + [0.2338, 0.1688, 0.3506, 0.2468], + [0.2078, 0.1818, 0.4026, 0.2078], + [0.2078, 0.1948, 0.3896, 0.2078], + [0.2857, 0.2597, 0.3117, 0.1429], + [0.3377, 0.1169, 0.2208, 0.3247], + [0.3377, 0.1688, 0.2727, 0.2208], + [0.2987, 0.1558, 0.3247, 0.2208], + [0.2078, 0.2468, 0.3247, 0.2208], + [0.2208, 0.2987, 0.2597, 0.2208], + [0.3636, 0.1429, 0.2078, 0.2857], + [0.2727, 0.1818, 0.2987, 0.2468], + [0.2208, 0.2597, 0.2987, 0.2208], + [0.3117, 0.2857, 0.2468, 0.1558], + [0.3377, 0.1688, 0.2857, 0.2078], + [0.1818, 0.2338, 0.2987, 0.2857], + [0.2078, 0.1688, 0.2857, 0.3377], + [0.2987, 0.1948, 0.3247, 0.1818], + [0.2727, 0.2338, 0.3377, 0.1558], + [0.2727, 0.1299, 0.2987, 0.2987], + [0.2208, 0.2727, 0.2597, 0.2468], + [0.2338, 0.2468, 0.2468, 0.2727], + [0.2078, 0.2468, 0.2338, 0.3117], + [0.2987, 0.2338, 0.2208, 0.2468], + [0.3117, 0.2597, 0.2987, 0.1299], + [0.2468, 0.2857, 0.2468, 0.2208], + [0.3117, 0.1169, 0.2727, 0.2987] + ], 4) - np.testing.assert_allclose(f['pos_patterns']['pattern_2']['sequence'], np.array([ - [0.125 , 0.34375, 0.28125, 0.25 ], - [0.25 , 0.3125 , 0.15625, 0.28125], - [0.25 , 0.28125, 0.34375, 0.125 ], - [0.15625, 0.28125, 0.1875 , 0.375 ], - [0.21875, 0.1875 , 0.28125, 0.3125 ], - [0.375 , 0.125 , 0.28125, 0.21875], - [0.21875, 0.21875, 0.375 , 0.1875 ], - [0.3125 , 0.21875, 0.21875, 0.25 ], - [0.25 , 0.25 , 0.375 , 0.125 ], - [0.34375, 0.25 , 0.3125 , 0.09375], - [0.28125, 0.40625, 0.21875, 0.09375], - [0.21875, 0.21875, 0.34375, 0.21875], - [0.21875, 0.28125, 0.09375, 0.40625], - [0.125 , 0.6875 , 0.15625, 0.03125], - [0.90625, 0.0625 , 0. , 0.03125], - [0. , 0. , 0. , 1. ], - [0.03125, 0.21875, 0. , 0.75 ], - [0.53125, 0. , 0.03125, 0.4375 ], - [0.1875 , 0. , 0.75 , 0.0625 ], - [0.03125, 0.96875, 0. , 0. ], - [1. , 0. , 0. , 0. ], - [0.09375, 0.0625 , 0. , 0.84375], - [0.46875, 0.125 , 0.15625, 0.25 ], - [0.375 , 0.125 , 0. , 0.5 ], - [0.1875 , 0.4375 , 0.25 , 0.125 ], - [0.84375, 0.03125, 0.0625 , 0.0625 ], - [0.875 , 0.03125, 0.0625 , 0.03125], - [0.5 , 0.0625 , 0.03125, 0.40625], - [0.25 , 0.125 , 0.5 , 0.125 ], - [0.53125, 0.0625 , 0.3125 , 0.09375], - [0.25 , 0.21875, 0.34375, 0.1875 ], - [0.28125, 0.3125 , 0.21875, 0.1875 ], - [0.25 , 0.34375, 0.21875, 0.1875 ], - [0.25 , 0.375 , 0.09375, 0.28125], - [0.125 , 0.34375, 0.25 , 0.28125], - [0.375 , 0.21875, 0.15625, 0.25 ], - [0.3125 , 0.21875, 0.21875, 0.25 ], - [0.21875, 0.28125, 0.125 , 0.375 ], - [0.21875, 0.125 , 0.53125, 0.125 ], - [0.375 , 0.15625, 0.3125 , 0.15625], - [0.28125, 0.25 , 0.21875, 0.25 ], - [0.21875, 0.25 , 0.34375, 0.1875 ], - [0.25 , 0.15625, 0.28125, 0.3125 ], - [0.125 , 0.25 , 0.21875, 0.40625], - [0.21875, 0.28125, 0.28125, 0.21875], - [0.15625, 0.28125, 0.21875, 0.34375], - [0.28125, 0.28125, 0.21875, 0.21875], - [0.3125 , 0.0625 , 0.34375, 0.28125], - [0.4375 , 0.21875, 0.09375, 0.25 ], - [0.125 , 0.28125, 0.3125 , 0.28125] - ]), atol=1e-3, rtol=1e-3) \ No newline at end of file + pattern = f['pos_patterns']['pattern_1'] + assert sorted(pattern.keys()) == ['contrib_scores', + 'hypothetical_contribs', 'seqlets', 'sequence', 'subpattern_0'] + assert_array_equal(pattern['seqlets']['n_seqlets'][:], [24]) diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..74724b4 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,501 @@ +# test_report.py +# Contact: Jacob Schreiber + +import os +import shutil +import struct + +from html.parser import HTMLParser + +import numpy +import pandas +import pytest + +from modiscolite.report import compute_per_position_ic +from modiscolite.report import write_meme_file +from modiscolite.report import fetch_tomtom_matches +from modiscolite.report import generate_tomtom_dataframe +from modiscolite.report import tomtomlite_dataframe +from modiscolite.report import path_to_image_html +from modiscolite.report import _plot_weights +from modiscolite.report import make_logo +from modiscolite.report import create_modisco_logos +from modiscolite.report import report_motifs + +from modiscolite.io import save_hdf5 + +from memelite.io import read_meme + +from .synthetic import random_track_set +from .synthetic import synthetic_patterns + +from numpy.testing import assert_raises +from numpy.testing import assert_array_almost_equal + + +requires_tomtom = pytest.mark.skipif(shutil.which("tomtom") is None, + reason="the MEME suite tomtom executable is not installed") + + +def _png_size(filename): + with open(filename, "rb") as f: + header = f.read(24) + + assert header[:8] == b"\x89PNG\r\n\x1a\n" + return struct.unpack(">II", header[16:24]) + + +def _ic(ppm, background, pseudocount): + p = (ppm + pseudocount) / (1 + pseudocount * len(background)) + return (numpy.log2(p) * ppm).sum(axis=1) - (numpy.log2(background) * + background).sum() + + +class _TableParser(HTMLParser): + """Collect the cells of an HTML table, using the src of any image.""" + + def __init__(self): + super().__init__() + self.rows, self._cell = [], None + + def handle_starttag(self, tag, attrs): + if tag == 'tr': + self.rows.append([]) + elif tag in ('td', 'th'): + self._cell = '' + elif tag == 'img' and self._cell is not None: + self._cell += dict(attrs)['src'] + + def handle_endtag(self, tag): + if tag in ('td', 'th'): + self.rows[-1].append(self._cell.strip()) + self._cell = None + + def handle_data(self, data): + if self._cell is not None: + self._cell += data + + +def _read_table(filename): + """Return the header and the rows of the table in an HTML file.""" + + parser = _TableParser() + parser.feed(open(filename).read()) + return parser.rows[0], parser.rows[1:] + + +def _column(filename, name): + header, rows = _read_table(filename) + return [row[header.index(name)] for row in rows] + + +@pytest.fixture +def small_h5(pos_patterns, neg_patterns, tmp_path): + """One positive and one negative pattern from the real results.""" + + filename = tmp_path / "small.h5" + save_hdf5(filename, pos_patterns[:1], neg_patterns[:1], window_size=300) + return filename + + +@pytest.fixture +def ppm(): + return numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=10) + + +## + + +@pytest.mark.parametrize("length", [1, 8, 30]) +@pytest.mark.parametrize("pseudocount", [0.001, 0.1]) +def test_compute_per_position_ic(length, pseudocount): + ppm = numpy.random.RandomState(length).dirichlet(numpy.ones(4), size=length) + background = numpy.full(4, 0.25) + + ic = compute_per_position_ic(ppm, background, pseudocount) + assert ic.shape == (length,) + assert_array_almost_equal(ic, _ic(ppm, background, pseudocount)) + + +def test_compute_per_position_ic_background(): + ppm = numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=5) + background = numpy.array([0.3, 0.2, 0.2, 0.3]) + assert_array_almost_equal(compute_per_position_ic(ppm, background, 0.001), + _ic(ppm, background, 0.001)) + + +def test_compute_per_position_ic_uniform(): + ic = compute_per_position_ic(numpy.full((4, 4), 0.25), numpy.full(4, 0.25), + 0.001) + assert_array_almost_equal(ic, numpy.zeros(4)) + + +def test_compute_per_position_ic_unnormalized(): + # Unlike util.compute_per_position_ic, rows are not renormalized. + ppm = numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=5) + ic0 = compute_per_position_ic(ppm, numpy.full(4, 0.25), 0.001) + ic1 = compute_per_position_ic(ppm * 2, numpy.full(4, 0.25), 0.001) + + assert not numpy.allclose(ic0, ic1) + assert_array_almost_equal(ic1, _ic(ppm * 2, numpy.full(4, 0.25), 0.001)) + + +## + + +@pytest.mark.parametrize("width", [1, 6, 25]) +def test_write_meme_file(tmp_path, width): + ppm = numpy.random.RandomState(width).dirichlet(numpy.ones(4), size=width) + filename = tmp_path / "motif.meme" + write_meme_file(ppm, [0.25, 0.25, 0.25, 0.25], filename) + + lines = open(filename).read().split("\n") + assert lines[:8] == ["MEME version 4", "", "ALPHABET= ACGT", "", + "strands: + -", "", + "Background letter frequencies (from unknown source):", + "A 0.250 C 0.250 G 0.250 T 0.250"] + assert lines[9] == "MOTIF 1 TEMP" + assert lines[10] == ("letter-probability matrix: alength= 4 w= {} " + "nsites= 1 E= 0e+0".format(width)) + assert lines[11:11+width] == ["%.5f %.5f %.5f %.5f" % tuple(row) + for row in ppm] + assert lines[11+width:] == ["URL", "", ""] + + +def test_write_meme_file_background(tmp_path, ppm): + filename = tmp_path / "motif.meme" + write_meme_file(ppm, numpy.array([0.3, 0.2, 0.2, 0.3]), filename) + assert "A 0.300 C 0.200 G 0.200 T 0.300\n" in open(filename).read() + + +def test_write_meme_file_read_meme(tmp_path, ppm): + filename = tmp_path / "motif.meme" + write_meme_file(ppm, [0.25, 0.25, 0.25, 0.25], filename) + + motifs = read_meme(str(filename)) + assert list(motifs.keys()) == ["1 TEMP"] + assert_array_almost_equal(motifs["1 TEMP"].T, ppm, 5) + + +## + + +def test_fetch_tomtom_matches_raises(tmp_path, ppm, meme_db): + assert_raises(ValueError, fetch_tomtom_matches, ppm, ppm, False, tmp_path, + "pattern_0", meme_db, tomtom_exec_path="not-a-real-tomtom-binary") + + +@requires_tomtom +def test_fetch_tomtom_matches(pos_patterns, tmp_path, meme_db): + pattern = pos_patterns[0] + results = fetch_tomtom_matches(pattern.sequence, pattern.contrib_scores, + False, tmp_path, "pos_patterns.pattern_0", meme_db) + + assert isinstance(results, pandas.DataFrame) + assert list(results.columns) == ["Target_ID", "q-value"] + assert results["Target_ID"].iloc[0] == "db_motif_0" + assert numpy.all(numpy.diff(results["q-value"]) >= 0) or len(results) < 3 + assert not os.path.exists(tmp_path / "tomtom") + + +@requires_tomtom +def test_fetch_tomtom_matches_write(pos_patterns, tmp_path, meme_db): + pattern = pos_patterns[1] + results = fetch_tomtom_matches(pattern.sequence, pattern.contrib_scores, + True, tmp_path, "pos_patterns.pattern_1", meme_db) + + filename = tmp_path / "tomtom" / "pos_patterns.pattern_1.tomtom.tsv" + assert filename.exists() + assert results["Target_ID"].iloc[0] == "db_motif_1" + assert "db_motif_1" in open(filename).read() + + +## + + +@requires_tomtom +@pytest.mark.parametrize("top_n_matches", [1, 3]) +def test_generate_tomtom_dataframe(modisco_h5, tmp_path, meme_db, + top_n_matches): + df = generate_tomtom_dataframe(modisco_h5, tmp_path, meme_db, False, + ['pos_patterns', 'neg_patterns'], top_n_matches=top_n_matches) + + columns = [] + for i in range(top_n_matches): + columns += ["match{}".format(i), "qval{}".format(i)] + + assert list(df.columns) == columns + assert len(df) == 4 + # The negative patterns carry the same two motifs as the positive ones. + assert list(df["match0"]) == ["db_motif_0", "db_motif_1", "db_motif_0", + "db_motif_1"] + + +@requires_tomtom +def test_generate_tomtom_dataframe_groups(modisco_h5, tmp_path, meme_db): + df = generate_tomtom_dataframe(modisco_h5, tmp_path, meme_db, False, + ['neg_patterns', 'missing_patterns'], top_n_matches=1) + assert list(df["match0"]) == ["db_motif_0", "db_motif_1"] + + +## + + +@pytest.mark.parametrize("top_n_matches", [1, 2, 3, 5]) +def test_tomtomlite_dataframe(modisco_h5, meme_db, top_n_matches): + df = tomtomlite_dataframe(modisco_h5, meme_db, + ['pos_patterns', 'neg_patterns'], top_n_matches=top_n_matches) + + columns = [] + for i in range(top_n_matches): + columns += ["match{}".format(i), "pval{}".format(i)] + + assert list(df.columns) == columns + assert len(df) == 4 + + +def test_tomtomlite_dataframe_matches(modisco_h5, meme_db): + # Each positive pattern best matches the database entry made from its own + # PPM, and each negative pattern the entry for the same motif. + df = tomtomlite_dataframe(modisco_h5, meme_db, + ['pos_patterns', 'neg_patterns'], top_n_matches=3) + + assert list(df["match0"]) == ["db_motif_0 NAME0", "db_motif_1 NAME1", + "db_motif_0 NAME0", "db_motif_1 NAME1"] + assert numpy.all(df["pval0"] <= df["pval1"]) + assert numpy.all(df["pval1"] <= df["pval2"]) + assert numpy.all((df["pval0"] >= 0) & (df["pval0"] <= 1)) + + +@pytest.mark.parametrize("groups,expected", [(['pos_patterns'], 2), + (['neg_patterns'], 2), (['neg_patterns', 'pos_patterns'], 4), + (['pos_patterns', 'missing'], 2)]) +def test_tomtomlite_dataframe_groups(modisco_h5, meme_db, groups, expected): + df = tomtomlite_dataframe(modisco_h5, meme_db, groups, top_n_matches=1) + assert len(df) == expected + + +def test_tomtomlite_dataframe_group_order(modisco_h5, meme_db): + df0 = tomtomlite_dataframe(modisco_h5, meme_db, + ['pos_patterns', 'neg_patterns'], top_n_matches=2) + df1 = tomtomlite_dataframe(modisco_h5, meme_db, + ['neg_patterns', 'pos_patterns'], top_n_matches=2) + + assert df0["pval0"].nunique() == 4 + pandas.testing.assert_frame_equal(df0, + df1.iloc[[2, 3, 0, 1]].reset_index(drop=True)) + + +@pytest.mark.parametrize("trim_threshold", [0.0, 0.3, 0.6, 1.0]) +def test_tomtomlite_dataframe_trim_threshold(modisco_h5, meme_db, + trim_threshold): + df = tomtomlite_dataframe(modisco_h5, meme_db, ['pos_patterns'], + top_n_matches=2, trim_threshold=trim_threshold) + assert len(df) == 2 + assert df["match0"].str.startswith("db_motif_").all() + + +## + + +@pytest.mark.parametrize("path", ["a.png", "./trimmed_logos/x.cwm.fwd.png", + "/abs/path with space.png"]) +def test_path_to_image_html(path): + assert path_to_image_html(path) == ''.format( + path) + + +## + + +# A single position with a negative sum gives identical y-limits. +@pytest.mark.filterwarnings("ignore:Attempting to set identical low and high") +@pytest.mark.parametrize("length", [1, 10, 50]) +def test_plot_weights(tmp_path, length): + array = numpy.random.RandomState(length).randn(length, 4) + filename = tmp_path / "logo.png" + _plot_weights(array, filename) + + assert _png_size(filename) == (1000, 300) + + +@pytest.mark.parametrize("figsize", [(4, 2), (8, 1.2), (12, 3)]) +def test_plot_weights_figsize(tmp_path, figsize): + filename = tmp_path / "logo.png" + _plot_weights(numpy.random.RandomState(0).randn(10, 4), filename, + figsize=figsize) + + assert _png_size(filename) == (int(figsize[0]*100), int(figsize[1]*100)) + + +@pytest.mark.parametrize("clamp", [True, False]) +def test_plot_weights_clamp(tmp_path, clamp): + filename = tmp_path / "logo.png" + _plot_weights(numpy.abs(numpy.random.RandomState(0).randn(10, 4)), + filename, clamp=clamp) + assert filename.stat().st_size > 0 + + +def test_plot_weights_closes_figure(tmp_path): + import matplotlib.pyplot as plt + n = len(plt.get_fignums()) + _plot_weights(numpy.ones((5, 4)), tmp_path / "logo.png") + assert len(plt.get_fignums()) == n + + +def test_plot_weights_raises(tmp_path): + assert_raises(ValueError, _plot_weights, numpy.ones((5, 3)), + tmp_path / "logo.png") + + +## + + +def test_make_logo(tmp_path, ppm): + make_logo("motif_a", tmp_path, {"motif_a": ppm, "motif_b": ppm}) + + assert [p.name for p in tmp_path.iterdir()] == ["motif_a.png"] + _png_size(tmp_path / "motif_a.png") + + +def test_make_logo_na(tmp_path, ppm): + assert make_logo("NA", tmp_path, {"motif_a": ppm}) is None + assert list(tmp_path.iterdir()) == [] + + +def test_make_logo_raises(tmp_path, ppm): + assert_raises(KeyError, make_logo, "motif_c", tmp_path, {"motif_a": ppm}) + + +## + + +def test_create_modisco_logos(small_h5, tmp_path): + logo_dir = tmp_path / "logos" + logo_dir.mkdir() + tags = create_modisco_logos(small_h5, logo_dir, 0.3, + ['pos_patterns', 'neg_patterns']) + + assert tags == ["pos_patterns.pattern_0", "neg_patterns.pattern_0"] + assert sorted(p.name for p in logo_dir.iterdir()) == [ + "neg_patterns.pattern_0.cwm.fwd.png", + "neg_patterns.pattern_0.cwm.rev.png", + "pos_patterns.pattern_0.cwm.fwd.png", + "pos_patterns.pattern_0.cwm.rev.png"] + + +@pytest.mark.parametrize("groups,expected", [(['pos_patterns'], + ["pos_patterns.pattern_0"]), (['missing'], []), + (['neg_patterns', 'missing'], ["neg_patterns.pattern_0"])]) +def test_create_modisco_logos_groups(small_h5, tmp_path, groups, expected): + logo_dir = tmp_path / "logos" + logo_dir.mkdir() + + tags = create_modisco_logos(small_h5, logo_dir, 0.3, groups) + assert tags == expected + assert len(list(logo_dir.iterdir())) == 2 * len(expected) + + +def test_create_modisco_logos_order(tmp_path): + # Patterns are ordered by their index rather than by name. + track_set = random_track_set(n=6, length=40) + h5 = tmp_path / "results.h5" + save_hdf5(h5, synthetic_patterns(track_set, 11, 2, 8), None, 40) + + logo_dir = tmp_path / "logos" + logo_dir.mkdir() + + import modiscolite.report + calls = [] + original = modiscolite.report._plot_weights + modiscolite.report._plot_weights = lambda array, path, **kw: calls.append( + path) + try: + tags = create_modisco_logos(h5, logo_dir, 0.3, ['pos_patterns']) + finally: + modiscolite.report._plot_weights = original + + assert tags == ["pos_patterns.pattern_{}".format(i) for i in range(11)] + assert len(calls) == 22 + + +def test_create_modisco_logos_raises(small_h5, tmp_path): + assert_raises(FileNotFoundError, create_modisco_logos, small_h5, + tmp_path / "missing", 0.3, ['pos_patterns']) + + +## + + +def test_report_motifs(small_h5, tmp_path): + output_dir = tmp_path / "report" + report_motifs(small_h5, output_dir, "./", None, False) + + assert sorted(p.name for p in output_dir.iterdir()) == ["motifs.html", + "trimmed_logos"] + assert len(list((output_dir / "trimmed_logos").iterdir())) == 4 + + html = output_dir / "motifs.html" + header, rows = _read_table(html) + assert header == ["pattern", "num_seqlets", "modisco_cwm_fwd", + "modisco_cwm_rev"] + assert rows == [ + ["pos_patterns.pattern_0", "74", + "./trimmed_logos/pos_patterns.pattern_0.cwm.fwd.png", + "./trimmed_logos/pos_patterns.pattern_0.cwm.rev.png"], + ["neg_patterns.pattern_0", "77", + "./trimmed_logos/neg_patterns.pattern_0.cwm.fwd.png", + "./trimmed_logos/neg_patterns.pattern_0.cwm.rev.png"]] + + +@pytest.mark.parametrize("suffix", ["./", "report/", "/abs/"]) +def test_report_motifs_suffix(small_h5, tmp_path, suffix): + output_dir = tmp_path / "report" + report_motifs(small_h5, output_dir, suffix, None, False) + + html = open(output_dir / "motifs.html").read() + assert ''.format(os.path.join(suffix, + "trimmed_logos", "pos_patterns.pattern_0.cwm.fwd.png")) in html + + +def test_report_motifs_existing_dir(small_h5, tmp_path): + output_dir = tmp_path / "report" + (output_dir / "trimmed_logos").mkdir(parents=True) + + report_motifs(small_h5, output_dir, "./", None, False) + assert (output_dir / "motifs.html").exists() + + +def test_report_motifs_ttl(small_h5, tmp_path, meme_db): + output_dir = tmp_path / "report" + report_motifs(small_h5, output_dir, "./", meme_db, False, top_n_matches=1, + ttl=True) + + html = output_dir / "motifs.html" + header, rows = _read_table(html) + assert header == ["pattern", "num_seqlets", "modisco_cwm_fwd", + "modisco_cwm_rev", "match0", "pval0", "match0_logo"] + assert _column(html, "match0") == ["db_motif_0 NAME0", "db_motif_0 NAME0"] + assert _column(html, "match0_logo") == ["./db_motif_0 NAME0.png"] * 2 + assert all(0 <= float(p) <= 1 for p in _column(html, "pval0")) + assert (output_dir / "db_motif_0 NAME0.png").exists() + + +@requires_tomtom +def test_report_motifs_tomtom(small_h5, tmp_path, meme_db): + output_dir = tmp_path / "report" + report_motifs(small_h5, output_dir, "./", meme_db, True, top_n_matches=1) + + html = output_dir / "motifs.html" + header, rows = _read_table(html) + assert header == ["pattern", "num_seqlets", "modisco_cwm_fwd", + "modisco_cwm_rev", "match0", "qval0", "match0_logo"] + assert _column(html, "match0") == ["db_motif_0", "db_motif_0"] + assert (output_dir / "db_motif_0.png").exists() + assert (output_dir / "tomtom" / "pos_patterns.pattern_0.tomtom.tsv" + ).exists() + + +def test_report_motifs_raises(small_h5, tmp_path): + assert_raises(FileNotFoundError, report_motifs, small_h5, + tmp_path / "a" / "b", "./", None, False) diff --git a/tests/test_tfmodisco.py b/tests/test_tfmodisco.py new file mode 100644 index 0000000..b1eafc8 --- /dev/null +++ b/tests/test_tfmodisco.py @@ -0,0 +1,680 @@ +# test_tfmodisco.py +# Contact: Jacob Schreiber + +import numpy +import scipy.sparse +import pytest + +from modiscolite.tfmodisco import _density_adaptation +from modiscolite.tfmodisco import _filter_patterns +from modiscolite.tfmodisco import _patterns_from_clusters +from modiscolite.tfmodisco import _filter_by_correlation +from modiscolite.tfmodisco import seqlets_to_patterns +from modiscolite.tfmodisco import TFMoDISco + +from modiscolite.affinitymat import pairwise_jaccard +from modiscolite.core import Seqlet +from modiscolite.core import SeqletSet +from modiscolite.core import TrackSet +from modiscolite.util import binary_search_perplexity +from modiscolite.util import get_2d_data_from_patterns + +from .synthetic import planted_track_set +from .synthetic import two_motif_track_set +from .synthetic import random_track_set +from .synthetic import make_seqlets +from .synthetic import make_pattern + +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +BG = numpy.full(4, 0.25) + +KWARGS = dict(sliding_window_size=20, flank_size=5, trim_to_window_size=30, + initial_flank_to_add=10, target_seqlet_fdr=0.05, n_leiden_runs=2) + + +def _coords(pattern): + return [(s.example_idx, s.start, s.end, s.is_revcomp) + for s in pattern.seqlets] + + +def _affinity(n, k, random_state=0): + X = numpy.random.RandomState(random_state).uniform(0, 1, size=(n, 12)) + affmat, neighbors = pairwise_jaccard(X, k) + return affmat, neighbors + + +def _density_reference(affmat_nn, seqlet_neighbors, perplexity): + n = len(affmat_nn) + D = numpy.zeros((n, n)) + C = numpy.zeros((n, n)) + for i in range(n): + for j, a in zip(seqlet_neighbors[i], affmat_nn[i]): + d = max(numpy.log(1.0 / (0.5 * max(a, 1e-7)) - 1), 0) + if d > 0: + D[i, j] = d + C[i, j] = 1 + + D, C = D + D.T, C + C.T + D = numpy.divide(D, C, out=numpy.zeros_like(D), where=C > 0) + + betas, norms = [], [] + for i in range(n): + d = D[i][C[i] > 0] + beta = binary_search_perplexity(perplexity, d) + betas.append(beta) + norms.append(numpy.exp(-d / beta).sum() + 1) + + A = numpy.zeros((n, n)) + for i in range(n): + for j in range(n): + if C[i, j] > 0: + rbf_i = numpy.exp(-D[i, j] / betas[i]) / norms[i] + rbf_j = numpy.exp(-D[i, j] / betas[j]) / norms[j] + A[i, j] = numpy.sqrt(rbf_i * rbf_j) + + return A + numpy.diag(1.0 / numpy.array(norms)) + + +@pytest.fixture +def motif_ts(): + return planted_track_set(n=40, length=60, motif_length=8, position=26) + + +## + + +@pytest.mark.parametrize("n,k", [(10, 5), (20, 10), (50, 20), (30, 30)]) +@pytest.mark.parametrize("perplexity", [2.0, 5.0, 10.0]) +def test_density_adaptation(n, k, perplexity): + affmat, neighbors = _affinity(n, k, random_state=n) + A = _density_adaptation(affmat, neighbors, perplexity) + + assert isinstance(A, scipy.sparse.csr_matrix) + assert A.shape == (n, n) + assert A.dtype == numpy.float64 + assert_array_almost_equal(A.toarray(), _density_reference(affmat, + neighbors, perplexity)) + + +def test_density_adaptation_symmetric(): + affmat, neighbors = _affinity(40, 12) + A = _density_adaptation(affmat, neighbors, 5.0).toarray() + + assert_array_almost_equal(A, A.T) + assert numpy.all(A >= 0) + assert numpy.all(numpy.diag(A) > 0) + + +def test_density_adaptation_diagonal(): + # The diagonal holds 1 / (1 + sum_j exp(-d_ij / beta_i)). + affmat, neighbors = _affinity(30, 10) + A = _density_adaptation(affmat, neighbors, 5.0).toarray() + + assert numpy.all(numpy.diag(A) > 0) + assert numpy.all(numpy.diag(A) <= 1) + + +def test_density_adaptation_lists(): + # Neighbor lists may be ragged after filtering by correlation. + affmat = [[1.0, 0.5, 0.3], [1.0, 0.4], [1.0, 0.5, 0.4]] + neighbors = [[0, 1, 2], [1, 2], [2, 0, 1]] + A = _density_adaptation(affmat, neighbors, 1.5) + + assert A.shape == (3, 3) + assert_array_almost_equal(A.toarray(), _density_reference(affmat, + neighbors, 1.5)) + + +def test_density_adaptation_self_removed(): + # A self affinity of 1 is a distance of 0, which is dropped, so listing + # each point as its own neighbor changes nothing. + affmat, neighbors = _affinity(15, 6) + assert_array_equal(neighbors[:, 0], numpy.arange(15)) + + A0 = _density_adaptation(affmat, neighbors, 3.0) + A1 = _density_adaptation(affmat[:, 1:], neighbors[:, 1:], 3.0) + assert_array_almost_equal(A0.toarray(), A1.toarray()) + + +def test_density_adaptation_real(seqlets): + X = get_2d_data_from_patterns(seqlets[:80])[0].reshape(80, -1) + affmat, neighbors = pairwise_jaccard(X, 20) + A = _density_adaptation(affmat, neighbors, 10.0) + + assert A.shape == (80, 80) + assert_array_almost_equal(A.diagonal()[:4], [0.4414, 0.3231, 0.1511, + 0.1317], 4) + + +## + + +def test_filter_patterns_support(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(n)]) + for n in [5, 10, 20, 40]] + passing = _filter_patterns(patterns, min_seqlet_support=10, window_size=6, + min_ic_in_window=0.6, background=BG, ppm_pseudocount=0.001) + + assert [len(p.seqlets) for p in passing] == [10, 20, 40] + + +def test_filter_patterns_ic(): + track_set = random_track_set(n=40, length=60) + background = make_pattern(track_set, [(i, 20, 40, False) for i in range(40)]) + motif = make_pattern(planted_track_set(n=40, length=60, motif_length=8, + position=26), [(i, 21, 39, False) for i in range(40)]) + + passing = _filter_patterns([background, motif], min_seqlet_support=10, + window_size=6, min_ic_in_window=0.6, background=BG, + ppm_pseudocount=0.001) + + assert passing == [motif] + + +@pytest.mark.parametrize("min_ic,expected", [(0.0, 2), (0.6, 1), (11.0, 1), + (12.1, 0)]) +def test_filter_patterns_min_ic(min_ic, expected): + # Six bases of a perfectly conserved motif carry just under 12 bits. + track_set = random_track_set(n=40, length=60) + background = make_pattern(track_set, [(i, 20, 40, False) for i in range(40)]) + motif = make_pattern(planted_track_set(n=40, length=60, motif_length=8, + position=26), [(i, 21, 39, False) for i in range(40)]) + + passing = _filter_patterns([background, motif], min_seqlet_support=10, + window_size=6, min_ic_in_window=min_ic, background=BG, + ppm_pseudocount=0.001) + assert len(passing) == expected + + +@pytest.mark.parametrize("window_size,expected", [(4, 1), (8, 1), (18, 1), + (30, 1)]) +def test_filter_patterns_window_size(motif_ts, window_size, expected): + # Windows longer than the pattern fall back to the total IC. + pattern = make_pattern(motif_ts, [(i, 21, 39, False) for i in range(40)]) + passing = _filter_patterns([pattern], min_seqlet_support=10, + window_size=window_size, min_ic_in_window=7.5, background=BG, + ppm_pseudocount=0.001) + assert len(passing) == expected + + +def test_filter_patterns_window_short(motif_ts): + pattern = make_pattern(motif_ts, [(i, 21, 39, False) for i in range(40)]) + passing = _filter_patterns([pattern], min_seqlet_support=10, + window_size=2, min_ic_in_window=4.1, background=BG, + ppm_pseudocount=0.001) + assert passing == [] + + +def test_filter_patterns_order(motif_ts): + patterns = [make_pattern(motif_ts, [(i, 21, 39, False) for i in range(n)]) + for n in [30, 12, 25]] + passing = _filter_patterns(patterns, 10, 6, 0.6, BG, 0.001) + assert passing == patterns + + +def test_filter_patterns_empty(): + assert _filter_patterns([], 10, 6, 0.6, BG, 0.001) == [] + + +def test_filter_patterns_real(pos_patterns, neg_patterns): + patterns = pos_patterns + neg_patterns + assert len(_filter_patterns(patterns, 20, 6, 0.6, BG, 0.001)) == 4 + assert len(_filter_patterns(patterns, 27, 6, 0.6, BG, 0.001)) == 3 + assert len(_filter_patterns(patterns, 75, 6, 0.6, BG, 0.001)) == 1 + + +## + + +def test_patterns_from_clusters(): + track_set = two_motif_track_set(n=20) + seqlets = make_seqlets(track_set, [(i, 15, 35, bool(i % 4 == 0)) + for i in range(40)]) + cluster_indices = numpy.repeat([0, 1], 20) + + patterns = _patterns_from_clusters(seqlets, track_set, min_overlap=0.7, + min_frac=0.2, min_num=30, flank_to_add=5, window_size=10, bg_freq=BG, + cluster_indices=cluster_indices, track_sign=1) + + assert len(patterns) == 2 + for pattern, examples in zip(patterns, [range(20), range(20, 40)]): + assert isinstance(pattern, SeqletSet) + assert len(pattern) == 20 + assert set(s.example_idx for s in pattern.seqlets) == set(examples) + + +def test_patterns_from_clusters_sign(): + track_set = two_motif_track_set(n=20) + seqlets = make_seqlets(track_set, [(i, 15, 35, False) for i in range(40)]) + cluster_indices = numpy.repeat([0, 1], 20) + + patterns = _patterns_from_clusters(seqlets, track_set, 0.7, 0.2, 30, 5, + 10, BG, cluster_indices, track_sign=-1) + assert patterns == [] + + +def test_patterns_from_clusters_interleaved(): + track_set = two_motif_track_set(n=20) + seqlets = make_seqlets(track_set, [(i, 15, 35, False) for i in range(40)]) + cluster_indices = numpy.array([i % 2 for i in range(40)]) + + patterns = _patterns_from_clusters(seqlets, track_set, 0.7, 0.2, 30, 5, + 10, BG, cluster_indices, track_sign=1) + + assert len(patterns) == 2 + assert [len(p.seqlets) for p in patterns] == [20, 20] + + +@pytest.mark.parametrize("window_size,flank_to_add", [(6, 0), (8, 3), + (10, 5), (12, 8)]) +def test_patterns_from_clusters_length(motif_ts, window_size, flank_to_add): + seqlets = make_seqlets(motif_ts, [(i, 16, 44, False) for i in range(40)]) + patterns = _patterns_from_clusters(seqlets, motif_ts, 0.7, 0.2, 30, + flank_to_add, window_size, BG, numpy.zeros(40, dtype=int), 1) + + assert len(patterns) == 1 + assert len(patterns[0]) == window_size + 2*flank_to_add + + +def test_patterns_from_clusters_singleton(motif_ts): + seqlets = make_seqlets(motif_ts, [(0, 16, 44, False), (1, 16, 44, False)]) + patterns = _patterns_from_clusters(seqlets, motif_ts, 0.7, 0.2, 30, 5, 8, + BG, numpy.array([0, 1]), 1) + + # Every position of a single seqlet is fully conserved, so the IC trim + # keeps the first window. + assert len(patterns) == 2 + assert [_coords(p) for p in patterns] == [[(0, 6, 24, False)], + [(1, 6, 24, False)]] + + +def test_patterns_from_clusters_seed(motif_ts): + # The pattern is seeded with the seqlet with the most attribution. + seqlets = make_seqlets(motif_ts, [(i, 16, 44, False) for i in range(10)]) + strongest = max(seqlets, key=lambda s: numpy.abs(s.contrib_scores).sum()) + + patterns = _patterns_from_clusters(seqlets, motif_ts, 0.7, 0.2, 30, 5, 8, + BG, numpy.zeros(10, dtype=int), 1) + assert patterns[0].seqlets[0].example_idx == strongest.example_idx + + +def test_patterns_from_clusters_real(pos_seqlets, track_set): + cluster_indices = numpy.arange(len(pos_seqlets)) % 3 + patterns = _patterns_from_clusters(pos_seqlets, track_set, 0.7, 0.2, 30, + 10, 30, BG, cluster_indices, 1) + + assert len(patterns) == 3 + assert all(len(p) == 50 for p in patterns) + assert sum(len(p.seqlets) for p in patterns) <= len(pos_seqlets) + + +## + + +def _correlation_inputs(): + seqlets = list(range(5)) + neighbors = [[0, 1, 2, 3], [1, 0, 2, 4], [2, 3, 0, 1], [3, 4, 2, 0], + [4, 3, 1, 0]] + # The Spearman correlations of the rows are 1, -1, 1, -0.4 and 1. + fine = numpy.array([[1.0, 0.8, 0.6, 0.4], [0.9, 0.7, 0.5, 0.3], + [1.0, 0.9, 0.2, 0.1], [0.9, 0.6, 0.5, 0.2], [1.0, 0.9, 0.8, 0.1]]) + coarse = numpy.array([[1.0, 0.7, 0.5, 0.2], [0.2, 0.4, 0.6, 0.8], + [1.0, 0.8, 0.3, 0.2], [0.5, 0.3, 0.4, 0.6], [1.0, 0.8, 0.5, 0.3]]) + return seqlets, neighbors, coarse, fine + + +def test_filter_by_correlation(): + seqlets, neighbors, coarse, fine = _correlation_inputs() + kept, new_neighbors, new_affmat = _filter_by_correlation(seqlets, + neighbors, coarse, fine, 0.0) + + assert kept == [0, 2, 4] + assert [list(map(int, row)) for row in new_neighbors] == [[0, 1], + [1, 0], [2, 0]] + assert new_affmat == [[1.0, 0.6], [1.0, 0.2], [1.0, 0.1]] + + +@pytest.mark.parametrize("threshold,expected", [(-1.1, [0, 1, 2, 3, 4]), + (-0.5, [0, 2, 3, 4]), (0.0, [0, 2, 4]), (0.99, [0, 2, 4]), (1.0, [])]) +def test_filter_by_correlation_threshold(threshold, expected): + seqlets, neighbors, coarse, fine = _correlation_inputs() + kept, new_neighbors, new_affmat = _filter_by_correlation(seqlets, + neighbors, coarse, fine, threshold) + + assert kept == expected + assert len(new_neighbors) == len(expected) + assert len(new_affmat) == len(expected) + for row, affs in zip(new_neighbors, new_affmat): + assert len(row) == len(affs) + assert all(0 <= r < len(expected) for r in row) + + +def test_filter_by_correlation_mask(): + # Only entries where the fine affinity is nonzero are compared. + seqlets = [0, 1] + neighbors = [[0, 1, 1], [1, 0, 0]] + fine = numpy.array([[1.0, 0.5, 0.0], [1.0, 0.5, 0.0]]) + coarse = numpy.array([[1.0, 0.5, 9.0], [1.0, 0.5, 9.0]]) + + kept, _, _ = _filter_by_correlation(seqlets, neighbors, coarse, fine, 0.5) + assert kept == [0, 1] + + +def test_filter_by_correlation_real(seqlets): + from modiscolite.affinitymat import cosine_similarity_from_seqlets + from modiscolite.affinitymat import jaccard_from_seqlets + + seqlets = seqlets[:100] + coarse, neighbors = cosine_similarity_from_seqlets(seqlets, 20, sign=1) + fine = jaccard_from_seqlets(seqlets, 0.7, seqlet_neighbors=neighbors) + kept, new_neighbors, new_affmat = _filter_by_correlation(seqlets, + neighbors, coarse, fine, 0.15) + + assert len(kept) == 77 + assert all(s in seqlets for s in kept) + assert len(new_neighbors) == 77 + assert all(max(row) < 77 for row in new_neighbors) + + +## + + +def test_seqlets_to_patterns(pos_seqlets, track_set): + patterns = seqlets_to_patterns(pos_seqlets, track_set, track_signs=1, + n_leiden_runs=2, trim_to_window_size=30, initial_flank_to_add=10) + + assert isinstance(patterns, list) + assert [len(p.seqlets) for p in patterns] == [77, 24] + for pattern in patterns: + assert isinstance(pattern, SeqletSet) + assert len(pattern) == 50 + assert pattern.subclusters is not None + + +def test_seqlets_to_patterns_deterministic(pos_seqlets, track_set): + kwargs = dict(track_signs=1, n_leiden_runs=1, trim_to_window_size=30, + initial_flank_to_add=10) + patterns0 = seqlets_to_patterns(pos_seqlets[:100], track_set, **kwargs) + patterns1 = seqlets_to_patterns(pos_seqlets[:100], track_set, **kwargs) + + assert [_coords(p) for p in patterns0] == [_coords(p) for p in patterns1] + + +@pytest.mark.parametrize("trim,initial,final", [(20, 5, 0), (30, 10, 0), + (30, 5, 5), (24, 0, 3)]) +def test_seqlets_to_patterns_length(pos_seqlets, track_set, trim, initial, + final): + patterns = seqlets_to_patterns(pos_seqlets[:120], track_set, + track_signs=1, n_leiden_runs=1, trim_to_window_size=trim, + initial_flank_to_add=initial, final_flank_to_add=final) + + assert len(patterns) > 0 + assert all(len(p) == trim + 2*initial + 2*final for p in patterns) + + +@pytest.mark.parametrize("final_min_cluster_size,expected", [(1, 3), + (20, 1), (200, 0)]) +def test_seqlets_to_patterns_min_cluster_size(pos_seqlets, track_set, + final_min_cluster_size, expected): + patterns = seqlets_to_patterns(pos_seqlets[:120], track_set, + track_signs=1, n_leiden_runs=1, trim_to_window_size=30, + initial_flank_to_add=10, final_min_cluster_size=final_min_cluster_size) + assert len(patterns) == expected + + +def test_seqlets_to_patterns_min_ic(pos_seqlets, track_set): + patterns = seqlets_to_patterns(pos_seqlets[:120], track_set, + track_signs=1, n_leiden_runs=1, min_ic_in_window=100.0) + assert patterns == [] + + +def test_seqlets_to_patterns_sign(pos_seqlets, track_set): + # Positive seqlets never produce patterns with a negative sign. + patterns = seqlets_to_patterns(pos_seqlets[:120], track_set, + track_signs=-1, n_leiden_runs=1) + assert patterns is None or patterns == [] + + +def test_seqlets_to_patterns_planted(): + track_set = two_motif_track_set(n=40, length=80, position=35) + seqlets = make_seqlets(track_set, [(i, 25, 55, bool(i % 3 == 0)) + for i in range(80)]) + + patterns = seqlets_to_patterns(seqlets, track_set, track_signs=1, + n_leiden_runs=2, trim_to_window_size=10, initial_flank_to_add=5, + final_min_cluster_size=10, subcluster_perplexity=10, + nearest_neighbors_to_compute=40) + + assert len(patterns) == 2 + for pattern in patterns: + assert len(pattern.seqlets) >= 36 + assert len(set(s.example_idx < 40 for s in pattern.seqlets)) == 1 + + +@pytest.mark.skip(reason="bug: seqlets_to_patterns([]) raises numpy AxisError " + "while computing bg_freq, before it reaches its check for an empty " + "list of seqlets") +def test_seqlets_to_patterns_empty(track_set): + assert seqlets_to_patterns([], track_set, track_signs=1) is None + + +## + + +def test_tfmodisco_real(one_hot, hypothetical_contribs): + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=150, **KWARGS) + + assert neg is None + assert [len(p.seqlets) for p in pos] == [77, 24] + assert all(len(p) == 50 for p in pos) + + assert_array_almost_equal(pos[0].sequence[19:23], [ + [0.4286, 0.1169, 0.2468, 0.2078], + [0. , 0. , 1. , 0. ], + [0. , 0. , 1. , 0. ], + [0.4026, 0.2597, 0.1688, 0.1688]], 4) + + +def test_tfmodisco_signed(pos_patterns, neg_patterns): + assert [len(p.seqlets) for p in pos_patterns] == [74, 26] + assert [len(p.seqlets) for p in neg_patterns] == [77, 27] + assert all(len(p) == 50 for p in pos_patterns + neg_patterns) + + for pattern in pos_patterns: + assert pattern.contrib_scores.sum() > 0 + assert all(s.example_idx < 150 for s in pattern.seqlets) + + for pattern in neg_patterns: + assert pattern.contrib_scores.sum() < 0 + assert all(s.example_idx >= 150 for s in pattern.seqlets) + + +def _shift(pattern0, pattern1): + """The set of (|start shift|, same strand) over shared examples, and the + fraction of pattern0's examples that pattern1 shares.""" + + a = {s.example_idx: s for s in pattern0.seqlets} + b = {s.example_idx: s for s in pattern1.seqlets} + common = set(a) & set(b) + + shifts = set((abs(b[i].start - a[i].start), a[i].is_revcomp == + b[i].is_revcomp) for i in common) + return shifts, len(common) / len(a) + + +def test_tfmodisco_mirror(one_hot, hypothetical_contribs): + # Negating every attribution swaps the positive and negative patterns. + # The Laplacian null draws its samples differently for the two signs, so + # seqlets near the threshold, and with them the framing of the pattern, + # can move by a few bp. + kwargs = dict(KWARGS, max_seqlets_per_metacluster=80) + pos0, neg0 = TFMoDISco(one_hot, hypothetical_contribs, **kwargs) + pos1, neg1 = TFMoDISco(one_hot, -hypothetical_contribs, **kwargs) + + assert neg0 is None + assert pos1 is None + assert len(pos0) == len(neg1) + + shifts, frac = _shift(pos0[0], neg1[0]) + assert len(shifts) == 1 + assert list(shifts)[0][1] is True + assert frac > 0.85 + + +def test_seqlets_to_patterns_mirror(pos_seqlets, track_set): + # The clustering itself is exactly symmetric in the sign of the + # attributions. + negated = TrackSet(track_set.one_hot, -track_set.contrib_scores, + -track_set.hypothetical_contribs) + neg_seqlets = negated.create_seqlets([Seqlet(s.example_idx, s.start, + s.end, s.is_revcomp) for s in pos_seqlets[:100]]) + + kwargs = dict(n_leiden_runs=1, trim_to_window_size=30, + initial_flank_to_add=10) + patterns0 = seqlets_to_patterns(pos_seqlets[:100], track_set, + track_signs=1, **kwargs) + patterns1 = seqlets_to_patterns(neg_seqlets, negated, track_signs=-1, + **kwargs) + + assert [_coords(p) for p in patterns0] == [_coords(p) for p in patterns1] + for p0, p1 in zip(patterns0, patterns1): + assert_array_almost_equal(p0.contrib_scores, -p1.contrib_scores) + + +@pytest.mark.parametrize("max_seqlets", [60, 100, 150]) +def test_tfmodisco_max_seqlets(one_hot, hypothetical_contribs, max_seqlets): + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=max_seqlets, min_metacluster_size=50, + **KWARGS) + + assert sum(len(p.seqlets) for p in pos) <= max_seqlets + for pattern in pos: + for seqlet in pattern.seqlets: + assert 0 <= seqlet.start < seqlet.end <= 300 + + +@pytest.mark.parametrize("min_metacluster_size", [100, 540, 10000]) +def test_tfmodisco_min_metacluster_size(one_hot, hypothetical_contribs, + min_metacluster_size): + # About 540 positive seqlets pass the threshold. + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=80, + min_metacluster_size=min_metacluster_size, **KWARGS) + + assert neg is None + if min_metacluster_size > 540: + assert pos is None + else: + assert len(pos) > 0 + + +@pytest.mark.parametrize("sliding_window_size,flank_size", [(15, 5), + (20, 3), (21, 10), (12, 4)]) +def test_tfmodisco_window(one_hot, hypothetical_contribs, + sliding_window_size, flank_size): + kwargs = dict(KWARGS, sliding_window_size=sliding_window_size, + flank_size=flank_size) + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=100, **kwargs) + + assert len(pos) > 0 + assert all(len(p) == 50 for p in pos) + + +@pytest.mark.parametrize("trim,initial,final", [(20, 5, 0), (30, 10, 5), + (16, 8, 2)]) +def test_tfmodisco_pattern_length(one_hot, hypothetical_contribs, trim, + initial, final): + kwargs = dict(KWARGS, trim_to_window_size=trim, + initial_flank_to_add=initial, final_flank_to_add=final) + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=100, **kwargs) + + assert all(len(p) == trim + 2*initial + 2*final for p in pos) + + +@pytest.mark.parametrize("n,length", [(300, 300), (200, 300), (300, 200), + (150, 250)]) +def test_tfmodisco_shapes(one_hot, hypothetical_contribs, n, length): + start = (300 - length) // 2 + X = numpy.ascontiguousarray(one_hot[:n, start:start+length]) + A = numpy.ascontiguousarray(hypothetical_contribs[:n, start:start+length]) + + pos, neg = TFMoDISco(X, A, max_seqlets_per_metacluster=100, + min_metacluster_size=50, **KWARGS) + + assert len(pos) > 0 + for pattern in pos: + assert all(s.example_idx < n for s in pattern.seqlets) + assert all(s.end <= length for s in pattern.seqlets) + + +def test_tfmodisco_float64(one_hot, hypothetical_contribs): + # Rounding differences can move the framing of a pattern by a few bp. + pos0, _ = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=80, **KWARGS) + pos1, _ = TFMoDISco(one_hot.astype('float64'), + hypothetical_contribs.astype('float64'), + max_seqlets_per_metacluster=80, **KWARGS) + + assert len(pos0) == len(pos1) + shifts, frac = _shift(pos0[0], pos1[0]) + assert len(shifts) == 1 + assert list(shifts)[0][1] is True + assert frac > 0.9 + + +@pytest.mark.parametrize("n_leiden_runs", [1, 2, 4]) +def test_tfmodisco_n_leiden_runs(one_hot, hypothetical_contribs, + n_leiden_runs): + kwargs = dict(KWARGS, n_leiden_runs=n_leiden_runs) + pos, neg = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=80, **kwargs) + assert len(pos) > 0 + + +def test_tfmodisco_verbose(one_hot, hypothetical_contribs, capsys): + TFMoDISco(one_hot, hypothetical_contribs, max_seqlets_per_metacluster=60, + verbose=True, **KWARGS) + assert capsys.readouterr().out == "Using 60 positive seqlets\n" + + +def test_tfmodisco_verbose_negative(one_hot, hypothetical_contribs, capsys): + TFMoDISco(one_hot, -hypothetical_contribs, max_seqlets_per_metacluster=60, + verbose=True, **KWARGS) + assert capsys.readouterr().out == "Extracted 60 negative seqlets\n" + + +def test_tfmodisco_quiet(one_hot, hypothetical_contribs, capsys): + TFMoDISco(one_hot, hypothetical_contribs, max_seqlets_per_metacluster=60, + **KWARGS) + assert capsys.readouterr().out == "" + + +def test_tfmodisco_does_not_modify(one_hot, hypothetical_contribs): + X, A = one_hot.copy(), hypothetical_contribs.copy() + TFMoDISco(one_hot, hypothetical_contribs, max_seqlets_per_metacluster=60, + **KWARGS) + + assert_array_equal(one_hot, X) + assert_array_equal(hypothetical_contribs, A) + + +def test_tfmodisco_seqlet_data(one_hot, hypothetical_contribs): + pos, _ = TFMoDISco(one_hot, hypothetical_contribs, + max_seqlets_per_metacluster=60, **KWARGS) + + for pattern in pos: + for s in pattern.seqlets: + sequence = one_hot[s.example_idx, s.start:s.end] + hyp = hypothetical_contribs[s.example_idx, s.start:s.end] + if s.is_revcomp: + sequence, hyp = sequence[::-1, ::-1], hyp[::-1, ::-1] + + assert_array_equal(s.sequence, sequence) + assert_array_almost_equal(s.hypothetical_contribs, hyp) + assert_array_almost_equal(s.contrib_scores, sequence * hyp) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..c3b52bb --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,539 @@ +# test_util.py +# Contact: Jacob Schreiber + +import numpy +import pytest + +from modiscolite.util import MemeDataType +from modiscolite.util import cpu_sliding_window_sum +from modiscolite.util import binary_search_perplexity +from modiscolite.util import compute_per_position_ic +from modiscolite.util import rolling_window +from modiscolite.util import magnitude +from modiscolite.util import l1 +from modiscolite.util import get_2d_data_from_patterns +from modiscolite.util import calculate_window_offsets +from modiscolite.util import filter_bed_rows_by_chrom + +from modiscolite.core import SeqletSet + +from .synthetic import random_track_set +from .synthetic import make_seqlets + +from numpy.testing import assert_raises +from numpy.testing import assert_array_equal +from numpy.testing import assert_array_almost_equal + + +def _entropy(beta, distances): + ps = numpy.exp(-distances * beta) + sum_ps = numpy.sum(ps) + 1 + ps = ps / sum_ps + return numpy.log(sum_ps) + beta * numpy.sum(distances * ps) + + +def _ic(ppm, background, pseudocount): + ic = numpy.zeros(len(ppm)) + for i in range(len(ppm)): + for j in range(len(background)): + p = (ppm[i, j] + pseudocount) / (1 + pseudocount * len(background)) + ic[i] += numpy.log2(p) * ppm[i, j] + ic[i] -= numpy.log2(background[j]) * background[j] + + return ic + + +@pytest.fixture +def X(): + return numpy.random.RandomState(0).randn(20, 4) + + +@pytest.fixture +def random_seqlets(): + track_set = random_track_set(n=6, length=40) + coords = [(0, 0, 10, False), (1, 5, 15, True), (2, 20, 30, False), + (5, 30, 40, True)] + return make_seqlets(track_set, coords) + + +## + + +@pytest.mark.parametrize("name,value", [("PFM", "PFM"), ("CWM", "CWM"), + ("hCWM", "hCWM"), ("CWM_PFM", "CWM-PFM"), ("hCWM_PFM", "hCWM-PFM")]) +def test_meme_data_type_value(name, value): + member = getattr(MemeDataType, name) + assert member.value == value + assert str(member) == value + assert MemeDataType(value) is member + + +def test_meme_data_type_members(): + assert [m.value for m in MemeDataType] == ["PFM", "CWM", "hCWM", + "CWM-PFM", "hCWM-PFM"] + + +@pytest.mark.parametrize("value", ["pfm", "cwm", "CWM_PFM", "hcwm", "", "PWM"]) +def test_meme_data_type_raises(value): + assert_raises(ValueError, MemeDataType, value) + + +## + + +@pytest.mark.parametrize("length,window_size", [(1, 1), (2, 1), (2, 2), + (7, 1), (7, 3), (7, 7), (50, 2), (50, 5), (50, 20), (301, 1), (301, 20), + (301, 30), (301, 301)]) +def test_cpu_sliding_window_sum(length, window_size): + arr = numpy.random.RandomState(length).randn(length) + y = cpu_sliding_window_sum(arr, window_size) + + assert y.shape == (length - window_size + 1,) + assert y.dtype == numpy.float64 + assert_array_almost_equal(y, numpy.convolve(arr, numpy.ones(window_size), + 'valid')) + + +def test_cpu_sliding_window_sum_values(): + arr = numpy.array([1.0, 2.0, 3.0, 4.0, 5.0]) + assert_array_almost_equal(cpu_sliding_window_sum(arr, 2), [3, 5, 7, 9]) + assert_array_almost_equal(cpu_sliding_window_sum(arr, 3), [6, 9, 12]) + assert_array_almost_equal(cpu_sliding_window_sum(arr, 5), [15]) + + +@pytest.mark.parametrize("dtype", ['int32', 'int64', 'float32', 'float64']) +def test_cpu_sliding_window_sum_dtypes(dtype): + arr = numpy.arange(10).astype(dtype) + y = cpu_sliding_window_sum(arr, 4) + + assert y.dtype == numpy.float64 + assert_array_almost_equal(y, [6, 10, 14, 18, 22, 26, 30]) + + +def test_cpu_sliding_window_sum_negative(): + arr = -numpy.ones(6) + assert_array_almost_equal(cpu_sliding_window_sum(arr, 3), [-3, -3, -3, -3]) + + +def test_cpu_sliding_window_sum_list(): + assert_array_almost_equal(cpu_sliding_window_sum([1, 0, 2, 0], 2), + [1, 2, 2]) + + +def test_cpu_sliding_window_sum_argmax(): + arr = numpy.zeros(40) + arr[22:27] = 1 + assert numpy.argmax(cpu_sliding_window_sum(arr, 5)) == 22 + + +def test_cpu_sliding_window_sum_raises(): + assert_raises(ValueError, cpu_sliding_window_sum, numpy.ones(3), 5) + + +## + + +@pytest.mark.parametrize("k", [5, 10, 50, 200]) +@pytest.mark.parametrize("perplexity", [1.5, 2.0, 3.0, 4.5]) +def test_binary_search_perplexity(k, perplexity): + distances = numpy.random.RandomState(k).uniform(0.1, 5, size=k) + beta = binary_search_perplexity(perplexity, distances) + + assert isinstance(beta, float) + assert beta > 0 + assert abs(_entropy(beta, distances) - numpy.log(perplexity)) < 1e-4 + + +@pytest.mark.parametrize("k", [3, 20]) +def test_binary_search_perplexity_monotonic(k): + distances = numpy.random.RandomState(0).uniform(0.1, 5, size=k) + betas = [binary_search_perplexity(p, distances) for p in [1.2, 1.5, 2, 3]] + assert all(numpy.diff(betas) < 0) + + +def test_binary_search_perplexity_permutation(): + distances = numpy.random.RandomState(0).uniform(0.1, 5, size=30) + beta0 = binary_search_perplexity(5.0, distances) + beta1 = binary_search_perplexity(5.0, distances[::-1].copy()) + assert abs(beta0 - beta1) < 1e-8 + + +def test_binary_search_perplexity_first_step(): + # beta starts at 1.0 so a matching entropy returns immediately. + distances = numpy.array([0.5, 1.0, 2.0]) + perplexity = numpy.exp(_entropy(1.0, distances)) + assert binary_search_perplexity(perplexity, distances) == 1.0 + + +def test_binary_search_perplexity_unreachable(): + # Perplexity above k + 1 cannot be reached, so beta shrinks towards zero + # and the entropy approaches its maximum of log(k + 1). + distances = numpy.random.RandomState(0).uniform(0.1, 5, size=5) + beta = binary_search_perplexity(20.0, distances) + + assert beta < 1e-20 + assert abs(_entropy(beta, distances) - numpy.log(6)) < 1e-6 + + +def test_binary_search_perplexity_raises(): + distances = numpy.ones(5, dtype='float32') + assert_raises(TypeError, binary_search_perplexity, 2.0, distances) + + +## + + +@pytest.mark.parametrize("length", [1, 5, 30, 100]) +@pytest.mark.parametrize("pseudocount", [0.001, 0.01, 0.1]) +def test_compute_per_position_ic(length, pseudocount): + ppm = numpy.random.RandomState(length).dirichlet(numpy.ones(4), size=length) + background = numpy.array([0.25, 0.25, 0.25, 0.25]) + + ic = compute_per_position_ic(ppm, background, pseudocount) + assert ic.shape == (length,) + assert_array_almost_equal(ic, _ic(ppm, background, pseudocount)) + + +@pytest.mark.parametrize("background", [[0.25, 0.25, 0.25, 0.25], + [0.3, 0.2, 0.2, 0.3], [0.1, 0.4, 0.4, 0.1], [0.7, 0.1, 0.1, 0.1]]) +def test_compute_per_position_ic_background(background): + ppm = numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=12) + background = numpy.array(background) + + ic = compute_per_position_ic(ppm, background, 0.001) + assert_array_almost_equal(ic, _ic(ppm, background, 0.001)) + + +@pytest.mark.parametrize("pseudocount", [0.0, 0.001, 0.5]) +def test_compute_per_position_ic_uniform(pseudocount): + ppm = numpy.full((10, 4), 0.25) + ic = compute_per_position_ic(ppm, numpy.full(4, 0.25), pseudocount) + assert_array_almost_equal(ic, numpy.zeros(10)) + + +def test_compute_per_position_ic_one_hot(): + ppm = numpy.eye(4)[[0, 1, 2, 3, 3, 2]] + ic = compute_per_position_ic(ppm, numpy.full(4, 0.25), 0.001) + assert_array_almost_equal(ic, numpy.full(6, 2 + numpy.log2(1.001 / 1.004))) + + +def test_compute_per_position_ic_ordering(): + ppm = numpy.array([[0.25, 0.25, 0.25, 0.25], [0.4, 0.2, 0.2, 0.2], + [0.7, 0.1, 0.1, 0.1], [0.97, 0.01, 0.01, 0.01]]) + + ic = compute_per_position_ic(ppm, numpy.full(4, 0.25), 0.001) + assert all(numpy.diff(ic) > 0) + + +@pytest.mark.parametrize("scale", [0.5, 2, 10, 137]) +def test_compute_per_position_ic_unnormalized(scale): + ppm = numpy.random.RandomState(0).dirichlet(numpy.ones(4), size=8) + background = numpy.full(4, 0.25) + + ic0 = compute_per_position_ic(ppm, background, 0.001) + ic1 = compute_per_position_ic(ppm * scale, background, 0.001) + assert_array_almost_equal(ic0, ic1) + + +def test_compute_per_position_ic_mixed_rows(): + ppm = numpy.array([[0.25, 0.25, 0.25, 0.25], [2.0, 2.0, 0.0, 0.0]]) + ic = compute_per_position_ic(ppm, numpy.full(4, 0.25), 0.001) + assert_array_almost_equal(ic, _ic(ppm / ppm.sum(axis=1, keepdims=True), + numpy.full(4, 0.25), 0.001)) + + +def test_compute_per_position_ic_patterns(pos_patterns): + for pattern in pos_patterns: + ic = compute_per_position_ic(pattern.sequence, numpy.full(4, 0.25), + 0.001) + + assert ic.shape == (len(pattern),) + assert numpy.all(ic > -0.01) + assert numpy.all(ic < 2.0) + assert ic.max() > 1.0 + + +## + + +@pytest.mark.parametrize("shape", [(10,), (1, 10), (3, 10), (2, 3, 10), + (2, 1, 3, 25)]) +@pytest.mark.parametrize("window", [1, 3, 10]) +def test_rolling_window(shape, window): + a = numpy.random.RandomState(0).randn(*shape) + y = rolling_window(a, window) + + assert y.shape == shape[:-1] + (shape[-1] - window + 1, window) + for i in range(shape[-1] - window + 1): + assert_array_equal(y[..., i, :], a[..., i:i+window]) + + +def test_rolling_window_view(): + a = numpy.arange(12.0) + y = rolling_window(a, 4) + + assert numpy.shares_memory(a, y) + assert_array_equal(y.sum(axis=-1), numpy.convolve(a, numpy.ones(4), + 'valid')) + + +@pytest.mark.parametrize("dtype", ['int8', 'int64', 'float32', 'float64']) +def test_rolling_window_dtypes(dtype): + a = numpy.arange(6).astype(dtype) + y = rolling_window(a, 2) + + assert y.dtype == a.dtype + assert_array_equal(y, [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5]]) + + +## + + +@pytest.mark.parametrize("shape", [(4,), (20, 4), (30, 8), (3, 20, 4)]) +def test_magnitude(shape): + X = numpy.random.RandomState(0).randn(*shape) + 3 + y = magnitude(X) + + assert y.shape == shape + assert abs(y.mean()) < 1e-7 + assert abs(numpy.linalg.norm(y.ravel()) - 1) < 1e-5 + + +def test_magnitude_values(): + y = magnitude(numpy.array([1.0, 3.0])) + assert_array_almost_equal(y, [-0.7071068, 0.7071068]) + + +@pytest.mark.parametrize("scale", [0.01, 1, 17]) +def test_magnitude_scale(X, scale): + assert_array_almost_equal(magnitude(X), magnitude(X * scale)) + + +@pytest.mark.parametrize("shift", [-4, 0.5, 100]) +def test_magnitude_shift(X, shift): + assert_array_almost_equal(magnitude(X), magnitude(X + shift)) + + +@pytest.mark.parametrize("value", [0.0, 1.0, -2.5]) +def test_magnitude_constant(value): + y = magnitude(numpy.full((10, 4), value)) + assert_array_almost_equal(y, numpy.zeros((10, 4))) + + +def test_magnitude_does_not_modify(X): + X0 = X.copy() + magnitude(X) + assert_array_equal(X, X0) + + +## + + +@pytest.mark.parametrize("shape", [(4,), (20, 4), (30, 8), (3, 20, 4)]) +def test_l1(shape): + X = numpy.random.RandomState(0).randn(*shape) + y = l1(X) + + assert y.shape == shape + assert abs(numpy.abs(y).sum() - 1) < 1e-7 + assert_array_equal(numpy.sign(y), numpy.sign(X)) + + +def test_l1_values(): + assert_array_almost_equal(l1(numpy.array([1.0, -3.0])), [0.25, -0.75]) + + +@pytest.mark.parametrize("scale", [0.01, 1, 17]) +def test_l1_scale(X, scale): + assert_array_almost_equal(l1(X), l1(X * scale)) + + +def test_l1_negate(X): + assert_array_almost_equal(l1(-X), -l1(X)) + + +def test_l1_zeros(): + X = numpy.zeros((10, 4)) + y = l1(X) + + assert y is X + assert_array_equal(y, numpy.zeros((10, 4))) + + +@pytest.mark.parametrize("dtype", ['float32', 'float64']) +def test_l1_dtype(dtype): + assert l1(numpy.ones((3, 4), dtype=dtype)).dtype == dtype + + +## + + +@pytest.mark.parametrize("n", [1, 2, 5]) +@pytest.mark.parametrize("length", [4, 20, 31]) +@pytest.mark.parametrize("transformer", ['l1', 'magnitude']) +@pytest.mark.parametrize("include_hypothetical", [True, False]) +def test_get_2d_data_from_patterns_shape(n, length, transformer, + include_hypothetical): + track_set = random_track_set(n=n, length=40) + seqlets = make_seqlets(track_set, [(i, 3, 3+length, False) + for i in range(n)]) + + fwd, rev = get_2d_data_from_patterns(seqlets, transformer=transformer, + include_hypothetical=include_hypothetical) + + d = 8 if include_hypothetical else 4 + assert fwd.shape == (n, length, d) + assert rev.shape == (n, length, d) + + +@pytest.mark.parametrize("transformer,func", [('l1', l1), + ('magnitude', magnitude), ('other', magnitude)]) +def test_get_2d_data_from_patterns_hypothetical(random_seqlets, transformer, func): + fwd, rev = get_2d_data_from_patterns(random_seqlets, transformer=transformer) + + for i, seqlet in enumerate(random_seqlets): + hyp, contrib = seqlet.hypothetical_contribs, seqlet.contrib_scores + + assert_array_almost_equal(fwd[i, :, :4], func(hyp)) + assert_array_almost_equal(fwd[i, :, 4:], func(contrib)) + assert_array_almost_equal(rev[i, :, :4], func(hyp[::-1, ::-1])) + assert_array_almost_equal(rev[i, :, 4:], func(contrib[::-1, ::-1])) + + +@pytest.mark.parametrize("transformer,func", [('l1', l1), + ('magnitude', magnitude)]) +def test_get_2d_data_from_patterns_contrib_only(random_seqlets, transformer, func): + fwd, rev = get_2d_data_from_patterns(random_seqlets, transformer=transformer, + include_hypothetical=False) + + for i, seqlet in enumerate(random_seqlets): + contrib = seqlet.contrib_scores + assert_array_almost_equal(fwd[i], func(contrib)) + assert_array_almost_equal(rev[i], func(contrib[::-1, ::-1])) + + +@pytest.mark.parametrize("include_hypothetical", [True, False]) +def test_get_2d_data_from_patterns_revcomp(random_seqlets, include_hypothetical): + fwd, rev = get_2d_data_from_patterns(random_seqlets, + include_hypothetical=include_hypothetical) + + d = fwd.shape[-1] + for i in range(0, d, 4): + assert_array_almost_equal(rev[:, :, i:i+4], fwd[:, ::-1, i:i+4][:, :, ::-1]) + + +def test_get_2d_data_from_patterns_l1_norm(random_seqlets): + fwd, rev = get_2d_data_from_patterns(random_seqlets, transformer='l1') + + assert_array_almost_equal(numpy.abs(fwd[:, :, :4]).sum(axis=(1, 2)), + numpy.ones(4)) + assert_array_almost_equal(numpy.abs(rev[:, :, 4:]).sum(axis=(1, 2)), + numpy.ones(4)) + + +def test_get_2d_data_from_patterns_seqlet_sets(random_seqlets): + patterns = [SeqletSet(random_seqlets[:2]), SeqletSet(random_seqlets[2:])] + fwd, rev = get_2d_data_from_patterns(patterns) + + assert fwd.shape == (2, 10, 8) + assert_array_almost_equal(fwd[0, :, 4:], l1(patterns[0].contrib_scores)) + assert_array_almost_equal(fwd[1, :, :4], + l1(patterns[1].hypothetical_contribs)) + + +def test_get_2d_data_from_patterns_real(seqlets): + fwd, rev = get_2d_data_from_patterns(seqlets, transformer='magnitude') + + assert fwd.shape == (551, 30, 8) + assert rev.shape == (551, 30, 8) + assert fwd.dtype == numpy.float32 + + assert_array_almost_equal(fwd[0, 10:13], [ + [-0.0146, 0.0622, -0.0045, -0.0432, -0.0232, -0.0232, -0.0287, -0.0232], + [ 0.0144, -0.013 , -0.0022, 0.0007, -0.0057, -0.0232, -0.0232, -0.0232], + [-0.0373, -0.0119, 0.0668, -0.0177, -0.0232, -0.0232, 0.0579, -0.0232] + ], 4) + + assert_array_almost_equal(rev[-1, 14:16], [ + [ 0.0381, -0.0083, -0.0169, -0.0129, 0.0189, -0.0281, -0.0281, -0.0281], + [-0.0977, -0.1159, 0.3375, -0.1239, -0.0281, -0.0281, 0.3877, -0.0281] + ], 4) + + +def test_get_2d_data_from_patterns_empty(): + fwd, rev = get_2d_data_from_patterns([]) + assert fwd.shape == (0,) + assert rev.shape == (0,) + + +## + + +@pytest.mark.parametrize("center,window_size,expected", [(200, 400, (0, 400)), + (200, 200, (100, 300)), (1057, 2, (1056, 1058)), (5, 10, (0, 10)), + (0, 0, (0, 0)), (50, 30, (35, 65)), (1000, 1000, (500, 1500))]) +def test_calculate_window_offsets(center, window_size, expected): + assert calculate_window_offsets(center, window_size) == expected + + +@pytest.mark.parametrize("window_size", [2, 10, 50, 400, 1000]) +def test_calculate_window_offsets_width(window_size): + start, end = calculate_window_offsets(1057, window_size) + assert end - start == window_size + + +@pytest.mark.skip(reason="bug: calculate_window_offsets returns a window of " + "width window_size - 1 when window_size is odd, e.g. (8, 12) for " + "center=10, window_size=5") +@pytest.mark.parametrize("window_size", [1, 5, 21, 401]) +def test_calculate_window_offsets_width_odd(window_size): + start, end = calculate_window_offsets(1057, window_size) + assert end - start == window_size + + +@pytest.mark.parametrize("center", [-5, 0, 3]) +def test_calculate_window_offsets_negative(center): + assert calculate_window_offsets(center, 20) == (center - 10, center + 10) + + +## + + +@pytest.fixture +def peak_rows(): + return ['chr1\t1\t2\tpeak1', 'chr2\t1\t2\tpeak2', 'chrX\t5\t9\tpeak3', + 'chr1\t7\t8\tpeak4', 'chr10\t3\t4\tpeak5'] + + +@pytest.mark.parametrize("valid_chroms,expected", [ + (['chr1'], [0, 3]), + (['chr2'], [1]), + (['chr1', 'chrX'], [0, 2, 3]), + (['chrX', 'chr1'], [0, 2, 3]), + (['chr1', 'chr2', 'chrX', 'chr10'], [0, 1, 2, 3, 4]), + (['chr10'], [4]), + (['1'], []), + (['chr'], []), + ([], []) +]) +def test_filter_bed_rows_by_chrom(peak_rows, valid_chroms, expected): + rows = filter_bed_rows_by_chrom(peak_rows, valid_chroms) + assert rows == [peak_rows[i] for i in expected] + + +def test_filter_bed_rows_by_chrom_no_prefix(): + rows = ['1\t0\t10', '2\t0\t10', 'X\t0\t10', '1\t20\t30'] + assert filter_bed_rows_by_chrom(rows, ['1']) == ['1\t0\t10', '1\t20\t30'] + assert filter_bed_rows_by_chrom(rows, ['X', '2']) == ['2\t0\t10', 'X\t0\t10'] + + +def test_filter_bed_rows_by_chrom_empty_rows(): + assert filter_bed_rows_by_chrom([], ['chr1']) == [] + assert filter_bed_rows_by_chrom([''], ['chr1']) == [] + assert filter_bed_rows_by_chrom([''], ['']) == [''] + + +def test_filter_bed_rows_by_chrom_spaces(): + # Only tabs delimit columns. + rows = ['chr1 0 10', 'chr1\t0\t10'] + assert filter_bed_rows_by_chrom(rows, ['chr1']) == ['chr1\t0\t10']