diff options
| author | Karsten Schöke <karsten.schoeke@geobasis-bb.de> | 2026-06-03 08:58:42 +0200 |
|---|---|---|
| committer | git-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com> | 2026-06-03 22:38:31 +0000 |
| commit | ed751d0afa97b7bf6a8e0a96bd4fc87d083fe122 (patch) | |
| tree | 4e2fd31b1d707a6ca0b45c13d2917f6ea1813cf9 | |
| parent | 7f64489e4a6e1a00d20b66cfac0e56dce810f577 (diff) | |
| parent | 365d9b832318110594aa5f72b6ec9cad94bc2561 (diff) | |
0.12.1-1 (patches applied)applied/0.12.1-1applied/ubuntu/stonking-proposedapplied/ubuntu/stonking-develapplied/ubuntu/stonkingapplied/ubuntu/develapplied/debian/sid
Imported using git-ubuntu import.
57 files changed, 6448 insertions, 4287 deletions
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..c0f911d --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,53 @@ +name: Checks + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: test (python ${{ matrix.python-version }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' + - '3.13' + - '3.14' + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest wheel + - name: Install treetime + run: python -m pip install . + - name: Run tests + run: bash test.sh + + lint: + name: lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.14' + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint ruff colorama pygments + - name: Run lint + run: make lint diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951cd6e..f5701ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,30 +12,5 @@ concurrency: cancel-in-progress: true jobs: - test: - name: 'test with python ${{ matrix.python-version }}' - runs-on: ubuntu-20.04 - strategy: - fail-fast: false - matrix: - python-version: - - '3.7' - - '3.8' - - '3.9' - - '3.10' - - '3.11' - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest wheel - - name: Install treetime - run: python -m pip install . - - name: Run tests - run: bash test.sh + checks: + uses: ./.github/workflows/checks.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a6aacaa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: release-${{ github.sha }} + cancel-in-progress: false + +jobs: + checks: + uses: ./.github/workflows/checks.yml + + github-release: + name: GitHub Release + needs: checks + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Extract release notes + run: python3 scripts/extract-release-notes changelog.md > /tmp/notes.md + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${{ github.ref_name }}" &>/dev/null; then + gh release edit "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file /tmp/notes.md + else + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file /tmp/notes.md \ + --target "${{ github.sha }}" + fi + + pypi: + name: Publish to PyPI + needs: checks + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + python -m pip install --upgrade pip + pip install build twine + python -m build + twine upload dist/* @@ -1,57 +1,74 @@ [MASTER] -ignore=.git .idea .input .output .temp .venv .vscode +py-version=3.7 +ignore=.git,.idea,.input,.output,.temp,.venv,.vscode +load-plugins=pylint.extensions.no_self_use [MESSAGES CONTROL] disable= - bad-continuation, - bad-whitespace, + arguments-renamed, + attribute-defined-outside-init, bare-except, + broad-exception-caught, + broad-exception-raised, chained-comparison, + consider-using-enumerate, + consider-using-f-string, + consider-using-generator, + consider-using-get, consider-using-in, + consider-using-set-comprehension, + consider-using-with, + cyclic-import, + deprecated-class, + duplicate-code, + f-string-without-interpolation, fixme, + import-error, + import-outside-toplevel, invalid-name, len-as-condition, line-too-long, missing-docstring, multiple-imports, + multiple-statements, no-else-continue, no-else-raise, no-else-return, + no-member, no-self-use, protected-access, + raise-missing-from, + redefined-outer-name, + reimported, + simplifiable-if-expression, + simplifiable-if-statement, + singleton-comparison, + super-with-arguments, + superfluous-parens, too-many-arguments, too-many-branches, - too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-nested-blocks, + too-many-positional-arguments, too-many-public-methods, too-many-statements, trailing-newlines, + trailing-whitespace, unidiomatic-typecheck, unnecessary-comprehension, + unnecessary-dunder-call, + unnecessary-lambda-assignment, unnecessary-pass, - useless-object-inheritance, - - duplicate-code, - + unspecified-encoding, unused-argument, unused-import, unused-variable, - - simplifiable-if-expression, - simplifiable-if-statement, - singleton-comparison, - - attribute-defined-outside-init, - multiple-statements, - redefined-outer-name, - - cyclic-import, - import-error, - import-outside-toplevel, - reimported, + unused-wildcard-import, + use-a-generator, + useless-object-inheritance, + wildcard-import, wrong-import-order, wrong-import-position, @@ -59,4 +76,12 @@ disable= bad-names=foo,baz,toto,tutu,tata,let,const,nil,null,define good-names=a,b,c,e,f,g,i,j,k,x,y,z,ex,Run,_,__,___ -extension-pkg-whitelist=numpy +extension-pkg-allow-list=numpy + +[REPORTS] +output-format=colorized +reports=no +score=no + +[FORMAT] +max-line-length=120 @@ -1,15 +1,39 @@ -include .env.example -include .env -export UID=$(shell id -u) -export GID=$(shell id -g) - -export DOCS_CONTAINER_NAME=treetime-docs +MAKE_NOPRINT := $(MAKE) --no-print-directory SHELL := bash .ONESHELL: +.SHELLFLAGS := -euo pipefail -c +.SILENT: + +.PHONY: docs docker-docs lint format + +define RUN_COLOR + script -qfec '$(1); ec=$$?; exit $$ec' /dev/null +endef + +lint l: + @parallel --jobs=0 --line-buffer --tag "$(MAKE_NOPRINT) {}" ::: \ + lint-pylint \ + lint-ruff-check \ + lint-ruff-format \ + +lint-pylint: + $(call RUN_COLOR, PYTHONPATH=. pylint --fail-under=10.0 --output-format=pylint_source_reporter.SourceCodeReporter treetime) + +lint-ruff-check: + $(call RUN_COLOR, ruff check -q treetime) + +lint-ruff-format: + $(call RUN_COLOR, ruff format -q --check treetime) + +lint-pyright: + $(call RUN_COLOR, pyright) -.PHONY: docs docker-docs +format fmt f: + @ruff format . docs: @$(MAKE) --no-print-directory -C docs/ html @@ -18,7 +42,9 @@ docs-clean: rm -rf docs/build docker-docs: - set -euox + export DOCS_CONTAINER_NAME=treetime-docs + export UID=$(shell id -u) + export GID=$(shell id -g) docker build -t $${DOCS_CONTAINER_NAME} \ --network=host \ diff --git a/benchmarking/sequence_algorithms.py b/benchmarking/sequence_algorithms.py index cfc0035..9b3f251 100644 --- a/benchmarking/sequence_algorithms.py +++ b/benchmarking/sequence_algorithms.py @@ -5,10 +5,10 @@ from Bio import Phylo if __name__ == '__main__': from treetime.seq_utils import normalize_profile, prof2seq, seq2prof - from treetime.gtr import GTR + from treetime.gtr import GTR gtr = GTR.standard('JC69') - dummy_prof = np.random.random(size=(10000,5)) + dummy_prof = np.random.random(size=(10000, 5)) # used a lot (300us) norm_prof = normalize_profile(dummy_prof)[0] diff --git a/benchmarking/timetree_algorithms.py b/benchmarking/timetree_algorithms.py index a6a28e4..a07eeb1 100644 --- a/benchmarking/timetree_algorithms.py +++ b/benchmarking/timetree_algorithms.py @@ -7,15 +7,21 @@ from treetime.node_interpolator import Distribution, NodeInterpolator if __name__ == '__main__': - base_name = 'test/treetime_examples/data/h3n2_na/h3n2_na_20' - dates = parse_dates(base_name+'.metadata.csv') - tt = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', use_fft=True, - aln = base_name+'.fasta', verbose = 3, dates = dates, debug=True) + dates = parse_dates(base_name + '.metadata.csv') + tt = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + use_fft=True, + aln=base_name + '.fasta', + verbose=3, + dates=dates, + debug=True, + ) # rerooting can be done along with the tree time inference - tt.run(root="best", branch_length_mode='input', max_iter=2, time_marginal=True) + tt.run(root='best', branch_length_mode='input', max_iter=2, time_marginal=True) # initialize date constraints and branch length interpolators # this called in each iteration. 44ms @@ -25,36 +31,58 @@ if __name__ == '__main__': # joint inference of node times. done in every generation. 0.7s tt._ml_t_joint() # individual steps in joint inference - post-order - msgs_to_multiply = [child.joint_pos_Lx for child in tt.tree.root.clades - if child.joint_pos_Lx is not None] + msgs_to_multiply = [child.joint_pos_Lx for child in tt.tree.root.clades if child.joint_pos_Lx is not None] # 330us subtree_distribution = Distribution.multiply(msgs_to_multiply) # 30ms (there are 19 nodes here, so about 20 internal branches -> 1s) - res, res_t = NodeInterpolator.convolve(subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, max_or_integral='max', inverse_time=True, n_grid_points = tt.node_grid_points, n_integral=tt.n_integral, rel_tol=tt.rel_tol_refine) - + res, res_t = NodeInterpolator.convolve( + subtree_distribution, + tt.tree.root.clades[1].branch_length_interpolator, + max_or_integral='max', + inverse_time=True, + n_grid_points=tt.node_grid_points, + n_integral=tt.n_integral, + rel_tol=tt.rel_tol_refine, + ) ########################################################### # marginal inference. done only for confidence estimation: 2.7s tt._ml_t_marginal() # individual steps in marginal inference - post-order - msgs_to_multiply = [child.marginal_pos_Lx for child in tt.tree.root.clades - if child.marginal_pos_Lx is not None] + msgs_to_multiply = [child.marginal_pos_Lx for child in tt.tree.root.clades if child.marginal_pos_Lx is not None] # 330us subtree_distribution = Distribution.multiply(msgs_to_multiply) # 60ms (there are 19 nodes here, so about 20 internal branches -> 1s) - res, res_t = NodeInterpolator.convolve(subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, max_or_integral='integral', inverse_time=True, n_grid_points = tt.node_grid_points, n_integral=tt.n_integral, rel_tol=tt.rel_tol_refine) + res, res_t = NodeInterpolator.convolve( + subtree_distribution, + tt.tree.root.clades[1].branch_length_interpolator, + max_or_integral='integral', + inverse_time=True, + n_grid_points=tt.node_grid_points, + n_integral=tt.n_integral, + rel_tol=tt.rel_tol_refine, + ) # 80ms (there are 19 nodes here, so about 20 internal branches -> 1s) - res, res_t = NodeInterpolator.convolve(subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, max_or_integral='integral', inverse_time=False, n_grid_points = tt.node_grid_points, n_integral=tt.n_integral, rel_tol=tt.rel_tol_refine) + res, res_t = NodeInterpolator.convolve( + subtree_distribution, + tt.tree.root.clades[1].branch_length_interpolator, + max_or_integral='integral', + inverse_time=False, + n_grid_points=tt.node_grid_points, + n_integral=tt.n_integral, + rel_tol=tt.rel_tol_refine, + ) # 1ms (there are 19 nodes here, so about 20 internal branches) - res = NodeInterpolator.convolve_fft(subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, inverse_time=True) + res = NodeInterpolator.convolve_fft( + subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, inverse_time=True + ) # 1ms (there are 19 nodes here, so about 20 internal branches) - res = NodeInterpolator.convolve_fft(subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, inverse_time=False) + res = NodeInterpolator.convolve_fft( + subtree_distribution, tt.tree.root.clades[1].branch_length_interpolator, inverse_time=False + ) # This points towards the convolution being the biggest computational expense. - - - diff --git a/changelog.md b/changelog.md index aa08c3a..c090446 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,18 @@ +# 0.12.1 + +- fix `infer_ancestral_sequences` silently falling back to joint reconstruction instead of marginal in all iterations after the first when `branch_length_mode='marginal'` [issue #601](https://github.com/neherlab/treetime/issues/601) [PR #602](https://github.com/neherlab/treetime/pull/602) + +# 0.12.0 + +- fix `treetime clock` not writing `molecular_clock.txt` with the rate estimate, despite documentation listing it among output files. Also fix copy-paste message in `treetime timetree` that said "Inferred sequence evolution model" for the molecular clock file. Reported by @1ucyb [issue #574](https://github.com/neherlab/treetime/issues/574), fixed by @ivan-aksamentov [PR #575](https://github.com/neherlab/treetime/pull/575) +- drop Python 3.7 from supported versions + +# 0.11.5 + +- fix `fill_overhangs` filling terminal gaps with `N` (asparagine) instead of `X` (ambiguous) for amino acid sequences by @jbloom [issue #522](https://github.com/neherlab/treetime/issues/522) [PR #523](https://github.com/neherlab/treetime/pull/523) +- fix crash when combining `branch_length_mode='marginal'` with `stochastic_resolve=True`. Reported by @jameshadfield [issue #516](https://github.com/neherlab/treetime/issues/516), fixed by @ivan-aksamentov [PR #517](https://github.com/neherlab/treetime/pull/517) +- fix `KeyError: 1` when loading mugration weights CSV with pandas 3.x by @ivan-aksamentov [PR #518](https://github.com/neherlab/treetime/pull/518) + # 0.11.4: Bug fixed - fix output of mutations into the `branch_mutation.txt` file which was masked by a conditional - adjust CLI help diff --git a/contributing.md b/contributing.md index c666baa..3d0697d 100644 --- a/contributing.md +++ b/contributing.md @@ -7,7 +7,39 @@ We welcome pull-requests that fix bugs or implement new features. If you come across a bug or unexpected behavior, please file an issue. ## Testing -Upon pushing a commit, travis will run a few simple tests. These use data available in the [neherlab/treetime_examples](https://github.com/neherlab/treetime_examples) repository. +Upon pushing a commit, GitHub Actions runs tests across supported Python versions and lint checks. Tests use data from the [neherlab/treetime_examples](https://github.com/neherlab/treetime_examples) repository. + +## Releasing + +### Steps + +1. Add a `# Unreleased` heading at the top of `changelog.md` followed by bullet points describing the changes. Commit to master (directly or via PR). +2. Run the release script from the repo root: + ```bash + ./release 0.x.y + ``` +3. Review the diff shown by the script, then confirm to push. +4. Watch CI complete at https://github.com/neherlab/treetime/actions. On success, the [GitHub Release](https://github.com/neherlab/treetime/releases) and the [PyPI package](https://pypi.org/project/phylo-treetime/) are created automatically. + +### How it works + +The [`release`](release) script: +- Validates the working tree: clean, on master, up-to-date with origin, tag absent, `# Unreleased` present in changelog. +- Bumps the version in [`treetime/__init__.py`](treetime/__init__.py). +- Renames the `# Unreleased` heading to `# 0.x.y` in [`changelog.md`](changelog.md). +- Commits (`chore: release 0.x.y`) and creates tag `v0.x.y`. +- Pushes the commit and tag after confirmation. + +The tag push triggers the [release CI workflow](.github/workflows/release.yml), which: +- Runs the full test matrix and lint. +- Extracts the changelog section using [`scripts/extract-release-notes`](scripts/extract-release-notes) and creates a [GitHub Release](https://github.com/neherlab/treetime/releases). +- Builds and publishes the package to [PyPI](https://pypi.org/project/phylo-treetime/) using the `PYPI_API_TOKEN` repository secret. + + To verify the secret is present: + + ```bash + gh secret list + ``` ## Coding conventions (loosly adhered to) diff --git a/debian/changelog b/debian/changelog index 0e22c29..61bdd03 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,17 @@ +python-treetime (0.12.1-1) unstable; urgency=medium + + * Team upload + * New upstream version 0.12.1 + * remove obsolete syntaxwarning.patch + * Standards-Version: 4.7.4 (routine-update) + * Reflow Uploaders field (cme) + * Remove Priority field (cme) + * Drop 'Rules-Requires-Root: no' from d/control (routine-update) + * Set upstream metadata fields: Bug-Database, Repository, Repository-Browse. + * d/watch: Rewrite in v5 format. + + -- Karsten Schöke <karsten.schoeke@geobasis-bb.de> Wed, 03 Jun 2026 08:58:42 +0200 + python-treetime (0.11.4-1) unstable; urgency=medium * New upstream version 0.11.4 diff --git a/debian/control b/debian/control index 1f8e989..1f7c40e 100644 --- a/debian/control +++ b/debian/control @@ -1,35 +1,38 @@ Source: python-treetime +Standards-Version: 4.7.4 Maintainer: Debian Med Packaging Team <debian-med-packaging@lists.alioth.debian.org> -Uploaders: Andreas Tille <tille@debian.org>, - Étienne Mollier <emollier@debian.org> +Uploaders: + Andreas Tille <tille@debian.org>, + Étienne Mollier <emollier@debian.org>, Section: science Testsuite: autopkgtest-pkg-python -Priority: optional -Build-Depends: debhelper-compat (= 13), - dh-sequence-python3, - python3-all, - python3-biopython, - python3-numpy, - python3-pandas, - python3-scipy, - python3-setuptools, - debhelper -Standards-Version: 4.7.0 +Build-Depends: + debhelper-compat (= 13), + dh-sequence-python3, + pybuild-plugin-pyproject, + python3-all, + python3-biopython, + python3-numpy, + python3-pandas, + python3-scipy, + python3-setuptools, + debhelper, Vcs-Browser: https://salsa.debian.org/med-team/python-treetime Vcs-Git: https://salsa.debian.org/med-team/python-treetime.git Homepage: https://github.com/neherlab/treetime -Rules-Requires-Root: no Package: python3-treetime Architecture: all Section: python -Depends: ${python3:Depends}, - ${misc:Depends}, - python3-numpy, - python3-scipy, - python3-biopython, - python3-pandas -Recommends: python3-matplotlib +Depends: + ${python3:Depends}, + ${misc:Depends}, + python3-numpy, + python3-scipy, + python3-biopython, + python3-pandas, +Recommends: + python3-matplotlib, Description: inference of time stamped phylogenies and ancestral reconstruction (Python 3) TreeTime provides routines for ancestral sequence reconstruction and the maximum likelihoo inference of molecular-clock phylogenies, i.e., a tree diff --git a/debian/copyright b/debian/copyright index e884e27..0e07f1e 100644 --- a/debian/copyright +++ b/debian/copyright @@ -11,6 +11,13 @@ Files: debian/* Copyright: 2017-2018 Andreas Tille <tille@debian.org> License: MIT +Files: debian-tests-data/* +Copyright: 2016-208 Pavel Sagulenko, Emma Hodcroft, and Richard Neher +License: MIT +Comment: + Test data shipped in the debian-tests-data component tarball. + These files are covered by the same license as the main source. + License: MIT Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/debian/patches/series b/debian/patches/series index 73e1f04..60611ea 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -1,2 +1 @@ shebang.patch -syntaxwarning.patch diff --git a/debian/patches/syntaxwarning.patch b/debian/patches/syntaxwarning.patch deleted file mode 100644 index 60f7736..0000000 --- a/debian/patches/syntaxwarning.patch +++ /dev/null @@ -1,60 +0,0 @@ -Description: fix SyntaxWarnings in treetime/merger_models.py. - Embedded TeX mathematical snippets are causing the below warnings: - . - /usr/lib/python3/dist-packages/treetime/merger_models.py:187: - SyntaxWarning: invalid escape sequence '\k' - ''' - /usr/lib/python3/dist-packages/treetime/merger_models.py:197: - SyntaxWarning: invalid escape sequence '\l' - ''' - /usr/lib/python3/dist-packages/treetime/merger_models.py:209: - SyntaxWarning: invalid escape sequence '\l' - ''' - . - Switching the affected strings to raw strings with prefix r resolves - the issue first, and second allows getting back our τ characters with - single slash; it is assumed that the duplicate slash was necessary - because Python's regular string would otherwise throw a <Tab>au instead - of a \tau. -Author: Étienne Mollier <emollier@debian.org> -Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1086965 -Forwarded: https://github.com/neherlab/treetime/pull/285 -Last-Update: 2024-11-09 ---- -This patch header follows DEP-3: http://dep.debian.net/deps/dep3/ ---- python-treetime.orig/treetime/merger_models.py -+++ python-treetime/treetime/merger_models.py -@@ -184,7 +184,7 @@ - np.concatenate(([cost[0]], cost,[cost[-1]])), kind='linear') - - def branch_merger_rate(self, t): -- ''' -+ r''' - rate at which one particular branch merges with any other branch at time t, - in the Kingman model this is: :math:`\kappa(t) = (k(t)-1)/(2Tc(t))` - ''' -@@ -194,7 +194,7 @@ - return 0.5*np.maximum(0.5,self.nbranches(t)-1.0)/self.Tc(t) - - def total_merger_rate(self, t): -- ''' -+ r''' - rate at which any branch merges with any other branch at time t, - in the Kingman model this is: :math:`\lambda(t) = k(t)(k(t)-1)/(2Tc(t))` - ''' -@@ -206,12 +206,12 @@ - - - def cost(self, t_node, branch_length, multiplicity=2.0): -- ''' -+ r''' - returns the cost associated with a branch starting with divergence time t_node (:math:`t_n`) -- having a branch length :math:`\\tau`. -+ having a branch length :math:`\tau`. - This is equivalent to the probability of there being no merger on that branch and a merger at the end of the branch, - calculated in the negative log -- :math:`-log(\lambda(t_n+ \\tau)^{(m-1)/m}) + \int_{t_n}^{t_n+ \\tau} \kappa(t) dt`, where m is the multiplicity -+ :math:`-log(\lambda(t_n+ \tau)^{(m-1)/m}) + \int_{t_n}^{t_n+ \tau} \kappa(t) dt`, where m is the multiplicity - - Parameters - ---------- diff --git a/debian/rules b/debian/rules index 9130ab4..d71debe 100755 --- a/debian/rules +++ b/debian/rules @@ -3,17 +3,16 @@ # DH_VERBOSE := 1 export PYBUILD_NAME = treetime +export PYBUILD_AFTER_INSTALL= rm -fr {destdir}/usr/lib/python3*/dist-packages/phylo_treetime-* EXECDIR=debian/python3-$(PYBUILD_NAME)/usr/share/treetime %: dh $@ --buildsystem=pybuild -override_dh_install: - dh_install +execute_after_dh_install: ## enable dh_python finding dependency find debian -name "*.py" -exec sed -i '1s:/usr/local/bin/python:/usr/bin/python:' \{\} \; -override_dh_auto_clean: - dh_auto_clean - rm -rf phylo_treetime.egg-info +#execute_after_dh_auto_clean: +# rm -rf phylo_treetime-*.egg-info diff --git a/debian/upstream/metadata b/debian/upstream/metadata index 60f7e92..225c462 100644 --- a/debian/upstream/metadata +++ b/debian/upstream/metadata @@ -1,3 +1,4 @@ +Bug-Database: https://github.com/neherlab/treetime/issues Registry: - Name: OMICtools Entry: OMICS_14804 @@ -11,3 +12,5 @@ Bug-Database: https://github.com/neherlab/treetime/issues Bug-Submit: https://github.com/neherlab/treetime/issues/new Repository: https://github.com/neherlab/treetime.git Repository-Browse: https://github.com/neherlab/treetime +Repository: https://github.com/neherlab/treetime.git +Repository-Browse: https://github.com/neherlab/treetime diff --git a/debian/watch b/debian/watch index 63a0839..2e0fc06 100644 --- a/debian/watch +++ b/debian/watch @@ -1,3 +1,6 @@ -version=4 -opts="uversionmangle=s/-beta/~beta/,filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%@PACKAGE@-$1.tar.gz%" \ -https://github.com/neherlab/treetime/tags (?:.*?/)?v?@ANY_VERSION@@ARCHIVE_EXT@ +Version: 5 + +Template: Github +Owner: neherlab +Project: treetime +pgpmode: none diff --git a/docs/source/conf.py b/docs/source/conf.py index df8f143..2bf980e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -25,7 +25,7 @@ from treetime import version # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -39,7 +39,7 @@ extensions = [ 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', 'recommonmark', - 'sphinxarg.ext' + 'sphinxarg.ext', ] # Napoleon settings @@ -65,15 +65,15 @@ templates_path = ['_templates'] source_suffix = ['.rst', '.md'] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. -project = u'TreeTime' -copyright = u'2017-2021, Pavel Sagulenko and Richard Neher' -author = u'Pavel Sagulenko and Richard Neher' +project = 'TreeTime' +copyright = '2017-2021, Pavel Sagulenko and Richard Neher' +author = 'Pavel Sagulenko and Richard Neher' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -91,9 +91,9 @@ language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -102,27 +102,27 @@ exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True @@ -140,26 +140,26 @@ html_theme_options = {} # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. # "<project> v<release> documentation" by default. -#html_title = u'TreeTime v1.0' +# html_title = u'TreeTime v1.0' # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -170,64 +170,64 @@ html_static_path = [] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. -#html_last_updated_fmt = None +# html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a <link> tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'TreeTimedoc' @@ -235,59 +235,52 @@ htmlhelp_basename = 'TreeTimedoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'TreeTime.tex', u'TreeTime Documentation', - u'Pavel Sagulenko and Richard Neher', 'manual'), + (master_doc, 'TreeTime.tex', 'TreeTime Documentation', 'Pavel Sagulenko and Richard Neher', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'treetime', u'TreeTime Documentation', - [author], 1) -] +man_pages = [(master_doc, 'treetime', 'TreeTime Documentation', [author], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -296,19 +289,25 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'TreeTime', u'TreeTime Documentation', - author, 'TreeTime', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + 'TreeTime', + 'TreeTime Documentation', + author, + 'TreeTime', + 'One line description of project.', + 'Miscellaneous', + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst index 3f3e7e4..61882e2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,7 +9,7 @@ TreeTime: time-tree and ancestral sequence inference .. image:: https://github.com/neherlab/treetime/actions/workflows/ci.yml/badge.svg?branch=master :target: https://github.com/neherlab/treetime/actions/workflows/ci.yml -.. image:: https://anaconda.org/bioconda/treetime/badges/installer/conda.svg +.. image:: https://img.shields.io/badge/install%20with-bioconda-brightgreen.svg :target: https://anaconda.org/bioconda/treetime TreeTime provides routines for ancestral sequence reconstruction and inference of molecular-clock phylogenies, i.e., a tree where all branches are scaled such that the positions of terminal nodes correspond to their sampling times and internal nodes are placed at the most likely time of divergence. diff --git a/pylint_source_reporter.py b/pylint_source_reporter.py new file mode 100644 index 0000000..7cbef8e --- /dev/null +++ b/pylint_source_reporter.py @@ -0,0 +1,56 @@ +""" +Adds source code snippets to pylint output + +Usage: + +pip3 install pylint colorama pygments + +PYTHONPATH=. pylint treetime/ --output-format=pylint_source_reporter.SourceCodeReporter +""" + +from colorama import Fore, Style, init +from pygments import highlight +from pygments.formatters import Terminal256Formatter +from pygments.lexers import PythonLexer +from pylint.reporters.text import TextReporter + +init(autoreset=True) + + +class SourceCodeReporter(TextReporter): + def handle_message(self, msg): + # Color and bold diagnostic message + level_color = { + 'fatal': Fore.RED, + 'error': Fore.RED, + 'warning': Fore.YELLOW, + 'refactor': Fore.MAGENTA, + 'convention': Fore.CYAN, + 'info': Fore.GREEN, + }.get(msg.category, '') + + self.writeln( + f'{Style.BRIGHT}{level_color}' + f'{msg.msg_id}: {msg.msg} ({msg.symbol}) @ {msg.path}:{msg.line}:{msg.column}' + f'{Style.RESET_ALL}' + ) + + try: + with open(msg.path, encoding='utf-8') as f: + lines = f.readlines() + start = max(msg.line - 5, 0) + end = min(msg.line + 4, len(lines)) + raw_block = ''.join(lines[start:end]) + + highlighted_block = highlight(raw_block, PythonLexer(), Terminal256Formatter(style='material')) + highlighted_lines = highlighted_block.splitlines() + + for i, rendered in enumerate(highlighted_lines, start=start + 1): + prefix = '>>' if i == msg.line else ' ' + style = Style.BRIGHT + Fore.RED if i == msg.line else Style.DIM + self.writeln(f'{style}{prefix} {i:4}:{Style.RESET_ALL} {rendered}') + except Exception: + pass + + self.writeln('') + self.writeln('') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6a6cd47 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,104 @@ +[build-system] +requires = ["setuptools>=40.8.0,<70", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 120 +target-version = "py38" +exclude = [ + ".git", + ".idea", + ".input", + ".output", + ".temp", + ".venv", + ".vscode", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle: basic style issues (indentation, whitespace, etc.) + "F", # pyflakes: logical errors (unused variables, undefined names) + "B", # flake8-bugbear: likely bugs and design issues + # "I", # isort: import order and grouping + "UP", # pyupgrade: use modern Python idioms (e.g., f-strings) + # "SIM", # flake8-simplify: code simplifications (e.g., redundant conditions) + "C4", # flake8-comprehensions: efficient and readable comprehensions + "PIE", # flake8-pie: misc cleanups and best practices + "RUF", # Ruff-specific rules: idiomatic Python and internal consistency + "PL" # pylint +] + + +extend-ignore = [ + "ARG002", # unused-argument + "B006", # mutable default argument + "B007", # loop variable override + "B008", # function call in argument defaults + "B904", # raise from inside `except` to preserve exception context + "B905", # zip() without an explicit `strict=` parameter + "C403", # unnecessary list comprehension (use set comprehension) + "C405", # unnecessary list literal (use set literal) + "C416", # unnecessary dict comprehension (use dict constructor instead) + "C419", # unnecessary list comprehension + "E401", # multiple imports on one line + "E402", # module level import not at top + "E501", # line too long + "E701", # multiple statements on one line (colon) + "E711", # comparison to `None` should use `is` or `is not` + "E712", # avoid comparing directly to `True` or `False` + "E721", # use `isinstance()` or `is`/`is not` for type comparisons + "E722", # do not use bare `except` + "E731", # do not assign a lambda expression, use `def` + "E741", # ambiguous variable name + "F401", # unused import + "F403", # wildcard import + "F405", # wildcard import undefined name + "F541", # f-string without any placeholders + "F841", # unused variable + "PERF401", # unnecessary comprehension + "PIE790", # unnecessary `pass` statement + "PIE804", # unnecessary lambda assignment + "PLC0415", # import not at top-level (deferred imports are idiomatic) + "PLC1802", # len(x) used as condition without explicit comparison + "PLR0911", # too many return statements + "PLR0912", # too many branches + "PLR0913", # too many arguments + "PLR0914", # too many local variables + "PLR0915", # too many statements + "PLR1714", # consider merging multiple comparisons + "PLR1722", # use-a-generator + "PLR1733", # unnecessary dict index lookup + "PLR2004", # magic value used in comparison, consider replacing with a named constant + "PLR2044", # line contains an empty comment + "PLR5501", # consider-using-with + "PLW0120", # redefined-outer-name + "PLW0603", # global-statement + "RUF001", # ambiguous-unicode + "RUF002", # ambiguous character in docstring (e.g., EN DASH vs HYPHEN) + "RUF005", # prefer iterable unpacking over concatenation + "RUF012", # mutable default for dataclass field + "RUF015", # prefer `next(...)` over single-element slice + "RUF019", # unnecessary key check before dictionary access + "RUF034", # useless `if`-`else` condition + "RUF059", # unused unpacked variable + "SIM101", # simplifiable-if-statement + "SIM102", # nested-if can be simplified + "SIM103", # if-else can be simplified + "SIM115", # use a context manager for opening files + "TRY003", # avoid specifying long exception messages + "UP004", # class inherits from `object` unnecessarily + "UP008", # use `super()` instead of `super(__class__, self)` + "UP009", # UTF-8 encoding declaration is unnecessary + "UP015", # unnecessary open(mode='r') + "UP031", # use format specifiers instead of percent formatting + "UP032", # use f-string instead of `.format()` call + "UP035", # import from `collections.abc` instead of `collections` +] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # ignore unused imports in package inits diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..8a529e1 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,19 @@ +{ + "pythonVersion": "3.7", + "typeCheckingMode": "basic", + "reportMissingImports": true, + "reportMissingTypeStubs": false, + "reportUnknownMemberType": false, + "useLibraryCodeForTypes": true, + "exclude": [ + "**/__pycache__", + "**/node_modules", + ".git", + ".idea", + ".input", + ".output", + ".temp", + ".venv", + ".vscode" + ] +} @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# +# Usage: release <version> +# +# Prepare a release: bump version in code and changelog, commit, tag, and +# push. CI then runs checks, creates the GitHub Release, and publishes to PyPI. +# +# The changelog must have a '# Unreleased' or '# Unreleased: <title>' line +# before running. That line is renamed to '# <version>' or '# <version>: <title>'. +# +# Arguments: +# <version> Version to release in MAJOR.MINOR.PATCH format +# +# Options: +# -h, --help Show this help +# +set -euo pipefail +trap "exit" INT + +THIS_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" + +ask_yes_no() { + local prompt="$1" + local choice= + while [[ "${choice}" != "y" ]]; do + read -r -p "${prompt} [y/n] " choice + case "${choice}" in + y|Y) return 0 ;; + n|N) printf "Aborted.\n"; exit 0 ;; + *) printf "Type 'y' or 'n'.\n" ;; + esac + done +} + +main() { + show_help() { + sed -n '2,${/^#/!q; s/^# \?//p}' "${BASH_SOURCE[0]}" | sed "s|\$0|${0##*/}|g" + } + + [[ "${1:-}" =~ ^(-h|--help)$ ]] && { show_help; exit 0; } + + if [[ $# -ne 1 ]]; then + show_help >&2 + exit 1 + fi + + local version="$1" + local tag="v${version}" + + printf "Pre-check: version format... " + if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf "\nInvalid version '%s': expected MAJOR.MINOR.PATCH\n" "${version}" >&2 + exit 1 + fi + printf "✅\n" + + printf "Pre-check: required tools (git, python3)... " + for tool in git python3; do + if ! command -v "${tool}" &>/dev/null; then + printf "\nRequired tool not found: %s\n" "${tool}" >&2 + exit 1 + fi + done + printf "✅\n" + + printf "Pre-check: working tree is clean (except changelog.md)... " + if ! git -C "${THIS_DIR}" diff --quiet -- ':!changelog.md' \ + || ! git -C "${THIS_DIR}" diff --cached --quiet -- ':!changelog.md'; then + printf "\nWorking tree has uncommitted changes outside changelog.md. Commit or stash before releasing.\n" >&2 + exit 1 + fi + printf "✅\n" + + printf "Pre-check: on master branch... " + local branch + branch=$(git -C "${THIS_DIR}" rev-parse --abbrev-ref HEAD) + if [[ "${branch}" != "master" ]]; then + printf "\nNot on master branch (current: %s)\n" "${branch}" >&2 + exit 1 + fi + printf "✅\n" + + printf "Pre-check: not behind origin/master... " + git -C "${THIS_DIR}" fetch origin --quiet + if ! git -C "${THIS_DIR}" merge-base --is-ancestor origin/master HEAD; then + printf "\nLocal master is behind or has diverged from origin/master. Pull first.\n" >&2 + exit 1 + fi + printf "✅\n" + + printf "Pre-check: tag %s does not exist on origin... " "${tag}" + if git -C "${THIS_DIR}" ls-remote --tags origin | grep -q "refs/tags/${tag}$"; then + printf "\nTag %s already exists on origin\n" "${tag}" >&2 + exit 1 + fi + printf "✅\n" + + printf "Pre-check: changelog has '# Unreleased' section... " + if ! grep -qE "^# Unreleased" "${THIS_DIR}/changelog.md"; then + printf "\nchangelog.md does not have a '# Unreleased' section.\n" >&2 + printf "Add '# Unreleased: <title>' above the previous release and retry.\n" >&2 + exit 1 + fi + printf "✅\n" + + printf "Setting version in treetime/__init__.py to %s... " "${version}" + sed -i "s/^version = '.*'/version = '${version}'/" "${THIS_DIR}/treetime/__init__.py" + if ! grep -q "^version = '${version}'" "${THIS_DIR}/treetime/__init__.py"; then + printf "\nFailed to set version in treetime/__init__.py\n" >&2 + exit 1 + fi + printf "✅\n" + + printf "Updating changelog.md: '# Unreleased' -> '# %s'... " "${version}" + # Preserves any trailing ': <title>' on the Unreleased line. + sed -i "s/^# Unreleased/# ${version}/" "${THIS_DIR}/changelog.md" + if ! grep -qE "^# ${version}" "${THIS_DIR}/changelog.md"; then + printf "\nFailed to update changelog.md\n" >&2 + exit 1 + fi + printf "✅\n" + + printf "\nChanges staged for release commit:\n\n" + git -C "${THIS_DIR}" --no-pager diff --unified=3 -- treetime/__init__.py changelog.md + printf "\n" + + printf "Committing... " + git -C "${THIS_DIR}" add treetime/__init__.py changelog.md + git -C "${THIS_DIR}" commit -m "chore: release ${version}" --quiet + printf "✅\n" + + printf "Creating tag %s... " "${tag}" + git -C "${THIS_DIR}" tag "${tag}" + printf "✅\n" + + printf "\nRelease %s is ready locally.\n" "${version}" + ask_yes_no "Push commit and tag to origin?" + + git -C "${THIS_DIR}" push origin master --quiet + git -C "${THIS_DIR}" push origin "${tag}" --quiet + printf "Tag %s pushed. CI will run checks, create the GitHub Release, and publish to PyPI.\n" "${tag}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/extract-release-notes b/scripts/extract-release-notes new file mode 100755 index 0000000..68fa852 --- /dev/null +++ b/scripts/extract-release-notes @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Extract the topmost versioned section from changelog.md and print to stdout. + +The changelog uses level-1 headings: '# X.Y.Z' or '# X.Y.Z: title'. +The '# Unreleased' heading is skipped. The first versioned heading found +and all lines up to (but not including) the next heading are extracted. +""" +import sys + + +def extract(path: str) -> str: + lines: list[str] = [] + inside = False + with open(path) as f: + for line in f: + if line.startswith("# ") and not line.startswith("# Unreleased"): + if inside: + break + inside = True + # skip the heading — GitHub Release title already carries the version + elif inside: + lines.append(line) + return "".join(lines).strip() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} <changelog.md>", file=sys.stderr) + sys.exit(1) + notes = extract(sys.argv[1]) + if not notes: + print("No versioned release section found in changelog", file=sys.stderr) + sys.exit(1) + print(notes) @@ -1,51 +1,46 @@ from setuptools import setup + def get_version(): - v = "0.0.0" + v = '0.0.0' with open('treetime/__init__.py') as ifile: for line in ifile: - if line[:7]=='version': + if line[:7] == 'version': v = line.split('=')[-1].strip()[1:-1] break return v -with open("README.md", "r") as fh: + +with open('README.md', 'r') as fh: long_description = fh.read() setup( - name = "phylo-treetime", - version = get_version(), - author = "Pavel Sagulenko, Emma Hodcroft, and Richard Neher", - author_email = "richard.neher@unibas.ch", - description = ("Maximum-likelihood phylodynamic inference"), - long_description = long_description, - long_description_content_type="text/markdown", - license = "MIT", - keywords = "Time-stamped phylogenies, phylogeography, virus evolution", - url = "https://github.com/neherlab/treetime", - packages=['treetime'], - install_requires = [ - 'biopython>=1.67,!=1.77,!=1.78', - 'numpy>=1.10.4', - 'pandas>=0.17.1', - 'scipy>=0.16.1' - ], - extras_require = { - ':python_version >= "3.6"':['matplotlib>=2.0'], - }, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Topic :: Scientific/Engineering :: Bio-Informatics", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], - entry_points = { - "console_scripts": [ - "treetime = treetime.__main__:main", - ] - } - ) - + name='phylo-treetime', + version=get_version(), + author='Pavel Sagulenko, Emma Hodcroft, and Richard Neher', + author_email='richard.neher@unibas.ch', + description=('Maximum-likelihood phylodynamic inference'), + long_description=long_description, + long_description_content_type='text/markdown', + license='MIT', + keywords='Time-stamped phylogenies, phylogeography, virus evolution', + url='https://github.com/neherlab/treetime', + packages=['treetime'], + install_requires=['biopython>=1.67,!=1.77,!=1.78', 'numpy>=1.10.4', 'pandas>=0.17.1', 'scipy>=0.16.1'], + extras_require={ + ':python_version >= "3.6"': ['matplotlib>=2.0'], + }, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Topic :: Scientific/Engineering :: Bio-Informatics', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + ], + entry_points={ + 'console_scripts': [ + 'treetime = treetime.__main__:main', + ] + }, +) diff --git a/test/coalescent_tests.py b/test/coalescent_tests.py index 6f276cc..137d03a 100644 --- a/test/coalescent_tests.py +++ b/test/coalescent_tests.py @@ -13,35 +13,35 @@ from treetime.utils import parse_dates from fft_tests import get_tree_events, get_test_nodes, get_large_differences, compare -def write_to_file(times_and_names, name = 'master.txt'): +def write_to_file(times_and_names, name='master.txt'): with open(name, 'w') as f: - f.write("time \t name \t bad_branch\n") + f.write('time \t name \t bad_branch\n') for t in times_and_names: f.write(str(t[0])) - f.write("\t") + f.write('\t') f.write(t[1]) - f.write("\t") + f.write('\t') f.write(str(t[2])) - f.write("\n") + f.write('\n') f.close() return None + def read_from_file(file_name): times_and_names = [] with open(file_name, 'r') as f: for line in f.readlines()[1:]: - lines = line.split("\n")[0].split("\t") + lines = line.split('\n')[0].split('\t') times_and_names += [[float(lines[0]), lines[1], lines[2]]] return times_and_names - if __name__ == '__main__': plt.ion() - ebola=True - master = False ##should be True for master branch - location_master = '../../TreeTimeMaster/treetime/' ##only needed for test branch + ebola = True + master = False ##should be True for master branch + location_master = '../../TreeTimeMaster/treetime/' ##only needed for test branch if ebola: node_pattern = 'EM_004555' @@ -52,77 +52,101 @@ if __name__ == '__main__': base_name = '../treetime_examples/data/h3n2_na/h3n2_na_20' clock_rate = 0.0028 - seq_kwargs = {"marginal_sequences":True, - "branch_length_mode": 'input', - "sample_from_profile":"root", - "reconstruct_tip_states":False} - tt_kwargs = {'clock_rate':clock_rate, - 'time_marginal':'assign'} - coal_kwargs ={'Tc':10000, - 'time_marginal':'assign'} - - dates = parse_dates(base_name+'.metadata.csv') - tt = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', use_fft=False, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) - - tt._set_branch_length_mode(seq_kwargs["branch_length_mode"]) - tt.infer_ancestral_sequences(infer_gtr=False, marginal=seq_kwargs["marginal_sequences"]) + seq_kwargs = { + 'marginal_sequences': True, + 'branch_length_mode': 'input', + 'sample_from_profile': 'root', + 'reconstruct_tip_states': False, + } + tt_kwargs = {'clock_rate': clock_rate, 'time_marginal': 'assign'} + coal_kwargs = {'Tc': 10000, 'time_marginal': 'assign'} + + dates = parse_dates(base_name + '.metadata.csv') + tt = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + use_fft=False, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) + + tt._set_branch_length_mode(seq_kwargs['branch_length_mode']) + tt.infer_ancestral_sequences(infer_gtr=False, marginal=seq_kwargs['marginal_sequences']) tt.prune_short_branches() - tt.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=tt_kwargs["clock_rate"]) - tt.reroot(root='least-squares', clock_rate=tt_kwargs["clock_rate"]) + tt.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=tt_kwargs['clock_rate']) + tt.reroot(root='least-squares', clock_rate=tt_kwargs['clock_rate']) tt.infer_ancestral_sequences(**seq_kwargs) - tt.make_time_tree(clock_rate=tt_kwargs["clock_rate"], time_marginal=tt_kwargs["time_marginal"]) + tt.make_time_tree(clock_rate=tt_kwargs['clock_rate'], time_marginal=tt_kwargs['time_marginal']) ##should be no difference at this point unless 'joint' is used for "branch_length_mode" tree_events_tt = get_tree_events(tt) if master: write_to_file(tree_events_tt) else: - output_comparison = compare(tree_events_tt, read_from_file(location_master + "master.txt")) + output_comparison = compare(tree_events_tt, read_from_file(location_master + 'master.txt')) large_differences = get_large_differences(output_comparison[1]) ##plot differences for non bad-branches plt.figure() - plt.plot(output_comparison[1][output_comparison[1]['bad_branch']==0].time, output_comparison[1][output_comparison[1]['bad_branch']==0].difference, 'o') + plt.plot( + output_comparison[1][output_comparison[1]['bad_branch'] == 0].time, + output_comparison[1][output_comparison[1]['bad_branch'] == 0].difference, + 'o', + ) # add coalescent model, assume there will be some changes after this point - tt.add_coalescent_model(coal_kwargs ["Tc"]) - tt.make_time_tree(clock_rate=tt_kwargs ["clock_rate"], time_marginal=coal_kwargs ["time_marginal"]) + tt.add_coalescent_model(coal_kwargs['Tc']) + tt.make_time_tree(clock_rate=tt_kwargs['clock_rate'], time_marginal=coal_kwargs['time_marginal']) tree_events_tt_post_coal = get_tree_events(tt) if master: write_to_file(tree_events_tt_post_coal) else: - write_to_file(tree_events_tt, "fft_branch"+ ".txt") - output_comparison = compare(tree_events_tt_post_coal, read_from_file(location_master + "master.txt")) + write_to_file(tree_events_tt, 'fft_branch' + '.txt') + output_comparison = compare(tree_events_tt_post_coal, read_from_file(location_master + 'master.txt')) ##plot differences and color according to bad-branch label, save figure plt.figure() groups = output_comparison[1].groupby('bad_branch') color = ['red', 'black', 'yellow', 'green'] for name, group in groups: - plt.plot(output_comparison[1].time, output_comparison[1].difference, marker='o', color=color[int(name)], linestyle='', ms=1, label=name) - plt.xlabel("nodes ranging from root at 0 to most recent") - plt.ylabel("difference time_before_present coalescent branch - master branch") + plt.plot( + output_comparison[1].time, + output_comparison[1].difference, + marker='o', + color=color[int(name)], + linestyle='', + ms=1, + label=name, + ) + plt.xlabel('nodes ranging from root at 0 to most recent') + plt.ylabel('difference time_before_present coalescent branch - master branch') plt.legend() if ebola: - plt.savefig("time_before_present-differences-ebola.png") + plt.savefig('time_before_present-differences-ebola.png') else: - plt.savefig("time_before_present-differences-h3n2_na.png") + plt.savefig('time_before_present-differences-h3n2_na.png') large_differences = get_large_differences(output_comparison[1]) ##plot differences for non bad-branches plt.figure() - plt.plot(output_comparison[1][output_comparison[1]['bad_branch']==0].time, output_comparison[1][output_comparison[1]['bad_branch']==0].difference, 'o') + plt.plot( + output_comparison[1][output_comparison[1]['bad_branch'] == 0].time, + output_comparison[1][output_comparison[1]['bad_branch'] == 0].difference, + 'o', + ) ##draw tree for optical comparison - Phylo.draw(tt.tree, label_func=lambda x:"") + Phylo.draw(tt.tree, label_func=lambda x: '') ##plot LH distributions for optical comparison - if coal_kwargs['time_marginal']=='assign': + if coal_kwargs['time_marginal'] == 'assign': test_node = get_test_nodes([tt], node_pattern)[0] while test_node: if test_node.name != tt.tree.root.name: plt.figure() - t = np.linspace(test_node.time_before_present-0.0001,test_node.time_before_present+0.0001,1000) + t = np.linspace(test_node.time_before_present - 0.0001, test_node.time_before_present + 0.0001, 1000) plt.plot(t, test_node.marginal_pos_LH.prob_relative(t), 'o-', label='new') plt.title(test_node.name) plt.show() - test_node= test_node.up
\ No newline at end of file + test_node = test_node.up diff --git a/test/fft_accuracy.ipynb b/test/fft_accuracy.ipynb index c11c7f6..573805b 100644 --- a/test/fft_accuracy.ipynb +++ b/test/fft_accuracy.ipynb @@ -106,6 +106,7 @@ ], "source": [ "from matplotlib import rcParams\n", + "\n", "rcParams.update({'figure.autolayout': True})\n", "\n", "beta = 100\n", @@ -114,39 +115,39 @@ "\n", "start = 0\n", "end = 1\n", - "x = np.linspace(start, end, num=1000, endpoint=False) #create an x vector of points that should be sampled\n", - "y_1 = gamma.pdf(x, alpha_1, scale=1/beta)\n", - "y_1[y_1 == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "x = np.linspace(start, end, num=1000, endpoint=False) # create an x vector of points that should be sampled\n", + "y_1 = gamma.pdf(x, alpha_1, scale=1 / beta)\n", + "y_1[y_1 == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", "\n", - "fig= plt.figure()\n", + "fig = plt.figure()\n", "plt.autoscale()\n", - "plt.plot(x, gamma.pdf(x, alpha_1, scale=1/beta), label=\"PDF\")\n", - "plt.ylabel(\"PDF Gamma(\"+ str(alpha_1) +\",\"+str(beta)+ \")\")\n", - "plt.title(\"Distribution 1\")\n", + "plt.plot(x, gamma.pdf(x, alpha_1, scale=1 / beta), label='PDF')\n", + "plt.ylabel('PDF Gamma(' + str(alpha_1) + ',' + str(beta) + ')')\n", + "plt.title('Distribution 1')\n", "plt.show()\n", "dist_1 = Distribution(x, y_1, kind='linear', is_log=False)\n", "\n", - "y_2 = gamma.pdf(x, alpha_2, scale=1/beta)\n", - "y_2[y_2 == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "y_2 = gamma.pdf(x, alpha_2, scale=1 / beta)\n", + "y_2[y_2 == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", "\n", "dist_2 = Distribution(x, y_2, kind='linear', is_log=False)\n", "\n", "plt.figure()\n", - "plt.plot(x, gamma.pdf(x, alpha_2, scale=1/beta), color=\"blue\")\n", - "plt.plot(x, np.exp(-dist_2.y), color=\"green\") ##just to check that distribution object is initialized accurately \n", - "plt.ylabel(\"PDF Gamma(\"+ str(alpha_2) +\",\"+str(beta)+ \")\")\n", - "plt.title(\"Distribution 2\")\n", + "plt.plot(x, gamma.pdf(x, alpha_2, scale=1 / beta), color='blue')\n", + "plt.plot(x, np.exp(-dist_2.y), color='green') ##just to check that distribution object is initialized accurately\n", + "plt.ylabel('PDF Gamma(' + str(alpha_2) + ',' + str(beta) + ')')\n", + "plt.title('Distribution 2')\n", "plt.show()\n", "\n", "\n", "alpha_sol = alpha_1 + alpha_2\n", - "y_sol = gamma.pdf(x, alpha_sol, scale=1/beta)\n", - "y_sol[y_sol == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "y_sol = gamma.pdf(x, alpha_sol, scale=1 / beta)\n", + "y_sol[y_sol == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", "dist_sol = Distribution(x, y_sol, kind='linear', is_log=False)\n", "plt.figure()\n", - "plt.plot(x, gamma.pdf(x, alpha_sol, scale=1/beta))\n", - "plt.ylabel(\"PDF Gamma(\"+ str(alpha_sol) +\",\"+str(beta)+ \")\")\n", - "plt.title(\"Convolution Distribution 1 and Distribution 2\")\n", + "plt.plot(x, gamma.pdf(x, alpha_sol, scale=1 / beta))\n", + "plt.ylabel('PDF Gamma(' + str(alpha_sol) + ',' + str(beta) + ')')\n", + "plt.title('Convolution Distribution 1 and Distribution 2')\n", "plt.show()\n", "\n", "plt.figure()" @@ -166,37 +167,38 @@ "outputs": [], "source": [ "def accuracy_and_time(dist_1, dist_2, calc_type, grid_size):\n", - " '''\n", + " \"\"\"\n", " Output: res - convolution output (Distribution object)\n", " conv_time - time needed for convolution\n", " true_grid_size - grid size of convolution output (as grid is set in convolution function and can vary)\n", - " '''\n", - " if calc_type==\"fft\":\n", + " \"\"\"\n", + " if calc_type == 'fft':\n", " start = time.process_time()\n", - " if alpha_1==1 or alpha_2==1:\n", + " if alpha_1 == 1 or alpha_2 == 1:\n", " factor = 100\n", " else:\n", " factor = 50\n", - " res = NodeInterpolator.convolve_fft(dist_1, dist_2, fft_grid_size=int(grid_size/factor))\n", + " res = NodeInterpolator.convolve_fft(dist_1, dist_2, fft_grid_size=int(grid_size / factor))\n", " conv_time = time.process_time() - start\n", " true_grid_size = len(res.y)\n", - " if calc_type==\"num\":\n", + " if calc_type == 'num':\n", " start = time.process_time()\n", - " res = NodeInterpolator.convolve(dist_1, dist_2, n_grid_points=grid_size, n_integral=grid_size)[0] ##second output is grid scale, which is also given in the x parameter\n", + " res = NodeInterpolator.convolve(dist_1, dist_2, n_grid_points=grid_size, n_integral=grid_size)[\n", + " 0\n", + " ] ##second output is grid scale, which is also given in the x parameter\n", " conv_time = time.process_time() - start\n", " true_grid_size = len(res.y)\n", " return res, conv_time, true_grid_size\n", "\n", + "\n", "def get_P(x, neg_log_P):\n", - " '''\n", + " \"\"\"\n", " Given a Distribution object (neg_log_P) this returns the original likelihood function evaluated at points x\n", " As a Distribution object stores the function as the negative loglikelihood of the normalized function\n", - " '''\n", + " \"\"\"\n", " y = neg_log_P(x)\n", " y = np.exp(-y + neg_log_P.peak_val)\n", - " return y\n", - "\n", - " " + " return y" ] }, { @@ -215,19 +217,25 @@ "##in order to perform the convolution dist_2 needs a one_mutation parameter (as this should be a BranchLenInterpolator object)\n", "dist_2.one_mutation = 0\n", "\n", - "grid_size = [100, 200, 300, 500, 1000] #more desired grid_sizes, true grid size is given as output when the accuracy_and_time function is called\n", - "time_fft = np.empty((len(grid_size),2))\n", + "grid_size = [\n", + " 100,\n", + " 200,\n", + " 300,\n", + " 500,\n", + " 1000,\n", + "] # more desired grid_sizes, true grid size is given as output when the accuracy_and_time function is called\n", + "time_fft = np.empty((len(grid_size), 2))\n", "accuracy_fft = []\n", - "time_num = np.empty((len(grid_size),2))\n", + "time_num = np.empty((len(grid_size), 2))\n", "accuracy_num = []\n", "for t in range(len(grid_size)):\n", - " #print(t)\n", - " time_fft_full = accuracy_and_time(dist_1, dist_2, \"fft\", grid_size[t])\n", + " # print(t)\n", + " time_fft_full = accuracy_and_time(dist_1, dist_2, 'fft', grid_size[t])\n", " time_fft[t] = time_fft_full[1:3]\n", " accuracy_fft.append(time_fft_full[0])\n", - " time_num_full = accuracy_and_time(dist_1, dist_2, \"num\", grid_size[t])\n", + " time_num_full = accuracy_and_time(dist_1, dist_2, 'num', grid_size[t])\n", " time_num[t] = time_num_full[1:3]\n", - " accuracy_num.append(time_num_full[0])\n" + " accuracy_num.append(time_num_full[0])" ] }, { @@ -406,41 +414,43 @@ "source": [ "norm_y_sol = get_P(x, dist_sol)\n", "\n", + "\n", "def plot_P(accuracy_list_num, accuracy_list_fft, time_list_num, time_list_fft):\n", " for t in range(len(grid_size)):\n", " eff_support = accuracy_list_fft[t].effective_support\n", - " print(\"grid points NUM (output):\" +str(time_list_num[t][1]))\n", - " norm_y_est_num = get_P(x, accuracy_list_num[t] )\n", - " print(\"grid points FFT (output):\" +str(time_list_fft[t][1]))\n", - " norm_y_est_fft = get_P(x, accuracy_list_fft[t] )\n", + " print('grid points NUM (output):' + str(time_list_num[t][1]))\n", + " norm_y_est_num = get_P(x, accuracy_list_num[t])\n", + " print('grid points FFT (output):' + str(time_list_fft[t][1]))\n", + " norm_y_est_fft = get_P(x, accuracy_list_fft[t])\n", " plt.figure()\n", " plt.rcParams['font.size'] = '6'\n", - " #plt.autoscale()\n", + " # plt.autoscale()\n", " fig.tight_layout()\n", - " #plt.subplot(1,2,1)\n", - " plt.plot(x, norm_y_sol, color= \"green\", label=\"true dist\")\n", - " plt.plot(x, norm_y_est_num, color=\"blue\", label=\"est num dist\")\n", - " plt.plot(x, norm_y_est_fft, color=\"red\", label=\"est fft dist\")\n", - " plt.axvline(x=eff_support[0], label= \"effective support start\")\n", - " plt.axvline(x=eff_support[1], label= \"effective support end\")\n", + " # plt.subplot(1,2,1)\n", + " plt.plot(x, norm_y_sol, color='green', label='true dist')\n", + " plt.plot(x, norm_y_est_num, color='blue', label='est num dist')\n", + " plt.plot(x, norm_y_est_fft, color='red', label='est fft dist')\n", + " plt.axvline(x=eff_support[0], label='effective support start')\n", + " plt.axvline(x=eff_support[1], label='effective support end')\n", " plt.legend()\n", - " plt.ylabel(\"P\", fontsize=7)\n", - " plt.xlim((0,1))\n", + " plt.ylabel('P', fontsize=7)\n", + " plt.xlim((0, 1))\n", " plt.show()\n", " plt.close()\n", " plt.figure()\n", - " #plt.subplot(1,2,2)\n", - " plt.plot(x, norm_y_sol, color= \"green\", label=\"true dist\")\n", - " plt.plot(x, norm_y_est_num, color=\"blue\", label=\"est num dist\")\n", - " plt.plot(x, norm_y_est_fft, color=\"red\", label=\"est fft dist\")\n", - " plt.axvline(x=eff_support[0], label= \"effective support start\")\n", - " plt.axvline(x=eff_support[1], label= \"effective support end\")\n", + " # plt.subplot(1,2,2)\n", + " plt.plot(x, norm_y_sol, color='green', label='true dist')\n", + " plt.plot(x, norm_y_est_num, color='blue', label='est num dist')\n", + " plt.plot(x, norm_y_est_fft, color='red', label='est fft dist')\n", + " plt.axvline(x=eff_support[0], label='effective support start')\n", + " plt.axvline(x=eff_support[1], label='effective support end')\n", " plt.legend()\n", - " plt.xlim((0,1))\n", - " plt.yscale(\"log\")\n", - " plt.ylabel(\"log(P)\", fontsize=7)\n", + " plt.xlim((0, 1))\n", + " plt.yscale('log')\n", + " plt.ylabel('log(P)', fontsize=7)\n", " plt.show()\n", "\n", + "\n", "plot_P(accuracy_num, accuracy_fft, time_num, time_fft)" ] }, @@ -641,26 +651,42 @@ "source": [ "epsilon = 1e-11\n", "\n", + "\n", "def print_relative_diff(t):\n", - " print(\"FFT grid points (output):\" +str(time_fft[t][1]))\n", - " print(\"NUM grid points (output):\" +str(time_num[t][1]))\n", + " print('FFT grid points (output):' + str(time_fft[t][1]))\n", + " print('NUM grid points (output):' + str(time_num[t][1]))\n", " eff_support = accuracy_fft[t].effective_support\n", " norm_y_est_fft = get_P(x, accuracy_fft[t])\n", " norm_y_est_num = get_P(x, accuracy_num[t])\n", " plt.figure()\n", - " plt.plot(x[2:], ((norm_y_est_fft -norm_y_sol)/(norm_y_sol+epsilon))[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", - " plt.plot(x, (norm_y_est_num -norm_y_sol)/(norm_y_sol+epsilon), 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", + " plt.plot(\n", + " x[2:],\n", + " ((norm_y_est_fft - norm_y_sol) / (norm_y_sol + epsilon))[2:],\n", + " 'o',\n", + " markersize=1,\n", + " color='blue',\n", + " label='normalized diff FFT',\n", + " )\n", + " plt.plot(\n", + " x,\n", + " (norm_y_est_num - norm_y_sol) / (norm_y_sol + epsilon),\n", + " 'o',\n", + " markersize=1,\n", + " color='red',\n", + " label='normalized diff NUM',\n", + " )\n", " # plt.plot(x, norm_y_est_fft, 'o', markersize=1, color=\"blue\", label=\"FFT\")\n", " # plt.plot(x, norm_y_est_num, 'o', markersize=1, color=\"red\", label=\"NUM\")\n", " # plt.plot(x, norm_y_sol, 'o', markersize=1, color=\"green\", label=\"analytical sol\")\n", - " plt.axvline(x=eff_support[0], label= \"effective support start\")\n", - " plt.axvline(x=eff_support[1], label= \"effective support end\")\n", - " plt.legend(prop={\"size\":10})\n", - " plt.xlim((0,1))\n", - " #plt.yscale(\"log\")\n", - " plt.ylabel(\"(calculation - analytical_sol)/(analytical_sol + epsilon)\", fontsize=7)\n", - " plt.xlabel(\"Time\", fontsize=7)\n", - " plt.title(\"Normalized Difference Estimated and True Solution\", fontsize=12)\n", + " plt.axvline(x=eff_support[0], label='effective support start')\n", + " plt.axvline(x=eff_support[1], label='effective support end')\n", + " plt.legend(prop={'size': 10})\n", + " plt.xlim((0, 1))\n", + " # plt.yscale(\"log\")\n", + " plt.ylabel('(calculation - analytical_sol)/(analytical_sol + epsilon)', fontsize=7)\n", + " plt.xlabel('Time', fontsize=7)\n", + " plt.title('Normalized Difference Estimated and True Solution', fontsize=12)\n", + "\n", "\n", "def print_relative_diff_grids(plot_support_closeup=True):\n", " for t in range(len(grid_size)):\n", @@ -668,29 +694,55 @@ " plt.show()\n", " eff_support = accuracy_fft[t].effective_support\n", " if plot_support_closeup:\n", - " x_support = np.linspace(eff_support[1]-0.1, eff_support[1]+0.1, num=1000, endpoint=False) #create an x vector of points that should be sampled\n", + " x_support = np.linspace(\n", + " eff_support[1] - 0.1, eff_support[1] + 0.1, num=1000, endpoint=False\n", + " ) # create an x vector of points that should be sampled\n", " norm_y_sol_sup = get_P(x_support, dist_sol)\n", " norm_y_est_fft = get_P(x_support, accuracy_fft[t])\n", - " norm_y_est_num = get_P(x_support, accuracy_num[t]) \n", - " #print(accuracy_fft[t].x)\n", + " norm_y_est_num = get_P(x_support, accuracy_num[t])\n", + " # print(accuracy_fft[t].x)\n", " plt.figure()\n", - " plt.plot(x_support[2:], ((norm_y_est_fft -norm_y_sol_sup)/(norm_y_sol_sup+epsilon))[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", - " #plt.plot(x_support, norm_y_est_num, 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", - " #plt.plot(x_support[2:], norm_y_est_fft[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", - " #plt.plot(x_support, norm_y_sol_sup, 'o', markersize=1, color=\"green\", label=\"normalized diff NUM\")\n", - " plt.plot(x_support, (norm_y_est_num -norm_y_sol_sup)/(norm_y_sol_sup+epsilon), 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", - " plt.ylabel(\"(calculation - analytical_sol)/(analytical_sol + epsilon)\", fontsize=7)\n", - " plt.xlabel(\"Time\", fontsize=7)\n", - " plt.title(\"Normalized Difference Estimated and True Solution\", fontsize=12)\n", - " plt.axvline(x=eff_support[1], label= \"effective support end\")\n", - " plt.legend(prop={\"size\":10})\n", - " #plt.yscale(\"log\")\n", + " plt.plot(\n", + " x_support[2:],\n", + " ((norm_y_est_fft - norm_y_sol_sup) / (norm_y_sol_sup + epsilon))[2:],\n", + " 'o',\n", + " markersize=1,\n", + " color='blue',\n", + " label='normalized diff FFT',\n", + " )\n", + " # plt.plot(x_support, norm_y_est_num, 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", + " # plt.plot(x_support[2:], norm_y_est_fft[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", + " # plt.plot(x_support, norm_y_sol_sup, 'o', markersize=1, color=\"green\", label=\"normalized diff NUM\")\n", + " plt.plot(\n", + " x_support,\n", + " (norm_y_est_num - norm_y_sol_sup) / (norm_y_sol_sup + epsilon),\n", + " 'o',\n", + " markersize=1,\n", + " color='red',\n", + " label='normalized diff NUM',\n", + " )\n", + " plt.ylabel('(calculation - analytical_sol)/(analytical_sol + epsilon)', fontsize=7)\n", + " plt.xlabel('Time', fontsize=7)\n", + " plt.title('Normalized Difference Estimated and True Solution', fontsize=12)\n", + " plt.axvline(x=eff_support[1], label='effective support end')\n", + " plt.legend(prop={'size': 10})\n", + " # plt.yscale(\"log\")\n", " plt.show()\n", - " print(\"Absolute difference at end of effective support: \" +str(get_P(eff_support[1] , accuracy_fft[t])- get_P(eff_support[1], dist_sol)))\n", - " print(\"Absolute difference at end plus epsilon of effective support: \" +str(get_P(eff_support[1] + epsilon, accuracy_fft[t])- get_P(eff_support[1]+epsilon, dist_sol)))\n", - " print(\"Absolute difference at start of effective support: \" +str(get_P(eff_support[0], accuracy_fft[t])- get_P(eff_support[0], dist_sol)))\n", - " \n", - "print_relative_diff_grids(plot_support_closeup=True)\n" + " print(\n", + " 'Absolute difference at end of effective support: '\n", + " + str(get_P(eff_support[1], accuracy_fft[t]) - get_P(eff_support[1], dist_sol))\n", + " )\n", + " print(\n", + " 'Absolute difference at end plus epsilon of effective support: '\n", + " + str(get_P(eff_support[1] + epsilon, accuracy_fft[t]) - get_P(eff_support[1] + epsilon, dist_sol))\n", + " )\n", + " print(\n", + " 'Absolute difference at start of effective support: '\n", + " + str(get_P(eff_support[0], accuracy_fft[t]) - get_P(eff_support[0], dist_sol))\n", + " )\n", + "\n", + "\n", + "print_relative_diff_grids(plot_support_closeup=True)" ] }, { @@ -888,43 +940,65 @@ ], "source": [ "def get_convolve_linear_grid(dist_1, dist_2, pr=False):\n", - " '''\n", - " get the location of the start and end points of the linearly spaced output grid \n", + " \"\"\"\n", + " get the location of the start and end points of the linearly spaced output grid\n", " of the convolve function applied to the Distribution objects dist_1 and dist_2\n", - " '''\n", - " est_new_peak_pos = dist_1.peak_pos + dist_2.peak_pos ##as we have assumed inverse time\n", - " est_joint_fwhm = (dist_1.fwhm + dist_2.fwhm)\n", - " center_width = 3*est_joint_fwhm\n", + " \"\"\"\n", + " est_new_peak_pos = dist_1.peak_pos + dist_2.peak_pos ##as we have assumed inverse time\n", + " est_joint_fwhm = dist_1.fwhm + dist_2.fwhm\n", + " center_width = 3 * est_joint_fwhm\n", " if pr:\n", - " print(\"The bounds for the linear grid should be:\" + str(est_new_peak_pos - center_width) + \"to \" + str(est_new_peak_pos + center_width))\n", + " print(\n", + " 'The bounds for the linear grid should be:'\n", + " + str(est_new_peak_pos - center_width)\n", + " + 'to '\n", + " + str(est_new_peak_pos + center_width)\n", + " )\n", " return max(est_new_peak_pos - center_width, 0), est_new_peak_pos + center_width\n", "\n", + "\n", "def print_relative_diff_grids_lin(print_lin_grid=True):\n", " start_conv_linear, end_conv_linear = get_convolve_linear_grid(dist_1, dist_2, True)\n", " for t in range(len(grid_size)):\n", " eff_support = accuracy_fft[t].effective_support\n", " print_relative_diff(t)\n", - " plt.axvline(x=end_conv_linear, color=\"green\", label= \"linear grid end\")\n", - " plt.legend(prop={\"size\":8})\n", + " plt.axvline(x=end_conv_linear, color='green', label='linear grid end')\n", + " plt.legend(prop={'size': 8})\n", " plt.show()\n", " if print_lin_grid:\n", - "\n", - " x_linear = np.linspace(end_conv_linear-0.1, end_conv_linear+0.1, num=1000, endpoint=False) #create an x vector of points that should be sampled\n", + " x_linear = np.linspace(\n", + " end_conv_linear - 0.1, end_conv_linear + 0.1, num=1000, endpoint=False\n", + " ) # create an x vector of points that should be sampled\n", " norm_y_sol_sup = get_P(x_linear, dist_sol)\n", " norm_y_est_fft = get_P(x_linear, accuracy_fft[t])\n", - " norm_y_est_num = get_P(x_linear, accuracy_num[t]) \n", + " norm_y_est_num = get_P(x_linear, accuracy_num[t])\n", " plt.figure()\n", - " plt.plot(x_linear[2:], ((norm_y_est_fft -norm_y_sol_sup)/(norm_y_sol_sup+epsilon))[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", - " plt.plot(x_linear, (norm_y_est_num -norm_y_sol_sup)/(norm_y_sol_sup+epsilon), 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", - " plt.ylabel(\"(calculation - analytical_sol)/(analytical_sol + epsilon)\", fontsize=7)\n", - " plt.xlabel(\"Time\", fontsize=7)\n", - " plt.title(\"Normalized Difference Estimated and True Solution\", fontsize=12)\n", - " plt.axvline(x=end_conv_linear, color=\"green\", label= \"linear grid end\")\n", - " if (end_conv_linear+0.1 > eff_support[1]):\n", - " plt.axvline(x=eff_support[1], color=\"blue\", label= \"effective support end\")\n", - " plt.legend(prop={\"size\":10})\n", + " plt.plot(\n", + " x_linear[2:],\n", + " ((norm_y_est_fft - norm_y_sol_sup) / (norm_y_sol_sup + epsilon))[2:],\n", + " 'o',\n", + " markersize=1,\n", + " color='blue',\n", + " label='normalized diff FFT',\n", + " )\n", + " plt.plot(\n", + " x_linear,\n", + " (norm_y_est_num - norm_y_sol_sup) / (norm_y_sol_sup + epsilon),\n", + " 'o',\n", + " markersize=1,\n", + " color='red',\n", + " label='normalized diff NUM',\n", + " )\n", + " plt.ylabel('(calculation - analytical_sol)/(analytical_sol + epsilon)', fontsize=7)\n", + " plt.xlabel('Time', fontsize=7)\n", + " plt.title('Normalized Difference Estimated and True Solution', fontsize=12)\n", + " plt.axvline(x=end_conv_linear, color='green', label='linear grid end')\n", + " if end_conv_linear + 0.1 > eff_support[1]:\n", + " plt.axvline(x=eff_support[1], color='blue', label='effective support end')\n", + " plt.legend(prop={'size': 10})\n", " plt.show()\n", "\n", + "\n", "print_relative_diff_grids_lin(print_lin_grid=True)" ] }, @@ -1049,28 +1123,45 @@ " dist_sol._adjust_grid(rel_tol=REL_TOL_PRUNE)\n", " for t in range(len(grid_size)):\n", " eff_support = accuracy_fft[t].effective_support\n", - " print(\"FFT grid points (output):\" +str(time_fft[t][1]))\n", - " print(\"NUM grid points (output):\" +str(time_num[t][1]))\n", + " print('FFT grid points (output):' + str(time_fft[t][1]))\n", + " print('NUM grid points (output):' + str(time_num[t][1]))\n", " accuracy_fft[t]._adjust_grid(rel_tol=REL_TOL_PRUNE)\n", " accuracy_num[t]._adjust_grid(rel_tol=REL_TOL_PRUNE)\n", - " print(\"FFT grid points, after adjusting grid (output):\" +str(len(accuracy_fft[t].y)))\n", - " print(\"NUM grid points, after adjusting grid (output):\" +str(len(accuracy_num[t].y)))\n", - " x_support = np.linspace(eff_support[1]-0.1, eff_support[1]+0.1, num=1000, endpoint=False) #create an x vector of points that should be sampled\n", + " print('FFT grid points, after adjusting grid (output):' + str(len(accuracy_fft[t].y)))\n", + " print('NUM grid points, after adjusting grid (output):' + str(len(accuracy_num[t].y)))\n", + " x_support = np.linspace(\n", + " eff_support[1] - 0.1, eff_support[1] + 0.1, num=1000, endpoint=False\n", + " ) # create an x vector of points that should be sampled\n", " norm_y_sol_sup = get_P(x_support, dist_sol)\n", " norm_y_est_fft = get_P(x_support, accuracy_fft[t])\n", " norm_y_est_num = get_P(x_support, accuracy_num[t])\n", " plt.figure()\n", - " plt.plot(x_support[2:], ((norm_y_est_fft -norm_y_sol_sup)/(norm_y_sol_sup+epsilon))[2:], 'o', markersize=1, color=\"blue\", label=\"normalized diff FFT\")\n", - " plt.plot(x_support, (norm_y_est_num -norm_y_sol_sup)/(norm_y_sol_sup+epsilon), 'o', markersize=1, color=\"red\", label=\"normalized diff NUM\")\n", - " plt.ylabel(\"(calculation - analytical_sol)/(analytical_sol + epsilon)\", fontsize=7)\n", - " plt.xlabel(\"Time\", fontsize=7)\n", - " plt.title(\"Normalized Difference Estimated and True Solution\", fontsize=12)\n", - " plt.axvline(x=eff_support[1], label= \"effective support end\")\n", - " plt.legend(prop={\"size\":10})\n", + " plt.plot(\n", + " x_support[2:],\n", + " ((norm_y_est_fft - norm_y_sol_sup) / (norm_y_sol_sup + epsilon))[2:],\n", + " 'o',\n", + " markersize=1,\n", + " color='blue',\n", + " label='normalized diff FFT',\n", + " )\n", + " plt.plot(\n", + " x_support,\n", + " (norm_y_est_num - norm_y_sol_sup) / (norm_y_sol_sup + epsilon),\n", + " 'o',\n", + " markersize=1,\n", + " color='red',\n", + " label='normalized diff NUM',\n", + " )\n", + " plt.ylabel('(calculation - analytical_sol)/(analytical_sol + epsilon)', fontsize=7)\n", + " plt.xlabel('Time', fontsize=7)\n", + " plt.title('Normalized Difference Estimated and True Solution', fontsize=12)\n", + " plt.axvline(x=eff_support[1], label='effective support end')\n", + " plt.legend(prop={'size': 10})\n", "\n", " plt.show()\n", "\n", - "plot_effects_adjust_grid() " + "\n", + "plot_effects_adjust_grid()" ] }, { @@ -1115,46 +1206,49 @@ } ], "source": [ - "print(MAX_BRANCH_LENGTH) \n", + "print(MAX_BRANCH_LENGTH)\n", + "\n", + "\n", "def get_BranchLenInterpolator_grid(sigma):\n", " n_grid_points = BRANCH_GRID_SIZE\n", - " grid_left = sigma * (1 - np.linspace(1, 0.0, n_grid_points//3)**2.0)\n", - " grid_zero = grid_left[1]*np.logspace(-20,0,6)[:5]\n", - " grid_zero2 = grid_left[1]*np.linspace(0,1,10)[1:-1]\n", + " grid_left = sigma * (1 - np.linspace(1, 0.0, n_grid_points // 3) ** 2.0)\n", + " grid_zero = grid_left[1] * np.logspace(-20, 0, 6)[:5]\n", + " grid_zero2 = grid_left[1] * np.linspace(0, 1, 10)[1:-1]\n", " # from optimal branch length to the right (--> 3*branch lengths),\n", - " grid_right = sigma + (3*sigma*(np.linspace(0, 1, n_grid_points//3)**2))\n", + " grid_right = sigma + (3 * sigma * (np.linspace(0, 1, n_grid_points // 3) ** 2))\n", " # far to the right (3*branch length ---> MAX_LEN), very sparse\n", - " far_grid = grid_right.max() + MAX_BRANCH_LENGTH*np.linspace(0, 1, n_grid_points//3)**2\n", + " far_grid = grid_right.max() + MAX_BRANCH_LENGTH * np.linspace(0, 1, n_grid_points // 3) ** 2\n", "\n", - " grid = np.concatenate((grid_zero,grid_zero2, grid_left,grid_right[1:],far_grid[1:]))\n", - " grid.sort() # just for safety\n", + " grid = np.concatenate((grid_zero, grid_zero2, grid_left, grid_right[1:], far_grid[1:]))\n", + " grid.sort() # just for safety\n", " return grid\n", "\n", + "\n", "sigma_1 = dist_1.peak_pos\n", "sigma_2 = dist_2.peak_pos\n", "x_1 = get_BranchLenInterpolator_grid(sigma_1)\n", "x_2 = get_BranchLenInterpolator_grid(sigma_2)\n", - "y_1_bl = gamma.pdf(x_1, alpha_1, scale=1/beta)\n", - "y_1_bl[y_1_bl == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "y_1_bl = gamma.pdf(x_1, alpha_1, scale=1 / beta)\n", + "y_1_bl[y_1_bl == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", "\n", "dist_1_bl = Distribution(x_1, y_1_bl, kind='linear', is_log=False)\n", "\n", - "y_2_bl = gamma.pdf(x_2, alpha_2, scale=1/beta)\n", - "y_2_bl[y_2_bl == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "y_2_bl = gamma.pdf(x_2, alpha_2, scale=1 / beta)\n", + "y_2_bl[y_2_bl == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", "\n", "dist_2_bl = Distribution(x_2, y_2_bl, kind='linear', is_log=False)\n", "\n", "plt.figure()\n", - "plt.plot(x_2, gamma.pdf(x_2, alpha_2, scale=1/beta), color=\"blue\")\n", - "plt.plot(x_2, np.exp(-dist_2_bl.y), color=\"green\") ##just to check that distribution object is initialized accurately \n", - "plt.ylabel(\"PDF Gamma(\"+ str(alpha_2) +\",\"+str(beta)+ \")\")\n", - "plt.title(\"Distribution 2\")\n", + "plt.plot(x_2, gamma.pdf(x_2, alpha_2, scale=1 / beta), color='blue')\n", + "plt.plot(x_2, np.exp(-dist_2_bl.y), color='green') ##just to check that distribution object is initialized accurately\n", + "plt.ylabel('PDF Gamma(' + str(alpha_2) + ',' + str(beta) + ')')\n", + "plt.title('Distribution 2')\n", "plt.show()\n", "\n", "alpha_sol = alpha_1 + alpha_2\n", - "y_sol = gamma.pdf(x, alpha_sol, scale=1/beta)\n", - "y_sol[y_sol == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", - "dist_sol = Distribution(x, y_sol, kind='linear', is_log=False)\n" + "y_sol = gamma.pdf(x, alpha_sol, scale=1 / beta)\n", + "y_sol[y_sol == 0] = TINY_NUMBER ##remove -inf to prevent numerical errors\n", + "dist_sol = Distribution(x, y_sol, kind='linear', is_log=False)" ] }, { @@ -1503,20 +1597,26 @@ "##in order to perform the convolution dist_2 needs a one_mutation parameter (as this should be a BranchLenInterpolator object)\n", "dist_2_bl.one_mutation = 0\n", "\n", - "grid_size = [100, 200, 300, 500, 1000] #more desired grid_sizes, true grid size is given as output when the accuracy_and_time function is called\n", - "time_fft = np.empty((len(grid_size),2))\n", + "grid_size = [\n", + " 100,\n", + " 200,\n", + " 300,\n", + " 500,\n", + " 1000,\n", + "] # more desired grid_sizes, true grid size is given as output when the accuracy_and_time function is called\n", + "time_fft = np.empty((len(grid_size), 2))\n", "accuracy_fft = []\n", - "time_num = np.empty((len(grid_size),2))\n", + "time_num = np.empty((len(grid_size), 2))\n", "accuracy_num = []\n", "for t in range(len(grid_size)):\n", - " #print(t)\n", - " time_fft_full = accuracy_and_time(dist_1_bl, dist_2_bl, \"fft\", grid_size[t])\n", + " # print(t)\n", + " time_fft_full = accuracy_and_time(dist_1_bl, dist_2_bl, 'fft', grid_size[t])\n", " time_fft[t] = time_fft_full[1:3]\n", " accuracy_fft.append(time_fft_full[0])\n", - " time_num_full = accuracy_and_time(dist_1_bl, dist_2_bl, \"num\", grid_size[t])\n", + " time_num_full = accuracy_and_time(dist_1_bl, dist_2_bl, 'num', grid_size[t])\n", " time_num[t] = time_num_full[1:3]\n", " accuracy_num.append(time_num_full[0])\n", - " \n", + "\n", "print_relative_diff_grids()\n", "print_relative_diff_grids_lin()" ] diff --git a/test/fft_tests.py b/test/fft_tests.py index 252d952..d68bba2 100644 --- a/test/fft_tests.py +++ b/test/fft_tests.py @@ -14,50 +14,64 @@ from treetime.distribution import Distribution def get_test_nodes(tree_list, pattern): return [[n for n in tt.tree.find_clades() if pattern in n.name][0] for tt in tree_list] + def get_tree_events(tt): - tree_events_tt = sorted([(n.time_before_present, n.name, int(n.bad_branch)) for n in tt.tree.find_clades()], key=lambda x:-x[0]) + tree_events_tt = sorted( + [(n.time_before_present, n.name, int(n.bad_branch)) for n in tt.tree.find_clades()], key=lambda x: -x[0] + ) return tree_events_tt + def compare(new_times_and_names, old_times_and_names): new_times_and_names = pd.DataFrame(new_times_and_names) old_times_and_names = pd.DataFrame(old_times_and_names) old_times_and_names = old_times_and_names.set_index(1) new_times_and_names = new_times_and_names.set_index(1) old_times_and_names = old_times_and_names.reindex(index=new_times_and_names.index) - bad_branches = np.float_(old_times_and_names.iloc[:,1]) + 2*np.float_(new_times_and_names.iloc[:,1]) - df = pd.DataFrame(dict(time=np.float_(new_times_and_names.iloc[:,0]), - difference=(np.float_(new_times_and_names.iloc[:,0])-np.float_(old_times_and_names.iloc[:,0])), - bad_branch=bad_branches), index=new_times_and_names.index) - return np.all(new_times_and_names.iloc[:,0]==old_times_and_names.iloc[:,0]), df + bad_branches = np.float_(old_times_and_names.iloc[:, 1]) + 2 * np.float_(new_times_and_names.iloc[:, 1]) + df = pd.DataFrame( + dict( + time=np.float_(new_times_and_names.iloc[:, 0]), + difference=(np.float_(new_times_and_names.iloc[:, 0]) - np.float_(old_times_and_names.iloc[:, 0])), + bad_branch=bad_branches, + ), + index=new_times_and_names.index, + ) + return np.all(new_times_and_names.iloc[:, 0] == old_times_and_names.iloc[:, 0]), df + def get_large_differences(df): - large_differences = df[abs(df.difference) > abs(np.mean(df.difference)) + 2*np.std(df.difference)] + large_differences = df[abs(df.difference) > abs(np.mean(df.difference)) + 2 * np.std(df.difference)] return large_differences + def plot_differences(df, title=None): groups = df.groupby('bad_branch') fig = plt.figure() for name, group in groups: plt.plot(df.time, df.difference, marker='o', linestyle='', ms=1, label=name) - plt.xlabel("nodes ranging from root at 0 to most recent") - plt.ylabel("difference time_before_present fft - numerical") + plt.xlabel('nodes ranging from root at 0 to most recent') + plt.ylabel('difference time_before_present fft - numerical') if title: plt.title(title) plt.show() return fig + def compare_dist(node_num, node_fft, dist_node_num, dist_node_fft, dist_name, xlimits=None): fig = plt.figure() - plt.plot(dist_node_num.x, dist_node_num.prob_relative(dist_node_num.x), marker='o', linestyle='', ms=2, label='numerical') + plt.plot( + dist_node_num.x, dist_node_num.prob_relative(dist_node_num.x), marker='o', linestyle='', ms=2, label='numerical' + ) plt.plot(dist_node_fft.x, dist_node_fft.prob_relative(dist_node_fft.x), marker='o', linestyle='', ms=1, label='fft') if xlimits: - if xlimits!='empty': + if xlimits != 'empty': plt.xlim(xlimits) else: x_min = min(node_num.time_before_present, node_fft.time_before_present) x_max = max(node_num.time_before_present, node_fft.time_before_present) - plt.xlim((x_min-0.0001, x_max+0.0001)) - plt.title(node_num.name + "_" + dist_name) + plt.xlim((x_min - 0.0001, x_max + 0.0001)) + plt.title(node_num.name + '_' + dist_name) plt.legend() plt.show() return fig @@ -68,7 +82,7 @@ if __name__ == '__main__': ##model parameters for testing # choose if should be tested on ebola or h3n2_na dataset - ebola=True + ebola = True if ebola: base_name = '../treetime_examples/data/ebola/ebola' @@ -77,29 +91,46 @@ if __name__ == '__main__': base_name = '../treetime_examples/data/h3n2_na/h3n2_na_20' clock_rate = 0.0028 - seq_kwargs = {"marginal_sequences":True, - "branch_length_mode": 'input', - "sample_from_profile":"root", - "reconstruct_tip_states":False} - tt_kwargs = {'clock_rate':clock_rate, - 'time_marginal':'assign'} - coal_kwargs ={'Tc':10000, - 'time_marginal':'assign'} - - dates = parse_dates(base_name+'.metadata.csv') - tt = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', use_fft=False, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) - tt_fft = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', use_fft=True, precision_fft=200, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) + seq_kwargs = { + 'marginal_sequences': True, + 'branch_length_mode': 'input', + 'sample_from_profile': 'root', + 'reconstruct_tip_states': False, + } + tt_kwargs = {'clock_rate': clock_rate, 'time_marginal': 'assign'} + coal_kwargs = {'Tc': 10000, 'time_marginal': 'assign'} + + dates = parse_dates(base_name + '.metadata.csv') + tt = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + use_fft=False, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) + tt_fft = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + use_fft=True, + precision_fft=200, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) for tree in [tt, tt_fft]: - tree._set_branch_length_mode(seq_kwargs["branch_length_mode"]) - tree.infer_ancestral_sequences(infer_gtr=False, marginal=seq_kwargs["marginal_sequences"]) + tree._set_branch_length_mode(seq_kwargs['branch_length_mode']) + tree.infer_ancestral_sequences(infer_gtr=False, marginal=seq_kwargs['marginal_sequences']) tree.prune_short_branches() - tree.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=tt_kwargs["clock_rate"]) - tree.reroot(root='least-squares', clock_rate=tt_kwargs["clock_rate"]) + tree.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=tt_kwargs['clock_rate']) + tree.reroot(root='least-squares', clock_rate=tt_kwargs['clock_rate']) tree.infer_ancestral_sequences(**seq_kwargs) - tree.make_time_tree(clock_rate=tt_kwargs["clock_rate"], time_marginal=tt_kwargs["time_marginal"]) + tree.make_time_tree(clock_rate=tt_kwargs['clock_rate'], time_marginal=tt_kwargs['time_marginal']) tree_events_tt = get_tree_events(tt) tree_events_tt_fft = get_tree_events(tt_fft) @@ -114,62 +145,119 @@ if __name__ == '__main__': for n in list(large_differences.index): test_node, test_node_fft = get_test_nodes([tt, tt_fft], n) if test_node.name != tt.tree.root.name and not test_node.marginal_pos_LH.is_delta: - #compare marginal_pos_LH - compare_dist(test_node, test_node_fft, test_node.marginal_pos_LH, test_node_fft.marginal_pos_LH, 'pos_LH') - #compare msg_from_parent - compare_dist(test_node, test_node_fft, test_node.msg_from_parent, test_node_fft.msg_from_parent, 'msg_from_parent') - #compare subtree_distribution - compare_dist(test_node, test_node_fft, test_node.subtree_distribution, test_node_fft.subtree_distribution, 'subtree_dist') - #compare branch_length_interpolator - compare_dist(test_node, test_node_fft, test_node.branch_length_interpolator, test_node_fft.branch_length_interpolator, 'branch length dist', xlimits='empty') - #compare marginal_pos_Lx - xlimits = (test_node.up.time_before_present-0.01,test_node.up.time_before_present+0.01) - compare_dist(test_node, test_node_fft, test_node.marginal_pos_Lx, test_node_fft.marginal_pos_Lx, 'marginal_pos_Lx', xlimits=xlimits) - - #compare marginal_pos_LH of parent - print("The parent of numerical is: " + str(test_node.up.name) + " the parent of fft is: " + str(test_node_fft.up.name)) - compare_dist(test_node.up, test_node_fft.up, test_node.up.marginal_pos_LH, test_node_fft.up.marginal_pos_LH, 'pos_LH parent') - - #compare marginal_pos_LH of children - print("Now looking at children of node of interest") + # compare marginal_pos_LH + compare_dist( + test_node, test_node_fft, test_node.marginal_pos_LH, test_node_fft.marginal_pos_LH, 'pos_LH' + ) + # compare msg_from_parent + compare_dist( + test_node, + test_node_fft, + test_node.msg_from_parent, + test_node_fft.msg_from_parent, + 'msg_from_parent', + ) + # compare subtree_distribution + compare_dist( + test_node, + test_node_fft, + test_node.subtree_distribution, + test_node_fft.subtree_distribution, + 'subtree_dist', + ) + # compare branch_length_interpolator + compare_dist( + test_node, + test_node_fft, + test_node.branch_length_interpolator, + test_node_fft.branch_length_interpolator, + 'branch length dist', + xlimits='empty', + ) + # compare marginal_pos_Lx + xlimits = (test_node.up.time_before_present - 0.01, test_node.up.time_before_present + 0.01) + compare_dist( + test_node, + test_node_fft, + test_node.marginal_pos_Lx, + test_node_fft.marginal_pos_Lx, + 'marginal_pos_Lx', + xlimits=xlimits, + ) + + # compare marginal_pos_LH of parent + print( + 'The parent of numerical is: ' + + str(test_node.up.name) + + ' the parent of fft is: ' + + str(test_node_fft.up.name) + ) + compare_dist( + test_node.up, + test_node_fft.up, + test_node.up.marginal_pos_LH, + test_node_fft.up.marginal_pos_LH, + 'pos_LH parent', + ) + + # compare marginal_pos_LH of children + print('Now looking at children of node of interest') for c in test_node.clades: test_node, test_node_fft = get_test_nodes([tt, tt_fft], c.name) - print("Time difference is " + str(test_node.time_before_present - test_node_fft.time_before_present )) - print("Time of numerical:" + str(test_node.time_before_present) + " time of fft:"+ str(test_node_fft.time_before_present)) + print( + 'Time difference is ' + str(test_node.time_before_present - test_node_fft.time_before_present) + ) + print( + 'Time of numerical:' + + str(test_node.time_before_present) + + ' time of fft:' + + str(test_node_fft.time_before_present) + ) if test_node.marginal_pos_LH.is_delta: - print("delta") + print('delta') else: - compare_dist(test_node, test_node_fft, test_node.marginal_pos_LH, test_node_fft.marginal_pos_LH, 'pos_LH') + compare_dist( + test_node, test_node_fft, test_node.marginal_pos_LH, test_node_fft.marginal_pos_LH, 'pos_LH' + ) closer_look_differences() ##now do the same for when the coalescent model is added for tree in [tt, tt_fft]: - tree.add_coalescent_model(coal_kwargs ["Tc"]) - tree.make_time_tree(clock_rate=tt_kwargs ["clock_rate"], time_marginal=coal_kwargs ["time_marginal"]) + tree.add_coalescent_model(coal_kwargs['Tc']) + tree.make_time_tree(clock_rate=tt_kwargs['clock_rate'], time_marginal=coal_kwargs['time_marginal']) tree_events_tt = get_tree_events(tt) tree_events_tt_fft = get_tree_events(tt_fft) output_comparison = compare(tree_events_tt_fft, tree_events_tt) large_differences = get_large_differences(output_comparison[1]) - #plot_differences(output_comparison[1]) + # plot_differences(output_comparison[1]) closer_look_differences() nodes_to_look_at = ['NODE_0000120', 'NODE_0000098'] + def compare_multiply_functions(nodes_of_interest): ## code to look closer at the msgs of a child of a node and behavior of functions for n in nodes_of_interest: test_node, test_node_fft = get_test_nodes([tt, tt_fft], n) print([c.name for c in test_node.clades]) print([c.name for c in test_node_fft.clades]) - msgs_to_multiply_tt_fft = [child.marginal_pos_Lx for child in test_node_fft.clades - if child.marginal_pos_Lx is not None] - msgs_to_multiply_tt = [child.marginal_pos_Lx for child in test_node.clades - if child.marginal_pos_Lx is not None] + msgs_to_multiply_tt_fft = [ + child.marginal_pos_Lx for child in test_node_fft.clades if child.marginal_pos_Lx is not None + ] + msgs_to_multiply_tt = [ + child.marginal_pos_Lx for child in test_node.clades if child.marginal_pos_Lx is not None + ] subtree_distribution_tt = Distribution.multiply(msgs_to_multiply_tt) subtree_distribution_tt_fft = Distribution.multiply(msgs_to_multiply_tt_fft) - compare_dist(test_node, test_node_fft, subtree_distribution_tt, subtree_distribution_tt_fft, 'multiplied Lx of children') + compare_dist( + test_node, + test_node_fft, + subtree_distribution_tt, + subtree_distribution_tt_fft, + 'multiplied Lx of children', + ) # nodes_to_look_at = ['NODE_0000120', 'NODE_0000098'] - # compare_multiply_functions(nodes_to_look_at)
\ No newline at end of file + # compare_multiply_functions(nodes_to_look_at) diff --git a/test/fft_timer.py b/test/fft_timer.py index 93eabec..828eb71 100644 --- a/test/fft_timer.py +++ b/test/fft_timer.py @@ -17,25 +17,27 @@ from treetime.utils import parse_dates from resource import getrusage, RUSAGE_SELF from fft_tests import get_tree_events, compare, plot_differences + def sort(df_list): for i in range(0, len(df_list)): df_list[i] = pd.DataFrame(df_list[i]).set_index(1) for i in range(1, len(df_list)): - df_list[i]= df_list[i].reindex(index=df_list[0].index) + df_list[i] = df_list[i].reindex(index=df_list[0].index) return df_list def run_first_round(tree, kwargs): - tree._set_branch_length_mode(kwargs["branch_length_mode"]) - tree.infer_ancestral_sequences(infer_gtr=False, marginal=kwargs["marginal_sequences"]) + tree._set_branch_length_mode(kwargs['branch_length_mode']) + tree.infer_ancestral_sequences(infer_gtr=False, marginal=kwargs['marginal_sequences']) tree.prune_short_branches() - tree.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=kwargs["clock_rate"]) - tree.reroot(root='least-squares', clock_rate=kwargs["clock_rate"]) + tree.clock_filter(reroot='least-squares', n_iqd=1, plot=False, fixed_clock_rate=kwargs['clock_rate']) + tree.reroot(root='least-squares', clock_rate=kwargs['clock_rate']) tree.infer_ancestral_sequences(**kwargs) - tree.make_time_tree(clock_rate=kwargs["clock_rate"], time_marginal=kwargs["time_marginal"], divide=False) + tree.make_time_tree(clock_rate=kwargs['clock_rate'], time_marginal=kwargs['time_marginal'], divide=False) peak_memory = getrusage(RUSAGE_SELF).ru_maxrss return peak_memory + def plot_accuracy_memory_time(branch_grid_size, accuracy, time_branch, memory_branch, accuracy_label, title): plt.figure() @@ -47,41 +49,40 @@ def plot_accuracy_memory_time(branch_grid_size, accuracy, time_branch, memory_br offset = 60 new_fixed_axis = par2.get_grid_helper().new_fixed_axis - par2.axis["right"] = new_fixed_axis(loc="right", axes=par2, offset=(offset, 0)) - par2.axis["right"].toggle(all=True) - par1.axis["right"].toggle(all=True) + par2.axis['right'] = new_fixed_axis(loc='right', axes=par2, offset=(offset, 0)) + par2.axis['right'].toggle(all=True) + par1.axis['right'].toggle(all=True) - host.set_xlabel("Branch Grid Size") + host.set_xlabel('Branch Grid Size') host.set_ylabel(accuracy_label) - par1.set_ylabel("Time [sec]") - par2.set_ylabel("Memory Consumption [MB]") + par1.set_ylabel('Time [sec]') + par2.set_ylabel('Memory Consumption [MB]') - if len(accuracy)==(len(branch_grid_size)-1): - p1, = host.plot(branch_grid_size[1:], accuracy, label=title) - p2, = par1.plot(branch_grid_size, np.array(time_branch), label='time') - p3, = par2.plot(branch_grid_size, np.array(memory_branch), label='memory consumption') + if len(accuracy) == (len(branch_grid_size) - 1): + (p1,) = host.plot(branch_grid_size[1:], accuracy, label=title) + (p2,) = par1.plot(branch_grid_size, np.array(time_branch), label='time') + (p3,) = par2.plot(branch_grid_size, np.array(memory_branch), label='memory consumption') else: - p1, = host.plot(branch_grid_size, accuracy, label=title) - p2, = par1.plot(branch_grid_size, np.array(time_branch), label='time') - p3, = par2.plot(branch_grid_size, np.array(memory_branch), label='memory consumption') + (p1,) = host.plot(branch_grid_size, accuracy, label=title) + (p2,) = par1.plot(branch_grid_size, np.array(time_branch), label='time') + (p3,) = par2.plot(branch_grid_size, np.array(memory_branch), label='memory consumption') host.legend() - host.axis["left"].label.set_color(p1.get_color()) - par1.axis["right"].label.set_color(p2.get_color()) - par2.axis["right"].label.set_color(p3.get_color()) + host.axis['left'].label.set_color(p1.get_color()) + par1.axis['right'].label.set_color(p2.get_color()) + par2.axis['right'].label.set_color(p3.get_color()) plt.draw() plt.show() - plt.title(title + " - FFT Grid Size=" + str(prec_fft)) - plt.savefig("./results/Memory_Time_" + str(title)+ ".png") + plt.title(title + ' - FFT Grid Size=' + str(prec_fft)) + plt.savefig('./results/Memory_Time_' + str(title) + '.png') if __name__ == '__main__': - ##model parameters for testing # choose if should be tested on ebola or h3n2_na dataset - ebola=True + ebola = True if ebola: node_pattern = 'EM_004555' @@ -92,93 +93,131 @@ if __name__ == '__main__': base_name = '../treetime_examples/data/h3n2_na/h3n2_na_20' clock_rate = 0.0028 - dates = parse_dates(base_name+'.metadata.csv') + dates = parse_dates(base_name + '.metadata.csv') ##time the calculation and assess accuracy as difference from tt_numerical and other fft start = time.process_time() - tt_numerical = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', use_fft=False, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) - kwargs = {"marginal_sequences":True, - "branch_length_mode": 'input', - "sample_from_profile":"root", - "reconstruct_tip_states":False, - "max_iter":1, - 'clock_rate':clock_rate, - 'time_marginal':'assign'} + tt_numerical = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + use_fft=False, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) + kwargs = { + 'marginal_sequences': True, + 'branch_length_mode': 'input', + 'sample_from_profile': 'root', + 'reconstruct_tip_states': False, + 'max_iter': 1, + 'clock_rate': clock_rate, + 'time_marginal': 'assign', + } memory_numerical = run_first_round(tt_numerical, kwargs) print(memory_numerical) tree_make_time = time.process_time() - start tree_events_numerical = get_tree_events(tt_numerical) - print("Time to make tree using ultra fine numerical grid: " + str(tree_make_time)) + print('Time to make tree using ultra fine numerical grid: ' + str(tree_make_time)) - numerical_LH= tt_numerical.tree.positional_LH - print("likelihood of tree under numerical:" + str(numerical_LH)) + numerical_LH = tt_numerical.tree.positional_LH + print('likelihood of tree under numerical:' + str(numerical_LH)) precision_fft = [5, 25, 50, 75, 100, 150, 200, 300, 400] branch_grid_size = [50, 75, 100, 150, 200, 300, 400] def find_optimal_branch_fft_gridsizes(precision_fft, branch_grid_size): - ''' + """ Given an input list of FFT and branch grid sizes perform the first iteration of the run function for each combination, determine the inferred divergence times for each new timetree and plot the average rate of change in inferred divergence time for each branch length when changing the fft grid size - ''' - time_fft = [] #time needed to make fft + """ + time_fft = [] # time needed to make fft divergence_times_fft_list = [] divergence_time_diff_list = [] - pp = PdfPages('./results/DifferencesComparison.pdf') #output the difference plots to visually assess if there is an issue + pp = PdfPages( + './results/DifferencesComparison.pdf' + ) # output the difference plots to visually assess if there is an issue for b in branch_grid_size: - pre_divergence_times_fft_list =[] + pre_divergence_times_fft_list = [] pre_time_fft = [] for prec in precision_fft: start = time.process_time() - tt_fft = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', precision_fft=prec, use_fft=True, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, precision_branch=b, debug=True) + tt_fft = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + precision_fft=prec, + use_fft=True, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + precision_branch=b, + debug=True, + ) run_first_round(tt_fft, kwargs) tree_make_time_fft = time.process_time() - start - print("Time to make tree using fft grid of size " + str(prec) +" :"+str(tree_make_time_fft)) + print('Time to make tree using fft grid of size ' + str(prec) + ' :' + str(tree_make_time_fft)) tree_events_fft = get_tree_events(tt_fft) - if len([t[1] for t in tree_events_fft if np.isnan(t[0])])>0: - print("error in tree! a clade has no time before present!") + if len([t[1] for t in tree_events_fft if np.isnan(t[0])]) > 0: + print('error in tree! a clade has no time before present!') pre_time_fft.append(tree_make_time_fft) pre_divergence_times_fft_list.append(tree_events_fft) output_comparison = compare(tree_events_numerical, tree_events_fft) - fig = plot_differences(output_comparison[1], title="Differences branch grid " + str(b) + " fft grid " + str(prec)) + fig = plot_differences( + output_comparison[1], title='Differences branch grid ' + str(b) + ' fft grid ' + str(prec) + ) pp.savefig(fig) time_fft.append([pre_time_fft]) sorted_treelist = sort(pre_divergence_times_fft_list) divergence_times = [] for i in range(0, len(sorted_treelist)): - if len([t for t in sorted_treelist[i][0] if np.isnan(t)])>0: + if len([t for t in sorted_treelist[i][0] if np.isnan(t)]) > 0: print("error in sort function! a clade's time has been lost!") divergence_times.append(np.array(sorted_treelist[i][0])) divergence_times_fft_list.append([divergence_times]) divergence_time_diff = [] for i in range(1, len(divergence_times)): - divergence_time_diff.append(np.mean(np.abs(divergence_times[i] - divergence_times[i-1])/(precision_fft[i] - precision_fft[i-1]))) + divergence_time_diff.append( + np.mean( + np.abs(divergence_times[i] - divergence_times[i - 1]) + / (precision_fft[i] - precision_fft[i - 1]) + ) + ) divergence_time_diff_list.append([divergence_time_diff]) pp.close() - #for each possible branch length grid size plot the divergence time differences for different fft grid sizes + # for each possible branch length grid size plot the divergence time differences for different fft grid sizes plt.figure() for i in range(0, len(divergence_time_diff_list)): epsilon = 1e-9 - plt.plot(precision_fft[1:len(divergence_times_fft_list[i][0])], divergence_time_diff_list[i][0], 'o-', label=branch_grid_size[i]) - plt.legend(title="branch grid size") + plt.plot( + precision_fft[1 : len(divergence_times_fft_list[i][0])], + divergence_time_diff_list[i][0], + 'o-', + label=branch_grid_size[i], + ) + plt.legend(title='branch grid size') plt.axhline(y=epsilon, color='black', linestyle='--') - plt.ylabel("log(average rate of change)") - plt.xlabel("FFT grid size") + plt.ylabel('log(average rate of change)') + plt.xlabel('FFT grid size') plt.yscale('log') - plt.title("Change in Infered Divergence Times - Average Rate of Change") - plt.savefig("./results/Optimal_Grid_Size.png") + plt.title('Change in Infered Divergence Times - Average Rate of Change') + plt.savefig('./results/Optimal_Grid_Size.png') return time_fft, divergence_times_fft_list, divergence_time_diff_list - time_fft, divergence_times_fft_list, divergence_time_diff_list = find_optimal_branch_fft_gridsizes(precision_fft, branch_grid_size) + + time_fft, divergence_times_fft_list, divergence_time_diff_list = find_optimal_branch_fft_gridsizes( + precision_fft, branch_grid_size + ) prec_fft = 175 + def find_optimal_branch_gridsizes(prec_fft, branch_grid_size): """ For a given (optimal) FFT grid size plot the change of accuracy (either by the average rate of change or the LH) @@ -188,41 +227,66 @@ if __name__ == '__main__': memory_branch = [] fft_LH = [] - pre_divergence_times_fft_list =[] + pre_divergence_times_fft_list = [] for b in branch_grid_size: start = time.process_time() - tt_fft = TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', precision_fft=prec_fft, use_fft=True, - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, precision_branch = b, debug=True) + tt_fft = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + precision_fft=prec_fft, + use_fft=True, + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + precision_branch=b, + debug=True, + ) memory_fft = run_first_round(tt_fft, kwargs) tree_make_time_fft = time.process_time() - start - print("Time to make tree using fft grid of size " + str(150) +" :"+str(tree_make_time_fft)) + print('Time to make tree using fft grid of size ' + str(150) + ' :' + str(tree_make_time_fft)) tree_events_fft = get_tree_events(tt_fft) - if len([t[1] for t in tree_events_fft if np.isnan(t[0])])>0: - print("error in tree! a clade has no time before present!") + if len([t[1] for t in tree_events_fft if np.isnan(t[0])]) > 0: + print('error in tree! a clade has no time before present!') time_branch.append(tree_make_time_fft) memory_branch.append(memory_fft) pre_divergence_times_fft_list.append(tree_events_fft) output_comparison = compare(tree_events_numerical, tree_events_fft) - plot_differences(output_comparison[1], title="Differences branch grid " + str(b) + " fft grid " + str(prec_fft)) + plot_differences( + output_comparison[1], title='Differences branch grid ' + str(b) + ' fft grid ' + str(prec_fft) + ) fft_LH.append(tt_fft.tree.positional_LH) - print("likelihood of tree under fft:" + str(tt_fft.tree.positional_LH)) - + print('likelihood of tree under fft:' + str(tt_fft.tree.positional_LH)) sorted_treelist = sort(pre_divergence_times_fft_list) divergence_times = [] for i in range(0, len(sorted_treelist)): divergence_times.append(np.array(sorted_treelist[i][0])) - if len([t for t in sorted_treelist[i][0] if np.isnan(t)])>0: + if len([t for t in sorted_treelist[i][0] if np.isnan(t)]) > 0: print("error in sort function! a clade's time has been lost!") divergence_time_diff = [] for i in range(1, len(divergence_times)): - divergence_time_diff.append(np.nanmean(np.abs(divergence_times[i] - divergence_times[i-1])/(branch_grid_size[i] - branch_grid_size[i-1]))) - - memory_branch = np.array(memory_branch)/(10**6) - accuracy_rate = np.array(divergence_time_diff)/1e-8 - plot_accuracy_memory_time(branch_grid_size, accuracy_rate, time_branch, memory_branch, "Average Rate of Change Inferred Time [1e-8]", "average_rate_of_change") - plot_accuracy_memory_time(branch_grid_size, np.array(fft_LH), time_branch, memory_branch, "tree log(LH) ", "log(LH)") + divergence_time_diff.append( + np.nanmean( + np.abs(divergence_times[i] - divergence_times[i - 1]) + / (branch_grid_size[i] - branch_grid_size[i - 1]) + ) + ) + + memory_branch = np.array(memory_branch) / (10**6) + accuracy_rate = np.array(divergence_time_diff) / 1e-8 + plot_accuracy_memory_time( + branch_grid_size, + accuracy_rate, + time_branch, + memory_branch, + 'Average Rate of Change Inferred Time [1e-8]', + 'average_rate_of_change', + ) + plot_accuracy_memory_time( + branch_grid_size, np.array(fft_LH), time_branch, memory_branch, 'tree log(LH) ', 'log(LH)' + ) return time_branch, memory_branch, divergence_time_diff, fft_LH @@ -248,7 +312,6 @@ if __name__ == '__main__': # true_grid_size = len(res.y) # return conv_time, true_grid_size - # grid_size = [50, 75, 100, 150, 200, 300, 400, 600] # time_fft = np.empty((len(grid_size),2)) # time_num = np.empty((len(grid_size),2)) @@ -257,7 +320,6 @@ if __name__ == '__main__': # time_fft[t] = time_convolution("fft", grid_size[t]) # time_num[t] = time_convolution("num", grid_size[t]) - # #now additionally plot these differences # fig = plt.figure() # plt.autoscale() @@ -271,4 +333,3 @@ if __name__ == '__main__': # pp.savefig(fig) # plt.close() # pp.close() - diff --git a/test/test_calc_nbranches.py b/test/test_calc_nbranches.py index 82105a5..00d7274 100644 --- a/test/test_calc_nbranches.py +++ b/test/test_calc_nbranches.py @@ -9,26 +9,39 @@ from fft_tests import get_test_nodes def add_merger_cost(test_node, tt, t): - return test_node.branch_length_interpolator.prob(t)*np.exp(tt.merger_model.cost(test_node.time_before_present, t)) + return test_node.branch_length_interpolator.prob(t) * np.exp(tt.merger_model.cost(test_node.time_before_present, t)) if __name__ == '__main__': - ## checks difference between using posterior distributions for evaluating the number of lineages-nbranches function (smooth) vs old discrete approach plt.ion() - ebola=False + ebola = False if ebola: base_name = '../treetime_examples/data/ebola/ebola' else: base_name = '../treetime_examples/data/h3n2_na/h3n2_na_20' - dates = parse_dates(base_name+'.metadata.csv') - - tt_old= TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) - tt_smooth= TreeTime(gtr='Jukes-Cantor', tree = base_name+'.nwk', - aln = base_name+'.fasta', verbose = 1, dates = dates, precision=3, debug=True) + dates = parse_dates(base_name + '.metadata.csv') + + tt_old = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) + tt_smooth = TreeTime( + gtr='Jukes-Cantor', + tree=base_name + '.nwk', + aln=base_name + '.fasta', + verbose=1, + dates=dates, + precision=3, + debug=True, + ) fixed_clock_rate = 0.0028 @@ -36,80 +49,84 @@ if __name__ == '__main__': tt.reroot(root='least-squares', clock_rate=fixed_clock_rate) tt.infer_ancestral_sequences(infer_gtr=False, marginal=False) tt.make_time_tree(clock_rate=fixed_clock_rate, time_marginal=False) - #tt._ml_t_marginal(assign_dates=True) + # tt._ml_t_marginal(assign_dates=True) ## set the branch_count function using the smooth approach and time differences start = time.process_time() tt_smooth.add_coalescent_model(Tc=0.001, n_branches_posterior=True) - print("Time for smooth nbranches (using posterior dist):" + str(time.process_time()-start)) + print('Time for smooth nbranches (using posterior dist):' + str(time.process_time() - start)) start = time.process_time() tt_old.add_coalescent_model(Tc=0.001, n_branches_posterior=False) - print("Time for discrete nbranches:" + str(time.process_time()-start)) + print('Time for discrete nbranches:' + str(time.process_time() - start)) if ebola: - node_pattern= 'V517' + node_pattern = 'V517' else: node_pattern = 'Indiana' ## Plot differences in the nbranches interp1d object plt.figure() - plt.plot(tt_old.merger_model.nbranches.x/tt_old.date2dist.clock_rate, tt_old.merger_model.nbranches.y, label="old nbranches function") - plt.plot(tt_smooth.merger_model.nbranches.x/tt_smooth.date2dist.clock_rate, tt_smooth.merger_model.nbranches.y, label="new smooth nbranches function") - plt.xlabel("time before present") - plt.ylabel("nbranches") + plt.plot( + tt_old.merger_model.nbranches.x / tt_old.date2dist.clock_rate, + tt_old.merger_model.nbranches.y, + label='old nbranches function', + ) + plt.plot( + tt_smooth.merger_model.nbranches.x / tt_smooth.date2dist.clock_rate, + tt_smooth.merger_model.nbranches.y, + label='new smooth nbranches function', + ) + plt.xlabel('time before present') + plt.ylabel('nbranches') plt.legend() - plt.xlim((0,40)) - + plt.xlim((0, 40)) ## Plot effects on branch length distribution and cost function of coalescent test_nodes = get_test_nodes([tt_old, tt_smooth], node_pattern) while test_nodes[0] is not None: if test_nodes[0].name != tt.tree.root.name: plt.figure() - t = np.linspace(0,4*fixed_clock_rate,1000) + t = np.linspace(0, 4 * fixed_clock_rate, 1000) plt.plot(t, add_merger_cost(test_nodes[0], tt_old, t), label='old', ls='-') plt.plot(t, add_merger_cost(test_nodes[1], tt_smooth, t), label='smooth', ls='-') plt.legend() - test_nodes =[test_node.up for test_node in test_nodes] - + test_nodes = [test_node.up for test_node in test_nodes] ## Plot effects on final constructed time trees for tt in [tt_old, tt_smooth]: tt.make_time_tree(clock_rate=fixed_clock_rate, time_marginal=True) - fig, axs = plt.subplots(1,2, sharey=True, figsize=(12,8)) - Phylo.draw(tt_old.tree, label_func=lambda x:"", axes=axs[0]) - axs[0].set_title("old time tree") - Phylo.draw(tt_smooth.tree, label_func=lambda x:"", axes=axs[1]) - axs[1].set_title("smooth time tree") + fig, axs = plt.subplots(1, 2, sharey=True, figsize=(12, 8)) + Phylo.draw(tt_old.tree, label_func=lambda x: '', axes=axs[0]) + axs[0].set_title('old time tree') + Phylo.draw(tt_smooth.tree, label_func=lambda x: '', axes=axs[1]) + axs[1].set_title('smooth time tree') ## Plot effects on final LH function test_nodes = get_test_nodes([tt_old, tt_smooth], node_pattern) - next=0 - fig, axs = plt.subplots(2,2, sharey=True, figsize=(12,8)) - fig.suptitle("Effect of smooth nbranches on LH distribution") + next = 0 + fig, axs = plt.subplots(2, 2, sharey=True, figsize=(12, 8)) + fig.suptitle('Effect of smooth nbranches on LH distribution') while test_nodes[0] is not None: if test_nodes[0].name != tt.tree.root.name: - if next==0: - i, j=0, 0 - elif next==1: - i, j=1, 0 - elif next==2: - i, j=0, 1 - elif next==3: - i, j=1, 1 - t = np.linspace(test_nodes[0].time_before_present-0.005,test_nodes[0].time_before_present+0.003,2000) - axs[i,j].plot(t, test_nodes[0].marginal_pos_LH.prob_relative(t), label='old', ls='-') - axs[i,j].plot(t, test_nodes[1].marginal_pos_LH.prob_relative(t), label='smooth', ls='-') - axs[i,j].set_xlabel("time before present") - axs[i,j].set_ylabel("marginal LH") - axs[i,j].legend() + if next == 0: + i, j = 0, 0 + elif next == 1: + i, j = 1, 0 + elif next == 2: + i, j = 0, 1 + elif next == 3: + i, j = 1, 1 + t = np.linspace(test_nodes[0].time_before_present - 0.005, test_nodes[0].time_before_present + 0.003, 2000) + axs[i, j].plot(t, test_nodes[0].marginal_pos_LH.prob_relative(t), label='old', ls='-') + axs[i, j].plot(t, test_nodes[1].marginal_pos_LH.prob_relative(t), label='smooth', ls='-') + axs[i, j].set_xlabel('time before present') + axs[i, j].set_ylabel('marginal LH') + axs[i, j].legend() next += 1 - if next==4: - next=0 - fig, axs = plt.subplots(2,2, sharey=True, figsize=(12,8)) - fig.suptitle("Effect of smooth nbranches on LH distribution") - test_nodes =[test_node.up for test_node in test_nodes] - - print("finished") - + if next == 4: + next = 0 + fig, axs = plt.subplots(2, 2, sharey=True, figsize=(12, 8)) + fig.suptitle('Effect of smooth nbranches on LH distribution') + test_nodes = [test_node.up for test_node in test_nodes] + print('finished') diff --git a/test/test_treetime.py b/test/test_treetime.py index 4be1b57..d0555e6 100644 --- a/test/test_treetime.py +++ b/test/test_treetime.py @@ -4,64 +4,84 @@ from io import StringIO # Tests def test_import_short(): - print("testing short imports") + print('testing short imports') from treetime import GTR from treetime import TreeTime from treetime import TreeAnc from treetime import seq_utils + def test_assign_gamma(root_dir=None): - print("testing assign gamma") + print('testing assign gamma') import os from treetime import TreeTime from treetime.utils import parse_dates + if root_dir is None: root_dir = os.path.dirname(os.path.realpath(__file__)) - fasta = root_dir + "/treetime_examples/data/h3n2_na/h3n2_na_20.fasta" - nwk = root_dir + "/treetime_examples/data/h3n2_na/h3n2_na_20.nwk" - dates = parse_dates(root_dir + "/treetime_examples/data/h3n2_na/h3n2_na_20.metadata.csv") - seq_kwargs = {"marginal_sequences":True, - "branch_length_mode": 'input', - "reconstruct_tip_states":False} - tt_kwargs = {'clock_rate': 0.0001, - 'time_marginal':'assign'} - myTree = TreeTime(gtr='Jukes-Cantor', tree = nwk, use_fft=False, - aln = fasta, verbose = 1, dates = dates, precision=3, debug=True, rng_seed=1234) + fasta = root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.fasta' + nwk = root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.nwk' + dates = parse_dates(root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.metadata.csv') + seq_kwargs = {'marginal_sequences': True, 'branch_length_mode': 'input', 'reconstruct_tip_states': False} + tt_kwargs = {'clock_rate': 0.0001, 'time_marginal': 'assign'} + myTree = TreeTime( + gtr='Jukes-Cantor', + tree=nwk, + use_fft=False, + aln=fasta, + verbose=1, + dates=dates, + precision=3, + debug=True, + rng_seed=1234, + ) + def assign_gamma(tree): return tree + success = myTree.run(infer_gtr=False, assign_gamma=assign_gamma, max_iter=1, verbose=3, **seq_kwargs, **tt_kwargs) assert success + def test_GTR(root_dir=None): from treetime import GTR import numpy as np import os + if root_dir is None: root_dir = os.path.dirname(os.path.realpath(__file__)) ##check custom GTR model - custom_gtr = root_dir + "/test_sequence_evolution_model.txt" + custom_gtr = root_dir + '/test_sequence_evolution_model.txt' gtr = GTR.from_file(custom_gtr) - assert (gtr.Pi.sum() - 1.0)**2<1e-14 + assert (gtr.Pi.sum() - 1.0) ** 2 < 1e-14 assert np.allclose(gtr.Pi, np.array([0.3088, 0.1897, 0.2335, 0.2581, 0.0099])) assert np.all(gtr.alphabet == np.array(['A', 'C', 'G', 'T', '-'])) assert abs(gtr.mu - 1.0) < 1e-4 assert abs(gtr.Q.sum(0)).sum() < 1e-14 - assert np.allclose(gtr.W, np.array([[0, 0.7003, 3.0669, 0.2651, 0.9742], - [0.7003, 0, 0.3354, 3.399, 0.999], - [3.0669, 0.3354, 0, 0.4258, 0.9892], - [0.2651, 3.399, 0.4258, 0, 0.9848], - [0.9742, 0.999, 0.9892, 0.9848, 0]]), atol=1e-4) + assert np.allclose( + gtr.W, + np.array( + [ + [0, 0.7003, 3.0669, 0.2651, 0.9742], + [0.7003, 0, 0.3354, 3.399, 0.999], + [3.0669, 0.3354, 0, 0.4258, 0.9892], + [0.2651, 3.399, 0.4258, 0, 0.9848], + [0.9742, 0.999, 0.9892, 0.9848, 0], + ] + ), + atol=1e-4, + ) for model in ['Jukes-Cantor']: - print('testing GTR, model:',model) + print('testing GTR, model:', model) myGTR = GTR.standard(model, alphabet='nuc') print('Frequency sum:', myGTR.Pi.sum()) - assert (myGTR.Pi.sum() - 1.0)**2<1e-14 + assert (myGTR.Pi.sum() - 1.0) ** 2 < 1e-14 # the matrix is the rate matrix assert abs(myGTR.Q.sum(0)).sum() < 1e-14 # eigendecomposition is made correctly n_states = myGTR.v.shape[0] assert abs((myGTR.v.dot(myGTR.v_inv) - np.identity(n_states)).sum() < 1e-10) - assert np.abs(myGTR.v.sum()) > 1e-10 # **and** v is not zero + assert np.abs(myGTR.v.sum()) > 1e-10 # **and** v is not zero def test_reconstruct_discrete_traits(): @@ -69,28 +89,28 @@ def test_reconstruct_discrete_traits(): from treetime.wrappers import reconstruct_discrete_traits # Create a minimal tree with traits to reconstruct. - tiny_tree = Phylo.read(StringIO("((A:0.60100000009,B:0.3010000009):0.1,C:0.2):0.001;"), 'newick') + tiny_tree = Phylo.read(StringIO('((A:0.60100000009,B:0.3010000009):0.1,C:0.2):0.001;'), 'newick') traits = { - "A": "?", - "B": "North America", - "C": "West Asia", + 'A': '?', + 'B': 'North America', + 'C': 'West Asia', } # Reconstruct traits with "?" as missing data. mugration, letter_to_state, reverse_alphabet = reconstruct_discrete_traits( tiny_tree, traits, - missing_data="?", + missing_data='?', ) # With two known states, the letters "A" and "B" should be in the alphabet # mapping to those states. - assert "A" in letter_to_state - assert "B" in letter_to_state + assert 'A' in letter_to_state + assert 'B' in letter_to_state # The letter for missing data should be the next letter in the alphabet, # following the two known state letters. - assert letter_to_state["C"] == "?" + assert letter_to_state['C'] == '?' def test_ancestral(root_dir=None): @@ -98,6 +118,7 @@ def test_ancestral(root_dir=None): from Bio import AlignIO import numpy as np from treetime import TreeAnc, GTR + if root_dir is None: root_dir = os.path.dirname(os.path.realpath(__file__)) fasta = str(os.path.join(root_dir, 'treetime_examples/data/h3n2_na/h3n2_na_20.fasta')) @@ -106,23 +127,32 @@ def test_ancestral(root_dir=None): for marginal in [True, False]: print('loading flu example') t = TreeAnc(gtr='Jukes-Cantor', tree=nwk, aln=fasta, rng_seed=1234) - print('ancestral reconstruction' + ("marginal" if marginal else "joint")) + print('ancestral reconstruction' + ('marginal' if marginal else 'joint')) t.reconstruct_anc(method='ml', marginal=marginal) - assert t.data.compressed_to_full_sequence(t.tree.root.cseq, as_string=True) == 'ATGAATCCAAATCAAAAGATAATAACGATTGGCTCTGTTTCTCTCACCATTTCCACAATATGCTTCTTCATGCAAATTGCCATCTTGATAACTACTGTAACATTGCATTTCAAGCAATATGAATTCAACTCCCCCCCAAACAACCAAGTGATGCTGTGTGAACCAACAATAATAGAAAGAAACATAACAGAGATAGTGTATCTGACCAACACCACCATAGAGAAGGAAATATGCCCCAAACCAGCAGAATACAGAAATTGGTCAAAACCGCAATGTGGCATTACAGGATTTGCACCTTTCTCTAAGGACAATTCGATTAGGCTTTCCGCTGGTGGGGACATCTGGGTGACAAGAGAACCTTATGTGTCATGCGATCCTGACAAGTGTTATCAATTTGCCCTTGGACAGGGAACAACACTAAACAACGTGCATTCAAATAACACAGTACGTGATAGGACCCCTTATCGGACTCTATTGATGAATGAGTTGGGTGTTCCTTTTCATCTGGGGACCAAGCAAGTGTGCATAGCATGGTCCAGCTCAAGTTGTCACGATGGAAAAGCATGGCTGCATGTTTGTATAACGGGGGATGATAAAAATGCAACTGCTAGCTTCATTTACAATGGGAGGCTTGTAGATAGTGTTGTTTCATGGTCCAAAGAAATTCTCAGGACCCAGGAGTCAGAATGCGTTTGTATCAATGGAACTTGTACAGTAGTAATGACTGATGGAAGTGCTTCAGGAAAAGCTGATACTAAAATACTATTCATTGAGGAGGGGAAAATCGTTCATACTAGCACATTGTCAGGAAGTGCTCAGCATGTCGAAGAGTGCTCTTGCTATCCTCGATATCCTGGTGTCAGATGTGTCTGCAGAGACAACTGGAAAGGCTCCAATCGGCCCATCGTAGATATAAACATAAAGGATCATAGCATTGTTTCCAGTTATGTGTGTTCAGGACTTGTTGGAGACACACCCAGAAAAAACGACAGCTCCAGCAGTAGCCATTGTTTGGATCCTAACAATGAAGAAGGTGGTCATGGAGTGAAAGGCTGGGCCTTTGATGATGGAAATGACGTGTGGATGGGAAGAACAATCAACGAGACGTCACGCTTAGGGTATGAAACCTTCAAAGTCATTGAAGGCTGGTCCAACCCTAAGTCCAAATTGCAGATAAATAGGCAAGTCATAGTTGACAGAGGTGATAGGTCCGGTTATTCTGGTATTTTCTCTGTTGAAGGCAAAAGCTGCATCAATCGGTGCTTTTATGTGGAGTTGATTAGGGGAAGAAAAGAGGAAACTGAAGTCTTGTGGACCTCAAACAGTATTGTTGTGTTTTGTGGCACCTCAGGTACATATGGAACAGGCTCATGGCCTGATGGGGCGGACCTCAATCTCATGCCTATA' + assert ( + t.data.compressed_to_full_sequence(t.tree.root.cseq, as_string=True) + == 'ATGAATCCAAATCAAAAGATAATAACGATTGGCTCTGTTTCTCTCACCATTTCCACAATATGCTTCTTCATGCAAATTGCCATCTTGATAACTACTGTAACATTGCATTTCAAGCAATATGAATTCAACTCCCCCCCAAACAACCAAGTGATGCTGTGTGAACCAACAATAATAGAAAGAAACATAACAGAGATAGTGTATCTGACCAACACCACCATAGAGAAGGAAATATGCCCCAAACCAGCAGAATACAGAAATTGGTCAAAACCGCAATGTGGCATTACAGGATTTGCACCTTTCTCTAAGGACAATTCGATTAGGCTTTCCGCTGGTGGGGACATCTGGGTGACAAGAGAACCTTATGTGTCATGCGATCCTGACAAGTGTTATCAATTTGCCCTTGGACAGGGAACAACACTAAACAACGTGCATTCAAATAACACAGTACGTGATAGGACCCCTTATCGGACTCTATTGATGAATGAGTTGGGTGTTCCTTTTCATCTGGGGACCAAGCAAGTGTGCATAGCATGGTCCAGCTCAAGTTGTCACGATGGAAAAGCATGGCTGCATGTTTGTATAACGGGGGATGATAAAAATGCAACTGCTAGCTTCATTTACAATGGGAGGCTTGTAGATAGTGTTGTTTCATGGTCCAAAGAAATTCTCAGGACCCAGGAGTCAGAATGCGTTTGTATCAATGGAACTTGTACAGTAGTAATGACTGATGGAAGTGCTTCAGGAAAAGCTGATACTAAAATACTATTCATTGAGGAGGGGAAAATCGTTCATACTAGCACATTGTCAGGAAGTGCTCAGCATGTCGAAGAGTGCTCTTGCTATCCTCGATATCCTGGTGTCAGATGTGTCTGCAGAGACAACTGGAAAGGCTCCAATCGGCCCATCGTAGATATAAACATAAAGGATCATAGCATTGTTTCCAGTTATGTGTGTTCAGGACTTGTTGGAGACACACCCAGAAAAAACGACAGCTCCAGCAGTAGCCATTGTTTGGATCCTAACAATGAAGAAGGTGGTCATGGAGTGAAAGGCTGGGCCTTTGATGATGGAAATGACGTGTGGATGGGAAGAACAATCAACGAGACGTCACGCTTAGGGTATGAAACCTTCAAAGTCATTGAAGGCTGGTCCAACCCTAAGTCCAAATTGCAGATAAATAGGCAAGTCATAGTTGACAGAGGTGATAGGTCCGGTTATTCTGGTATTTTCTCTGTTGAAGGCAAAAGCTGCATCAATCGGTGCTTTTATGTGGAGTTGATTAGGGGAAGAAAAGAGGAAACTGAAGTCTTGTGGACCTCAAACAGTATTGTTGTGTTTTGTGGCACCTCAGGTACATATGGAACAGGCTCATGGCCTGATGGGGCGGACCTCAATCTCATGCCTATA' + ) print('testing LH normalization') - from Bio import Phylo,AlignIO - tiny_tree = Phylo.read(StringIO("((A:0.60100000009,B:0.3010000009):0.1,C:0.2):0.001;"), 'newick') - tiny_aln = AlignIO.read(StringIO(">A\nAAAAAAAAAAAAAAAACCCCCCCCCCCCCCCCGGGGGGGGGGGGGGGGTTTTTTTTTTTTTTTT\n" - ">B\nAAAACCCCGGGGTTTTAAAACCCCGGGGTTTTAAAACCCCGGGGTTTTAAAACCCCGGGGTTTT\n" - ">C\nACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT\n"), 'fasta') + from Bio import Phylo, AlignIO - mygtr = GTR.custom(alphabet = np.array(['A', 'C', 'G', 'T']), pi = np.array([0.9, 0.06, 0.02, 0.02]), W=np.ones((4,4))) + tiny_tree = Phylo.read(StringIO('((A:0.60100000009,B:0.3010000009):0.1,C:0.2):0.001;'), 'newick') + tiny_aln = AlignIO.read( + StringIO( + '>A\nAAAAAAAAAAAAAAAACCCCCCCCCCCCCCCCGGGGGGGGGGGGGGGGTTTTTTTTTTTTTTTT\n' + '>B\nAAAACCCCGGGGTTTTAAAACCCCGGGGTTTTAAAACCCCGGGGTTTTAAAACCCCGGGGTTTT\n' + '>C\nACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGT\n' + ), + 'fasta', + ) + + mygtr = GTR.custom(alphabet=np.array(['A', 'C', 'G', 'T']), pi=np.array([0.9, 0.06, 0.02, 0.02]), W=np.ones((4, 4))) t = TreeAnc(gtr=mygtr, tree=tiny_tree, aln=tiny_aln, rng_seed=1234) t.reconstruct_anc('ml', marginal=True, debug=True) - lhsum = np.exp(t.sequence_LH(pos=np.arange(4**3))).sum() - print (lhsum) - assert(np.abs(lhsum-1.0)<1e-6) + lhsum = np.exp(t.sequence_LH(pos=np.arange(4**3))).sum() + print(lhsum) + assert np.abs(lhsum - 1.0) < 1e-6 t.optimize_branch_len() @@ -143,11 +173,8 @@ def test_seq_joint_reconstruction_correct(): import numpy as np from collections import defaultdict - tiny_tree = Phylo.read(StringIO("((A:.060,B:.01200)C:.020,D:.0050)E:.004;"), 'newick') - mygtr = GTR.custom(alphabet = np.array(['A', 'C', 'G', 'T']), - pi = np.array([0.15, 0.95, 0.05, 0.3]), - W=np.ones((4,4))) - + tiny_tree = Phylo.read(StringIO('((A:.060,B:.01200)C:.020,D:.0050)E:.004;'), 'newick') + mygtr = GTR.custom(alphabet=np.array(['A', 'C', 'G', 'T']), pi=np.array([0.15, 0.95, 0.05, 0.3]), W=np.ones((4, 4))) myTree = TreeAnc(gtr=mygtr, tree=tiny_tree, aln=None, verbose=4, rng_seed=1234) @@ -155,7 +182,7 @@ def test_seq_joint_reconstruction_correct(): tree = myTree.tree seq_len = 400 tree.root.ref_seq = myTree.rng.choice(mygtr.alphabet, p=mygtr.Pi, size=seq_len) - print ("Root sequence: " + ''.join(tree.root.ref_seq.astype('U'))) + print('Root sequence: ' + ''.join(tree.root.ref_seq.astype('U'))) mutation_list = defaultdict(list) for node in tree.find_clades(): for c in node.clades: @@ -163,27 +190,28 @@ def test_seq_joint_reconstruction_correct(): if hasattr(node, 'ref_seq'): continue t = node.branch_length - p = mygtr.evolve( seq_utils.seq2prof(node.up.ref_seq, mygtr.profile_map), t) + p = mygtr.evolve(seq_utils.seq2prof(node.up.ref_seq, mygtr.profile_map), t) # normalize profile - p=(p.T/p.sum(axis=1)).T + p = (p.T / p.sum(axis=1)).T # sample mutations randomly ref_seq_idxs = np.array([int(myTree.rng.choice(np.arange(p.shape[1]), p=p[k])) for k in np.arange(p.shape[0])]) node.ref_seq = np.array([mygtr.alphabet[k] for k in ref_seq_idxs]) - node.ref_mutations = [(anc, pos, der) for pos, (anc, der) in - enumerate(zip(node.up.ref_seq, node.ref_seq)) if anc!=der] + node.ref_mutations = [ + (anc, pos, der) for pos, (anc, der) in enumerate(zip(node.up.ref_seq, node.ref_seq)) if anc != der + ] for anc, pos, der in node.ref_mutations: mutation_list[pos].append((node.name, anc, der)) - print (node.name, len(node.ref_mutations), node.ref_mutations) + print(node.name, len(node.ref_mutations), node.ref_mutations) # set as the starting sequences to the terminal nodes: - alnstr = "" + alnstr = '' i = 1 for leaf in tree.get_terminals(): - alnstr += ">" + leaf.name + "\n" + ''.join(leaf.ref_seq.astype('U')) + '\n' + alnstr += '>' + leaf.name + '\n' + ''.join(leaf.ref_seq.astype('U')) + '\n' i += 1 - print (alnstr) + print(alnstr) myTree.aln = AlignIO.read(StringIO(alnstr), 'fasta') # reconstruct ancestral sequences: myTree.infer_ancestral_sequences(final=True, debug=True, reconstruct_leaves=True) @@ -195,19 +223,19 @@ def test_seq_joint_reconstruction_correct(): mut_count += len(node.ref_mutations) diff_count += np.sum(node.sequence != node.ref_seq) if np.sum(node.sequence != node.ref_seq): - print("%s: True sequence does not equal inferred sequence. parent %s"%(node.name, node.up.name)) + print('%s: True sequence does not equal inferred sequence. parent %s' % (node.name, node.up.name)) else: - print("%s: True sequence equals inferred sequence. parent %s"%(node.name, node.up.name)) + print('%s: True sequence equals inferred sequence. parent %s' % (node.name, node.up.name)) # the assignment of mutations to the root node is probabilistic. Hence some differences are expected - assert diff_count/seq_len<2*(1.0*mut_count/seq_len)**2 + assert diff_count / seq_len < 2 * (1.0 * mut_count / seq_len) ** 2 # prove the likelihood value calculation is correct LH = myTree.ancestral_likelihood() - LH_p = (myTree.tree.sequence_LH) + LH_p = myTree.tree.sequence_LH - print ("Difference between reference and inferred LH:", (LH - LH_p).sum()) - assert ((LH - LH_p).sum())<1e-9 + print('Difference between reference and inferred LH:', (LH - LH_p).sum()) + assert ((LH - LH_p).sum()) < 1e-9 def test_seq_joint_lh_is_max(): @@ -222,18 +250,20 @@ def test_seq_joint_lh_is_max(): from Bio import Phylo, AlignIO import numpy as np - mygtr = GTR.custom(alphabet = np.array(['A', 'C', 'G', 'T']), pi = np.array([0.91, 0.05, 0.02, 0.02]), W=np.ones((4,4))) - tiny_tree = Phylo.read(StringIO("((A:.0060,B:.30)C:.030,D:.020)E:.004;"), 'newick') + mygtr = GTR.custom( + alphabet=np.array(['A', 'C', 'G', 'T']), pi=np.array([0.91, 0.05, 0.02, 0.02]), W=np.ones((4, 4)) + ) + tiny_tree = Phylo.read(StringIO('((A:.0060,B:.30)C:.030,D:.020)E:.004;'), 'newick') - #terminal node sequences (single nuc) + # terminal node sequences (single nuc) A_char = 'A' B_char = 'C' D_char = 'G' # for brute-force, expand them to the strings - A_seq = ''.join(np.repeat(A_char,16)) - B_seq = ''.join(np.repeat(B_char,16)) - D_seq = ''.join(np.repeat(D_char,16)) + A_seq = ''.join(np.repeat(A_char, 16)) + B_seq = ''.join(np.repeat(B_char, 16)) + D_seq = ''.join(np.repeat(D_char, 16)) # def ref_lh(): @@ -242,14 +272,14 @@ def test_seq_joint_lh_is_max(): of the internal node sequences """ - tiny_aln = AlignIO.read(StringIO(">A\n" + A_seq + "\n" - ">B\n" + B_seq + "\n" - ">D\n" + D_seq + "\n" - ">C\nAAAACCCCGGGGTTTT\n" - ">E\nACGTACGTACGTACGT\n"), 'fasta') + tiny_aln = AlignIO.read( + StringIO( + '>A\n' + A_seq + '\n>B\n' + B_seq + '\n>D\n' + D_seq + '\n>C\nAAAACCCCGGGGTTTT\n>E\nACGTACGTACGTACGT\n' + ), + 'fasta', + ) - myTree = TreeAnc(gtr=mygtr, tree = tiny_tree, - aln =tiny_aln, verbose = 4, rng_seed=1234) + myTree = TreeAnc(gtr=mygtr, tree=tiny_tree, aln=tiny_aln, verbose=4, rng_seed=1234) logLH_ref = myTree.ancestral_likelihood() @@ -261,20 +291,73 @@ def test_seq_joint_lh_is_max(): Likelihood of the sequences calculated by the joint ancestral sequence reconstruction """ - tiny_aln_1 = AlignIO.read(StringIO(">A\n"+A_char+"\n" - ">B\n"+B_char+"\n" - ">D\n"+D_char+"\n"), 'fasta') + tiny_aln_1 = AlignIO.read(StringIO('>A\n' + A_char + '\n>B\n' + B_char + '\n>D\n' + D_char + '\n'), 'fasta') - myTree_1 = TreeAnc(gtr=mygtr, tree = tiny_tree, - aln=tiny_aln_1, verbose = 4, rng_seed=1234) + myTree_1 = TreeAnc(gtr=mygtr, tree=tiny_tree, aln=tiny_aln_1, verbose=4, rng_seed=1234) myTree_1.reconstruct_anc(method='ml', marginal=False, debug=True) logLH = myTree_1.tree.sequence_LH return logLH ref = ref_lh() - real = real_lh() + real = real_lh() - print(abs(ref.max() - real) ) + print(abs(ref.max() - real)) # joint chooses the most likely realization of the tree - assert(abs(ref.max() - real) < 1e-10) + assert abs(ref.max() - real) < 1e-10 + + +def test_marginal_mode_used_in_all_iterations(root_dir=None): + """Verify that marginal reconstruction is used in every call to + infer_ancestral_sequences when branch_length_mode='marginal'. + + Regression test for https://github.com/neherlab/treetime/issues/601: + later iterations passed marginal_sequences via **seq_kwargs but not as the + explicit `marginal` parameter, causing infer_ancestral_sequences to default + to joint reconstruction. + """ + import os + from unittest.mock import patch + from treetime import TreeTime + from treetime.utils import parse_dates + + if root_dir is None: + root_dir = os.path.dirname(os.path.realpath(__file__)) + + fasta = root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.fasta' + nwk = root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.nwk' + dates = parse_dates(root_dir + '/treetime_examples/data/h3n2_na/h3n2_na_20.metadata.csv') + + myTree = TreeTime( + gtr='Jukes-Cantor', + tree=nwk, + use_fft=False, + aln=fasta, + verbose=1, + dates=dates, + precision=3, + debug=True, + rng_seed=1234, + ) + + marginal_args_log = [] + original = myTree.infer_ancestral_sequences.__func__ + + def tracking_wrapper(self, *args, marginal=False, **kwargs): + marginal_args_log.append(marginal) + return original(self, *args, marginal=marginal, **kwargs) + + with patch.object(type(myTree), 'infer_ancestral_sequences', tracking_wrapper): + myTree.run( + branch_length_mode='marginal', + infer_gtr=False, + max_iter=2, + time_marginal=False, + ) + + assert len(marginal_args_log) > 0, "infer_ancestral_sequences was never called" + for i, was_marginal in enumerate(marginal_args_log): + assert was_marginal, ( + f"Call #{i} to infer_ancestral_sequences used marginal=False (joint) " + f"when marginal reconstruction was requested" + ) diff --git a/test/test_vcf.py b/test/test_vcf.py index 26793a4..81db88c 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -3,46 +3,67 @@ from textwrap import dedent from treetime.vcf_utils import read_vcf, write_vcf from treetime import TreeTimeError + def create_vcf(dir, content): d = dir / 'data' d.mkdir() - fname = d / "no-header.vcf" + fname = d / 'no-header.vcf' with open(fname, 'w') as fh: print(dedent(content), file=fh) return str(fname) -class TestMalformedVcf: +class TestMalformedVcf: def test_no_header(self, tmp_path): with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 - """)) + """, + ) + ) def test_meta_info_after_header(self, tmp_path): with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C ##fileformat=VCFv4.3 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 - """)) - + """, + ) + ) + def test_inconsistent_sample_numbers(self, tmp_path): with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 1\t6\t.\tT\tC\t.\t.\t.\tGT\t1\t1 - """)) + """, + ) + ) def test_no_data_lines(self, tmp_path): with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C - """)) + """, + ) + ) def test_consistent_ploidy(self, tmp_path): """ @@ -53,49 +74,65 @@ class TestMalformedVcf: haploid genotype)." """ with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1/1 - """)) + """, + ) + ) + class TestMultipleChromosomes: """ These are valid VCFs but TreeTime cannot yet parse them """ + def test_multiple_chromosomes(self, tmp_path): with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 2\t6\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 - """)) + """, + ) + ) + def zero_based(idx): """Useful as a form of code commentary. Convert idx from 1-based to 0-based""" - return idx-1 + return idx - 1 + -def write_data(tmp_path_factory, sample_names, data_lines, reference_seq, meta_lines=None, reference_name='reference_name'): +def write_data( + tmp_path_factory, sample_names, data_lines, reference_seq, meta_lines=None, reference_name='reference_name' +): """helper function to create and write a VCF and FASTA file. Returns a tuple of filenames""" if meta_lines: - vcf = "\n".join(meta_lines)+"\n" + vcf = '\n'.join(meta_lines) + '\n' else: vcf = dedent("""\ ##fileformat=VCFv4.3 ##contig=<ID=1,length=50> ##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype"> """) - vcf += "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t" + "\t".join(sample_names) + "\n" + vcf += '#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t' + '\t'.join(sample_names) + '\n' for line in data_lines: - vcf += "\t".join(line) + "\n" - reference=dedent(f"""\ + vcf += '\t'.join(line) + '\n' + reference = dedent(f"""\ >{reference_name} {reference_seq} """) - dir = tmp_path_factory.mktemp("data") - vcf_fn = dir / "snps.vcf" - ref_fn = dir / "reference.fasta" + dir = tmp_path_factory.mktemp('data') + vcf_fn = dir / 'snps.vcf' + ref_fn = dir / 'reference.fasta' with open(vcf_fn, 'w') as fh: print(vcf, file=fh) with open(ref_fn, 'w') as fh: @@ -104,7 +141,7 @@ def write_data(tmp_path_factory, sample_names, data_lines, reference_seq, meta_l class TestSimpleVcf: - @pytest.fixture(scope="class") + @pytest.fixture(scope='class') def data(self, tmp_path_factory): """ Creates the VCF and reference files (in a temporary location) to be used by @@ -113,29 +150,29 @@ class TestSimpleVcf: we can use as comparisons in tests. NOTE: this function is only run once - _not_ once per test. """ - sample_names = ["sample_A", "sample_B", "sample_C"] + sample_names = ['sample_A', 'sample_B', 'sample_C'] data_lines = [ - ["1", "5", ".", "T", "C", ".", ".", ".", "GT", "1", "1", "1"], - ["1", "7", ".", "T", "G", ".", ".", ".", "GT", "1", "1", "0"], - ["1", "14", ".", "C", "T", ".", ".", ".", "GT", "1", "1", "0"], - ["1", "18", ".", "C", "T", ".", ".", ".", "GT", "0", "0", "1"], - ["1", "28", ".", "A", "N", ".", ".", ".", "GT", "0", "0", "1"], - ["1", "29", ".", "A", "N", ".", ".", ".", "GT", "0", "0", "1"], - ["1", "30", ".", "A", "N", ".", ".", ".", "GT", "0", "0", "1"], - ["1", "33", ".", "A", "C,G", ".", ".", ".", "GT", "1", "2", "0"], - ["1", "39", ".", "C", "T", ".", ".", ".", "GT", "1", "0", "0"], - ["1", "42", ".", "G", "A", ".", ".", ".", "GT", "0", "1", "0"], - ["1", "43", ".", "A", "T", ".", ".", ".", "GT", "1", "1", "0"], + ['1', '5', '.', 'T', 'C', '.', '.', '.', 'GT', '1', '1', '1'], + ['1', '7', '.', 'T', 'G', '.', '.', '.', 'GT', '1', '1', '0'], + ['1', '14', '.', 'C', 'T', '.', '.', '.', 'GT', '1', '1', '0'], + ['1', '18', '.', 'C', 'T', '.', '.', '.', 'GT', '0', '0', '1'], + ['1', '28', '.', 'A', 'N', '.', '.', '.', 'GT', '0', '0', '1'], + ['1', '29', '.', 'A', 'N', '.', '.', '.', 'GT', '0', '0', '1'], + ['1', '30', '.', 'A', 'N', '.', '.', '.', 'GT', '0', '0', '1'], + ['1', '33', '.', 'A', 'C,G', '.', '.', '.', 'GT', '1', '2', '0'], + ['1', '39', '.', 'C', 'T', '.', '.', '.', 'GT', '1', '0', '0'], + ['1', '42', '.', 'G', 'A', '.', '.', '.', 'GT', '0', '1', '0'], + ['1', '43', '.', 'A', 'T', '.', '.', '.', 'GT', '1', '1', '0'], ] positions = [int(line[1]) for line in data_lines] reference_seq = 'AAAAAAAAAATGCCCTGCGGGTAAAAAAAAAAAAACTACTTGACCATAAA' filenames = write_data(tmp_path_factory, sample_names, data_lines, reference_seq) return { - "filenames": filenames, - "positions": positions, - "data_lines": data_lines, - "sample_names": sample_names, - "reference_seq": reference_seq, + 'filenames': filenames, + 'positions': positions, + 'data_lines': data_lines, + 'sample_names': sample_names, + 'reference_seq': reference_seq, } def test_mutation_structure(self, data): @@ -143,34 +180,35 @@ class TestSimpleVcf: # The structure of insertions & sequences is a dict a key for each sample, irregardless of whether # any insertions/mutations are defined for that sample. # samples = {'sample_A', 'sample_B', 'sample_C'} - assert(set(data['sample_names'])==set(vcf_data['sequences'].keys())) - assert(set(data['sample_names'])==set(vcf_data['insertions'].keys())) + assert set(data['sample_names']) == set(vcf_data['sequences'].keys()) + assert set(data['sample_names']) == set(vcf_data['insertions'].keys()) def test_mutation_parsing(self, data): """ Test that the correct SNPs (ALTs) are extracted from the VCF for the three samples defined in the VCF """ vcf_data = read_vcf(*data['filenames']) + def summarise(sample): # uses 1-based position so we can easily compare with the VCF file - return ",".join([f"{pos+1}{alt}" for pos,alt in vcf_data['sequences'][sample].items()]) + return ','.join([f'{pos + 1}{alt}' for pos, alt in vcf_data['sequences'][sample].items()]) - assert("5C,7G,14T,33C,39T,43T"==summarise('sample_A')) - assert("5C,7G,14T,33G,42A,43T"==summarise('sample_B')) - assert("5C,18T,28N,29N,30N"==summarise('sample_C')) + assert '5C,7G,14T,33C,39T,43T' == summarise('sample_A') + assert '5C,7G,14T,33G,42A,43T' == summarise('sample_B') + assert '5C,18T,28N,29N,30N' == summarise('sample_C') def test_no_insertions_parsed(self, data): vcf_data = read_vcf(*data['filenames']) for sample, ins in vcf_data['insertions'].items(): - assert(ins=={}) + assert ins == {} def test_reference_sequence(self, data): vcf_data = read_vcf(*data['filenames']) - assert(data['reference_seq']==vcf_data['reference']) + assert data['reference_seq'] == vcf_data['reference'] def test_positions_with_mutations(self, data): vcf_data = read_vcf(*data['filenames']) - assert(data['positions'] == [pos+1 for pos in vcf_data['positions']]) + assert data['positions'] == [pos + 1 for pos in vcf_data['positions']] class TestNoCallsOrMissing: @@ -192,64 +230,87 @@ class TestNoCallsOrMissing: v4.2 VCF files, e.g. those produced by `snp_sites`. """ - @pytest.fixture(scope="class") + @pytest.fixture(scope='class') def data(self, tmp_path_factory): - reference="ATCGA" - sample_names = ["sample_A", "sample_B", "sample_C", "sample_D"] + reference = 'ATCGA' + sample_names = ['sample_A', 'sample_B', 'sample_C', 'sample_D'] data_lines = [ ## No call alleles - ["1", "2", ".", "T", "A", ".", ".", ".", "GT", ".", "1", "0", "0"], # sample A has T2N - ["1", "4", ".", "GA", "C", ".", ".", ".", "GT", ".", "1", "0", "0"], # sample A has GA -> NN + ['1', '2', '.', 'T', 'A', '.', '.', '.', 'GT', '.', '1', '0', '0'], # sample A has T2N + ['1', '4', '.', 'GA', 'C', '.', '.', '.', 'GT', '.', '1', '0', '0'], # sample A has GA -> NN ## star alleles & dot alleles indicate missing - ["1", "3", ".", "C", "*,G", ".", ".", ".", "GT", "1", "2", "0", "0"], # sample A has C->N - ["1", "3", ".", "C", "T,.", ".", ".", ".", "GT", "0", "0", "1", "2"], # sample D has C->N - ["1", "4", ".", "GA", ".,*", ".", ".", ".", "GT", "0", "0", "1", "2"], # both samples C & D have G->N and A->N + ['1', '3', '.', 'C', '*,G', '.', '.', '.', 'GT', '1', '2', '0', '0'], # sample A has C->N + ['1', '3', '.', 'C', 'T,.', '.', '.', '.', 'GT', '0', '0', '1', '2'], # sample D has C->N + [ + '1', + '4', + '.', + 'GA', + '.,*', + '.', + '.', + '.', + 'GT', + '0', + '0', + '1', + '2', + ], # both samples C & D have G->N and A->N ] filenames = write_data(tmp_path_factory, sample_names, data_lines, reference) - return {"filenames": filenames} + return {'filenames': filenames} def test_no_call_allele(self, data): vcf_data = read_vcf(*data['filenames']) - assert(vcf_data['sequences']['sample_A'][zero_based(2)]=='N') - assert(vcf_data['sequences']['sample_A'][zero_based(4)]=='N') - assert(vcf_data['sequences']['sample_A'][zero_based(5)]=='N') - assert(vcf_data['sequences']['sample_B'][zero_based(4)]=='C') - assert(vcf_data['sequences']['sample_B'][zero_based(5)]=='-') + assert vcf_data['sequences']['sample_A'][zero_based(2)] == 'N' + assert vcf_data['sequences']['sample_A'][zero_based(4)] == 'N' + assert vcf_data['sequences']['sample_A'][zero_based(5)] == 'N' + assert vcf_data['sequences']['sample_B'][zero_based(4)] == 'C' + assert vcf_data['sequences']['sample_B'][zero_based(5)] == '-' def test_star_allele(self, data): vcf_data = read_vcf(*data['filenames']) - assert(vcf_data['sequences']['sample_A'][zero_based(3)]=='N') - assert(vcf_data['sequences']['sample_B'][zero_based(3)]=='G') + assert vcf_data['sequences']['sample_A'][zero_based(3)] == 'N' + assert vcf_data['sequences']['sample_B'][zero_based(3)] == 'G' - assert(vcf_data['sequences']['sample_D'][zero_based(4)]=='N') - assert(vcf_data['sequences']['sample_D'][zero_based(5)]=='N') + assert vcf_data['sequences']['sample_D'][zero_based(4)] == 'N' + assert vcf_data['sequences']['sample_D'][zero_based(5)] == 'N' def test_dot_allele(self, data): vcf_data = read_vcf(*data['filenames']) - assert(vcf_data['sequences']['sample_C'][zero_based(3)]=='T') - assert(vcf_data['sequences']['sample_D'][zero_based(3)]=='N') - - assert(vcf_data['sequences']['sample_C'][zero_based(4)]=='N') - assert(vcf_data['sequences']['sample_C'][zero_based(5)]=='N') + assert vcf_data['sequences']['sample_C'][zero_based(3)] == 'T' + assert vcf_data['sequences']['sample_D'][zero_based(3)] == 'N' + assert vcf_data['sequences']['sample_C'][zero_based(4)] == 'N' + assert vcf_data['sequences']['sample_C'][zero_based(5)] == 'N' def test_malformed_star_allele(self, tmp_path): """* must be an entire allele, not together with other bases""" with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C ##fileformat=VCFv4.3 1\t5\t.\tT\tC*\t.\t.\t.\tGT\t1\t1\t1 - """)) + """, + ) + ) def test_malformed_dot_allele(self, tmp_path): """. must be an entire allele, not together with other bases""" with pytest.raises(TreeTimeError): - read_vcf(create_vcf(tmp_path, """\ + read_vcf( + create_vcf( + tmp_path, + """\ #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C ##fileformat=VCFv4.3 1\t5\t.\tT\tC.,G\t.\t.\t.\tGT\t1\t1\t1 - """)) + """, + ) + ) class TestVcfSpecExamples: @@ -259,33 +320,40 @@ class TestVcfSpecExamples: required to create the example data. Note that the actual `test_<name>` methods are added dynamically after the class definition. """ + def create(self, tmp_path, input): - lines = ["##fileformat=VCFv4.2", # Actual version may differ, but we don't parse this so it doesn't matter (yet) - "##contig=<ID=1,length=50>", - "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">", - "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t" + "\t".join(input['samples']), + lines = [ + '##fileformat=VCFv4.2', # Actual version may differ, but we don't parse this so it doesn't matter (yet) + '##contig=<ID=1,length=50>', + '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', + '#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t' + '\t'.join(input['samples']), ] for d in input['data']: - lines.append(f"{d['chrom']}\t{d['pos']}\t.\t{d['ref']}\t{d['alt']}\t.\t.\t.\tGT\t" + "\t".join([str(x) for x in d['values']])) - return create_vcf(tmp_path, "\n".join(lines)) + lines.append( + f'{d["chrom"]}\t{d["pos"]}\t.\t{d["ref"]}\t{d["alt"]}\t.\t.\t.\tGT\t' + + '\t'.join([str(x) for x in d['values']]) + ) + return create_vcf(tmp_path, '\n'.join(lines)) @staticmethod def add_test(example_data): def test(self, tmp_path): vcf = read_vcf(self.create(tmp_path, example_data)) for sample_name, sample_data in example_data['expect_sequences'].items(): - assert(sample_name in vcf['sequences']) + assert sample_name in vcf['sequences'] for snp in sample_data: - assert(len(snp)==2) - assert(vcf['sequences'][sample_name][zero_based(int(snp[0]))] == snp[1]) - assert(len(vcf['sequences'][sample_name].keys()) == len(sample_data)) + assert len(snp) == 2 + assert vcf['sequences'][sample_name][zero_based(int(snp[0]))] == snp[1] + assert len(vcf['sequences'][sample_name].keys()) == len(sample_data) for sample_name, sample_data in example_data['expect_insertions'].items(): - assert(sample_name in vcf['insertions']) + assert sample_name in vcf['insertions'] for ins in sample_data: - assert(vcf['insertions'][sample_name][zero_based(int(ins[0]))] == ins[1:]) - assert(len(vcf['insertions'][sample_name].keys()) == len(sample_data)) + assert vcf['insertions'][sample_name][zero_based(int(ins[0]))] == ins[1:] + assert len(vcf['insertions'][sample_name].keys()) == len(sample_data) + return test + """ -------- comment re: TreeTime's encoding of insertions -------- @@ -304,19 +372,17 @@ vcf_spec_data = { 'samples': ['A', 'B', 'C'], 'data': [ {'chrom': 20, 'pos': 3, 'ref': 'C', 'alt': 'G', 'values': [1, 0, 0]}, - {'chrom': 20, 'pos': 2, 'ref': 'TC', 'alt': 'T,TCA', 'values': [0, 1, 2]} + {'chrom': 20, 'pos': 2, 'ref': 'TC', 'alt': 'T,TCA', 'values': [0, 1, 2]}, ], # Note that with TC->TCA, there are no sequence changes (T->T, C->C) but one insertion ("A") 'expect_sequences': {'A': ['3G'], 'B': ['3-']}, - 'expect_insertions': {'C': ['3CA']} # see comment above. Actually only a single base "A" insertion + 'expect_insertions': {'C': ['3CA']}, # see comment above. Actually only a single base "A" insertion }, '5_1_2': { 'samples': ['A', 'B'], - 'data': [ - {'chrom': 20, 'pos': 2, 'ref': 'TC', 'alt': 'TG,T', 'values': [1,2]} - ], + 'data': [{'chrom': 20, 'pos': 2, 'ref': 'TC', 'alt': 'TG,T', 'values': [1, 2]}], 'expect_sequences': {'A': ['3G'], 'B': ['3-']}, - 'expect_insertions': {} + 'expect_insertions': {}, }, '5_1_3': { 'samples': ['A', 'B', 'C'], @@ -327,42 +393,38 @@ vcf_spec_data = { ## actual placement of equivalent g isn't retained" (also explained in example 5.2.4) ## Similarly, for sample C, the actual event is described as "A base is inserted wrt the reference ## sequence" but the way the VCF file reads we are going to have a SNP (G->A) + a insertion (G) - 'data': [ - {'chrom': 20, 'pos': 2, 'ref': 'TCG', 'alt': 'TG,T,TCAG', 'values': [1,2,3]} - ], + 'data': [{'chrom': 20, 'pos': 2, 'ref': 'TCG', 'alt': 'TG,T,TCAG', 'values': [1, 2, 3]}], 'expect_sequences': {'A': ['3G', '4-'], 'B': ['3-', '4-'], 'C': ['4A']}, - 'expect_insertions': {'C': ['4AG']} # See comment above + 'expect_insertions': {'C': ['4AG']}, # See comment above }, '5_2_1': { 'samples': ['A'], - 'data': [ - {'chrom': 20, 'pos': 3, 'ref': 'C', 'alt': 'T', 'values': [1]} - ], + 'data': [{'chrom': 20, 'pos': 3, 'ref': 'C', 'alt': 'T', 'values': [1]}], 'expect_sequences': {'A': ['3T']}, - 'expect_insertions': {} + 'expect_insertions': {}, }, '5_2_2': { 'samples': ['A'], - 'data': [ - {'chrom': 20, 'pos': 3, 'ref': 'C', 'alt': 'CTAG', 'values': [1]} - ], + 'data': [{'chrom': 20, 'pos': 3, 'ref': 'C', 'alt': 'CTAG', 'values': [1]}], 'expect_sequences': {}, - 'expect_insertions': {'A': ['3CTAG']} # See comment above + 'expect_insertions': {'A': ['3CTAG']}, # See comment above }, '5_2_3': { 'samples': ['A'], - 'data': [ - {'chrom': 20, 'pos': 2, 'ref': 'TCG', 'alt': 'T', 'values': [1]} - ], + 'data': [{'chrom': 20, 'pos': 2, 'ref': 'TCG', 'alt': 'T', 'values': [1]}], 'expect_sequences': {'A': ['3-', '4-']}, - 'expect_insertions': {} + 'expect_insertions': {}, }, } } # dynamically create a test for each example in the above data (by adding methods to the class) for spec_version, spec_data in vcf_spec_data.items(): for example_key, example_data in spec_data.items(): - setattr(TestVcfSpecExamples, f"test_{spec_version}_example_{example_key}", TestVcfSpecExamples.add_test(example_data)) + setattr( + TestVcfSpecExamples, + f'test_{spec_version}_example_{example_key}', + TestVcfSpecExamples.add_test(example_data), + ) class TestMutationAndInsertion: @@ -370,64 +432,100 @@ class TestMutationAndInsertion: Tests the situation where a reference base is mutated _and_ there's an insertion """ - @pytest.fixture(scope="class") + @pytest.fixture(scope='class') def data(self, tmp_path_factory): - reference="ATCGA" - sample_names = ["sample_A", "sample_B"] + reference = 'ATCGA' + sample_names = ['sample_A', 'sample_B'] data_lines = [ - ["1", "2", ".", "T", "GA", ".", ".", ".", "GT", "1", "0"], # sample A has both a C->G mutation _and_ a subsequent "A" insertion - ["1", "3", ".", "CGA", "NTTTA,CCGT", ".", ".", ".", "GT", "1", "2"], # complex! Both samples have multiple mutations + an insertion + [ + '1', + '2', + '.', + 'T', + 'GA', + '.', + '.', + '.', + 'GT', + '1', + '0', + ], # sample A has both a C->G mutation _and_ a subsequent "A" insertion + [ + '1', + '3', + '.', + 'CGA', + 'NTTTA,CCGT', + '.', + '.', + '.', + 'GT', + '1', + '2', + ], # complex! Both samples have multiple mutations + an insertion ] filenames = write_data(tmp_path_factory, sample_names, data_lines, reference) - return {"filenames": filenames} + return {'filenames': filenames} def test_single_ref_base_mutation_and_insertion(self, data): # This case was missed in treetime 0.11.1 vcf_data = read_vcf(*data['filenames']) - assert(vcf_data['sequences']['sample_A'][zero_based(2)]=='G') - assert(vcf_data['insertions']['sample_A'][zero_based(2)]=='GA') # see comment above re: insertion encoding + assert vcf_data['sequences']['sample_A'][zero_based(2)] == 'G' + assert vcf_data['insertions']['sample_A'][zero_based(2)] == 'GA' # see comment above re: insertion encoding def test_multi_ref_base_mutations_and_insertion(self, data): # This case was missed in treetime 0.11.1 vcf_data = read_vcf(*data['filenames']) - assert(vcf_data['sequences']['sample_A'][zero_based(3)]=='N') - assert(vcf_data['sequences']['sample_A'][zero_based(4)]=='T') - assert(vcf_data['sequences']['sample_A'][zero_based(5)]=='T') - assert(vcf_data['insertions']['sample_A'][zero_based(5)]=='TTA') # see comment above re: insertion encoding + assert vcf_data['sequences']['sample_A'][zero_based(3)] == 'N' + assert vcf_data['sequences']['sample_A'][zero_based(4)] == 'T' + assert vcf_data['sequences']['sample_A'][zero_based(5)] == 'T' + assert vcf_data['insertions']['sample_A'][zero_based(5)] == 'TTA' # see comment above re: insertion encoding + + assert vcf_data['sequences']['sample_B'][zero_based(4)] == 'C' + assert vcf_data['sequences']['sample_B'][zero_based(5)] == 'G' + assert vcf_data['insertions']['sample_B'][zero_based(5)] == 'GT' # see comment above re: insertion encoding - assert(vcf_data['sequences']['sample_B'][zero_based(4)]=='C') - assert(vcf_data['sequences']['sample_B'][zero_based(5)]=='G') - assert(vcf_data['insertions']['sample_B'][zero_based(5)]=='GT') # see comment above re: insertion encoding class TestMetadataParsing: - def test_simple_haploid(self, tmp_path): - data = read_vcf(create_vcf(tmp_path, """\ + data = read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A\tsample_B\tsample_C 1\t5\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 1\t6\t.\tT\tC\t.\t.\t.\tGT\t1\t1\t1 - """)) - assert(data['metadata']['chrom']=='1') - assert(data['metadata']['ploidy']==1) - assert(data['metadata']['meta_lines']==['##fileformat=VCFv4.3']) + """, + ) + ) + assert data['metadata']['chrom'] == '1' + assert data['metadata']['ploidy'] == 1 + assert data['metadata']['meta_lines'] == ['##fileformat=VCFv4.3'] def test_simple_diploid(self, tmp_path): - data = read_vcf(create_vcf(tmp_path, """\ + data = read_vcf( + create_vcf( + tmp_path, + """\ ##fileformat=VCFv4.3 ##contig=<ID=1,length=50> ##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype"> #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tsample_A foo\t5\t.\tT\tC\t.\t.\t.\tGT\t1/1 foo\t6\t.\tT\tC\t.\t.\t.\tGT\t.|. - """)) - assert(data['metadata']['chrom']=='foo') - assert(data['metadata']['ploidy']==2) - assert(data['metadata']['meta_lines']==['##fileformat=VCFv4.3', '##contig=<ID=1,length=50>', '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">']) - + """, + ) + ) + assert data['metadata']['chrom'] == 'foo' + assert data['metadata']['ploidy'] == 2 + assert data['metadata']['meta_lines'] == [ + '##fileformat=VCFv4.3', + '##contig=<ID=1,length=50>', + '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', + ] - def roundtrip(tmp_path, sample_names, data_lines, reference, meta_lines, pass_metadata=True): """ Write the provided data to a (temporary) VCF file. @@ -438,21 +536,26 @@ def roundtrip(tmp_path, sample_names, data_lines, reference, meta_lines, pass_me dir = tmp_path / 'data' dir.mkdir() - vcf_filename_a = str(dir / "a.vcf") - vcf_filename_b = str(dir / "b.vcf") + vcf_filename_a = str(dir / 'a.vcf') + vcf_filename_b = str(dir / 'b.vcf') reference_filename = str(dir / 'reference.fasta') with open(str(dir / 'reference.fasta'), 'w') as fh: - print(dedent(f"""\ + print( + dedent(f"""\ >reference_name {reference} - """), file=fh) + """), + file=fh, + ) with open(vcf_filename_a, 'w') as fh: - vcf_lines = meta_lines[:] + \ - ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t" + "\t".join(sample_names)] + \ - ["\t".join(data) for data in data_lines] - print("\n".join(vcf_lines), file=fh) + vcf_lines = ( + meta_lines[:] + + ['#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t' + '\t'.join(sample_names)] + + ['\t'.join(data) for data in data_lines] + ) + print('\n'.join(vcf_lines), file=fh) vcf_a = read_vcf(vcf_filename_a, reference_filename) @@ -483,66 +586,80 @@ class TestWriting: """ def test_basic_roundtripping_vcf(self, tmp_path): - [vcf_a, vcf_b] = roundtrip(tmp_path, - reference = "ATCGACC", - meta_lines = [ - "##fileformat=VCFv4.3", - "##contig=<ID=1,length=7>", - "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">" + [vcf_a, vcf_b] = roundtrip( + tmp_path, + reference='ATCGACC', + meta_lines=[ + '##fileformat=VCFv4.3', + '##contig=<ID=1,length=7>', + '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', ], - sample_names = ["sample_A", "sample_B"], - data_lines = [ - ["foo", "2", ".", "T", "G", ".", ".", ".", "GT", "1", "0"], - ["foo", "3", ".", "C", "GA,*", ".", ".", ".", "GT", "1", "2"], # "A" insertion will be lost on roundtrip! - ["foo", "5", ".", "A", "TC", ".", ".", ".", "GT", ".", "1"], - ["foo", "6", ".", "CC", "C", ".", ".", ".", "GT", "1", "0"], # del of (1-based) pos 7 in sample A + sample_names=['sample_A', 'sample_B'], + data_lines=[ + ['foo', '2', '.', 'T', 'G', '.', '.', '.', 'GT', '1', '0'], + [ + 'foo', + '3', + '.', + 'C', + 'GA,*', + '.', + '.', + '.', + 'GT', + '1', + '2', + ], # "A" insertion will be lost on roundtrip! + ['foo', '5', '.', 'A', 'TC', '.', '.', '.', 'GT', '.', '1'], + ['foo', '6', '.', 'CC', 'C', '.', '.', '.', 'GT', '1', '0'], # del of (1-based) pos 7 in sample A ], - pass_metadata=False + pass_metadata=False, ) ## reference is certainly the same, the same FASTA file is being used for both read_vcf commands, but check anyway - assert(vcf_a['reference']==vcf_a['reference']) + assert vcf_a['reference'] == vcf_a['reference'] ## insertions, if there are any, _won't_ be the same because `write_vcf` doesn't read insertions even if they're provided ## as input. Note that vcf_a does have an insertion! ## The meta-lines are different (as expected) and since we are not passing the metadata information to `write_vcf` ## we get back the defaults, which differ from the input - assert(vcf_a['metadata']['chrom'] == "foo" and vcf_b['metadata']['chrom'] == "1") - assert(vcf_a['metadata']['ploidy'] == 1 and vcf_b['metadata']['ploidy'] == 2) + assert vcf_a['metadata']['chrom'] == 'foo' and vcf_b['metadata']['chrom'] == '1' + assert vcf_a['metadata']['ploidy'] == 1 and vcf_b['metadata']['ploidy'] == 2 ## positions should be the same (positions are only reflective of data in `sequences`, so the ignoring of insertions is ok) - assert(vcf_a['positions']==vcf_a['positions']) + assert vcf_a['positions'] == vcf_a['positions'] ## check sequences are the same - assert(vcf_a['sequences']==vcf_b['sequences']) + assert vcf_a['sequences'] == vcf_b['sequences'] def test_basic_roundtripping_vcf_with_metadata(self, tmp_path): - [vcf_a, vcf_b] = roundtrip(tmp_path, - reference = "ATCGACC", - meta_lines = [ - "##fileformat=VCFv4.3", - "##contig=<ID=1,length=7>", - "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">" + [vcf_a, vcf_b] = roundtrip( + tmp_path, + reference='ATCGACC', + meta_lines=[ + '##fileformat=VCFv4.3', + '##contig=<ID=1,length=7>', + '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', ], - sample_names = ["sample_A", "sample_B"], - data_lines = [ - ["1", "2", ".", "T", "G", ".", ".", ".", "GT", "1", "0"], - ["1", "3", ".", "C", "GA,*", ".", ".", ".", "GT", "1", "2"], # "A" insertion will be lost on roundtrip! - ["1", "5", ".", "A", "TC", ".", ".", ".", "GT", ".", "1"], - ["1", "6", ".", "CC", "C", ".", ".", ".", "GT", "1", "0"], # del of (1-based) pos 7 in sample A + sample_names=['sample_A', 'sample_B'], + data_lines=[ + ['1', '2', '.', 'T', 'G', '.', '.', '.', 'GT', '1', '0'], + ['1', '3', '.', 'C', 'GA,*', '.', '.', '.', 'GT', '1', '2'], # "A" insertion will be lost on roundtrip! + ['1', '5', '.', 'A', 'TC', '.', '.', '.', 'GT', '.', '1'], + ['1', '6', '.', 'CC', 'C', '.', '.', '.', 'GT', '1', '0'], # del of (1-based) pos 7 in sample A ], - pass_metadata=True + pass_metadata=True, ) ## reference is certainly the same, the same FASTA file is being used for both read_vcf commands, but check anyway - assert(vcf_a['reference']==vcf_a['reference']) + assert vcf_a['reference'] == vcf_a['reference'] ## insertions, if there are any, _won't_ be the same because `write_vcf` doesn't read insertions even if they're provided ## as input. Note that vcf_a does have an insertion! ## The meta-lines are different (as expected) but the chrom + ploidy should be the same - assert(vcf_a['metadata']['chrom'] == vcf_b['metadata']['chrom']) - assert(vcf_a['metadata']['ploidy'] == vcf_b['metadata']['ploidy']) + assert vcf_a['metadata']['chrom'] == vcf_b['metadata']['chrom'] + assert vcf_a['metadata']['ploidy'] == vcf_b['metadata']['ploidy'] ## positions should be the same (positions are only reflective of data in `sequences`, so the ignoring of insertions is ok) - assert(vcf_a['positions']==vcf_a['positions']) + assert vcf_a['positions'] == vcf_a['positions'] ## check sequences are the same - assert(vcf_a['sequences']==vcf_b['sequences']) + assert vcf_a['sequences'] == vcf_b['sequences'] diff --git a/treetime/CLI_io.py b/treetime/CLI_io.py index f9bc7de..8c81328 100644 --- a/treetime/CLI_io.py +++ b/treetime/CLI_io.py @@ -6,28 +6,31 @@ from Bio import __version__ as bioversion from . import version as treetime_version import numpy as np + def get_outdir(params, suffix='_treetime'): if params.outdir: if os.path.exists(params.outdir): if os.path.isdir(params.outdir): return params.outdir.rstrip('/') + '/' else: - print("designated output location %s is not a directory"%params.outdir, file=sys.stderr) + print('designated output location %s is not a directory' % params.outdir, file=sys.stderr) else: os.makedirs(params.outdir) return params.outdir.rstrip('/') + '/' from datetime import datetime + outdir_stem = datetime.now().date().isoformat() - outdir = outdir_stem + suffix.rstrip('/')+'/' + outdir = outdir_stem + suffix.rstrip('/') + '/' count = 1 while os.path.exists(outdir): - outdir = outdir_stem + '-%04d'%count + suffix.rstrip('/')+'/' + outdir = outdir_stem + '-%04d' % count + suffix.rstrip('/') + '/' count += 1 os.makedirs(outdir) return outdir + def get_basename(params, outdir): # if params.aln: # basename = outdir + '.'.join(params.aln.split('/')[-1].split('.')[:-1]) @@ -37,6 +40,7 @@ def get_basename(params, outdir): basename = outdir return basename + def read_in_DRMs(drm_file, offset): import pandas as pd @@ -45,7 +49,7 @@ def read_in_DRMs(drm_file, offset): df = pd.read_csv(drm_file, sep='\t') for mi, m in df.iterrows(): - pos = m.GENOMIC_POSITION-1+offset #put in correct numbering + pos = m.GENOMIC_POSITION - 1 + offset # put in correct numbering drmPositions.append(pos) if pos in DRMs: @@ -61,8 +65,7 @@ def read_in_DRMs(drm_file, offset): drmPositions = np.unique(drmPositions) drmPositions = np.sort(drmPositions) - DRM_info = {'DRMs': DRMs, - 'drmPositions': drmPositions} + DRM_info = {'DRMs': DRMs, 'drmPositions': drmPositions} return DRM_info @@ -77,19 +80,19 @@ def read_if_vcf(params): if hasattr(params, 'aln') and params.aln is not None: if any([params.aln.lower().endswith(x) for x in ['.vcf', '.vcf.gz']]): if not params.vcf_reference: - print("ERROR: a reference Fasta is required with VCF-format alignments") + print('ERROR: a reference Fasta is required with VCF-format alignments') return -1 compress_seq = read_vcf(params.aln, params.vcf_reference) sequences = compress_seq['sequences'] ref = compress_seq['reference'] aln = sequences - if not hasattr(params, 'gtr') or params.gtr=="infer": #if not specified, set it: + if not hasattr(params, 'gtr') or params.gtr == 'infer': # if not specified, set it: alpha = alphabets['aa'] if params.aa else alphabets['nuc'] - fixed_pi = [ref.count(base)/len(ref) for base in alpha] + fixed_pi = [ref.count(base) / len(ref) for base in alpha] if fixed_pi[-1] == 0: fixed_pi[-1] = 0.05 - fixed_pi = [v-0.01 for v in fixed_pi] + fixed_pi = [v - 0.01 for v in fixed_pi] return aln, ref, fixed_pi @@ -98,22 +101,33 @@ def plot_rtt(tt, fname): tt.plot_root_to_tip() from matplotlib import pyplot as plt - plt.savefig(fname) - print("--- root-to-tip plot saved to \n\t"+fname) - -def export_sequences_and_tree(tt, basename, is_vcf=False, zero_based=False, - report_ambiguous=False, timetree=False, confidence=False, - reconstruct_tip_states=False, tree_suffix=''): + plt.savefig(fname) + print('--- root-to-tip plot saved to \n\t' + fname) + + +def export_sequences_and_tree( + tt, + basename, + is_vcf=False, + zero_based=False, + report_ambiguous=False, + timetree=False, + confidence=False, + reconstruct_tip_states=False, + tree_suffix='', +): seq_info = is_vcf or tt.aln if is_vcf: outaln_name = basename + f'ancestral_sequences{tree_suffix}.vcf' write_vcf(tt.get_reconstructed_alignment(reconstruct_tip_states=reconstruct_tip_states), outaln_name) elif tt.aln: outaln_name = basename + f'ancestral_sequences{tree_suffix}.fasta' - AlignIO.write(tt.get_reconstructed_alignment(reconstruct_tip_states=reconstruct_tip_states), outaln_name, 'fasta') + AlignIO.write( + tt.get_reconstructed_alignment(reconstruct_tip_states=reconstruct_tip_states), outaln_name, 'fasta' + ) if seq_info: - print("\n--- alignment including ancestral nodes saved as \n\t %s\n"%outaln_name) + print('\n--- alignment including ancestral nodes saved as \n\t %s\n' % outaln_name) # decorate tree with inferred mutations terminal_count = 0 @@ -127,96 +141,127 @@ def export_sequences_and_tree(tt, basename, is_vcf=False, zero_based=False, else: fh_dates.write('#node\tdate\tnumeric date\n') - mutations_out = open(basename + "branch_mutations.txt", "w") - mutations_out.write("node\tstate1\tpos\tstate2\n") + mutations_out = open(basename + 'branch_mutations.txt', 'w') + mutations_out.write('node\tstate1\tpos\tstate2\n') for n in tt.tree.find_clades(): if timetree: if confidence: if n.bad_branch: - fh_dates.write('%s\t--\t--\t--\t--\n'%(n.name)) + fh_dates.write('%s\t--\t--\t--\t--\n' % (n.name)) else: conf = tt.get_max_posterior_region(n, fraction=0.9) - fh_dates.write('%s\t%s\t%f\t%f\t%f\n'%(n.name, n.date, n.numdate,conf[0], conf[1])) + fh_dates.write('%s\t%s\t%f\t%f\t%f\n' % (n.name, n.date, n.numdate, conf[0], conf[1])) else: if n.bad_branch: - fh_dates.write('%s\t--\t--\n'%(n.name)) + fh_dates.write('%s\t--\t--\n' % (n.name)) else: - fh_dates.write('%s\t%s\t%f\n'%(n.name, n.date, n.numdate)) + fh_dates.write('%s\t%s\t%f\n' % (n.name, n.date, n.numdate)) - n.confidence=None + n.confidence = None # due to a bug in older versions of biopython that truncated filenames in nexus export # we truncate them by hand and make them unique. - if n.is_terminal() and len(n.name)>40 and bioversion<"1.69": - n.name = n.name[:35]+'_%03d'%terminal_count - terminal_count+=1 - n.comment='' + if n.is_terminal() and len(n.name) > 40 and bioversion < '1.69': + n.name = n.name[:35] + '_%03d' % terminal_count + terminal_count += 1 + n.comment = '' if seq_info and len(n.mutations): if n.mask is None: if report_ambiguous: - n.comment= '&mutations="' + ','.join([a+str(pos + offset)+d for (a,pos, d) in n.mutations])+'"' + n.comment = ( + '&mutations="' + ','.join([a + str(pos + offset) + d for (a, pos, d) in n.mutations]) + '"' + ) else: - n.comment= '&mutations="' + ','.join([a+str(pos + offset)+d for (a,pos, d) in n.mutations - if tt.gtr.ambiguous not in [a,d]])+'"' + n.comment = ( + '&mutations="' + + ','.join( + [a + str(pos + offset) + d for (a, pos, d) in n.mutations if tt.gtr.ambiguous not in [a, d]] + ) + + '"' + ) else: if report_ambiguous: - n.comment= '&mutations="' + ','.join([a+str(pos + offset)+d for (a,pos, d) in n.mutations if n.mask[pos]>0])+f'",mcc="{n.mcc}"' + n.comment = ( + '&mutations="' + + ','.join([a + str(pos + offset) + d for (a, pos, d) in n.mutations if n.mask[pos] > 0]) + + f'",mcc="{n.mcc}"' + ) else: - n.comment= '&mutations="' + ','.join([a+str(pos + offset)+d for (a,pos, d) in n.mutations - if tt.gtr.ambiguous not in [a,d] and n.mask[pos]>0])+f'",mcc="{n.mcc}"' - - for (a, pos, d) in n.mutations: - if tt.gtr.ambiguous not in [a,d] or report_ambiguous: - mutations_out.write("%s\t%s\t%s\t%s\n" %(n.name, a, pos + 1, d)) + n.comment = ( + '&mutations="' + + ','.join( + [ + a + str(pos + offset) + d + for (a, pos, d) in n.mutations + if tt.gtr.ambiguous not in [a, d] and n.mask[pos] > 0 + ] + ) + + f'",mcc="{n.mcc}"' + ) + + for a, pos, d in n.mutations: + if tt.gtr.ambiguous not in [a, d] or report_ambiguous: + mutations_out.write('%s\t%s\t%s\t%s\n' % (n.name, a, pos + 1, d)) if timetree: - n.comment+=(',' if n.comment else '&') + 'date=%1.2f'%n.numdate + n.comment += (',' if n.comment else '&') + 'date=%1.2f' % n.numdate mutations_out.close() # write tree to file - fmt_bl = "%1.7f" if tt.data.full_length<1e6 else "%1.9e" + fmt_bl = '%1.7f' if tt.data.full_length < 1e6 else '%1.9e' if timetree: outtree_name = basename + f'timetree{tree_suffix}.nexus' - print("--- saved divergence times in \n\t %s\n"%dates_fname) + print('--- saved divergence times in \n\t %s\n' % dates_fname) Phylo.write(tt.tree, outtree_name, 'nexus') else: outtree_name = basename + f'annotated_tree{tree_suffix}.nexus' Phylo.write(tt.tree, outtree_name, 'nexus', format_branch_length=fmt_bl) - print("--- tree saved in nexus format as \n\t %s\n"%outtree_name) + print('--- tree saved in nexus format as \n\t %s\n' % outtree_name) # Only create auspice json if there is sequence information auspice = create_auspice_json(tt, timetree=timetree, confidence=confidence, seq_info=seq_info) outtree_name_json = basename + f'auspice_tree{tree_suffix}.json' with open(outtree_name_json, 'w') as fh: import json + json.dump(auspice, fh, indent=0) - print("--- tree saved in auspice json format as \n\t %s\n"%outtree_name_json) + print('--- tree saved in auspice json format as \n\t %s\n' % outtree_name_json) if timetree: for n in tt.tree.find_clades(): n.branch_length = n.mutation_length outtree_name = basename + f'divergence_tree{tree_suffix}.nexus' Phylo.write(tt.tree, outtree_name, 'nexus', format_branch_length=fmt_bl) - print("--- divergence tree saved in nexus format as \n\t %s\n"%outtree_name) + print('--- divergence tree saved in nexus format as \n\t %s\n' % outtree_name) if hasattr(tt, 'outliers') and tt.outliers is not None: - print("--- saved detected outliers as " + basename + 'outliers.tsv') + print('--- saved detected outliers as ' + basename + 'outliers.tsv') tt.outliers.to_csv(basename + 'outliers.tsv', sep='\t') + def print_save_plot_skyline(tt, n_std=2.0, screen=True, save='', plot='', gen=50): if plot: import matplotlib.pyplot as plt skyline, conf = tt.merger_model.skyline_inferred(gen=gen, confidence=n_std) - if save: fh = open(save, 'w', encoding='utf-8') - header1 = "Skyline assuming "+ str(gen)+" gen/year and approximate confidence bounds (+/- %f standard deviations of the LH)\n"%n_std - header2 = "date \tN_e \tlower \tupper" - if screen: print('\t'+header1+'\t'+header2) - if save: fh.write("#"+ header1+'#'+header2+'\n') - for (x,y, y1, y2) in zip(skyline.x, skyline.y, conf[0], conf[1]): - if screen: print("\t%1.3f\t%1.3e\t%1.3e\t%1.3e"%(x,y, y1, y2)) - if save: fh.write("%1.3f\t%1.3e\t%1.3e\t%1.3e\n"%(x,y, y1, y2)) + if save: + fh = open(save, 'w', encoding='utf-8') + header1 = ( + 'Skyline assuming ' + + str(gen) + + ' gen/year and approximate confidence bounds (+/- %f standard deviations of the LH)\n' % n_std + ) + header2 = 'date \tN_e \tlower \tupper' + if screen: + print('\t' + header1 + '\t' + header2) + if save: + fh.write('#' + header1 + '#' + header2 + '\n') + for x, y, y1, y2 in zip(skyline.x, skyline.y, conf[0], conf[1]): + if screen: + print('\t%1.3f\t%1.3e\t%1.3e\t%1.3e' % (x, y, y1, y2)) + if save: + fh.write('%1.3f\t%1.3e\t%1.3e\t%1.3e\n' % (x, y, y1, y2)) if save: - print("\n --- written skyline to %s\n"%save) + print('\n --- written skyline to %s\n' % save) fh.close() if plot: @@ -225,68 +270,61 @@ def print_save_plot_skyline(tt, n_std=2.0, screen=True, save='', plot='', gen=50 plt.plot(skyline.x, skyline.y, label='maximum likelihood skyline') plt.yscale('log') plt.legend() - plt.ticklabel_format(axis='x',useOffset=False) + plt.ticklabel_format(axis='x', useOffset=False) plt.savefig(plot) - def create_auspice_json(tt, timetree=False, confidence=False, seq_info=False): # mock up meta data for auspice json from datetime import datetime + meta = { - "title": f"Auspice visualization of TreeTime (v{treetime_version}) analysis", - "build_url": "https://github.com/neherlab/treetime", - "last_updated": datetime.now().strftime("%Y-%m-%d"), - "treetime_version": treetime_version, - "genome_annotations": { - "nuc":{"start":1, "end":int(tt.data.full_length), "type":"source", "strand":"+:"} - }, - "panels":["tree", "entropy"], - "colorings": [ + 'title': f'Auspice visualization of TreeTime (v{treetime_version}) analysis', + 'build_url': 'https://github.com/neherlab/treetime', + 'last_updated': datetime.now().strftime('%Y-%m-%d'), + 'treetime_version': treetime_version, + 'genome_annotations': {'nuc': {'start': 1, 'end': int(tt.data.full_length), 'type': 'source', 'strand': '+:'}}, + 'panels': ['tree', 'entropy'], + 'colorings': [ { - "title": "Date", - "type": "continuous", - "key": "num_date", + 'title': 'Date', + 'type': 'continuous', + 'key': 'num_date', }, { - "title": "Genotype", - "type": "categorical", - "key": "gt", + 'title': 'Genotype', + 'type': 'categorical', + 'key': 'gt', }, - { - "title": "Excluded", - "type": "categorical", - "key": "bad_branch" - }, - { - "title": "Branch Support", - "type": "continuous", - "key": "confidence" - } + {'title': 'Excluded', 'type': 'categorical', 'key': 'bad_branch'}, + {'title': 'Branch Support', 'type': 'continuous', 'key': 'confidence'}, ], - "display_defaults": {"color_by":"bad_branch"}, - "filters": ["bad_branch"] + 'display_defaults': {'color_by': 'bad_branch'}, + 'filters': ['bad_branch'], } def node_to_json(n, pdiv=0.0): - j = {"name":n.name, "node_attrs":{}, "branch_attrs":{}} + j = {'name': n.name, 'node_attrs': {}, 'branch_attrs': {}} if n.clades: - j["children"] = [] + j['children'] = [] if timetree: - j["node_attrs"]["num_date"] = {"value":float(n.numdate)} + j['node_attrs']['num_date'] = {'value': float(n.numdate)} if confidence: conf = tt.get_max_posterior_region(n, fraction=0.9) - j["node_attrs"]["num_date"]["confidence"] = (float(conf[0]), float(conf[1])) - j["node_attrs"]["div"] = float(pdiv + n.mutation_length) - j["node_attrs"]["bad_branch"] = {"value": "Yes" if n.bad_branch else "No"} + j['node_attrs']['num_date']['confidence'] = (float(conf[0]), float(conf[1])) + j['node_attrs']['div'] = float(pdiv + n.mutation_length) + j['node_attrs']['bad_branch'] = {'value': 'Yes' if n.bad_branch else 'No'} - if seq_info: # only add mutations to the json if run with sequence data (fasta or vcf) - j["branch_attrs"]["mutations"] = {"nuc": [f"{a}{pos+1}{d}" for a,pos,d in n.mutations if d in "ACGT-"]} + if seq_info: # only add mutations to the json if run with sequence data (fasta or vcf) + j['branch_attrs']['mutations'] = {'nuc': [f'{a}{pos + 1}{d}' for a, pos, d in n.mutations if d in 'ACGT-']} # generate bootstrap confidence substitute via the negative exponential of the number of mutations # this is the bootstrap confidence for iid mutations (only ACGT mutations) - j["node_attrs"]["confidence"] = {"value":round(1-np.exp(-len([pos for a,pos,d in n.mutations if d in "ACGT"])),3) - if not n.is_terminal() else 1.0} + j['node_attrs']['confidence'] = { + 'value': round(1 - np.exp(-len([pos for a, pos, d in n.mutations if d in 'ACGT'])), 3) + if not n.is_terminal() + else 1.0 + } return j # create the tree data structure from the Biopython tree @@ -297,7 +335,7 @@ def create_auspice_json(tt, timetree=False, confidence=False, seq_info=False): n_json = node_lookup[n.name] for c in n.clades: # generate node jsons for all children and attach them the to parent - n_json["children"].append(node_to_json(c, n_json["node_attrs"]["div"])) - node_lookup[c.name] = n_json["children"][-1] + n_json['children'].append(node_to_json(c, n_json['node_attrs']['div'])) + node_lookup[c.name] = n_json['children'][-1] - return {"meta":meta, "tree":tree} + return {'meta': meta, 'tree': tree} diff --git a/treetime/__init__.py b/treetime/__init__.py index ac5d610..fe96a78 100644 --- a/treetime/__init__.py +++ b/treetime/__init__.py @@ -1,4 +1,6 @@ -version="0.11.4" +version = '0.12.1' + + ## Here we define an error class for TreeTime errors, MissingData, UnknownMethod and NotReady errors ## are all due to incorrect calling of TreeTime functions or input data that does not fit our base assumptions. ## Errors marked as TreeTimeUnknownErrors might be due to data not fulfilling base assumptions or due @@ -10,26 +12,37 @@ class TreeTimeError(Exception): Raised when treetime is used incorrectly in contrast with `TreeTimeUnknownError` `TreeTimeUnknownError` is raised when the reason of the error is unknown, could indicate bug """ + pass + class MissingDataError(TreeTimeError): """MissingDataError class raised when tree or alignment are missing""" + pass + class UnknownMethodError(TreeTimeError): """MissingDataError class raised when an unknown method is called""" + pass + class NotReadyError(TreeTimeError): """NotReadyError class raised when results are requested before inference""" + pass + class TreeTimeUnknownError(Exception): """TreeTimeUnknownError class raised when TreeTime fails during inference due to an unknown reason. This might be due to data not fulfilling base assumptions or due to bugs in TreeTime. Please report them to the developers if they persist.""" + pass + import os, sys -recursion_limit = os.environ.get("TREETIME_RECURSION_LIMIT") + +recursion_limit = os.environ.get('TREETIME_RECURSION_LIMIT') if recursion_limit: sys.setrecursionlimit(int(recursion_limit)) else: @@ -44,5 +57,3 @@ from .gtr_site_specific import GTR_site_specific from .merger_models import Coalescent from .treeregression import TreeRegression from .argument_parser import make_parser - - diff --git a/treetime/__main__.py b/treetime/__main__.py index 95492e0..1d48274 100644 --- a/treetime/__main__.py +++ b/treetime/__main__.py @@ -3,6 +3,7 @@ Stub function and module used as a setuptools entry point. Based on augur's __main__.py and setup.py """ + import sys from treetime import make_parser @@ -16,7 +17,8 @@ def main(): # Import matplotlib after parsing cli args # to speed up time till error if there's an arg error import matplotlib - matplotlib.use("AGG") + + matplotlib.use('AGG') return_code = params.func(params) @@ -24,5 +26,5 @@ def main(): # Run when called as `python -m treetime`, here for good measure. -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/treetime/aa_models.py b/treetime/aa_models.py index beb40ae..af8f41c 100644 --- a/treetime/aa_models.py +++ b/treetime/aa_models.py @@ -1,10 +1,11 @@ -from __future__ import division, print_function, absolute_import, absolute_import import numpy as np from .seq_utils import alphabets def JTT92(mu=1.0): from .gtr import GTR + + # fmt: off # stationary concentrations: pis = np.array([ 0.07674789, @@ -26,8 +27,11 @@ def JTT92(mu=1.0): 0.05856501, 0.01426057, 0.03210196, - 0.06600504]) + 0.06600504 + ]) + # fmt: on + # fmt: off # attempt matrix (FIXME) Q = np.array([ [-1.247831,0.044229,0.041179,0.061769,0.042704,0.043467,0.08007,0.136501,0.02059,0.027453,0.022877,0.02669,0.041179,0.011439,0.14794,0.288253,0.362223,0.006863,0.008388,0.227247 ], @@ -51,11 +55,11 @@ def JTT92(mu=1.0): [0.003509,0.006379,0.022328,0.014673,0.066664,0.007655,0.002233,0.002552,0.182769,0.010207,0.007655,0.002552,0.005741,0.170967,0.00319,0.020095,0.006698,0.022647,-0.605978,0.005103 ], [0.195438,0.011149,0.010493,0.020331,0.040662,0.013117,0.029512,0.030824,0.007214,0.630254,0.11805,0.009182,0.211834,0.040662,0.015084,0.024922,0.073453,0.016396,0.010493,-1.241722] ]) + # fmt: on - Spis = np.sqrt(pis[None, :] / pis[:,None]) + Spis = np.sqrt(pis[None, :] / pis[:, None]) W = Q * Spis - gtr = GTR(alphabet=alphabets['aa_nogap']) + gtr = GTR(alphabet='aa_nogap') gtr.assign_rates(mu=mu, pi=pis, W=W) return gtr - diff --git a/treetime/arg.py b/treetime/arg.py index 3d82008..877dea4 100644 --- a/treetime/arg.py +++ b/treetime/arg.py @@ -1,6 +1,7 @@ from matplotlib.pyplot import fill import numpy as np + def parse_arg(tree1, tree2, aln1, aln2, MCC_file, fill_overhangs=True): """parse the output of TreeKnit and return a file structure to be further consumed by TreeTime @@ -33,11 +34,11 @@ def parse_arg(tree1, tree2, aln1, aln2, MCC_file, fill_overhangs=True): MCCs.append(line.strip().split(',')) # read alignments and construct edge modified sequence arrays - a1 = {s.id:s for s in AlignIO.read(aln1, 'fasta')} - a2 = {s.id:s for s in AlignIO.read(aln2, 'fasta')} - for aln in [a1,a2]: - for s,seq in aln.items(): - seqstr = "".join(seq2array(seq, fill_overhangs=fill_overhangs)) + a1 = {s.id: s for s in AlignIO.read(aln1, 'fasta')} + a2 = {s.id: s for s in AlignIO.read(aln2, 'fasta')} + for aln in [a1, a2]: + for s, seq in aln.items(): + seqstr = ''.join(seq2array(seq, fill_overhangs=fill_overhangs)) seq.seq = Seq.Seq(seqstr) # construct concatenated alignment @@ -48,19 +49,38 @@ def parse_arg(tree1, tree2, aln1, aln2, MCC_file, fill_overhangs=True): aln_combined.append(seq) # construct masks for the concatenation and the two segments - l1 = len(a1[leaf]) - l2 = len(a2[leaf]) + l1 = len(a1[leaf]) # pylint: disable=undefined-loop-variable + l2 = len(a2[leaf]) # pylint: disable=undefined-loop-variable combined_mask = np.ones(l1 + l2) mask1 = np.zeros(l1 + l2) mask2 = np.zeros(l1 + l2) mask1[:l1] = 1 mask2[l1:] = 1 - return {"MCCs": MCCs, "trees":[t1,t2], "alignment":MultipleSeqAlignment(aln_combined), - "masks":[mask1,mask2], "combined_mask":combined_mask} - -def setup_arg(T, aln, total_mask, segment_mask, dates, MCCs, gtr='JC69', - verbose=0, fill_overhangs=True, reroot=True, fixed_clock_rate=None, alphabet='nuc', **kwargs): + return { + 'MCCs': MCCs, + 'trees': [t1, t2], + 'alignment': MultipleSeqAlignment(aln_combined), + 'masks': [mask1, mask2], + 'combined_mask': combined_mask, + } + + +def setup_arg( + T, + aln, + total_mask, + segment_mask, + dates, + MCCs, + gtr='JC69', + verbose=0, + fill_overhangs=True, + reroot=True, + fixed_clock_rate=None, + alphabet='nuc', + **kwargs, +): """construct a TreeTime object with the appropriate masks on each node for branch length optimization with full or segment only alignment. @@ -81,18 +101,25 @@ def setup_arg(T, aln, total_mask, segment_mask, dates, MCCs, gtr='JC69', """ from treetime import TreeTime - tt = TreeTime(dates=dates, tree=T, - aln=aln, gtr=gtr, alphabet=alphabet, verbose=verbose, - fill_overhangs=fill_overhangs, keep_node_order=True, - compress=False, **kwargs) - + tt = TreeTime( + dates=dates, + tree=T, + aln=aln, + gtr=gtr, + alphabet=alphabet, + verbose=verbose, + fill_overhangs=fill_overhangs, + keep_node_order=True, + compress=False, + **kwargs, + ) if reroot: - tt.reroot("least-squares", force_positive=True, clock_rate=fixed_clock_rate) + tt.reroot('least-squares', force_positive=True, clock_rate=fixed_clock_rate) # make a lookup for the MCCs and assign to tree leaf_to_MCC = {} - for mi,mcc in enumerate(MCCs): + for mi, mcc in enumerate(MCCs): for leaf in mcc: leaf_to_MCC[leaf] = mi @@ -100,7 +127,7 @@ def setup_arg(T, aln, total_mask, segment_mask, dates, MCCs, gtr='JC69', # assign masks to branches whenever child and parent are in the same MCC for n in tt.tree.find_clades(): - if (n.mcc is not None) and n.up and n.up.mcc==n.mcc: + if (n.mcc is not None) and n.up and n.up.mcc == n.mcc: n.mask = total_mask else: n.mask = segment_mask @@ -120,12 +147,12 @@ def assign_mccs(tree, mcc_map, one_mutation=1e-4): for leaf in tree.get_terminals(): leaf.child_mccs = set([mcc_map[leaf.name]]) leaf.mcc = mcc_map[leaf.name] - leaf.branch_length = max(0.5*one_mutation, leaf.branch_length) + leaf.branch_length = max(0.5 * one_mutation, leaf.branch_length) # reconstruct MCCs with Fitch algorithm for n in tree.get_nonterminals(order='postorder'): common_mccs = set.intersection(*[c.child_mccs for c in n]) - n.branch_length = max(0.5*one_mutation, n.branch_length) + n.branch_length = max(0.5 * one_mutation, n.branch_length) if len(common_mccs): n.child_mccs = common_mccs else: @@ -138,12 +165,12 @@ def assign_mccs(tree, mcc_map, one_mutation=1e-4): tree.root.mcc = None for n in tree.get_nonterminals(order='preorder'): - if n==tree.root: + if n == tree.root: continue else: - if n.up.mcc in n.child_mccs: # parent MCC part of children -> that is the MCC + if n.up.mcc in n.child_mccs: # parent MCC part of children -> that is the MCC n.mcc = n.up.mcc - elif len(n.child_mccs)==1: # child is an MCC + elif len(n.child_mccs) == 1: # child is an MCC n.mcc = list(n.child_mccs)[0] - else: # no unique child MCC and no match with parent -> not part of an MCCs + else: # no unique child MCC and no match with parent -> not part of an MCCs n.mcc = None diff --git a/treetime/argument_parser.py b/treetime/argument_parser.py index 51ef1b8..01dd10b 100644 --- a/treetime/argument_parser.py +++ b/treetime/argument_parser.py @@ -1,9 +1,16 @@ #!/usr/bin/env python import sys, argparse -from .wrappers import ancestral_reconstruction, mugration, scan_homoplasies,\ - timetree, estimate_clock_model, arg_time_trees +from .wrappers import ( + ancestral_reconstruction, + mugration, + scan_homoplasies, + timetree, + estimate_clock_model, + arg_time_trees, +) from . import version + def set_default_subparser(self, name, args=None, positional_args=0): """default subparser selection. Call after setup, just before parse_args() name: is the name of the subparser to call by default @@ -11,7 +18,7 @@ def set_default_subparser(self, name, args=None, positional_args=0): https://stackoverflow.com/questions/6365601/default-sub-command-or-handling-no-sub-command-with-argparse """ subparser_found = False - if len(sys.argv)==1: + if len(sys.argv) == 1: sys.argv.append('-h') else: for x in self._subparsers._actions: @@ -28,202 +35,313 @@ def set_default_subparser(self, name, args=None, positional_args=0): args.insert(1, name) -treetime_description = \ - "TreeTime: Maximum Likelihood Phylodynamics\n\n" -subcommand_description = \ - "In addition, TreeTime implements several sub-commands:\n\n"\ - "\t ancestral\tinfer ancestral sequences maximizing the joint or marginal likelihood.\n"\ - "\t homoplasy\tanalyze patterns of recurrent mutations aka homoplasies.\n"\ - "\t clock\t\testimate molecular clock parameters and reroot the tree.\n"\ - "\t mugration\tmap discrete character such as host or country to the tree.\n\n"\ - "(note that 'tt' is a default subcommand in python2 that doesn't need to be specified).\n"\ - "To print a description and argument list of the individual sub-commands, type:\n\n"\ - "\t treetime <subcommand> -h\n\n" - -ref_msg = \ - "If you use results from treetime in a publication, please cite:"\ - "\n\n\tSagulenko et al. TreeTime: Maximum-likelihood phylodynamic analysis"\ - "\n\tVirus Evolution, vol 4, https://academic.oup.com/ve/article/4/1/vex042/4794731\n" - -timetree_description=\ - "TreeTime infers a time scaled phylogeny given a tree topology, an alignment, "\ - "and tip dates. Reconstructs ancestral sequences and infers a molecular clock tree. "\ - "TreeTime will reroot the tree and resolve polytomies by default. "\ - "In addition, treetime will infer ancestral sequences and a GTR substitution model. "\ - "Inferred mutations are included as comments in the output tree.\n\n" - -gtr_description = "GTR model to use. '--gtr infer' will infer a model "\ - "from the data. Alternatively, specify the model type. If the specified model "\ +treetime_description = 'TreeTime: Maximum Likelihood Phylodynamics\n\n' +subcommand_description = ( + 'In addition, TreeTime implements several sub-commands:\n\n' + '\t ancestral\tinfer ancestral sequences maximizing the joint or marginal likelihood.\n' + '\t homoplasy\tanalyze patterns of recurrent mutations aka homoplasies.\n' + '\t clock\t\testimate molecular clock parameters and reroot the tree.\n' + '\t mugration\tmap discrete character such as host or country to the tree.\n\n' + "(note that 'tt' is a default subcommand in python2 that doesn't need to be specified).\n" + 'To print a description and argument list of the individual sub-commands, type:\n\n' + '\t treetime <subcommand> -h\n\n' +) + +ref_msg = ( + 'If you use results from treetime in a publication, please cite:' + '\n\n\tSagulenko et al. TreeTime: Maximum-likelihood phylodynamic analysis' + '\n\tVirus Evolution, vol 4, https://academic.oup.com/ve/article/4/1/vex042/4794731\n' +) + +timetree_description = ( + 'TreeTime infers a time scaled phylogeny given a tree topology, an alignment, ' + 'and tip dates. Reconstructs ancestral sequences and infers a molecular clock tree. ' + 'TreeTime will reroot the tree and resolve polytomies by default. ' + 'In addition, treetime will infer ancestral sequences and a GTR substitution model. ' + 'Inferred mutations are included as comments in the output tree.\n\n' +) + +gtr_description = ( + "GTR model to use. '--gtr infer' will infer a model " + 'from the data. Alternatively, specify the model type. If the specified model ' "requires additional options, use '--gtr-params' to specify those." +) + +gtr_params_description = ( + 'GTR parameters for the model specified by ' + "the --gtr argument. The parameters should be feed as 'key=value' " + "list of parameters. Example: '--gtr K80 --gtr-params kappa=0.2 " + "pis=0.25,0.25,0.25,0.25'. See the exact definitions of the " + 'parameters in the GTR creation methods in treetime/nuc_models.py ' + 'or treetime/aa_models.py' +) + +reroot_description = ( + 'Reroot the tree using root-to-tip regression. Valid choices are ' + "'min_dev', 'least-squares', 'oldest', and 'best' (alias for 'least-squares'). " + "'least-squares' adjusts the root to minimize residuals of the root-to-tip vs sampling time regression, " + "'min_dev' minimizes variance of root-to-tip distances. " + "'least-squares' can be combined with --covariation to account for shared ancestry. " + 'Alternatively, you can specify a node name or a list of node names ' + "to be used as outgroup or use 'oldest' to reroot to the oldest node. " + "By default, TreeTime will reroot using 'best'. " + 'Use --keep-root to keep the current root.' +) + +tree_description = ( + 'Name of file containing the tree in newick, nexus, or phylip format, ' + 'the branch length of the tree should be in units of average number of nucleotide or protein ' + 'substitutions per site. If no file is provided, treetime will attempt ' + 'to build a tree from the alignment using fasttree, iqtree, or raxml ' + '(assuming they are installed). ' +) + +aln_description = 'alignment file (fasta)' + +dates_description = ( + "csv file with dates for nodes with 'node_name, date' where date is float (as in 2012.15) or in ISO-format (YYYY-MM-DD). " + "Imprecisely known dates can be specified as '2023-XX-XX' or [2013.2:2013.7]" +) + +coalescent_description = ( + 'coalescent time scale -- sensible values are on the order of the average ' + "hamming distance of contemporaneous sequences. In addition, 'opt' " + "'skyline' are valid options and estimate a constant coalescent rate " + 'or a piecewise linear coalescent rate history' +) + +ancestral_description = ( + 'Reconstructs ancestral sequences and maps mutations to the tree. ' + "The output consists of a file 'ancestral.fasta' with ancestral sequences " + "and a tree 'annotated_tree.nexus' with mutations added as comments " + 'like A45G,G136T,..., number in SNPs used 1-based index by default. ' + 'The inferred GTR model is written to stdout.' +) + +homoplasy_description = ( + 'Reconstructs ancestral sequences and maps mutations to the tree. ' + 'The tree is then scanned for homoplasies. An excess number of homoplasies ' + 'might suggest contamination, recombination, culture adaptation or similar.' +) + +mugration_description = ( + 'Reconstructs discrete ancestral states, for example ' + 'geographic location, host, or similar. In addition to ancestral states, ' + 'a GTR model of state transitions is inferred.' +) -gtr_params_description = "GTR parameters for the model specified by "\ - "the --gtr argument. The parameters should be feed as 'key=value' "\ - "list of parameters. Example: '--gtr K80 --gtr-params kappa=0.2 "\ - "pis=0.25,0.25,0.25,0.25'. See the exact definitions of the "\ - "parameters in the GTR creation methods in treetime/nuc_models.py "\ - "or treetime/aa_models.py" - -reroot_description = "Reroot the tree using root-to-tip regression. Valid choices are "\ - "'min_dev', 'least-squares', and 'oldest'. 'least-squares' adjusts the root to "\ - "minimize residuals of the root-to-tip vs sampling time regression, " \ - "'min_dev' minimizes variance of root-to-tip distances. "\ - "'least-squares' can be combined with --covariation to account for shared ancestry. "\ - "Alternatively, you can specify a node name or a list of node names "\ - "to be used as outgroup or use 'oldest' to reroot to the oldest node. "\ - "By default, TreeTime will reroot using 'least-squares'. "\ - "Use --keep-root to keep the current root." - -tree_description = "Name of file containing the tree in newick, nexus, or phylip format, "\ - "the branch length of the tree should be in units of average number of nucleotide or protein "\ - "substitutions per site. If no file is provided, treetime will attempt "\ - "to build a tree from the alignment using fasttree, iqtree, or raxml "\ - "(assuming they are installed). " - -aln_description = "alignment file (fasta)" - -dates_description = "csv file with dates for nodes with 'node_name, date' where date is float (as in 2012.15) or in ISO-format (YYYY-MM-DD). "\ - "Imprecisely known dates can be specified as '2023-XX-XX' or [2013.2:2013.7]" - -coalescent_description = \ - "coalescent time scale -- sensible values are on the order of the average "\ - "hamming distance of contemporaneous sequences. In addition, 'opt' "\ - "'skyline' are valid options and estimate a constant coalescent rate "\ - "or a piecewise linear coalescent rate history" - -ancestral_description = \ - "Reconstructs ancestral sequences and maps mutations to the tree. "\ - "The output consists of a file 'ancestral.fasta' with ancestral sequences "\ - "and a tree 'annotated_tree.nexus' with mutations added as comments "\ - "like A45G,G136T,..., number in SNPs used 1-based index by default. "\ - "The inferred GTR model is written to stdout." - -homoplasy_description = \ - "Reconstructs ancestral sequences and maps mutations to the tree. "\ - "The tree is then scanned for homoplasies. An excess number of homoplasies "\ - "might suggest contamination, recombination, culture adaptation or similar." - -mugration_description = \ - "Reconstructs discrete ancestral states, for example "\ - "geographic location, host, or similar. In addition to ancestral states, "\ - "a GTR model of state transitions is inferred." def add_seq_len_aln_group(parser): - parser.add_argument('--sequence-length', type=int, help="length of the sequence, " - "used to calculate expected variation in branch length. " - "Not required if alignment is provided.") + parser.add_argument( + '--sequence-length', + type=int, + help='length of the sequence, ' + 'used to calculate expected variation in branch length. ' + 'Not required if alignment is provided.', + ) add_aln_group(parser, required=False) # seq_group_ex.add_argument('--aln', type=str, help=aln_description) + def add_aln_group(parser, required=True): parser.add_argument('--aln', required=required, type=str, help=aln_description) - parser.add_argument('--vcf-reference', type=str, help='only for vcf input: fasta file of the sequence the VCF was mapped to.') + parser.add_argument( + '--vcf-reference', type=str, help='only for vcf input: fasta file of the sequence the VCF was mapped to.' + ) def add_reroot_group(parser): - parser.add_argument('--clock-filter', type=float, default=4.0, - help="ignore tips that don't follow a loose clock, " - "'clock-filter=number of interquartile ranges from regression (method=`residual`)' " - "or z-score of local clock deviation (method=`local`). " - "Default=4.0, set to 0 to switch off.") - parser.add_argument('--clock-filter-method', choices=['residual', 'local'], default='residual', - help="Use residuals from global clock (`residual`, default) or local clock deviation (`clock`) " - "to filter out tips that don't follow the clock") + parser.add_argument( + '--clock-filter', + type=float, + default=4.0, + help="ignore tips that don't follow a loose clock, " + "'clock-filter=number of interquartile ranges from regression (method=`residual`)' " + 'or z-score of local clock deviation (method=`local`). ' + 'Default=4.0, set to 0 to switch off.', + ) + parser.add_argument( + '--clock-filter-method', + choices=['residual', 'local'], + default='residual', + help='Use residuals from global clock (`residual`, default) or local clock deviation (`clock`) ' + "to filter out tips that don't follow the clock", + ) reroot_group = parser.add_mutually_exclusive_group() reroot_group.add_argument('--reroot', nargs='+', default='best', help=reroot_description) - reroot_group.add_argument('--keep-root', required = False, action="store_true", default=False, - help ="don't reroot the tree. Otherwise, reroot to minimize the " - "the residual of the regression of root-to-tip distance and sampling time") - parser.add_argument('--tip-slack', type=float, default=3, - help="excess variance associated with terminal nodes accounting for " - " overdispersion of the molecular clock") - parser.add_argument('--covariation', action='store_true', help="Account for covariation when estimating rates " - "or rerooting using root-to-tip regression, default False.") + reroot_group.add_argument( + '--keep-root', + required=False, + action='store_true', + default=False, + help="don't reroot the tree. Otherwise, reroot to minimize the " + 'the residual of the regression of root-to-tip distance and sampling time', + ) + parser.add_argument( + '--tip-slack', + type=float, + default=3, + help='excess variance associated with terminal nodes accounting for overdispersion of the molecular clock', + ) + parser.add_argument( + '--covariation', + action='store_true', + help='Account for covariation when estimating rates or rerooting using root-to-tip regression, default False.', + ) + def add_gtr_arguments(parser): parser.add_argument('--gtr', default='infer', help=gtr_description) parser.add_argument('--gtr-params', nargs='+', help=gtr_params_description) - parser.add_argument('--aa', action='store_true', help="use aminoacid alphabet") - parser.add_argument('--custom-gtr', default = None, type=str, help="filename of pre-defined custom GTR model in standard TreeTime format") + parser.add_argument('--aa', action='store_true', help='use aminoacid alphabet') + parser.add_argument( + '--custom-gtr', + default=None, + type=str, + help='filename of pre-defined custom GTR model in standard TreeTime format', + ) + def add_time_arguments(parser): parser.add_argument('--dates', type=str, help=dates_description) - parser.add_argument('--name-column', type=str, help="label of the column to be used as taxon name") - parser.add_argument('--date-column', type=str, help="label of the column to be used as sampling date") + parser.add_argument('--name-column', type=str, help='label of the column to be used as taxon name') + parser.add_argument('--date-column', type=str, help='label of the column to be used as sampling date') + def add_anc_arguments(parser): - parser.add_argument('--keep-overhangs', default = False, action='store_true', help='do not fill terminal gaps') - parser.add_argument('--zero-based', default = False, action='store_true', help='zero based mutation indexing') - parser.add_argument('--reconstruct-tip-states', default = False, action='store_true', help='overwrite ambiguous states on tips with the most likely inferred state') - parser.add_argument('--report-ambiguous', default=False, action="store_true", help='include transitions involving ambiguous states') - parser.add_argument('--method-anc', default='probabilistic', type=str, choices = ['parsimony', 'fitch', 'probabilistic', 'ml'], - help="method used for reconstructing ancestral sequences, default is 'probabilistic'") + parser.add_argument('--keep-overhangs', default=False, action='store_true', help='do not fill terminal gaps') + parser.add_argument('--zero-based', default=False, action='store_true', help='zero based mutation indexing') + parser.add_argument( + '--reconstruct-tip-states', + default=False, + action='store_true', + help='overwrite ambiguous states on tips with the most likely inferred state', + ) + parser.add_argument( + '--report-ambiguous', default=False, action='store_true', help='include transitions involving ambiguous states' + ) + parser.add_argument( + '--method-anc', + default='probabilistic', + type=str, + choices=['parsimony', 'fitch', 'probabilistic', 'ml'], + help="method used for reconstructing ancestral sequences, default is 'probabilistic'", + ) def add_common_args(parser): - parser.add_argument('--verbose', default=1, type=int, help='verbosity of output 0-6') - parser.add_argument('--outdir', type=str, help='directory to write the output to') + parser.add_argument('--verbose', default=1, type=int, help='verbosity of output 0-6') + parser.add_argument('--outdir', type=str, help='directory to write the output to') + def add_timetree_args(parser): - parser.add_argument('--clock-rate', type=float, help="if specified, the rate of the molecular clock won't be optimized.") - parser.add_argument('--clock-std-dev', type=float, help="standard deviation of the provided clock rate estimate") - parser.add_argument('--branch-length-mode', default='auto', type=str, choices=['auto', 'input', 'joint', 'marginal'], - help="If set to 'input', the provided branch length will be used without modification. " - "Note that branch lengths optimized by treetime are only accurate at short evolutionary distances.") - parser.add_argument('--confidence', action='store_true', help="estimate confidence intervals of divergence times using the marginal" - " posterior distribution, if `--time-marginal` is False (default) inferred divergence" - " times will still be calculated using the jointly most likely tree configuration.") - parser.add_argument('--time-marginal', default='false', choices = ['false', 'true', 'assign', 'always', 'only-final', 'never'], - help="For 'false' or 'never', TreeTime uses the jointly most likely values for the divergence times. " - "For 'true' and 'always', it uses the marginal inference mode at every round of optimization, for 'only-final' " - "(or 'assign' for compatibility with previous versions) only uses the marginal " - "distribution in the final round.") - parser.add_argument('--keep-polytomies', default=False, action='store_true', - help="Don't resolve polytomies using temporal information.") - parser.add_argument('--stochastic-resolve', default=False, action='store_true', - help="Resolve polytomies using a random coalescent tree.") - parser.add_argument('--greedy-resolve', action='store_false', dest='stochastic_resolve', - help="Resolve polytomies greedily. Currently default, but will " - "switched to `stochastic-resolve` in future versions.") + parser.add_argument( + '--clock-rate', type=float, help="if specified, the rate of the molecular clock won't be optimized." + ) + parser.add_argument('--clock-std-dev', type=float, help='standard deviation of the provided clock rate estimate') + parser.add_argument( + '--branch-length-mode', + default='auto', + type=str, + choices=['auto', 'input', 'joint', 'marginal'], + help="If set to 'input', the provided branch length will be used without modification. " + 'Note that branch lengths optimized by treetime are only accurate at short evolutionary distances.', + ) + parser.add_argument( + '--confidence', + action='store_true', + help='estimate confidence intervals of divergence times using the marginal' + ' posterior distribution, if `--time-marginal` is False (default) inferred divergence' + ' times will still be calculated using the jointly most likely tree configuration.', + ) + parser.add_argument( + '--time-marginal', + default='false', + choices=['false', 'true', 'assign', 'always', 'only-final', 'never'], + help="For 'false' or 'never', TreeTime uses the jointly most likely values for the divergence times. " + "For 'true' and 'always', it uses the marginal inference mode at every round of optimization, for 'only-final' " + "(or 'assign' for compatibility with previous versions) only uses the marginal " + 'distribution in the final round.', + ) + parser.add_argument( + '--keep-polytomies', + default=False, + action='store_true', + help="Don't resolve polytomies using temporal information.", + ) + parser.add_argument( + '--stochastic-resolve', + default=False, + action='store_true', + help='Resolve polytomies using a random coalescent tree.', + ) + parser.add_argument( + '--greedy-resolve', + action='store_false', + dest='stochastic_resolve', + help='Resolve polytomies greedily. Currently default, but will ' + 'switched to `stochastic-resolve` in future versions.', + ) # parser.add_argument('--keep-node-order', default=False, action='store_true', # help="Don't ladderize the tree.") - parser.add_argument('--relax',nargs=2, type=float, - help='use an autocorrelated molecular clock. Strength of the gaussian priors on' - ' branch specific rate deviation and the coupling of parent and offspring' - ' rates can be specified e.g. as --relax 1.0 0.5. Values around 1.0 correspond' - ' to weak priors, larger values constrain rate deviations more strongly.' - ' Coupling 0 (--relax 1.0 0) corresponds to an un-correlated clock.') - parser.add_argument('--max-iter', default=2, type=int, - help='maximal number of iterations the inference cycle is run. Note that for polytomy resolution and coalescence models max_iter should be at least 2') - parser.add_argument('--coalescent', default="0.0", type=str, - help=coalescent_description) - parser.add_argument('--n-skyline', default="20", type=int, - help="number of grid points in skyline coalescent model") - parser.add_argument('--gen-per-year', default="50.0", type=float, - help="number of generations per year - used for estimating N_e in coalescent models") - parser.add_argument('--n-branches-posterior', default=False, action='store_true', - help= "add posterior LH to coalescent model: use the posterior probability distributions of " - "divergence times for estimating the number of branches when calculating the coalescent merger" - "rate or use inferred time before present (default)." ) - parser.add_argument('--plot-tree', default="timetree.pdf", - help = "filename to save the plot to. Suffix will determine format" - " (choices pdf, png, svg, default=pdf)") - parser.add_argument('--plot-rtt', default="root_to_tip_regression.pdf", - help = "filename to save the plot to. Suffix will determine format" - " (choices pdf, png, svg, default=pdf)") - parser.add_argument('--tip-labels', action='store_true', - help = "add tip labels (default for small trees with <30 leaves)") - parser.add_argument('--no-tip-labels', action='store_true', - help = "don't show tip labels (default for small trees with >=30 leaves)") + parser.add_argument( + '--relax', + nargs=2, + type=float, + help='use an autocorrelated molecular clock. Strength of the gaussian priors on' + ' branch specific rate deviation and the coupling of parent and offspring' + ' rates can be specified e.g. as --relax 1.0 0.5. Values around 1.0 correspond' + ' to weak priors, larger values constrain rate deviations more strongly.' + ' Coupling 0 (--relax 1.0 0) corresponds to an un-correlated clock.', + ) + parser.add_argument( + '--max-iter', + default=2, + type=int, + help='maximal number of iterations the inference cycle is run. Note that for polytomy resolution and coalescence models max_iter should be at least 2', + ) + parser.add_argument('--coalescent', default='0.0', type=str, help=coalescent_description) + parser.add_argument('--n-skyline', default='20', type=int, help='number of grid points in skyline coalescent model') + parser.add_argument( + '--gen-per-year', + default='50.0', + type=float, + help='number of generations per year - used for estimating N_e in coalescent models', + ) + parser.add_argument( + '--n-branches-posterior', + default=False, + action='store_true', + help='add posterior LH to coalescent model: use the posterior probability distributions of ' + 'divergence times for estimating the number of branches when calculating the coalescent merger' + 'rate or use inferred time before present (default).', + ) + parser.add_argument( + '--plot-tree', + default='timetree.pdf', + help='filename to save the plot to. Suffix will determine format (choices pdf, png, svg, default=pdf)', + ) + parser.add_argument( + '--plot-rtt', + default='root_to_tip_regression.pdf', + help='filename to save the plot to. Suffix will determine format (choices pdf, png, svg, default=pdf)', + ) + parser.add_argument( + '--tip-labels', action='store_true', help='add tip labels (default for small trees with <30 leaves)' + ) + parser.add_argument( + '--no-tip-labels', action='store_true', help="don't show tip labels (default for small trees with >=30 leaves)" + ) + def make_parser(): - parser = argparse.ArgumentParser(description = "", - usage=treetime_description) + parser = argparse.ArgumentParser(description='', usage=treetime_description) subparsers = parser.add_subparsers() t_parser = parser t_parser.add_argument('--tree', type=str, help=tree_description) - t_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") + t_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') add_seq_len_aln_group(t_parser) add_time_arguments(t_parser) add_timetree_args(t_parser) @@ -231,96 +349,129 @@ def make_parser(): add_gtr_arguments(t_parser) add_anc_arguments(t_parser) add_common_args(t_parser) - t_parser.add_argument("--version", action="version", version="%(prog)s " + version) + t_parser.add_argument('--version', action='version', version='%(prog)s ' + version) def toplevel(params): if (params.aln or params.tree) and params.dates: timetree(params) else: - print(treetime_description+timetree_description+subcommand_description+ - "'--dates' and '--aln' or '--tree' are REQUIRED inputs, type 'treetime -h' for a full list of arguments.\n") + print( + treetime_description + + timetree_description + + subcommand_description + + "'--dates' and '--aln' or '--tree' are REQUIRED inputs, type 'treetime -h' for a full list of arguments.\n" + ) t_parser.set_defaults(func=toplevel) - ## HOMOPLASY SCANNER h_parser = subparsers.add_parser('homoplasy', description=homoplasy_description) add_aln_group(h_parser) - h_parser.add_argument('--tree', type = str, help=tree_description) - h_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") - h_parser.add_argument('--const', type = int, default=0, help ="number of constant sites not included in alignment") - h_parser.add_argument('--rescale', type = float, default=1.0, help ="rescale branch lengths") - h_parser.add_argument('--detailed', required = False, action="store_true", help ="generate a more detailed report") + h_parser.add_argument('--tree', type=str, help=tree_description) + h_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') + h_parser.add_argument('--const', type=int, default=0, help='number of constant sites not included in alignment') + h_parser.add_argument('--rescale', type=float, default=1.0, help='rescale branch lengths') + h_parser.add_argument('--detailed', required=False, action='store_true', help='generate a more detailed report') add_gtr_arguments(h_parser) - h_parser.add_argument('--zero-based', default = False, action='store_true', help='zero based mutation indexing') - h_parser.add_argument('-n', default = 10, type=int, help='number of mutations/nodes that are printed to screen') - h_parser.add_argument('--drms', type=str, help='TSV file containing DRM info. columns headers: GENOMIC_POSITION, ALT_BASE, DRUG, GENE, SUBSTITUTION') + h_parser.add_argument('--zero-based', default=False, action='store_true', help='zero based mutation indexing') + h_parser.add_argument('-n', default=10, type=int, help='number of mutations/nodes that are printed to screen') + h_parser.add_argument( + '--drms', + type=str, + help='TSV file containing DRM info. columns headers: GENOMIC_POSITION, ALT_BASE, DRUG, GENE, SUBSTITUTION', + ) add_common_args(h_parser) h_parser.set_defaults(func=scan_homoplasies) ## ANCESTRAL RECONSTRUCTION a_parser = subparsers.add_parser('ancestral', description=ancestral_description) add_aln_group(a_parser) - a_parser.add_argument('--tree', type=str, help=tree_description) - a_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") + a_parser.add_argument('--tree', type=str, help=tree_description) + a_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') add_gtr_arguments(a_parser) - a_parser.add_argument('--marginal', default=False, action="store_true", help ="marginal reconstruction of ancestral sequences") + a_parser.add_argument( + '--marginal', default=False, action='store_true', help='marginal reconstruction of ancestral sequences' + ) add_anc_arguments(a_parser) add_common_args(a_parser) a_parser.set_defaults(func=ancestral_reconstruction) ## MUGRATION m_parser = subparsers.add_parser('mugration', description=mugration_description) - m_parser.add_argument('--tree', required = True, type=str, help=tree_description) - m_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") - m_parser.add_argument('--name-column', type=str, help="label of the column to be used as taxon name") - m_parser.add_argument('--attribute', type=str, help ="attribute to reconstruct, e.g. country") - m_parser.add_argument('--states', required = True, type=str, help ="csv or tsv file with discrete characters." - "\n#name,country,continent\ntaxon1,micronesia,oceania\n...") - m_parser.add_argument('--weights', type=str, help="csv or tsv file with probabilities of that a randomly sampled " - "sequence at equilibrium has a particular state. E.g. population of different continents or countries. E.g.:" - "\n#country,weight\nmicronesia,0.1\n...") - m_parser.add_argument('--confidence', action="store_true", help="output confidence of mugration inference") - m_parser.add_argument('--pc', type=float, default=1.0, help ="pseudo-counts higher numbers will results in 'flatter' models") - m_parser.add_argument('--missing-data', type=str, default='?', help ="string indicating missing data") - m_parser.add_argument('--sampling-bias-correction', type=float, - help='a rough estimate of how many more events would have been observed' - ' if sequences represented an even sample. This should be' - ' roughly the (1-sum_i p_i^2)/(1-sum_i t_i^2), where p_i' - ' are the equilibrium frequencies and t_i are apparent ones.' - '(or rather the time spent in a particular state on the tree)') + m_parser.add_argument('--tree', required=True, type=str, help=tree_description) + m_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') + m_parser.add_argument('--name-column', type=str, help='label of the column to be used as taxon name') + m_parser.add_argument('--attribute', type=str, help='attribute to reconstruct, e.g. country') + m_parser.add_argument( + '--states', + required=True, + type=str, + help='csv or tsv file with discrete characters.\n#name,country,continent\ntaxon1,micronesia,oceania\n...', + ) + m_parser.add_argument( + '--weights', + type=str, + help='csv or tsv file with probabilities of that a randomly sampled ' + 'sequence at equilibrium has a particular state. E.g. population of different continents or countries. E.g.:' + '\n#country,weight\nmicronesia,0.1\n...', + ) + m_parser.add_argument('--confidence', action='store_true', help='output confidence of mugration inference') + m_parser.add_argument( + '--pc', type=float, default=1.0, help="pseudo-counts higher numbers will results in 'flatter' models" + ) + m_parser.add_argument('--missing-data', type=str, default='?', help='string indicating missing data') + m_parser.add_argument( + '--sampling-bias-correction', + type=float, + help='a rough estimate of how many more events would have been observed' + ' if sequences represented an even sample. This should be' + ' roughly the (1-sum_i p_i^2)/(1-sum_i t_i^2), where p_i' + ' are the equilibrium frequencies and t_i are apparent ones.' + '(or rather the time spent in a particular state on the tree)', + ) add_common_args(m_parser) m_parser.set_defaults(func=mugration) - ## CLOCKSIGNAL - c_parser = subparsers.add_parser('clock', - description="Calculates the root-to-tip regression and quantifies the 'clock-i-ness' of the tree. " - "It will reroot the tree to maximize the clock-like " - "signal and recalculate branch length unless run with --keep-root.") - c_parser.add_argument('--tree', required=True, type=str, help=tree_description) - c_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") + c_parser = subparsers.add_parser( + 'clock', + description="Calculates the root-to-tip regression and quantifies the 'clock-i-ness' of the tree. " + 'It will reroot the tree to maximize the clock-like ' + 'signal and recalculate branch length unless run with --keep-root.', + ) + c_parser.add_argument('--tree', required=True, type=str, help=tree_description) + c_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') add_time_arguments(c_parser) add_seq_len_aln_group(c_parser) add_reroot_group(c_parser) - c_parser.add_argument('--prune-outliers', action='store_true', default=False, - help="remove detected outliers from the output tree") - c_parser.add_argument('--allow-negative-rate', required = False, action="store_true", default=False, - help="By default, rates are forced to be positive. For trees with little temporal " - "signal it is advisable to remove this restriction to achieve essentially mid-point rooting.") - c_parser.add_argument('--plot-rtt', default="root_to_tip_regression.pdf", - help = "filename to save the plot to. Suffix will determine format" - " (choices pdf, png, svg, default=pdf)") + c_parser.add_argument( + '--prune-outliers', action='store_true', default=False, help='remove detected outliers from the output tree' + ) + c_parser.add_argument( + '--allow-negative-rate', + required=False, + action='store_true', + default=False, + help='By default, rates are forced to be positive. For trees with little temporal ' + 'signal it is advisable to remove this restriction to achieve essentially mid-point rooting.', + ) + c_parser.add_argument( + '--plot-rtt', + default='root_to_tip_regression.pdf', + help='filename to save the plot to. Suffix will determine format (choices pdf, png, svg, default=pdf)', + ) add_common_args(c_parser) c_parser.set_defaults(func=estimate_clock_model) ## ARG - arg_parser = subparsers.add_parser('arg', - description="Calculates the root-to-tip regression and quantifies the 'clock-i-ness' of the tree. " - "It will reroot the tree to maximize the clock-like " - "signal and recalculate branch length unless run with --keep_root.") - arg_parser.add_argument('--rng-seed', type=int, help="random number generator seed for treetime") + arg_parser = subparsers.add_parser( + 'arg', + description="Calculates the root-to-tip regression and quantifies the 'clock-i-ness' of the tree. " + 'It will reroot the tree to maximize the clock-like ' + 'signal and recalculate branch length unless run with --keep_root.', + ) + arg_parser.add_argument('--rng-seed', type=int, help='random number generator seed for treetime') arg_parser.add_argument('--trees', nargs=2, required=True, type=str) arg_parser.add_argument('--alignments', nargs=2, required=True, type=str) arg_parser.add_argument('--mccs', required=True, type=str) @@ -333,9 +484,8 @@ def make_parser(): add_common_args(arg_parser) arg_parser.set_defaults(func=arg_time_trees) - # make a version subcommand v_parser = subparsers.add_parser('version', description='print version') - v_parser.set_defaults(func=lambda x: print("treetime "+version)) + v_parser.set_defaults(func=lambda x: print('treetime ' + version)) return parser diff --git a/treetime/branch_len_interpolator.py b/treetime/branch_len_interpolator.py index 4ad0c28..70a245c 100644 --- a/treetime/branch_len_interpolator.py +++ b/treetime/branch_len_interpolator.py @@ -2,51 +2,66 @@ import numpy as np from . import config as ttconf from .distribution import Distribution -class BranchLenInterpolator (Distribution): + +class BranchLenInterpolator(Distribution): """ This class defines the methods to manipulate the branch length probability distributions. """ - def __init__(self, node, gtr, one_mutation=None, min_width=ttconf.MIN_INTEGRATION_PEAK, - branch_length_mode = 'joint', pattern_multiplicity = None, - n_grid_points = ttconf.BRANCH_GRID_SIZE, ignore_gaps=True): - + def __init__( + self, + node, + gtr, + one_mutation=None, + min_width=ttconf.MIN_INTEGRATION_PEAK, + branch_length_mode='joint', + pattern_multiplicity=None, + n_grid_points=ttconf.BRANCH_GRID_SIZE, + ignore_gaps=True, + ): self.node = node self.gtr = gtr if node.up is None: - raise Exception("Cannot create branch length interpolator for the root node.") + raise Exception('Cannot create branch length interpolator for the root node.') self._gamma = 1.0 if one_mutation is None: L = node.sequence.shape[0] - one_mutation = 1.0/L + one_mutation = 1.0 / L self.one_mutation = one_mutation # optimal branch length mutation_length = node.mutation_length - if mutation_length < np.min((1e-5, 0.1*one_mutation)): # zero-length - short_range = 10*one_mutation - grid = np.concatenate([short_range*(np.linspace(0, 1.0 , n_grid_points//2)[:-1]), - (short_range + (ttconf.MAX_BRANCH_LENGTH - short_range)*(np.linspace(0, 1.0 , n_grid_points//2+1)**2))]) - - else: # branch length is not zero - sigma = mutation_length #np.max([self.average_branch_len, mutation_length]) + if mutation_length < np.min((1e-5, 0.1 * one_mutation)): # zero-length + short_range = 10 * one_mutation + grid = np.concatenate( + [ + short_range * (np.linspace(0, 1.0, n_grid_points // 2)[:-1]), + ( + short_range + + (ttconf.MAX_BRANCH_LENGTH - short_range) * (np.linspace(0, 1.0, n_grid_points // 2 + 1) ** 2) + ), + ] + ) + + else: # branch length is not zero + sigma = mutation_length # np.max([self.average_branch_len, mutation_length]) # from zero to optimal branch length - grid_left = mutation_length * (1 - np.linspace(1, 0.0, n_grid_points//3)**2.0) - grid_zero = grid_left[1]*np.logspace(-20,0,6)[:5] - grid_zero2 = grid_left[1]*np.linspace(0,1,10)[1:-1] + grid_left = mutation_length * (1 - np.linspace(1, 0.0, n_grid_points // 3) ** 2.0) + grid_zero = grid_left[1] * np.logspace(-20, 0, 6)[:5] + grid_zero2 = grid_left[1] * np.linspace(0, 1, 10)[1:-1] # from optimal branch length to the right (--> 3*branch lengths), - grid_right = mutation_length + (3*sigma*(np.linspace(0, 1, n_grid_points//3)**2)) + grid_right = mutation_length + (3 * sigma * (np.linspace(0, 1, n_grid_points // 3) ** 2)) # far to the right (3*branch length ---> MAX_LEN), very sparse - far_grid = grid_right.max() + ttconf.MAX_BRANCH_LENGTH*np.linspace(0, 1, n_grid_points//3)**2 + far_grid = grid_right.max() + ttconf.MAX_BRANCH_LENGTH * np.linspace(0, 1, n_grid_points // 3) ** 2 - grid = np.concatenate((grid_zero,grid_zero2, grid_left,grid_right[1:],far_grid[1:])) - grid.sort() # just for safety + grid = np.concatenate((grid_zero, grid_zero2, grid_left, grid_right[1:], far_grid[1:])) + grid.sort() # just for safety - if branch_length_mode=='input': + if branch_length_mode == 'input': # APPROXIMATE HANDLING OF BRANCH LENGTH PROPAGATOR WHEN USING INPUT BRANCH LENGTH # branch length are estimated from as those maximizing the likelihood and the # sensitivity of the likelihood depends on the branch length (gets soft for long branches) @@ -58,50 +73,61 @@ class BranchLenInterpolator (Distribution): # p_0(1-exp(-l/p0))(1-p_0(1-exp(-l/p0)))e^{2l/p0}/L which can be slightly rearranged to # p_0(exp(l/p0)-1)(exp(l/p0)-p_0(exp(l/p0)-1))/L - p0 = 1.0-np.sum(self.gtr.Pi**2) + p0 = 1.0 - np.sum(self.gtr.Pi**2) # variance_scale = one_mutation*ttconf.OVER_DISPERSION - if mutation_length<0.05: + if mutation_length < 0.05: # for short branches, the number of mutations is poissonian. the prob of a branch to have l=mutation_length*L # mutations when its length is k, is therefor e^{-kL}(kL)^(Ll)/(Ll)!. Ignoring constants, the log is # -kL + lL\log(k) - log_prob = np.array([ k - mutation_length*np.log(k+ttconf.MIN_BRANCH_LENGTH*one_mutation) for k in grid])/one_mutation + log_prob = ( + np.array([k - mutation_length * np.log(k + ttconf.MIN_BRANCH_LENGTH * one_mutation) for k in grid]) + / one_mutation + ) log_prob -= log_prob.min() else: # make it a Gaussian - #sigma_sq = (mutation_length+one_mutation)*variance_scale - l = (mutation_length+one_mutation) - nm_inv = np.exp(l/p0) - sigma_sq = p0*(nm_inv-1)*(nm_inv - p0*(nm_inv-1))*one_mutation - sigma = np.sqrt(sigma_sq+ttconf.MIN_BRANCH_LENGTH*one_mutation) - log_prob = np.array(np.min([[ 0.5*(mutation_length-k)**2/sigma_sq for k in grid], - 100 + np.abs([(mutation_length-k)/sigma for k in grid])], axis=0)) - elif branch_length_mode=='marginal': + # sigma_sq = (mutation_length+one_mutation)*variance_scale + l = mutation_length + one_mutation + nm_inv = np.exp(l / p0) + sigma_sq = p0 * (nm_inv - 1) * (nm_inv - p0 * (nm_inv - 1)) * one_mutation + sigma = np.sqrt(sigma_sq + ttconf.MIN_BRANCH_LENGTH * one_mutation) + log_prob = np.array( + np.min( + [ + [0.5 * (mutation_length - k) ** 2 / sigma_sq for k in grid], + 100 + np.abs([(mutation_length - k) / sigma for k in grid]), + ], + axis=0, + ) + ) + elif branch_length_mode == 'marginal': if hasattr(node, 'profile_pair'): - log_prob = np.array([-self.gtr.prob_t_profiles(node.profile_pair, - pattern_multiplicity, - k, - return_log=True) - for k in grid]) + log_prob = np.array( + [ + -self.gtr.prob_t_profiles(node.profile_pair, pattern_multiplicity, k, return_log=True) + for k in grid + ] + ) else: - raise Exception("profile pairs need to be assigned to node") - + raise Exception('profile pairs need to be assigned to node') - elif branch_length_mode=='joint': + elif branch_length_mode == 'joint': if not hasattr(node, 'branch_state'): - raise Exception("branch state pairs need to be assigned to nodes") - - log_prob = np.array([-self.gtr.prob_t_compressed(node.branch_state['pair'], - node.branch_state['multiplicity'], - k, - return_log=True) - for k in grid]) + raise Exception('branch state pairs need to be assigned to nodes') + + log_prob = np.array( + [ + -self.gtr.prob_t_compressed( + node.branch_state['pair'], node.branch_state['multiplicity'], k, return_log=True + ) + for k in grid + ] + ) else: - raise Exception("unknown branch length mode! "+branch_length_mode) + raise Exception('unknown branch length mode! ' + branch_length_mode) # tmp_dis = Distribution(grid, log_prob, is_log=True, kind='linear') # norm = tmp_dis.integrate(a=tmp_dis.xmin, b=tmp_dis.xmax, n=200) - super(BranchLenInterpolator, self).__init__(grid, log_prob, is_log=True, - kind='linear', min_width=min_width) - + super(BranchLenInterpolator, self).__init__(grid, log_prob, is_log=True, kind='linear', min_width=min_width) @property def gamma(self): @@ -110,12 +136,10 @@ class BranchLenInterpolator (Distribution): @gamma.setter def gamma(self, value): new_gamma = max(ttconf.TINY_NUMBER, value) - ratio = self._gamma/new_gamma + ratio = self._gamma / new_gamma self.x_rescale(ratio) self._gamma = new_gamma def __mul__(self, other): res = BranchLenInterpolator(super(BranchLenInterpolator, self).__mul__(other), gtr=self.gtr) return res - - diff --git a/treetime/clock_filter_methods.py b/treetime/clock_filter_methods.py index 6d6bfaa..744f697 100644 --- a/treetime/clock_filter_methods.py +++ b/treetime/clock_filter_methods.py @@ -1,44 +1,52 @@ import numpy as np import pandas as pd + def residual_filter(tt, n_iqd): terminals = tt.tree.get_terminals() clock_rate = tt.clock_model['slope'] icpt = tt.clock_model['intercept'] res = {} for node in terminals: - if hasattr(node, 'raw_date_constraint') and (node.raw_date_constraint is not None): - res[node] = node.dist2root - clock_rate*np.mean(node.raw_date_constraint) - icpt + if hasattr(node, 'raw_date_constraint') and (node.raw_date_constraint is not None): + res[node] = node.dist2root - clock_rate * np.mean(node.raw_date_constraint) - icpt residuals = np.array(list(res.values())) - iqd = np.percentile(residuals,75) - np.percentile(residuals,25) + iqd = np.percentile(residuals, 75) - np.percentile(residuals, 25) outliers = {} - for node,r in res.items(): - if abs(r)>n_iqd*iqd and node.up.up is not None: - node.bad_branch=True - outliers[node.name] = {'tau':(node.dist2root - icpt)/clock_rate, 'avg_date': np.mean(node.raw_date_constraint), - 'exact_date': node.raw_date_constraint if type(node) is float else None, - 'residual': r/iqd} + for node, r in res.items(): + if abs(r) > n_iqd * iqd and node.up.up is not None: + node.bad_branch = True + outliers[node.name] = { + 'tau': (node.dist2root - icpt) / clock_rate, + 'avg_date': np.mean(node.raw_date_constraint), + 'exact_date': node.raw_date_constraint if type(node) is float else None, + 'residual': r / iqd, + } else: - node.bad_branch=False + node.bad_branch = False - tt.outliers=None + tt.outliers = None if len(outliers): - outlier_df = pd.DataFrame(outliers).T.loc[:,['avg_date', 'tau', 'residual']]\ - .rename(columns={'avg_date':'given_date', 'tau':'apparent_date'}) - tt.logger("Clock_filter.residual_filter marked the following outliers:", 2, warn=True) - if tt.verbose>=2: + outlier_df = ( + pd.DataFrame(outliers) + .T.loc[:, ['avg_date', 'tau', 'residual']] + .rename(columns={'avg_date': 'given_date', 'tau': 'apparent_date'}) + ) + tt.logger('Clock_filter.residual_filter marked the following outliers:', 2, warn=True) + if tt.verbose >= 2: print(outlier_df) tt.outliers = outlier_df return len(outliers) + def local_filter(tt, z_score_threshold): - tt.logger(f"TreeTime.ClockFilter: starting local_outlier_detection", 2) + tt.logger(f'TreeTime.ClockFilter: starting local_outlier_detection', 2) node_info = collect_node_info(tt) node_info, z_scale = calculate_node_timings(tt, node_info) - tt.logger(f"TreeTime.ClockFilter: z-scale {z_scale:1.2f}", 2) + tt.logger(f'TreeTime.ClockFilter: z-scale {z_scale:1.2f}', 2) outliers = flag_outliers(tt, node_info, z_score_threshold, z_scale) @@ -46,12 +54,15 @@ def local_filter(tt, z_score_threshold): if n.name in outliers: n.bad_branch = True - tt.outliers=None + tt.outliers = None if len(outliers): - outlier_df = pd.DataFrame(outliers).T.loc[:,['avg_date', 'tau', 'z', 'diagnosis']]\ - .rename(columns={'avg_date':'given_date', 'tau':'apparent_date', 'z':'z-score'}) - tt.logger("Clock_filter.local_filter marked the following outliers", 2, warn=True) - if tt.verbose>=2: + outlier_df = ( + pd.DataFrame(outliers) + .T.loc[:, ['avg_date', 'tau', 'z', 'diagnosis']] + .rename(columns={'avg_date': 'given_date', 'tau': 'apparent_date', 'z': 'z-score'}) + ) + tt.logger('Clock_filter.local_filter marked the following outliers', 2, warn=True) + if tt.verbose >= 2: print(outlier_df) tt.outliers = outlier_df return len(outliers) @@ -60,12 +71,12 @@ def local_filter(tt, z_score_threshold): def flag_outliers(tt, node_info, z_score_threshold, z_scale): def add_outlier_info(z, n, n_info, parent_tau, mu): n_info['z'] = z - diagnosis='' + diagnosis = '' # muts = n_info["nmuts"] if n.is_terminal() else 0.0 # parent_tau = node_info[n.up.name]['tau'] if n.is_terminal() else n_info['tau'] - if z<0: - if np.abs(n_info['avg_date']-parent_tau) > n_info["nmuts"]/mu: - diagnosis='date_too_early' + if z < 0: + if np.abs(n_info['avg_date'] - parent_tau) > n_info['nmuts'] / mu: + diagnosis = 'date_too_early' else: diagnosis = 'excess_mutations' else: @@ -74,51 +85,57 @@ def flag_outliers(tt, node_info, z_score_threshold, z_scale): return n_info outliers = {} - mu = tt.clock_model['slope']*tt.data.full_length + mu = tt.clock_model['slope'] * tt.data.full_length for n in tt.tree.get_terminals(): if n.up.up is None: - continue # do not label children of the root as bad -- typically a problem with root choice that will be fixed anyway + continue # do not label children of the root as bad -- typically a problem with root choice that will be fixed anyway n_info = node_info[n.name] parent_tau = node_info[n.up.name]['tau'] if n_info['exact_date']: - z = (n_info['avg_date'] - n_info['tau'])/z_scale + z = (n_info['avg_date'] - n_info['tau']) / z_scale if np.abs(z) > z_score_threshold: outliers[n.name] = add_outlier_info(z, n, n_info, parent_tau, mu) elif n.raw_date_constraint and len(n.raw_date_constraint): - zs = [(n_info['tau'] - x)/z_scale for x in n.raw_date_constraint] - if zs[0]*zs[1]>0 and np.min(np.abs(zs))>z_score_threshold: - outliers[n.name] = add_outlier_info(zs[0] if np.abs(zs[0])<np.abs(zs[1]) else zs[1], - n, n_info, parent_tau, mu) + zs = [(n_info['tau'] - x) / z_scale for x in n.raw_date_constraint] + if zs[0] * zs[1] > 0 and np.min(np.abs(zs)) > z_score_threshold: + outliers[n.name] = add_outlier_info( + zs[0] if np.abs(zs[0]) < np.abs(zs[1]) else zs[1], n, n_info, parent_tau, mu + ) return outliers + def calculate_node_timings(tt, node_info, eps=0.2): - mu = tt.clock_model['slope']*tt.data.full_length - sigma_sq = (3/mu)**2 - tt.logger(f"Clockfilter.calculate_node_timings: mu={mu:1.3e}/y, sigma={3/mu:1.3e}y", 2) + mu = tt.clock_model['slope'] * tt.data.full_length + sigma_sq = (3 / mu) ** 2 + tt.logger(f'Clockfilter.calculate_node_timings: mu={mu:1.3e}/y, sigma={3 / mu:1.3e}y', 2) for n in tt.tree.find_clades(order='postorder'): p = node_info[n.name] if not p['exact_date'] or p['skip']: continue if n.is_terminal(): - prefactor = (p["observations"]/sigma_sq + mu**2/(p["nmuts"]+eps)) - p["a"] = (p["avg_date"]/sigma_sq + mu*p["nmuts"]/(p["nmuts"]+eps))/prefactor + prefactor = p['observations'] / sigma_sq + mu**2 / (p['nmuts'] + eps) + p['a'] = (p['avg_date'] / sigma_sq + mu * p['nmuts'] / (p['nmuts'] + eps)) / prefactor else: - children = [node_info[c.name] for c in n if (not node_info[c.name]['skip']) and node_info[c.name]['exact_date']] - if n==tt.tree.root: - tmp_children_1 = mu*np.sum([(mu*c["a"]-c["nmuts"])/(eps+c["nmuts"]) for c in children]) - tmp_children_2 = mu**2*np.sum([(1-c["b"])/(eps+c["nmuts"]) for c in children]) - prefactor = (p["observations"]/sigma_sq + tmp_children_2) - p["a"] = (p["observations"]*p["avg_date"]/sigma_sq + tmp_children_1)/prefactor + children = [ + node_info[c.name] for c in n if (not node_info[c.name]['skip']) and node_info[c.name]['exact_date'] + ] + if n == tt.tree.root: + tmp_children_1 = mu * np.sum([(mu * c['a'] - c['nmuts']) / (eps + c['nmuts']) for c in children]) + tmp_children_2 = mu**2 * np.sum([(1 - c['b']) / (eps + c['nmuts']) for c in children]) + prefactor = p['observations'] / sigma_sq + tmp_children_2 + p['a'] = (p['observations'] * p['avg_date'] / sigma_sq + tmp_children_1) / prefactor else: - tmp_children_1 = mu*np.sum([(mu*c["a"]-c["nmuts"])/(eps+c["nmuts"]) for c in children]) - tmp_children_2 = mu**2*np.sum([(1-c["b"])/(eps+c["nmuts"]) for c in children]) - prefactor = (p["observations"]/sigma_sq + mu**2/(p["nmuts"]+eps) + tmp_children_2) - p["a"] = (p["observations"]*p["avg_date"]/sigma_sq + mu*p["nmuts"]/(p["nmuts"]+eps)+tmp_children_1)/prefactor - p["b"] = mu**2/(p["nmuts"]+eps)/prefactor + tmp_children_1 = mu * np.sum([(mu * c['a'] - c['nmuts']) / (eps + c['nmuts']) for c in children]) + tmp_children_2 = mu**2 * np.sum([(1 - c['b']) / (eps + c['nmuts']) for c in children]) + prefactor = p['observations'] / sigma_sq + mu**2 / (p['nmuts'] + eps) + tmp_children_2 + p['a'] = ( + p['observations'] * p['avg_date'] / sigma_sq + mu * p['nmuts'] / (p['nmuts'] + eps) + tmp_children_1 + ) / prefactor + p['b'] = mu**2 / (p['nmuts'] + eps) / prefactor - node_info[tt.tree.root.name]["tau"] = node_info[tt.tree.root.name]["a"] + node_info[tt.tree.root.name]['tau'] = node_info[tt.tree.root.name]['a'] ## need to deal with tips without exact dates below. dev = [] @@ -127,14 +144,14 @@ def calculate_node_timings(tt, node_info, eps=0.2): for c in n: c_info = node_info[c.name] if c_info['skip']: - c_info['tau']=p['tau'] + c_info['tau'] = p['tau'] else: if c_info['exact_date']: - c_info["tau"] = c_info["a"] + c_info["b"]*p["tau"] + c_info['tau'] = c_info['a'] + c_info['b'] * p['tau'] else: - c_info["tau"] = p["tau"] + c_info['nmuts']/mu + c_info['tau'] = p['tau'] + c_info['nmuts'] / mu if c.is_terminal() and c_info['exact_date']: - dev.append(c_info['avg_date']-c_info['tau']) + dev.append(c_info['avg_date'] - c_info['tau']) return node_info, np.std(dev) @@ -146,50 +163,57 @@ def collect_node_info(tt, percentile_for_exact_date=90): tt.infer_ancestral_sequences(infer_gtr=False) L = tt.data.full_length - date_uncertainty = [np.abs(n.raw_date_constraint[1]-n.raw_date_constraint[0]) - if type(n.raw_date_constraint)!=float else 0.0 - for n in tt.tree.get_terminals() - if n.raw_date_constraint is not None] + date_uncertainty = [ + np.abs(n.raw_date_constraint[1] - n.raw_date_constraint[0]) if type(n.raw_date_constraint) != float else 0.0 + for n in tt.tree.get_terminals() + if n.raw_date_constraint is not None + ] from scipy.stats import scoreatpercentile - uncertainty_cutoff = scoreatpercentile(date_uncertainty, percentile_for_exact_date)*1.01 + + uncertainty_cutoff = scoreatpercentile(date_uncertainty, percentile_for_exact_date) * 1.01 for n in tt.tree.get_nonterminals(order='postorder'): - parent = {"dates": [], "tips": {}, "skip":False} + parent = {'dates': [], 'tips': {}, 'skip': False} exact_dates = 0 for c in n: if c.is_terminal(): - child = {'skip':False} - child["nmuts"] = len([m for m in c.mutations if m[-1] in 'ACGT']) if aln \ - else np.round(c.branch_length*L) + child = {'skip': False} + child['nmuts'] = ( + len([m for m in c.mutations if m[-1] in 'ACGT']) if aln else np.round(c.branch_length * L) + ) if c.raw_date_constraint is None: child['exact_date'] = False - elif type(c.raw_date_constraint)==float: + elif type(c.raw_date_constraint) == float: child['exact_date'] = True else: - child['exact_date'] = np.abs(c.raw_date_constraint[1]-c.raw_date_constraint[0])<=uncertainty_cutoff + child['exact_date'] = ( + np.abs(c.raw_date_constraint[1] - c.raw_date_constraint[0]) <= uncertainty_cutoff + ) if child['exact_date']: exact_dates += 1 - if child["nmuts"]==0: + if child['nmuts'] == 0: child['skip'] = True - parent["tips"][c.name]={'date': np.mean(c.raw_date_constraint), - 'exact_date':child['exact_date']} + parent['tips'][c.name] = { + 'date': np.mean(c.raw_date_constraint), + 'exact_date': child['exact_date'], + } else: child['skip'] = False child['observations'] = 1 if c.raw_date_constraint is not None: - child["avg_date"] = np.mean(c.raw_date_constraint) + child['avg_date'] = np.mean(c.raw_date_constraint) node_info[c.name] = child else: if node_info[c.name]['exact_date']: exact_dates += 1 - parent['exact_date'] = exact_dates>0 + parent['exact_date'] = exact_dates > 0 - parent["nmuts"] = len([m for m in n.mutations if m[-1] in 'ACGT']) if aln else np.round(n.branch_length*L) + parent['nmuts'] = len([m for m in n.mutations if m[-1] in 'ACGT']) if aln else np.round(n.branch_length * L) d = [v['date'] for v in parent['tips'].values() if v['exact_date']] - parent["observations"] = len(d) - parent["avg_date"] = np.mean(d) if len(d) else 0.0 + parent['observations'] = len(d) + parent['avg_date'] = np.mean(d) if len(d) else 0.0 node_info[n.name] = parent return node_info diff --git a/treetime/clock_tree.py b/treetime/clock_tree.py index 31c5f4b..8e51266 100644 --- a/treetime/clock_tree.py +++ b/treetime/clock_tree.py @@ -7,6 +7,7 @@ from .distribution import Distribution from .branch_len_interpolator import BranchLenInterpolator from .node_interpolator import NodeInterpolator + class ClockTree(TreeAnc): """ ClockTree is the main class to perform the optimization of the node @@ -22,10 +23,20 @@ class ClockTree(TreeAnc): is converted to the most likely time of the internal nodes. """ - def __init__(self, *args, dates=None, debug=False, real_dates=True, precision_fft = 'auto', - precision='auto', precision_branch='auto', branch_length_mode='joint', use_covariation=False, - use_fft=True, **kwargs): - + def __init__( + self, + *args, + dates=None, + debug=False, + real_dates=True, + precision_fft='auto', + precision='auto', + precision_branch='auto', + branch_length_mode='joint', + use_covariation=False, + use_fft=True, + **kwargs, + ): """ ClockTree constructor @@ -77,25 +88,24 @@ class ClockTree(TreeAnc): """ super(ClockTree, self).__init__(*args, **kwargs) if dates is None: - raise MissingDataError("ClockTree requires date constraints!") + raise MissingDataError('ClockTree requires date constraints!') - self.debug=debug + self.debug = debug self.real_dates = real_dates self.date_dict = dates self.use_fft = use_fft self._date2dist = None # we do not know anything about the conversion self.tip_slack = ttconf.OVER_DISPERSION # extra number of mutations added - # to terminal branches in covariance calculation + # to terminal branches in covariance calculation self.rel_tol_prune = ttconf.REL_TOL_PRUNE self.rel_tol_refine = ttconf.REL_TOL_REFINE self.branch_length_mode = branch_length_mode - self.clock_model=None - self.use_covariation=use_covariation # if false, covariation will be ignored in rate estimates. + self.clock_model = None + self.use_covariation = use_covariation # if false, covariation will be ignored in rate estimates. self._set_precision(precision) self._set_precision_fft(precision_fft, precision_branch) self._assign_dates() - def _assign_dates(self): """assign dates to nodes @@ -112,7 +122,9 @@ class ClockTree(TreeAnc): if node.name in self.date_dict: tmp_date = self.date_dict[node.name] if np.isscalar(tmp_date) and np.isnan(tmp_date): - self.logger("WARNING: ClockTree.init: node %s has a bad date: %s"%(node.name, str(tmp_date)), 2, warn=True) + self.logger( + 'WARNING: ClockTree.init: node %s has a bad date: %s' % (node.name, str(tmp_date)), 2, warn=True + ) node.raw_date_constraint = None node.bad_branch = True else: @@ -121,11 +133,14 @@ class ClockTree(TreeAnc): node.raw_date_constraint = tmp_date node.bad_branch = False except: - self.logger("WARNING: ClockTree.init: node %s has a bad date: %s"%(node.name, str(tmp_date)), 2, warn=True) + self.logger( + 'WARNING: ClockTree.init: node %s has a bad date: %s' % (node.name, str(tmp_date)), + 2, + warn=True, + ) node.raw_date_constraint = None node.bad_branch = True - else: # nodes without date contraints - + else: # nodes without date contraints node.raw_date_constraint = None if node.is_terminal(): @@ -139,49 +154,56 @@ class ClockTree(TreeAnc): if node.is_terminal() and node.bad_branch: bad_branch_counter += 1 - if bad_branch_counter>self.tree.count_terminals()-3: - raise MissingDataError("ERROR: ALMOST NO VALID DATE CONSTRAINTS") + if bad_branch_counter > self.tree.count_terminals() - 3: + raise MissingDataError('ERROR: ALMOST NO VALID DATE CONSTRAINTS') - self.logger("ClockTree._assign_dates: assigned date constraints to {} out of {} tips.".format(self.tree.count_terminals()-bad_branch_counter, self.tree.count_terminals()), 1) + self.logger( + 'ClockTree._assign_dates: assigned date constraints to {} out of {} tips.'.format( + self.tree.count_terminals() - bad_branch_counter, self.tree.count_terminals() + ), + 1, + ) return ttconf.SUCCESS - def _set_precision(self, precision): - ''' + """ function that sets precision to a (hopefully) reasonable guess based on the length of the sequence if not explicitly set - ''' + """ # if precision is explicitly specified, use it. if self.one_mutation: - self.min_width = 10*self.one_mutation + self.min_width = 10 * self.one_mutation else: self.min_width = 0.001 - if precision in [0,1,2,3]: - self.precision=precision - if self.one_mutation and self.one_mutation<1e-4 and precision<2: - self.logger("ClockTree._set_precision: FOR LONG SEQUENCES (>1e4) precision>=2 IS RECOMMENDED." - " precision %d was specified by the user"%precision, level=0) + if precision in [0, 1, 2, 3]: + self.precision = precision + if self.one_mutation and self.one_mutation < 1e-4 and precision < 2: + self.logger( + 'ClockTree._set_precision: FOR LONG SEQUENCES (>1e4) precision>=2 IS RECOMMENDED.' + ' precision %d was specified by the user' % precision, + level=0, + ) else: # otherwise adjust it depending on the minimal sensible branch length if self.one_mutation: - if self.one_mutation>1e-4: - self.precision=1 + if self.one_mutation > 1e-4: + self.precision = 1 else: - self.precision=2 + self.precision = 2 else: - self.precision=1 - self.logger("ClockTree: Setting precision to level %s"%self.precision, 2) + self.precision = 1 + self.logger('ClockTree: Setting precision to level %s' % self.precision, 2) - if self.precision==0: + if self.precision == 0: self.node_grid_points = ttconf.NODE_GRID_SIZE_ROUGH self.branch_grid_points = ttconf.BRANCH_GRID_SIZE_ROUGH self.n_integral = ttconf.N_INTEGRAL_ROUGH - elif self.precision==2: + elif self.precision == 2: self.node_grid_points = ttconf.NODE_GRID_SIZE_FINE self.branch_grid_points = ttconf.BRANCH_GRID_SIZE_FINE self.n_integral = ttconf.N_INTEGRAL_FINE - elif self.precision==3: + elif self.precision == 3: self.node_grid_points = ttconf.NODE_GRID_SIZE_ULTRA self.branch_grid_points = ttconf.BRANCH_GRID_SIZE_ULTRA self.n_integral = ttconf.N_INTEGRAL_ULTRA @@ -191,26 +213,33 @@ class ClockTree(TreeAnc): self.n_integral = ttconf.N_INTEGRAL def _set_precision_fft(self, precision_fft, precision_branch='auto'): - ''' - function to set the number of grid points for the minimal FWHM window and branch grid - when calculating the marginal distribution using the FFT-based approach - The default parameters ttconf.FFT_FWHM_GRID_SIZE and branch_grid_points determined in set_precision - are used unless an integer value is specified using precision_fft and precision_branch - ''' - - if type(precision_fft) is int: - self.logger("ClockTree.init._set_precision_fft: setting fft grid size explicitly," - " fft_grid_points=%.3e"%(precision_fft), 2) - self.fft_grid_size = precision_fft - elif precision_fft!='auto': - raise UnknownMethodError(f"ClockTree: precision_fft needs to be either 'auto' or an integer, got '{precision_fft}'.") - else: - self.fft_grid_size = ttconf.FFT_FWHM_GRID_SIZE - if type(precision_branch) is int: - self.logger("ClockTree.init._set_precision_fft: setting branch grid size explicitly," - " branch_grid_points=%.3e"%(precision_branch), 2) - self.branch_grid_points = precision_branch + """ + function to set the number of grid points for the minimal FWHM window and branch grid + when calculating the marginal distribution using the FFT-based approach + The default parameters ttconf.FFT_FWHM_GRID_SIZE and branch_grid_points determined in set_precision + are used unless an integer value is specified using precision_fft and precision_branch + """ + if type(precision_fft) is int: + self.logger( + 'ClockTree.init._set_precision_fft: setting fft grid size explicitly,' + ' fft_grid_points=%.3e' % (precision_fft), + 2, + ) + self.fft_grid_size = precision_fft + elif precision_fft != 'auto': + raise UnknownMethodError( + f"ClockTree: precision_fft needs to be either 'auto' or an integer, got '{precision_fft}'." + ) + else: + self.fft_grid_size = ttconf.FFT_FWHM_GRID_SIZE + if type(precision_branch) is int: + self.logger( + 'ClockTree.init._set_precision_fft: setting branch grid size explicitly,' + ' branch_grid_points=%.3e' % (precision_branch), + 2, + ) + self.branch_grid_points = precision_branch @property def date2dist(self): @@ -221,11 +250,13 @@ class ClockTree(TreeAnc): if val is None: self._date2dist = None else: - self.logger("ClockTree.date2dist: Setting new molecular clock." - " rate=%.3e, R^2=%.4f"%(val.clock_rate, val.r_val**2), 2) + self.logger( + 'ClockTree.date2dist: Setting new molecular clock.' + ' rate=%.3e, R^2=%.4f' % (val.clock_rate, val.r_val**2), + 2, + ) self._date2dist = val - def setup_TreeRegression(self, covariation=True): """instantiate a TreeRegression object and set its tip_value and branch_value function to defaults that are sensible for treetime instances. @@ -240,38 +271,45 @@ class ClockTree(TreeAnc): a TreeRegression instance with self.tree attached as tree. """ from .treeregression import TreeRegression - tip_value = lambda x:np.mean(x.raw_date_constraint) if (x.is_terminal() and (x.bad_branch is False)) else None - branch_value = lambda x:x.mutation_length + + tip_value = lambda x: np.mean(x.raw_date_constraint) if (x.is_terminal() and (x.bad_branch is False)) else None + branch_value = lambda x: x.mutation_length if covariation: om = self.one_mutation - branch_variance = lambda x:((max(0,x.clock_length) if hasattr(x,'clock_length') else x.mutation_length) - +(self.tip_slack**2*om if x.is_terminal() else 0.0))*om + branch_variance = lambda x: ( + ( + (max(0, x.clock_length) if hasattr(x, 'clock_length') else x.mutation_length) + + (self.tip_slack**2 * om if x.is_terminal() else 0.0) + ) + * om + ) else: - branch_variance = lambda x:1.0 if x.is_terminal() else 0.0 + branch_variance = lambda x: 1.0 if x.is_terminal() else 0.0 - Treg = TreeRegression(self.tree, tip_value=tip_value, - branch_value=branch_value, branch_variance=branch_variance) + Treg = TreeRegression( + self.tree, tip_value=tip_value, branch_value=branch_value, branch_variance=branch_variance + ) Treg.valid_confidence = covariation return Treg - def get_clock_model(self, covariation=True, slope=None): - self.logger(f'ClockTree.get_clock_model: estimating clock model with covariation={covariation}',3) + self.logger(f'ClockTree.get_clock_model: estimating clock model with covariation={covariation}', 3) Treg = self.setup_TreeRegression(covariation=covariation) self.clock_model = Treg.regression(slope=slope) if not np.isfinite(self.clock_model['slope']): - raise ValueError("Clock rate estimation failed. If your data lacks temporal signal, please specify the rate explicitly!") + raise ValueError( + 'Clock rate estimation failed. If your data lacks temporal signal, please specify the rate explicitly!' + ) if not Treg.valid_confidence or (slope is not None): if 'cov' in self.clock_model: self.clock_model.pop('cov') - self.clock_model['valid_confidence']=False + self.clock_model['valid_confidence'] = False else: - self.clock_model['valid_confidence']=True + self.clock_model['valid_confidence'] = True self.clock_model['r_val'] = Treg.explained_variance() self.date2dist = DateConversion.from_regression(self.clock_model) - def init_date_constraints(self, clock_rate=None, **kwarks): """ Get the conversion coefficients between the dates and the branch @@ -292,15 +330,16 @@ class ClockTree(TreeAnc): fixed clock rate as specified """ - self.logger("ClockTree.init_date_constraints...",2) + self.logger('ClockTree.init_date_constraints...', 2) self.tree.coalescent_joint_LH = 0 if self.aln and (not self.sequence_reconstruction): - self.infer_ancestral_sequences('probabilistic', marginal=self.branch_length_mode=='marginal', - sample_from_profile='root',**kwarks) + self.infer_ancestral_sequences( + 'probabilistic', marginal=self.branch_length_mode == 'marginal', sample_from_profile='root', **kwarks + ) # set the None for the date-related attributes in the internal nodes. # make interpolation objects for the branches - self.logger('ClockTree.init_date_constraints: Initializing branch length interpolation objects...',2) + self.logger('ClockTree.init_date_constraints: Initializing branch length interpolation objects...', 2) has_clock_length = [] for node in self.tree.find_clades(order='postorder'): if node.up is None: @@ -308,30 +347,35 @@ class ClockTree(TreeAnc): else: has_clock_length.append(hasattr(node, 'clock_length')) # copy the merger rate and gamma if they are set - if hasattr(node,'branch_length_interpolator') and node.branch_length_interpolator is not None: + if hasattr(node, 'branch_length_interpolator') and node.branch_length_interpolator is not None: gamma = node.branch_length_interpolator.gamma else: gamma = 1.0 - if self.branch_length_mode=='marginal': + if self.branch_length_mode == 'marginal': node.profile_pair = self.marginal_branch_profile(node) - elif self.branch_length_mode=='joint' and (not hasattr(node, 'branch_state')): + elif self.branch_length_mode == 'joint' and (not hasattr(node, 'branch_state')): self.add_branch_state(node) - node.branch_length_interpolator = BranchLenInterpolator(node, self.gtr, - pattern_multiplicity = self.data.multiplicity(mask=node.mask), min_width=self.min_width, - one_mutation=self.one_mutation, branch_length_mode=self.branch_length_mode, - n_grid_points = self.branch_grid_points) + node.branch_length_interpolator = BranchLenInterpolator( + node, + self.gtr, + pattern_multiplicity=self.data.multiplicity(mask=node.mask), + min_width=self.min_width, + one_mutation=self.one_mutation, + branch_length_mode=self.branch_length_mode, + n_grid_points=self.branch_grid_points, + ) node.branch_length_interpolator.gamma = gamma # use covariance in clock model only after initial timetree estimation is done - use_cov = (np.sum(has_clock_length) > len(has_clock_length)*0.7) and self.use_covariation + use_cov = (np.sum(has_clock_length) > len(has_clock_length) * 0.7) and self.use_covariation self.get_clock_model(covariation=use_cov, slope=clock_rate) self.logger('ClockTree.init_date_constraints: node date constraints objects...', 2) # make node distribution objects - for node in self.tree.find_clades(order="postorder"): + for node in self.tree.find_clades(order='postorder'): # node is constrained if hasattr(node, 'raw_date_constraint') and node.raw_date_constraint is not None: # set the absolute time before present in branch length units @@ -343,16 +387,19 @@ class ClockTree(TreeAnc): node.date_constraint = Distribution(tbp, np.ones_like(tbp), is_log=False, min_width=self.min_width) if hasattr(node, 'bad_branch') and node.bad_branch is True: - self.logger("ClockTree.init_date_constraints -- WARNING: Branch is marked as bad" - ", excluding it from the optimization process." - " Date constraint will be ignored!", 4, warn=True) - else: # node without sampling date set + self.logger( + 'ClockTree.init_date_constraints -- WARNING: Branch is marked as bad' + ', excluding it from the optimization process.' + ' Date constraint will be ignored!', + 4, + warn=True, + ) + else: # node without sampling date set node.raw_date_constraint = None node.date_constraint = None - def make_time_tree(self, time_marginal=False, clock_rate=None, **kwargs): - ''' + """ Use the date constraints to calculate the most likely positions of unconstrained nodes. @@ -365,8 +412,8 @@ class ClockTree(TreeAnc): **kwargs Key word arguments to initialize dates constraints - ''' - self.logger("ClockTree: Maximum likelihood tree optimization with temporal constraints",1) + """ + self.logger('ClockTree: Maximum likelihood tree optimization with temporal constraints', 1) self.init_date_constraints(clock_rate=clock_rate, **kwargs) @@ -378,7 +425,6 @@ class ClockTree(TreeAnc): self.tree.positional_LH = self.timetree_likelihood(time_marginal) self.convert_dates() - def _ml_t_joint(self): """ Compute the joint maximum likelihood assignment of the internal nodes positions by @@ -404,8 +450,7 @@ class ClockTree(TreeAnc): del node.joint_pos_Lx del node.joint_pos_Cx - - self.logger("ClockTree - Joint reconstruction: Propagating leaves -> root...", 2) + self.logger('ClockTree - Joint reconstruction: Propagating leaves -> root...', 2) # go through the nodes from leaves towards the root: for node in self.tree.find_clades(order='postorder'): # children first, msg to parents # Lx is the maximal likelihood of a subtree given the parent position @@ -414,8 +459,10 @@ class ClockTree(TreeAnc): # no information at the node node.joint_pos_Lx = None node.joint_pos_Cx = None - else: # all other nodes - if node.date_constraint is not None and node.date_constraint.is_delta: # there is a strict time constraint + else: # all other nodes + if ( + node.date_constraint is not None and node.date_constraint.is_delta + ): # there is a strict time constraint # subtree probability given the position of the parent node # Lx.x is the position of the parent node # Lx.y is the probability of the subtree (consisting of one terminal node in this case) @@ -424,12 +471,19 @@ class ClockTree(TreeAnc): x = bl + node.date_constraint.peak_pos # if a merger model is defined, add its (log) rate to the propagated distribution if hasattr(self, 'merger_model') and self.merger_model: - node.joint_pos_Lx = Distribution(x, -self.merger_model.integral_merger_rate(node.date_constraint.peak_pos) - + node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True) + node.joint_pos_Lx = Distribution( + x, + -self.merger_model.integral_merger_rate(node.date_constraint.peak_pos) + + node.branch_length_interpolator(bl), + min_width=self.min_width, + is_log=True, + ) else: - node.joint_pos_Lx = Distribution(x, node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True) - node.joint_pos_Cx = Distribution(x, bl, min_width=self.min_width) # map back to the branch length - else: # all nodes without precise constraint but positional information + node.joint_pos_Lx = Distribution( + x, node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True + ) + node.joint_pos_Cx = Distribution(x, bl, min_width=self.min_width) # map back to the branch length + else: # all nodes without precise constraint but positional information msgs_to_multiply = [node.date_constraint] if node.date_constraint is not None else [] child_messages = [child.joint_pos_Lx for child in node.clades if child.joint_pos_Lx is not None] msgs_to_multiply.extend(child_messages) @@ -443,39 +497,53 @@ class ClockTree(TreeAnc): if hasattr(self, 'merger_model') and self.merger_model: time_points = np.unique(np.concatenate([msg.x for msg in msgs_to_multiply])) if node.is_terminal(): - msgs_to_multiply.append(Distribution(time_points, -self.merger_model.integral_merger_rate(time_points), is_log=True)) + msgs_to_multiply.append( + Distribution( + time_points, -self.merger_model.integral_merger_rate(time_points), is_log=True + ) + ) else: msgs_to_multiply.append(self.merger_model.node_contribution(node, time_points)) # msgs_to_multiply combined returns the subtree likelihood given the node's constraint and child messages - if len(msgs_to_multiply) == 0: # there are no constraints + if len(msgs_to_multiply) == 0: # there are no constraints node.joint_pos_Lx = None node.joint_pos_Cx = None continue - elif len(msgs_to_multiply)>1: # combine the different msgs and constraints + elif len(msgs_to_multiply) > 1: # combine the different msgs and constraints subtree_distribution = Distribution.multiply(msgs_to_multiply) - else: # there is exactly one constraint. + else: # there is exactly one constraint. subtree_distribution = msgs_to_multiply[0] - if node.up is None: # this is the root, set dates + if node.up is None: # this is the root, set dates if hasattr(self, 'merger_model') and self.merger_model: # Removed merger rate must be added back at the root as nolonger an internal node - subtree_distribution = Distribution.multiply([subtree_distribution, Distribution(subtree_distribution.x, - self.merger_model.integral_merger_rate(subtree_distribution.x), is_log=True)]) + subtree_distribution = Distribution.multiply( + [ + subtree_distribution, + Distribution( + subtree_distribution.x, + self.merger_model.integral_merger_rate(subtree_distribution.x), + is_log=True, + ), + ] + ) subtree_distribution._adjust_grid(rel_tol=self.rel_tol_prune) # set root position and joint likelihood of the tree node.time_before_present = subtree_distribution.peak_pos node.joint_pos_Lx = subtree_distribution node.joint_pos_Cx = None node.clock_length = node.branch_length - else: # otherwise propagate to parent - res, res_t = NodeInterpolator.convolve(subtree_distribution, - node.branch_length_interpolator, - max_or_integral='max', - inverse_time=True, - n_grid_points = self.node_grid_points, - n_integral=self.n_integral, - rel_tol=self.rel_tol_refine) + else: # otherwise propagate to parent + res, res_t = NodeInterpolator.convolve( + subtree_distribution, + node.branch_length_interpolator, + max_or_integral='max', + inverse_time=True, + n_grid_points=self.node_grid_points, + n_integral=self.n_integral, + rel_tol=self.rel_tol_refine, + ) res._adjust_grid(rel_tol=self.rel_tol_prune) @@ -484,29 +552,37 @@ class ClockTree(TreeAnc): # construct the inverse cumulant distribution for the branch number estimates from scipy.interpolate import interp1d + if node.date_constraint is not None and node.date_constraint.is_delta: - node.joint_inverse_cdf=interp1d([0,1], node.date_constraint.peak_pos*np.ones(2), kind="linear") + node.joint_inverse_cdf = interp1d([0, 1], node.date_constraint.peak_pos * np.ones(2), kind='linear') elif isinstance(subtree_distribution, Distribution): dt = np.diff(subtree_distribution.x) y = subtree_distribution.prob_relative(subtree_distribution.x) - int_y = np.concatenate(([0], np.cumsum(dt*(y[1:]+y[:-1])/2.0))) - int_y/=int_y[-1] - node.joint_inverse_cdf = interp1d(int_y, subtree_distribution.x, kind="linear") - #node.joint_cdf = interp1d(subtree_distribution.x, int_y, kind="linear") - + int_y = np.concatenate(([0], np.cumsum(dt * (y[1:] + y[:-1]) / 2.0))) + int_y /= int_y[-1] + node.joint_inverse_cdf = interp1d(int_y, subtree_distribution.x, kind='linear') + # node.joint_cdf = interp1d(subtree_distribution.x, int_y, kind="linear") # go through the nodes from root towards the leaves and assign joint ML positions: - self.logger("ClockTree - Joint reconstruction: Propagating root -> leaves...", 2) + self.logger('ClockTree - Joint reconstruction: Propagating root -> leaves...', 2) for node in self.tree.find_clades(order='preorder'): # root first, msgs to children + if node.up is None: # root node + continue # the position was already set on the previous step - if node.up is None: # root node - continue # the position was already set on the previous step - - if node.joint_pos_Cx is None: # no constraints or branch is bad - reconstruct from the branch len interpolator + if ( + node.joint_pos_Cx is None + ): # no constraints or branch is bad - reconstruct from the branch len interpolator if hasattr(self, 'merger_model') and self.merger_model and node.up is not None: ##add merger_cost if using the coalescent model - merger_cost = Distribution(node.branch_length_interpolator.x, self.merger_model.cost(node.time_before_present, - node.branch_length_interpolator.x, multiplicity=len(node.up.clades)), is_log=True) + merger_cost = Distribution( + node.branch_length_interpolator.x, + self.merger_model.cost( + node.time_before_present, + node.branch_length_interpolator.x, + multiplicity=len(node.up.clades), + ), + is_log=True, + ) node.branch_length = Distribution.multiply([merger_cost, node.branch_length_interpolator]).peak_pos else: node.branch_length = node.branch_length_interpolator.peak_pos @@ -518,15 +594,21 @@ class ClockTree(TreeAnc): # the length of the branch corresponding to the most likely # subtree is node.Cx(node.up.time_before_present)) # subtree_LH = node.joint_pos_Lx(node.up.time_before_present) - node.branch_length = node.joint_pos_Cx(max(node.joint_pos_Cx.xmin, - node.up.time_before_present)) + node.branch_length = node.joint_pos_Cx(max(node.joint_pos_Cx.xmin, node.up.time_before_present)) # clean up tiny negative branch length, warn against bigger ones. - if node.branch_length<0: - if node.branch_length>-2*ttconf.TINY_NUMBER: - self.logger(f"ClockTree - Joint reconstruction: correcting rounding error of {node.name} bl={node.branch_length:1.2e}", 4) + if node.branch_length < 0: + if node.branch_length > -2 * ttconf.TINY_NUMBER: + self.logger( + f'ClockTree - Joint reconstruction: correcting rounding error of {node.name} bl={node.branch_length:1.2e}', + 4, + ) else: - self.logger(f"ClockTree - Joint reconstruction: NEGATIVE BRANCH LENGTH {node.name} bl={node.branch_length:1.2e}", 2, warn=True) + self.logger( + f'ClockTree - Joint reconstruction: NEGATIVE BRANCH LENGTH {node.name} bl={node.branch_length:1.2e}', + 2, + warn=True, + ) node.branch_length = 0 node.time_before_present = node.up.time_before_present - node.branch_length @@ -536,17 +618,18 @@ class ClockTree(TreeAnc): if not self.debug: _cleanup() - def timetree_likelihood(self, time_marginal): - ''' + """ Return the likelihood of the data given the current branch length in the tree - ''' + """ if time_marginal: - LH = self.tree.root.marginal_pos_LH.integrate(return_log=True, a=self.tree.root.marginal_pos_LH.xmin, b=self.tree.root.marginal_pos_LH.xmax, n=1000) + LH = self.tree.root.marginal_pos_LH.integrate( + return_log=True, a=self.tree.root.marginal_pos_LH.xmin, b=self.tree.root.marginal_pos_LH.xmax, n=1000 + ) else: LH = 0 for node in self.tree.find_clades(order='preorder'): # sum the likelihood contributions of all branches - if node.up is None: # root node + if node.up is None: # root node continue LH -= node.branch_length_interpolator(node.branch_length) @@ -555,7 +638,6 @@ class ClockTree(TreeAnc): LH += self.gtr.sequence_logLH(self.tree.root.cseq, pattern_multiplicity=self.data.multiplicity()) return LH - def _ml_t_marginal(self): """ Compute the marginal probability distribution of the internal nodes positions by @@ -583,25 +665,30 @@ class ClockTree(TreeAnc): root. """ + def _cleanup(): for node in self.tree.find_clades(): try: del node.marginal_pos_Lx del node.subtree_distribution del node.msg_from_parent - #del node.marginal_pos_LH + # del node.marginal_pos_LH except: pass method = 'FFT' if self.use_fft else 'explicit' - self.logger(f"ClockTree - Marginal reconstruction using {method} convolution: Propagating leaves -> root...", 2) + self.logger( + f'ClockTree - Marginal reconstruction using {method} convolution: Propagating leaves -> root...', 2 + ) # go through the nodes from leaves towards the root: for node in self.tree.find_clades(order='postorder'): # children first, msg to parents if node.bad_branch: # no information node.marginal_pos_Lx = None - else: # all other nodes - if node.date_constraint is not None and node.date_constraint.is_delta: # there is a hard time constraint + else: # all other nodes + if ( + node.date_constraint is not None and node.date_constraint.is_delta + ): # there is a hard time constraint # initialize the Lx for nodes with precise date constraint: # subtree probability given the position of the parent node # position of the parent node is given by the branch length @@ -610,24 +697,32 @@ class ClockTree(TreeAnc): bl = node.branch_length_interpolator.x x = bl + node.date_constraint.peak_pos if hasattr(self, 'merger_model') and self.merger_model: - node.marginal_pos_Lx = Distribution(x, -self.merger_model.integral_merger_rate(node.date_constraint.peak_pos) - +node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True) + node.marginal_pos_Lx = Distribution( + x, + -self.merger_model.integral_merger_rate(node.date_constraint.peak_pos) + + node.branch_length_interpolator(bl), + min_width=self.min_width, + is_log=True, + ) else: - node.marginal_pos_Lx = Distribution(x, node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True) - else: # all nodes without precise constraint but positional information - # subtree likelihood given the node's constraint and child msg: + node.marginal_pos_Lx = Distribution( + x, node.branch_length_interpolator(bl), min_width=self.min_width, is_log=True + ) + else: # all nodes without precise constraint but positional information + # subtree likelihood given the node's constraint and child msg: msgs_to_multiply = [node.date_constraint] if node.date_constraint is not None else [] - msgs_to_multiply.extend([child.marginal_pos_Lx for child in node.clades - if child.marginal_pos_Lx is not None]) + msgs_to_multiply.extend( + [child.marginal_pos_Lx for child in node.clades if child.marginal_pos_Lx is not None] + ) # combine the different msgs and constraints - if len(msgs_to_multiply)==0: + if len(msgs_to_multiply) == 0: # no information node.marginal_pos_Lx = None continue - elif len(msgs_to_multiply)==1: + elif len(msgs_to_multiply) == 1: node.product_of_child_messages = msgs_to_multiply[0] - else: # combine the different msgs and constraints + else: # combine the different msgs and constraints node.product_of_child_messages = Distribution.multiply(msgs_to_multiply) ## When a coalescent model is being used, the cost of having no merger events along the branch @@ -641,79 +736,121 @@ class ClockTree(TreeAnc): time_points = node.product_of_child_messages.x # set multiplicity of node to number of good child branches if node.is_terminal(): - merger_contribution = Distribution(time_points, -self.merger_model.integral_merger_rate(time_points), is_log=True) + merger_contribution = Distribution( + time_points, -self.merger_model.integral_merger_rate(time_points), is_log=True + ) else: merger_contribution = self.merger_model.node_contribution(node, time_points) - node.subtree_distribution = Distribution.multiply([merger_contribution, node.product_of_child_messages]) + node.subtree_distribution = Distribution.multiply( + [merger_contribution, node.product_of_child_messages] + ) else: node.subtree_distribution = node.product_of_child_messages - if node.up is None: # this is the root, set dates + if node.up is None: # this is the root, set dates node.subtree_distribution._adjust_grid(rel_tol=self.rel_tol_prune) node.marginal_pos_Lx = node.subtree_distribution if hasattr(self, 'merger_model') and self.merger_model: # Removed merger rate must be added back at the root as nolonger an internal node - node.marginal_pos_LH = Distribution.multiply([node.subtree_distribution, Distribution(node.subtree_distribution.x, - self.merger_model.integral_merger_rate(node.subtree_distribution.x), is_log=True)]) + node.marginal_pos_LH = Distribution.multiply( + [ + node.subtree_distribution, + Distribution( + node.subtree_distribution.x, + self.merger_model.integral_merger_rate(node.subtree_distribution.x), + is_log=True, + ), + ] + ) else: node.marginal_pos_LH = node.subtree_distribution - else: # otherwise propagate to parent + else: # otherwise propagate to parent if self.use_fft: - res, res_t = NodeInterpolator.convolve_fft(node.subtree_distribution, - node.branch_length_interpolator, self.fft_grid_size), None + res, res_t = ( + NodeInterpolator.convolve_fft( + node.subtree_distribution, node.branch_length_interpolator, self.fft_grid_size + ), + None, + ) else: - res, res_t = NodeInterpolator.convolve(node.subtree_distribution, - node.branch_length_interpolator, - max_or_integral='integral', - n_grid_points = self.node_grid_points, - n_integral=self.n_integral, - rel_tol=self.rel_tol_refine) + res, res_t = NodeInterpolator.convolve( + node.subtree_distribution, + node.branch_length_interpolator, + max_or_integral='integral', + n_grid_points=self.node_grid_points, + n_integral=self.n_integral, + rel_tol=self.rel_tol_refine, + ) res._adjust_grid(rel_tol=self.rel_tol_prune) node.marginal_pos_Lx = res - self.logger("ClockTree - Marginal reconstruction: Propagating root -> leaves...", 2) + self.logger('ClockTree - Marginal reconstruction: Propagating root -> leaves...', 2) from scipy.interpolate import interp1d + for node in self.tree.find_clades(order='preorder'): ## If a delta constraint in known no further work required if (node.date_constraint is not None) and (not node.bad_branch) and node.date_constraint.is_delta: node.marginal_pos_LH = node.date_constraint - node.msg_from_parent = None #if internal node has a delta constraint no previous information is passed on + node.msg_from_parent = ( + None # if internal node has a delta constraint no previous information is passed on + ) elif node.up is None: - node.msg_from_parent = None # nothing beyond the root + node.msg_from_parent = None # nothing beyond the root # all other cases (All internal nodes + unconstrained terminals) else: parent = node.up - msg_parent_to_node =None + msg_parent_to_node = None if node.marginal_pos_Lx is not None: - if len(parent.clades)<5: + if len(parent.clades) < 5: # messages from the complementary subtree (iterate over all sister nodes) complementary_msgs = [parent.date_constraint] if parent.date_constraint is not None else [] - complementary_msgs.extend([sister.marginal_pos_Lx for sister in parent.clades - if (sister != node) and (sister.marginal_pos_Lx is not None)]) + complementary_msgs.extend( + [ + sister.marginal_pos_Lx + for sister in parent.clades + if (sister != node) and (sister.marginal_pos_Lx is not None) + ] + ) else: - complementary_msgs = [Distribution.divide(parent.product_of_child_messages, node.marginal_pos_Lx)] + complementary_msgs = [ + Distribution.divide(parent.product_of_child_messages, node.marginal_pos_Lx) + ] # if parent itself got smth from the root node, include it if parent.msg_from_parent is not None: complementary_msgs.append(parent.msg_from_parent) if hasattr(self, 'merger_model') and self.merger_model: time_points = parent.marginal_pos_LH.x - if len(time_points)<5: - time_points = np.unique(np.concatenate([ - time_points, - np.linspace(np.min([x.xmin for x in complementary_msgs]), - np.max([x.xmax for x in complementary_msgs]), 50), - np.linspace(np.min([x.effective_support[0] for x in complementary_msgs]), - np.max([x.effective_support[1] for x in complementary_msgs]), 50), - ])) + if len(time_points) < 5: + time_points = np.unique( + np.concatenate( + [ + time_points, + np.linspace( + np.min([x.xmin for x in complementary_msgs]), + np.max([x.xmax for x in complementary_msgs]), + 50, + ), + np.linspace( + np.min([x.effective_support[0] for x in complementary_msgs]), + np.max([x.effective_support[1] for x in complementary_msgs]), + 50, + ), + ] + ) + ) # As Lx (the product of child messages) does not include the node contribution this must # be added to recover the full distribution of the parent node w/o contribution of the focal node. complementary_msgs.append(self.merger_model.node_contribution(parent, time_points)) # Removed merger rate must be added back if no msgs from parent (equivalent to root node case) if parent.msg_from_parent is None: - complementary_msgs.append(Distribution(time_points, self.merger_model.integral_merger_rate(time_points), is_log=True)) + complementary_msgs.append( + Distribution( + time_points, self.merger_model.integral_merger_rate(time_points), is_log=True + ) + ) if len(complementary_msgs): msg_parent_to_node = NodeInterpolator.multiply(complementary_msgs) @@ -724,42 +861,60 @@ class ClockTree(TreeAnc): if msg_parent_to_node is None: x = [parent.numdate, numeric_date()] - msg_parent_to_node = NodeInterpolator(x, [1.0, 1.0],min_width=self.min_width) + msg_parent_to_node = NodeInterpolator(x, [1.0, 1.0], min_width=self.min_width) # integral message, which delivers to the node the positional information # from the complementary subtree if self.use_fft: - res, res_t = NodeInterpolator.convolve_fft(msg_parent_to_node, node.branch_length_interpolator, - fft_grid_size = self.fft_grid_size, inverse_time=False), None + res, res_t = ( + NodeInterpolator.convolve_fft( + msg_parent_to_node, + node.branch_length_interpolator, + fft_grid_size=self.fft_grid_size, + inverse_time=False, + ), + None, + ) else: - res, res_t = NodeInterpolator.convolve(msg_parent_to_node, node.branch_length_interpolator, - max_or_integral='integral', - inverse_time=False, - n_grid_points = self.node_grid_points, - n_integral=self.n_integral, - rel_tol=self.rel_tol_refine) + res, res_t = NodeInterpolator.convolve( + msg_parent_to_node, + node.branch_length_interpolator, + max_or_integral='integral', + inverse_time=False, + n_grid_points=self.node_grid_points, + n_integral=self.n_integral, + rel_tol=self.rel_tol_refine, + ) node.msg_from_parent = res if node.marginal_pos_Lx is None: node.marginal_pos_LH = node.msg_from_parent else: - #node.subtree_distribution contains merger model contribution of this node + # node.subtree_distribution contains merger model contribution of this node node.marginal_pos_LH = NodeInterpolator.multiply((node.msg_from_parent, node.subtree_distribution)) - self.logger('ClockTree._ml_t_root_to_leaves: computed convolution' - ' with %d points at node %s'%(len(res.x),node.name), 4) + self.logger( + 'ClockTree._ml_t_root_to_leaves: computed convolution' + ' with %d points at node %s' % (len(res.x), node.name), + 4, + ) if self.debug: - tmp = np.diff(res.y-res.peak_val) - nsign_changed = np.sum((tmp[1:]*tmp[:-1]<0)&(res.y[1:-1]-res.peak_val<500)) - if nsign_changed>1: + tmp = np.diff(res.y - res.peak_val) + nsign_changed = np.sum((tmp[1:] * tmp[:-1] < 0) & (res.y[1:-1] - res.peak_val < 500)) + if nsign_changed > 1: import matplotlib.pyplot as plt + plt.ion() - plt.plot(res.x, res.y-res.peak_val, '-o') - plt.plot(res.peak_pos - node.branch_length_interpolator.x, - node.branch_length_interpolator(node.branch_length_interpolator.x)-node.branch_length_interpolator.peak_val, '-o') - plt.plot(msg_parent_to_node.x,msg_parent_to_node.y-msg_parent_to_node.peak_val, '-o') - plt.ylim(0,100) + plt.plot(res.x, res.y - res.peak_val, '-o') + plt.plot( + res.peak_pos - node.branch_length_interpolator.x, + node.branch_length_interpolator(node.branch_length_interpolator.x) + - node.branch_length_interpolator.peak_val, + '-o', + ) + plt.plot(msg_parent_to_node.x, msg_parent_to_node.y - msg_parent_to_node.peak_val, '-o') + plt.ylim(0, 100) plt.xlim(-0.05, 0.05) # assign positions of nodes and branch length @@ -771,34 +926,33 @@ class ClockTree(TreeAnc): # construct the inverse cumulative distribution to evaluate confidence intervals if node.marginal_pos_LH.is_delta: - node.marginal_inverse_cdf=interp1d([0,1], node.marginal_pos_LH.peak_pos*np.ones(2), kind="linear") - node.marginal_cdf = interp1d(node.marginal_pos_LH.peak_pos*np.ones(2), [0,1], kind="linear") + node.marginal_inverse_cdf = interp1d([0, 1], node.marginal_pos_LH.peak_pos * np.ones(2), kind='linear') + node.marginal_cdf = interp1d(node.marginal_pos_LH.peak_pos * np.ones(2), [0, 1], kind='linear') else: dt = np.diff(node.marginal_pos_LH.x) y = node.marginal_pos_LH.prob_relative(node.marginal_pos_LH.x) - int_y = np.concatenate(([0], np.cumsum(dt*(y[1:]+y[:-1])/2.0))) + int_y = np.concatenate(([0], np.cumsum(dt * (y[1:] + y[:-1]) / 2.0))) int_x = node.marginal_pos_LH.x if int_y[-1] == 0: - if len(dt)==0 or node.marginal_pos_LH.fwhm < 100*ttconf.TINY_NUMBER: + if len(dt) == 0 or node.marginal_pos_LH.fwhm < 100 * ttconf.TINY_NUMBER: ##delta function peak_idx = node.marginal_pos_LH._peak_idx - int_y = np.concatenate((np.zeros(peak_idx), np.ones(len(node.marginal_pos_LH.x)-peak_idx))) + int_y = np.concatenate((np.zeros(peak_idx), np.ones(len(node.marginal_pos_LH.x) - peak_idx))) if peak_idx == 0: int_y = np.concatenate(([0], int_y)) - int_x = np.concatenate(([int_x[0]- ttconf.TINY_NUMBER], int_x)) + int_x = np.concatenate(([int_x[0] - ttconf.TINY_NUMBER], int_x)) else: - raise TreeTimeUnknownError("Loss of probability in marginal time tree inference.") + raise TreeTimeUnknownError('Loss of probability in marginal time tree inference.') else: - int_y/=int_y[-1] - node.marginal_inverse_cdf = interp1d(int_y, int_x, kind="linear") - node.marginal_cdf = interp1d(int_x, int_y, kind="linear") + int_y /= int_y[-1] + node.marginal_inverse_cdf = interp1d(int_y, int_x, kind='linear') + node.marginal_cdf = interp1d(int_x, int_y, kind='linear') if not self.debug: _cleanup() - def convert_dates(self): - ''' + """ This function converts the estimated "time_before_present" properties of all nodes to numerical dates stored in the "numdate" attribute. This date is further converted into a human readable date string in format %Y-%m-%d assuming the usual calendar. @@ -808,26 +962,34 @@ class ClockTree(TreeAnc): None All manipulations are done in place on the tree - ''' + """ from datetime import datetime, timedelta + now = numeric_date() for node in self.tree.find_clades(): years_bp = self.date2dist.to_years(node.time_before_present) if years_bp < 0 and self.real_dates: - if not hasattr(node, "bad_branch") or node.bad_branch is False: - self.logger("ClockTree.convert_dates -- WARNING: The node is later than today, but it is not " - "marked as \"BAD\", which indicates the error in the " - "likelihood optimization.", 4, warn=True) + if not hasattr(node, 'bad_branch') or node.bad_branch is False: + self.logger( + 'ClockTree.convert_dates -- WARNING: The node is later than today, but it is not ' + 'marked as "BAD", which indicates the error in the ' + 'likelihood optimization.', + 4, + warn=True, + ) else: - self.logger("ClockTree.convert_dates -- WARNING: node which is marked as \"BAD\" optimized " - "later than present day", 4, warn=True) + self.logger( + 'ClockTree.convert_dates -- WARNING: node which is marked as "BAD" optimized ' + 'later than present day', + 4, + warn=True, + ) node.numdate = now - years_bp node.date = datestring_from_numeric(node.numdate) - def branch_length_to_years(self): - ''' + """ This function sets branch length to reflect the date differences between parent and child nodes measured in years. Should only be called after :py:meth:`timetree.ClockTree.convert_dates` has been called. @@ -836,16 +998,15 @@ class ClockTree(TreeAnc): None All manipulations are done in place on the tree - ''' + """ self.logger('ClockTree.branch_length_to_years: setting node positions in units of years', 2) if not hasattr(self.tree.root, 'numdate'): - self.logger('ClockTree.branch_length_to_years: infer ClockTree first', 2,warn=True) + self.logger('ClockTree.branch_length_to_years: infer ClockTree first', 2, warn=True) self.tree.root.branch_length = 0.1 for n in self.tree.find_clades(order='preorder'): if n.up is not None: n.branch_length = n.numdate - n.up.numdate - def calc_rate_susceptibility(self, rate_std=None, params=None): """return the time tree estimation of evolutionary rates +/- one standard deviation form the ML estimate. @@ -858,47 +1019,52 @@ class ClockTree(TreeAnc): params = params or {} if rate_std is None: if not (self.clock_model['valid_confidence'] and 'cov' in self.clock_model): - raise ValueError("ClockTree.calc_rate_susceptibility: need valid standard deviation of the clock rate to estimate dating error.") + raise ValueError( + 'ClockTree.calc_rate_susceptibility: need valid standard deviation of the clock rate to estimate dating error.' + ) - rate_std = np.sqrt(self.clock_model['cov'][0,0]) + rate_std = np.sqrt(self.clock_model['cov'][0, 0]) - if self.clock_model['slope']<0: - raise ValueError("ClockTree.calc_rate_susceptibility: rate estimate is negative. In this case the heuristic treetime uses to account for uncertainty in the rate estimate does not work. Please specify the clock-rate and its standard deviation explicitly via CLI parameters or arguments.") + if self.clock_model['slope'] < 0: + raise ValueError( + 'ClockTree.calc_rate_susceptibility: rate estimate is negative. In this case the heuristic treetime uses to account for uncertainty in the rate estimate does not work. Please specify the clock-rate and its standard deviation explicitly via CLI parameters or arguments.' + ) current_rate = np.abs(self.clock_model['slope']) upper_rate = self.clock_model['slope'] + rate_std - lower_rate = max(0.1*current_rate, self.clock_model['slope'] - rate_std) + lower_rate = max(0.1 * current_rate, self.clock_model['slope'] - rate_std) for n in self.tree.find_clades(): if n.up: n._orig_gamma = n.branch_length_interpolator.gamma - n.branch_length_interpolator.gamma = n._orig_gamma*upper_rate/current_rate + n.branch_length_interpolator.gamma = n._orig_gamma * upper_rate / current_rate - self.logger("###ClockTree.calc_rate_susceptibility: run with upper bound of rate estimate", 1) + self.logger('###ClockTree.calc_rate_susceptibility: run with upper bound of rate estimate', 1) self.make_time_tree(**params) - self.logger("###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f"%(upper_rate, self.tree.positional_LH), 2) + self.logger('###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f' % (upper_rate, self.tree.positional_LH), 2) for n in self.tree.find_clades(): n.numdate_rate_variation = [(upper_rate, n.numdate)] if n.up: - n.branch_length_interpolator.gamma = n._orig_gamma*lower_rate/current_rate + n.branch_length_interpolator.gamma = n._orig_gamma * lower_rate / current_rate - self.logger("###ClockTree.calc_rate_susceptibility: run with lower bound of rate estimate", 1) + self.logger('###ClockTree.calc_rate_susceptibility: run with lower bound of rate estimate', 1) self.make_time_tree(**params) - self.logger("###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f"%(lower_rate, self.tree.positional_LH), 2) + self.logger('###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f' % (lower_rate, self.tree.positional_LH), 2) for n in self.tree.find_clades(): n.numdate_rate_variation.append((lower_rate, n.numdate)) if n.up: - n.branch_length_interpolator.gamma = n._orig_gamma + n.branch_length_interpolator.gamma = n._orig_gamma - self.logger("###ClockTree.calc_rate_susceptibility: run with central rate estimate", 1) + self.logger('###ClockTree.calc_rate_susceptibility: run with central rate estimate', 1) self.make_time_tree(**params) - self.logger("###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f"%(current_rate, self.tree.positional_LH), 2) + self.logger( + '###ClockTree.calc_rate_susceptibility: rate: %f, LH:%f' % (current_rate, self.tree.positional_LH), 2 + ) for n in self.tree.find_clades(): n.numdate_rate_variation.append((current_rate, n.numdate)) - n.numdate_rate_variation.sort(key=lambda x:x[1]) # sort estimates for different rates by numdate + n.numdate_rate_variation.sort(key=lambda x: x[1]) # sort estimates for different rates by numdate return ttconf.SUCCESS - def date_uncertainty_due_to_rate(self, node, interval=(0.05, 0.095)): """use previously calculated variation of the rate to estimate the uncertainty in a particular numdate due to rate variation. @@ -911,12 +1077,12 @@ class ClockTree(TreeAnc): Array of length two, or tuple, defining the bounds of the confidence interval """ - if hasattr(node, "numdate_rate_variation"): + if hasattr(node, 'numdate_rate_variation'): from scipy.special import erfinv - nsig = [np.sqrt(2.0)*erfinv(-1.0 + 2.0*x) if x*(1.0-x) else 0 - for x in interval] - l,c,u = [x[1] for x in node.numdate_rate_variation] - return np.array([c + x*np.abs(y-c) for x,y in zip(nsig, (l,u))]) + + nsig = [np.sqrt(2.0) * erfinv(-1.0 + 2.0 * x) if x * (1.0 - x) else 0 for x in interval] + l, c, u = [x[1] for x in node.numdate_rate_variation] + return np.array([c + x * np.abs(y - c) for x, y in zip(nsig, (l, u))]) else: return None @@ -925,20 +1091,17 @@ class ClockTree(TreeAnc): if c1 is None and c2 is None: return np.array(limits) elif c1 is None: - min_val,max_val = c2 + min_val, max_val = c2 elif c2 is None: - min_val,max_val = c1 + min_val, max_val = c1 else: - min_val = center - np.sqrt((c1[0]-center)**2 + (c2[0]-center)**2) - max_val = center + np.sqrt((c1[1]-center)**2 + (c2[1]-center)**2) + min_val = center - np.sqrt((c1[0] - center) ** 2 + (c2[0] - center) ** 2) + max_val = center + np.sqrt((c1[1] - center) ** 2 + (c2[1] - center) ** 2) - return np.array([max(limits[0], min_val), - min(limits[1], max_val)]) + return np.array([max(limits[0], min_val), min(limits[1], max_val)]) - - - def get_confidence_interval(self, node, interval = (0.05, 0.95)): - ''' + def get_confidence_interval(self, node, interval=(0.05, 0.95)): + """ If temporal reconstruction was done using the marginal ML mode, the entire distribution of times is available. This function determines the 90% (or other) confidence interval, defined as the range where 5% of probability is below and above. Note that this does not necessarily contain @@ -961,24 +1124,27 @@ class ClockTree(TreeAnc): confidence_interval : numpy array Array with two numerical dates delineating the confidence interval - ''' + """ rate_contribution = self.date_uncertainty_due_to_rate(node, interval) + mutation_contribution = None - if hasattr(node, "marginal_inverse_cdf"): - min_date, max_date = [self.date2dist.to_numdate(x) for x in - (node.marginal_pos_LH.xmax, node.marginal_pos_LH.xmin)] - if node.marginal_inverse_cdf=="delta": + if hasattr(node, 'marginal_inverse_cdf'): + min_date, max_date = [ + self.date2dist.to_numdate(x) for x in (node.marginal_pos_LH.xmax, node.marginal_pos_LH.xmin) + ] + if node.marginal_inverse_cdf == 'delta': return np.array([node.numdate, node.numdate]) else: mutation_contribution = self.date2dist.to_numdate(node.marginal_inverse_cdf(np.array(interval))[::-1]) else: min_date, max_date = [-np.inf, np.inf] - return self.combine_confidence(node.numdate, (min_date, max_date), - c1=rate_contribution, c2=mutation_contribution) + return self.combine_confidence( + node.numdate, (min_date, max_date), c1=rate_contribution, c2=mutation_contribution + ) - def get_max_posterior_region(self, node, fraction = 0.9): - ''' + def get_max_posterior_region(self, node, fraction=0.9): + """ If temporal reconstruction was done using the marginal ML mode, the entire distribution of times is available. This function determines the interval around the highest posterior probability region that contains the specified fraction of the probability mass. @@ -1000,55 +1166,69 @@ class ClockTree(TreeAnc): max_posterior_region : numpy array Array with two numerical dates delineating the high posterior region - ''' - if node.marginal_inverse_cdf=="delta": + """ + if node.marginal_inverse_cdf == 'delta': return np.array([node.numdate, node.numdate]) - min_max = (node.marginal_pos_LH.xmin, node.marginal_pos_LH.xmax) min_date, max_date = [self.date2dist.to_numdate(x) for x in min_max][::-1] - if node.marginal_pos_LH.peak_pos == min_max[0]: #peak on the left + if node.marginal_pos_LH.peak_pos == min_max[0]: # peak on the left return self.get_confidence_interval(node, (0, fraction)) - elif node.marginal_pos_LH.peak_pos == min_max[1]: #peak on the right - return self.get_confidence_interval(node, (1.0-fraction, 1.0)) - else: # peak in the center of the distribution - rate_contribution = self.date_uncertainty_due_to_rate(node, ((1-fraction)*0.5, 1.0-(1.0-fraction)*0.5)) + elif node.marginal_pos_LH.peak_pos == min_max[1]: # peak on the right + return self.get_confidence_interval(node, (1.0 - fraction, 1.0)) + else: # peak in the center of the distribution + rate_contribution = self.date_uncertainty_due_to_rate( + node, ((1 - fraction) * 0.5, 1.0 - (1.0 - fraction) * 0.5) + ) # construct height to position interpolators left and right of the peak # this assumes there is only one peak --- might fail in odd cases from scipy.interpolate import interp1d from scipy.optimize import minimize_scalar as minimize + pidx = np.argmin(node.marginal_pos_LH.y) pval = np.min(node.marginal_pos_LH.y) # check if the distribution as at least 3 points and that the peak is not either of the two # end points. Otherwise, interpolation objects can be initialized. - if node.marginal_pos_LH.y.shape[0]<3 or pidx==0 or pidx==node.marginal_pos_LH.y.shape[0]-1: - value_str = "values: " + ','.join([str(x) for x in node.marginal_pos_LH.y]) - self.logger("get_max_posterior_region: peak on boundary or array too short." + value_str, 1, warn=True) - mutation_contribution=None + if node.marginal_pos_LH.y.shape[0] < 3 or pidx == 0 or pidx == node.marginal_pos_LH.y.shape[0] - 1: + value_str = 'values: ' + ','.join([str(x) for x in node.marginal_pos_LH.y]) + self.logger('get_max_posterior_region: peak on boundary or array too short.' + value_str, 1, warn=True) + mutation_contribution = None else: - left = interp1d(node.marginal_pos_LH.y[:(pidx+1)]-pval, node.marginal_pos_LH.x[:(pidx+1)], - kind='linear', fill_value=min_max[0], bounds_error=False) - right = interp1d(node.marginal_pos_LH.y[pidx:]-pval, node.marginal_pos_LH.x[pidx:], - kind='linear', fill_value=min_max[1], bounds_error=False) + left = interp1d( + node.marginal_pos_LH.y[: (pidx + 1)] - pval, + node.marginal_pos_LH.x[: (pidx + 1)], + kind='linear', + fill_value=min_max[0], + bounds_error=False, + ) + right = interp1d( + node.marginal_pos_LH.y[pidx:] - pval, + node.marginal_pos_LH.x[pidx:], + kind='linear', + fill_value=min_max[1], + bounds_error=False, + ) # function to minimize -- squared difference between prob mass and desired fracion def func(x, thres): interval = np.array([left(x), right(x)]).squeeze() - return (thres - np.diff(node.marginal_cdf(np.array(interval))))**2 + return (thres - np.diff(node.marginal_cdf(np.array(interval)))) ** 2 # minimze and determine success - sol = minimize(func, bracket=[0,10], args=(fraction,), method='brent') + sol = minimize(func, bracket=[0, 10], args=(fraction,), method='brent') if sol['success']: - mutation_contribution = self.date2dist.to_numdate(np.array([right(sol['x']), left(sol['x'])]).squeeze()) - else: # on failure, return standard confidence interval + mutation_contribution = self.date2dist.to_numdate( + np.array([right(sol['x']), left(sol['x'])]).squeeze() + ) + else: # on failure, return standard confidence interval mutation_contribution = None - return self.combine_confidence(node.numdate, (min_date, max_date), - c1=rate_contribution, c2=mutation_contribution) - + return self.combine_confidence( + node.numdate, (min_date, max_date), c1=rate_contribution, c2=mutation_contribution + ) -if __name__=="__main__": +if __name__ == '__main__': pass diff --git a/treetime/config.py b/treetime/config.py index a621559..b858c24 100644 --- a/treetime/config.py +++ b/treetime/config.py @@ -3,8 +3,8 @@ VERBOSE = 3 BIG_NUMBER = 1e10 TINY_NUMBER = 1e-12 SUPERTINY_NUMBER = 1e-24 -MIN_LOG = -1e8 # minimal log value -MIN_BRANCH_LENGTH = 1e-3 # fraction of length 'one_mutation' that is used as lower cut-off for branch lengths in GTR +MIN_LOG = -1e8 # minimal log value +MIN_BRANCH_LENGTH = 1e-3 # fraction of length 'one_mutation' that is used as lower cut-off for branch lengths in GTR OVER_DISPERSION = 10 # distribution parameters @@ -31,18 +31,19 @@ MIN_INTEGRATION_PEAK = 0.001 # clocktree parameters BRANCH_LEN_PENALTY = 0 -MAX_BRANCH_LENGTH = 4.0 # only relevant for branch length optimization and time trees - upper boundary of interpolator objects +MAX_BRANCH_LENGTH = ( + 4.0 # only relevant for branch length optimization and time trees - upper boundary of interpolator objects +) NINTEGRAL = 300 REL_TOL_PRUNE = 0.01 REL_TOL_REFINE = 0.05 NIQD = 3 # -SUCCESS = "success" -ERROR = "error" +SUCCESS = 'success' +ERROR = 'error' # treetime # autocorrelated molecular clock coefficients MU_ALPHA = 1 MU_BETA = 1 - diff --git a/treetime/distribution.py b/treetime/distribution.py index 46cfe2a..a444329 100644 --- a/treetime/distribution.py +++ b/treetime/distribution.py @@ -1,6 +1,7 @@ import numpy as np from . import TreeTimeUnknownError from scipy.interpolate import interp1d + try: from collections.abc import Iterable except ImportError: @@ -8,6 +9,7 @@ except ImportError: from .config import BIG_NUMBER, MIN_INTEGRATION_PEAK, TINY_NUMBER from .utils import clip + class Distribution(object): """ Class to implement the probability distribution. This class wraps the scipy @@ -26,10 +28,9 @@ class Distribution(object): """ if isinstance(distribution, interp1d): - if is_neg_log: ymin = distribution.y.min() - log_prob = distribution.y-ymin + log_prob = distribution.y - ymin else: log_prob = -np.log(distribution.y) log_prob -= log_prob.min() @@ -41,159 +42,167 @@ class Distribution(object): xvals = distribution._func.x log_prob = distribution._func.y else: - raise TypeError("Error in computing the FWHM for the distribution. " - " The input should be either Distribution or interpolation object") + raise TypeError( + 'Error in computing the FWHM for the distribution. ' + ' The input should be either Distribution or interpolation object' + ) L = xvals.shape[0] # 0.69... is log(2), there is always one value for which this is true since # the minimum is subtracted tmp = np.where(log_prob < 0.693147)[0] - if len(tmp)==0: - raise ValueError("Error in computing the FWHM for the distribution. This is " - "most likely caused by incorrect input data.") + if len(tmp) == 0: + raise ValueError( + 'Error in computing the FWHM for the distribution. This is most likely caused by incorrect input data.' + ) x_l, x_u = tmp[0], tmp[-1] if L < 2: - print ("Not enough points to compute FWHM: returning zero") + print('Not enough points to compute FWHM: returning zero') return min(TINY_NUMBER, distribution.xmax - distribution.xmin) else: # need to guard against out-of-bounds errors - return max(TINY_NUMBER, xvals[min(x_u+1,L-1)] - xvals[max(0,x_l-1)]) - + return max(TINY_NUMBER, xvals[min(x_u + 1, L - 1)] - xvals[max(0, x_l - 1)]) @classmethod - def delta_function(cls, x_pos, weight=1., min_width=MIN_INTEGRATION_PEAK): + def delta_function(cls, x_pos, weight=1.0, min_width=MIN_INTEGRATION_PEAK): """ Create delta function distribution. """ - distribution = cls(x_pos,0.,is_log=True, min_width=min_width) - distribution.weight = weight + distribution = cls(x_pos, 0.0, is_log=True, min_width=min_width) + distribution.weight = weight return distribution - @classmethod def shifted_x(cls, dist, delta_x): - return Distribution(dist.x+delta_x, dist.y, kind=dist.kind) - + return Distribution(dist.x + delta_x, dist.y, kind=dist.kind) @staticmethod def multiply(dists): - ''' + """ multiplies a list of Distribution objects - ''' - if not all([isinstance(k, Distribution) for k in dists]): - raise NotImplementedError("Can only multiply Distribution objects") + """ + if not all([isinstance(k, Distribution) for k in dists]): + raise NotImplementedError('Can only multiply Distribution objects') n_delta = np.sum([k.is_delta for k in dists]) min_width = np.max([k.min_width for k in dists]) - if n_delta>1: - raise ArithmeticError("Cannot multiply more than one delta functions!") - elif n_delta==1: + if n_delta > 1: + raise ArithmeticError('Cannot multiply more than one delta functions!') + elif n_delta == 1: delta_dist_ii = np.where([k.is_delta for k in dists])[0][0] delta_dist = dists[delta_dist_ii] new_xpos = delta_dist.peak_pos - new_weight = np.prod([k.prob(new_xpos) for k in dists if k!=delta_dist_ii]) * delta_dist.weight - res = Distribution.delta_function(new_xpos, weight = new_weight,min_width=min_width) + new_weight = np.prod([k.prob(new_xpos) for k in dists if k != delta_dist_ii]) * delta_dist.weight + res = Distribution.delta_function(new_xpos, weight=new_weight, min_width=min_width) else: new_xmin = np.max([k.xmin for k in dists]) new_xmax = np.min([k.xmax for k in dists]) x_vals = np.unique(np.concatenate([k.x for k in dists])) - x_vals = x_vals[(x_vals > new_xmin - TINY_NUMBER)&(x_vals < new_xmax + TINY_NUMBER)] + x_vals = x_vals[(x_vals > new_xmin - TINY_NUMBER) & (x_vals < new_xmax + TINY_NUMBER)] n_dists = len(dists) # for reduce number of points if there are many distributions - if len(x_vals)>100*n_dists and n_dists>3: + if len(x_vals) > 100 * n_dists and n_dists > 3: # make sure there are at least 3 points per distribution on average - n_bins = len(x_vals)//n_dists - 6 - lower_cut_off = n_dists*3 - upper_cut_off = n_dists*(n_bins + 3) + n_bins = len(x_vals) // n_dists - 6 + lower_cut_off = n_dists * 3 + upper_cut_off = n_dists * (n_bins + 3) # use peripheral points from the original array, average the center - x_vals = np.concatenate((x_vals[:lower_cut_off], - x_vals[lower_cut_off:upper_cut_off].reshape((-1,n_dists)).mean(axis=1), - x_vals[upper_cut_off:])) + x_vals = np.concatenate( + ( + x_vals[:lower_cut_off], + x_vals[lower_cut_off:upper_cut_off].reshape((-1, n_dists)).mean(axis=1), + x_vals[upper_cut_off:], + ) + ) # evaluate the function at the consolidated lists of x-values y_vals = np.sum([k.__call__(x_vals) for k in dists], axis=0) try: peak = y_vals.min() except: - raise TreeTimeUnknownError("Error: Unexpected behavior detected in multiply function" - " when determining peak of function with y-values '"+ str(y_vals) + "'.\n\n" - "If you see this error please let us know by filling an issue at: \n" - "https://github.com/neherlab/treetime/issues") + raise TreeTimeUnknownError( + 'Error: Unexpected behavior detected in multiply function' + " when determining peak of function with y-values '" + str(y_vals) + "'.\n\n" + 'If you see this error please let us know by filling an issue at: \n' + 'https://github.com/neherlab/treetime/issues' + ) # remove data points exp(-1000) less likely than the peak - ind = (y_vals-peak)<BIG_NUMBER/1000 + ind = (y_vals - peak) < BIG_NUMBER / 1000 n_points = ind.sum() if n_points == 0: - raise TreeTimeUnknownError("Error: Unexpected behavior detected in multiply function. " - "No valid points left after reducing to plausible region.\n\n" - "If you see this error please let us know by filling an issue at:\n" - "https://github.com/neherlab/treetime/issues") + raise TreeTimeUnknownError( + 'Error: Unexpected behavior detected in multiply function. ' + 'No valid points left after reducing to plausible region.\n\n' + 'If you see this error please let us know by filling an issue at:\n' + 'https://github.com/neherlab/treetime/issues' + ) elif n_points == 1: res = Distribution.delta_function(x_vals[0]) else: - res = Distribution(x_vals[ind], y_vals[ind], is_log=True, - min_width=min_width, kind='linear', assume_sorted=True) + res = Distribution( + x_vals[ind], y_vals[ind], is_log=True, min_width=min_width, kind='linear', assume_sorted=True + ) return res @staticmethod def divide(numerator, denominator): - ''' + """ divides one distribution object by another. Note that this is in general an ill-defined procedure. this is implemented here for the special case where the numerator is a product that contains the denominator to produce a reduced product without the denominator. - ''' + """ if numerator.is_delta or denominator.is_delta: - raise(ArithmeticError("Can not divide delta functions")) + raise (ArithmeticError('Can not divide delta functions')) dists = [numerator, denominator] min_width = np.max([k.min_width for k in dists]) new_xmin = np.max([k.xmin for k in dists]) new_xmax = np.min([k.xmax for k in dists]) x_vals = np.unique(np.concatenate([k.x for k in dists])) - x_vals = x_vals[(x_vals> new_xmin-TINY_NUMBER)&(x_vals< new_xmax+TINY_NUMBER)] + x_vals = x_vals[(x_vals > new_xmin - TINY_NUMBER) & (x_vals < new_xmax + TINY_NUMBER)] y_vals = numerator.__call__(x_vals) - denominator.__call__(x_vals) peak = y_vals.min() - ind = (y_vals-peak)<BIG_NUMBER/1000 + ind = (y_vals - peak) < BIG_NUMBER / 1000 n_points = ind.sum() if n_points == 0: - print("WARNING: Unexpected behavior detected in multiply function," - "if you see this error \n please let us know by filling an issue at: https://github.com/neherlab/treetime/issues") - x_vals = [0,1] - y_vals = [BIG_NUMBER,BIG_NUMBER] - res = Distribution(x_vals, y_vals, is_log=True, - min_width=min_width, kind='linear') + print( + 'WARNING: Unexpected behavior detected in multiply function,' + 'if you see this error \n please let us know by filling an issue at: https://github.com/neherlab/treetime/issues' + ) + x_vals = [0, 1] + y_vals = [BIG_NUMBER, BIG_NUMBER] + res = Distribution(x_vals, y_vals, is_log=True, min_width=min_width, kind='linear') elif n_points == 1: res = Distribution.delta_function(x_vals[0]) else: - res = Distribution(x_vals[ind], y_vals[ind], is_log=True, - min_width=min_width, kind='linear', assume_sorted=True) + res = Distribution( + x_vals[ind], y_vals[ind], is_log=True, min_width=min_width, kind='linear', assume_sorted=True + ) return res - def __init__(self, x, y, is_log=True, min_width = MIN_INTEGRATION_PEAK, - kind='linear', assume_sorted=False): - + def __init__(self, x, y, is_log=True, min_width=MIN_INTEGRATION_PEAK, kind='linear', assume_sorted=False): """ Create Distribution instance """ self.min_width = min_width - if isinstance(x, Iterable) and isinstance (y, Iterable): - - self._delta = False # NOTE in classmethod this value is set explicitly to True. + if isinstance(x, Iterable) and isinstance(y, Iterable): + self._delta = False # NOTE in classmethod this value is set explicitly to True. # first, prepare x, y values if assume_sorted: - xvals, yvals = x,y + xvals, yvals = x, y else: - xvals, yvals = np.array(sorted(zip(x,y))).T + xvals, yvals = np.array(sorted(zip(x, y))).T if not is_log: yvals = -np.log(yvals) # just for safety yvals[np.isnan(yvals)] = BIG_NUMBER # set the properties - self._kind=kind + self._kind = kind # remember range self._xmin, self._xmax = xvals[0], xvals[-1] self._support = self._xmax - self._xmin @@ -204,14 +213,15 @@ class Distribution(object): yvals -= self._peak_val self._ymax = yvals.max() # store the interpolation object - self._func= interp1d(xvals, yvals, kind=kind, fill_value=BIG_NUMBER, - bounds_error=False, assume_sorted=True) + self._func = interp1d( + xvals, yvals, kind=kind, fill_value=BIG_NUMBER, bounds_error=False, assume_sorted=True + ) self._fwhm = Distribution.calc_fwhm(self) # remember effective range self._effective_support = self.calc_effective_support() elif np.isscalar(x): - assert (np.isscalar(y) or y is None) + assert np.isscalar(y) or y is None self._delta = True self._peak_pos = x self._fwhm = 0 @@ -221,13 +231,11 @@ class Distribution(object): self._peak_val = y self._xmin, self._xmax = x, x - self._support = 0. - self._func = lambda x : (x==self.peak_pos)*self.peak_val + self._support = 0.0 + self._func = lambda x: (x == self.peak_pos) * self.peak_val self._effective_support = (self._xmin, self._xmax) else: - raise TypeError("Cannot create Distribution: " - "Input arguments should be scalars or iterables!") - + raise TypeError('Cannot create Distribution: Input arguments should be scalars or iterables!') @property def is_delta(self): @@ -271,7 +279,7 @@ class Distribution(object): @property def y(self): if self.is_delta: - print("Warning: evaluating log probability of a delta distribution.") + print('Warning: evaluating log probability of a delta distribution.') return [self.weight] else: return self._peak_val + self._func.y @@ -284,26 +292,26 @@ class Distribution(object): def xmax(self): return self._xmax - def __call__(self, x): if isinstance(x, Iterable): - valid_idxs = (x > self._xmin-TINY_NUMBER) & (x < self._xmax+TINY_NUMBER) - res = np.full(np.shape(x), BIG_NUMBER+self.peak_val, dtype=float) + valid_idxs = (x > self._xmin - TINY_NUMBER) & (x < self._xmax + TINY_NUMBER) + res = np.full(np.shape(x), BIG_NUMBER + self.peak_val, dtype=float) tmp_x = x[valid_idxs] - res[valid_idxs] = self._peak_val + self._func(clip(tmp_x, self._xmin+TINY_NUMBER, self._xmax-TINY_NUMBER)) + res[valid_idxs] = self._peak_val + self._func( + clip(tmp_x, self._xmin + TINY_NUMBER, self._xmax - TINY_NUMBER) + ) return res elif np.isreal(x): if x < self._xmin or x > self._xmax: - return BIG_NUMBER+self.peak_val + return BIG_NUMBER + self.peak_val # x is within interpolation range elif self._delta == True: return self._peak_val else: return self._peak_val + self._func(x) else: - raise TypeError("Wrong type: should be float or array") - + raise TypeError('Wrong type: should be float or array') def __mul__(self, other): return Distribution.multiply((self, other)) @@ -317,44 +325,43 @@ class Distribution(object): vals = log_cutoff - self.__call__(self.x) + self.peak_val above = vals > 0 above_idx = np.where(above)[0] - if len(above_idx)==0: + if len(above_idx) == 0: return (self.xmin, self.xmax) try: if above[0]: left = self.xmin else: - x1, x2 = self.x[above_idx[0]-1], self.x[above_idx[0]] - y1, y2 = vals[above_idx[0]-1], vals[above_idx[0]] - d = y2-y1 - left = x1*y2/d - x2*y1/d + x1, x2 = self.x[above_idx[0] - 1], self.x[above_idx[0]] + y1, y2 = vals[above_idx[0] - 1], vals[above_idx[0]] + d = y2 - y1 + left = x1 * y2 / d - x2 * y1 / d if above[-1]: right = self.xmax else: - x1, x2 = self.x[above_idx[-1]], self.x[above_idx[-1]+1] - y1, y2 = vals[above_idx[-1]], vals[above_idx[-1]+1] - d = y1-y2 - right = -x1*y2/d + x2*y1/d + x1, x2 = self.x[above_idx[-1]], self.x[above_idx[-1] + 1] + y1, y2 = vals[above_idx[-1]], vals[above_idx[-1] + 1] + d = y1 - y2 + right = -x1 * y2 / d + x2 * y1 / d except: - raise ArithmeticError("Region of support of the distribution could not be determined!") - - return (left,right) + raise ArithmeticError('Region of support of the distribution could not be determined!') + return (left, right) def _adjust_grid(self, rel_tol=0.01, yc=10): - n_iter=0 - while len(self.x)>200 and n_iter<5: - interp_err = 2*self.y[1:-1] - self.y[2:] - self.y[:-2] + n_iter = 0 + while len(self.x) > 200 and n_iter < 5: + interp_err = 2 * self.y[1:-1] - self.y[2:] - self.y[:-2] ind = np.ones_like(self.y, dtype=bool) - dy = self.y-self.peak_val - prune = interp_err[::2] > rel_tol*(1+ (dy[1:-1:2]/yc)**4) + dy = self.y - self.peak_val + prune = interp_err[::2] > rel_tol * (1 + (dy[1:-1:2] / yc) ** 4) ind[1:-1:2] = prune ind[self.peak_idx] = True - if np.mean(prune)<1.0: + if np.mean(prune) < 1.0: self._func.y = self._func.y[ind] self._func.x = self._func.x[ind] - n_iter+=1 + n_iter += 1 else: break @@ -362,85 +369,84 @@ class Distribution(object): self._peak_pos = self._func.x[self._peak_idx] self._peak_val = self.__call__(self.peak_pos) - - def prob(self,x): + def prob(self, x): return np.exp(-1 * self.__call__(x)) - def prob_relative(self,x): - return np.exp(-1 * (self.__call__(x)-self.peak_val)) + def prob_relative(self, x): + return np.exp(-1 * (self.__call__(x) - self.peak_val)) def x_rescale(self, factor): - self._func.x*=factor - self._peak_pos*=factor - if factor>=0: - self._xmin*=factor - self._xmax*=factor - self._fwhm*=factor - self._effective_support = [x*factor for x in self._effective_support] + self._func.x *= factor + self._peak_pos *= factor + if factor >= 0: + self._xmin *= factor + self._xmax *= factor + self._fwhm *= factor + self._effective_support = [x * factor for x in self._effective_support] else: tmp = self.xmin - self._xmin = factor*self.xmax - self._xmax = factor*tmp + self._xmin = factor * self.xmax + self._xmax = factor * tmp self._func.x = self._func.x[::-1] self._func.y = self._func.y[::-1] self._fwhm *= -factor - self._effective_support = [x*factor for x in self._effective_support[::-1]] - + self._effective_support = [x * factor for x in self._effective_support[::-1]] - def integrate(self, return_log=False ,**kwargs): + def integrate(self, return_log=False, **kwargs): if self.is_delta: return self.weight else: integral_result = self.integrate_simpson(**kwargs) if return_log: - if integral_result==0: + if integral_result == 0: return -self.peak_val - BIG_NUMBER else: return -self.peak_val + max(-BIG_NUMBER, np.log(integral_result)) else: - return np.exp(-self.peak_val)*integral_result + return np.exp(-self.peak_val) * integral_result - def integrate_trapez(self, a=None, b=None,n=None): + def integrate_trapez(self, a=None, b=None, n=None): mult = 0.5 - if a>b: - b,a = a,b - mult=-0.5 + if a > b: + b, a = a, b + mult = -0.5 - x = np.linspace(a,b,n) + x = np.linspace(a, b, n) dx = np.diff(x) y = self.prob_relative(x) - return mult*np.sum(dx*(y[:-1] + y[1:])) + return mult * np.sum(dx * (y[:-1] + y[1:])) - - def integrate_simpson(self, a=None,b=None,n=None): + def integrate_simpson(self, a=None, b=None, n=None): if n % 2 == 0: n += 1 - mult = 1.0/6 - dpeak = max(10*self.fwhm, self.min_width) - threshold = np.array([a,self.peak_pos-dpeak, self.peak_pos+dpeak,b]) - threshold = threshold[(threshold>=a)&(threshold<=b)] + mult = 1.0 / 6 + dpeak = max(10 * self.fwhm, self.min_width) + threshold = np.array([a, self.peak_pos - dpeak, self.peak_pos + dpeak, b]) + threshold = threshold[(threshold >= a) & (threshold <= b)] threshold.sort() res = [] for lw, up in zip(threshold[:-1], threshold[1:]): - x = np.linspace(lw,up,n) + x = np.linspace(lw, up, n) dx = np.diff(x[::2]) y = self.prob_relative(x) - res.append(mult*(dx[0]*y[0]+ np.sum(4*dx*y[1:-1:2]) - + np.sum((dx[:-1]+dx[1:])*y[2:-1:2]) + dx[-1]*y[-1])) + res.append( + mult + * (dx[0] * y[0] + np.sum(4 * dx * y[1:-1:2]) + np.sum((dx[:-1] + dx[1:]) * y[2:-1:2]) + dx[-1] * y[-1]) + ) return np.sum(res) - def fft(self, T, n=None, inverse_time=True): if self.is_delta: - raise TreeTimeUnknownError("attempting Fourier transform of delta function.") + raise TreeTimeUnknownError('attempting Fourier transform of delta function.') from numpy.fft import rfft + if n is None: - n=len(T) + n = len(T) vals = self.prob_relative(T) - if max(vals)<1e-15: + if max(vals) < 1e-15: # probability is lost due to sampling next to timepoints with # vanishing probability. Since we interpolate logarithms, this # results in loss of probability when we should have a delta-like @@ -452,5 +458,3 @@ class Distribution(object): return rfft(vals, n=n) else: return rfft(vals[::-1], n=n) - - diff --git a/treetime/gtr.py b/treetime/gtr.py index faf6d45..4ba2544 100644 --- a/treetime/gtr.py +++ b/treetime/gtr.py @@ -3,11 +3,12 @@ import numpy as np from . import config as ttconf, TreeTimeError, MissingDataError from .seq_utils import alphabets, profile_maps, alphabet_synonyms -def avg_transition(W,pi, gap_index=None): + +def avg_transition(W, pi, gap_index=None): if gap_index is None: return np.einsum('i,ij,j', pi, W, pi) else: - return (np.einsum('i,ij,j', pi, W, pi) - np.sum(pi*W[:,gap_index])*pi[gap_index])/(1-pi[gap_index]) + return (np.einsum('i,ij,j', pi, W, pi) - np.sum(pi * W[:, gap_index]) * pi[gap_index]) / (1 - pi[gap_index]) class GTR(object): @@ -42,11 +43,11 @@ class GTR(object): where msg is a string and level an integer to be compared against verbose. """ - self.debug=False - self.is_site_specific=False + self.debug = False + self.is_site_specific = False if isinstance(alphabet, str): if alphabet not in alphabet_synonyms: - raise AttributeError("Unknown alphabet type specified") + raise AttributeError('Unknown alphabet type specified') else: tmp_alphabet = alphabet_synonyms[alphabet] self.alphabet = alphabets[tmp_alphabet] @@ -54,18 +55,20 @@ class GTR(object): else: # not a predefined alphabet self.alphabet = np.array(alphabet) - if prof_map is None: # generate trivial unambiguous profile map is none is given - self.profile_map = {s:x for s,x in zip(self.alphabet, np.eye(len(self.alphabet)))} + if prof_map is None: # generate trivial unambiguous profile map is none is given + self.profile_map = {s: x for s, x in zip(self.alphabet, np.eye(len(self.alphabet)))} else: - self.profile_map = {x if type(x) is str else x:k for x,k in prof_map.items()} + self.profile_map = {x if type(x) is str else x: k for x, k in prof_map.items()} - self.state_index={s:si for si,s in enumerate(self.alphabet)} - self.state_index.update({s:si for si,s in enumerate(self.alphabet)}) + self.state_index = {s: si for si, s in enumerate(self.alphabet)} + self.state_index.update({s: si for si, s in enumerate(self.alphabet)}) if logger is None: - def logger_default(*args,**kwargs): + + def logger_default(*args, **kwargs): """standard logging function if none provided""" if self.debug: print(*args) + self.logger = logger_default else: self.logger = logger @@ -76,31 +79,29 @@ class GTR(object): self.assign_gap_and_ambiguous() # init all matrices with dummy values - self.logger("GTR: init with dummy values!", 3) - self.v = None # right eigenvectors - self.v_inv = None # left eigenvectors - self.eigenvals = None # eigenvalues + self.logger('GTR: init with dummy values!', 3) + self.v = None # right eigenvectors + self.v_inv = None # left eigenvectors + self.eigenvals = None # eigenvalues self.assign_rates() - def assign_gap_and_ambiguous(self): n_states = len(self.alphabet) - self.logger("GTR: with alphabet: "+str([x for x in self.alphabet]),1) + self.logger('GTR: with alphabet: ' + str([x for x in self.alphabet]), 1) # determine if a character exists that corresponds to no info, i.e. all one profile - if any([x.sum()==n_states for x in self.profile_map.values()]): - amb_states = [c for c,x in self.profile_map.items() if x.sum()==n_states] + if any([x.sum() == n_states for x in self.profile_map.values()]): + amb_states = [c for c, x in self.profile_map.items() if x.sum() == n_states] self.ambiguous = 'N' if 'N' in amb_states else amb_states[0] - self.logger("GTR: ambiguous character: "+self.ambiguous,2) + self.logger('GTR: ambiguous character: ' + self.ambiguous, 2) else: - self.ambiguous=None + self.ambiguous = None # check for a gap symbol try: self.gap_index = self.state_index['-'] except: - self.logger("GTR: no gap symbol!", 4, warn=True) - self.gap_index=None - + self.logger('GTR: no gap symbol!', 4, warn=True) + self.gap_index = None @property def mu(self): @@ -126,49 +127,47 @@ class GTR(object): def mu(self, value): self.assign_rates(mu=value, pi=self.Pi, W=self.W) - @property def Q(self): """function that return the product of the transition matrix - and the equilibrium frequencies to obtain the rate matrix - of the GTR model + and the equilibrium frequencies to obtain the rate matrix + of the GTR model """ - Q_tmp = (self.W*self.Pi).T + Q_tmp = (self.W * self.Pi).T Q_diag = -np.sum(Q_tmp, axis=0) np.fill_diagonal(Q_tmp, Q_diag) return Q_tmp - -###################################################################### -## constructor methods -###################################################################### + ###################################################################### + ## constructor methods + ###################################################################### def __str__(self): - ''' + """ String representation of the GTR model for pretty printing - ''' - multi_site = len(self.Pi.shape)==2 + """ + multi_site = len(self.Pi.shape) == 2 if multi_site: - eq_freq_str = "Average substitution rate (mu): "+str(np.round(self.average_rate,6))+'\n' + eq_freq_str = 'Average substitution rate (mu): ' + str(np.round(self.average_rate, 6)) + '\n' else: - eq_freq_str = "Substitution rate (mu): "+str(np.round(self.mu,6))+'\n' + eq_freq_str = 'Substitution rate (mu): ' + str(np.round(self.mu, 6)) + '\n' if not multi_site: - eq_freq_str += "\nEquilibrium frequencies (pi_i):\n" - for a,p in zip(self.alphabet, self.Pi): - eq_freq_str+=' '+a+': '+str(np.round(p,4))+'\n' + eq_freq_str += '\nEquilibrium frequencies (pi_i):\n' + for a, p in zip(self.alphabet, self.Pi): + eq_freq_str += ' ' + a + ': ' + str(np.round(p, 4)) + '\n' - W_str = "\nSymmetrized rates from j->i (W_ij):\n" - W_str+='\t'+'\t'.join(self.alphabet)+'\n' - for a,Wi in zip(self.alphabet, self.W): - W_str+= ' '+a+'\t'+'\t'.join([str(np.round(max(0,p),4)) for p in Wi])+'\n' + W_str = '\nSymmetrized rates from j->i (W_ij):\n' + W_str += '\t' + '\t'.join(self.alphabet) + '\n' + for a, Wi in zip(self.alphabet, self.W): + W_str += ' ' + a + '\t' + '\t'.join([str(np.round(max(0, p), 4)) for p in Wi]) + '\n' if not multi_site: - Q_str = "\nActual rates from j->i (Q_ij):\n" - Q_str+='\t'+'\t'.join(self.alphabet)+'\n' - for a,Qi in zip(self.alphabet, self.Q): - Q_str+= ' '+a+'\t'+'\t'.join([str(np.round(max(0,p),4)) for p in Qi])+'\n' + Q_str = '\nActual rates from j->i (Q_ij):\n' + Q_str += '\t' + '\t'.join(self.alphabet) + '\n' + for a, Qi in zip(self.alphabet, self.Q): + Q_str += ' ' + a + '\t' + '\t'.join([str(np.round(max(0, p), 4)) for p in Qi]) + '\n' return eq_freq_str + W_str + Q_str @@ -189,39 +188,49 @@ class GTR(object): with open(gtr_fname) as f: alphabet = [] pi = [] + mu = None while True: line = f.readline() if not line: break - if line.strip().startswith("Substitution rate (mu):"): - mu = float(line.split(":")[1].strip()) - elif line.strip().startswith("Equilibrium frequencies (pi_i):"): + if line.strip().startswith('Substitution rate (mu):'): + mu = float(line.split(':')[1].strip()) + elif line.strip().startswith('Equilibrium frequencies (pi_i):'): line = f.readline() - while line.strip()!="": - alphabet.append(line.split(":")[0].strip()) - pi.append(float(line.split(":")[1].strip())) + while line.strip() != '': + alphabet.append(line.split(':')[0].strip()) + pi.append(float(line.split(':')[1].strip())) line = f.readline() - if not np.any([len(alphabet) == len(a) and np.all(np.array(alphabet) == a) for a in alphabets.values()]): - raise ValueError("GTR: was unable to read custom GTR model in "+str(gtr_fname) +" - Alphabet not recognized") - elif line.strip().startswith("Symmetrized rates from j->i (W_ij):"): + if not np.any( + [len(alphabet) == len(a) and np.all(np.array(alphabet) == a) for a in alphabets.values()] + ): + raise ValueError( + 'GTR: was unable to read custom GTR model in ' + + str(gtr_fname) + + ' - Alphabet not recognized' + ) + elif line.strip().startswith('Symmetrized rates from j->i (W_ij):'): line = f.readline() line = f.readline() n = len(pi) - W = np.ones((n,n)) + W = np.ones((n, n)) j = 0 - while line.strip()!="": + while line.strip() != '': values = line.split() for i in range(n): - W[j,i] = float(values[i+1]) - j +=1 + W[j, i] = float(values[i + 1]) + j += 1 line = f.readline() if j != n: - raise ValueError("GTR: was unable to read custom GTR model in "+str(gtr_fname) +" - Number of lines in W matrix does not match alphabet length") - gtr = GTR.custom(mu, pi, W, alphabet = alphabet) + raise ValueError( + 'GTR: was unable to read custom GTR model in ' + + str(gtr_fname) + + ' - Number of lines in W matrix does not match alphabet length' + ) + gtr = GTR.custom(mu, pi, W, alphabet=alphabet) return gtr except: - raise MissingDataError('GTR: was unable to read custom GTR model in '+str(gtr_fname)) - + raise MissingDataError('GTR: was unable to read custom GTR model in ' + str(gtr_fname)) def assign_rates(self, mu=1.0, pi=None, W=None): """ @@ -242,38 +251,37 @@ class GTR(object): """ n = len(self.alphabet) self._mu = mu - self.is_site_specific=False + self.is_site_specific = False - if pi is not None and len(pi)==n: + if pi is not None and len(pi) == n: Pi = np.array(pi) else: - if pi is not None and len(pi)!=n: - self.logger("length of equilibrium frequency vector does not match alphabet length", 4, warn=True) - self.logger("Ignoring input equilibrium frequencies", 4, warn=True) + if pi is not None and len(pi) != n: + self.logger('length of equilibrium frequency vector does not match alphabet length', 4, warn=True) + self.logger('Ignoring input equilibrium frequencies', 4, warn=True) Pi = np.ones(shape=(n,)) - self._Pi = Pi/np.sum(Pi) + self._Pi = Pi / np.sum(Pi) - if W is None or W.shape!=(n,n): - if (W is not None) and W.shape!=(n,n): - self.logger("Substitution matrix size does not match alphabet size", 4, warn=True) - self.logger("Ignoring input substitution matrix", 4, warn=True) + if W is None or W.shape != (n, n): + if (W is not None) and W.shape != (n, n): + self.logger('Substitution matrix size does not match alphabet size', 4, warn=True) + self.logger('Ignoring input substitution matrix', 4, warn=True) # flow matrix - W = np.ones((n,n)) + W = np.ones((n, n)) np.fill_diagonal(W, 0.0) - np.fill_diagonal(W, - W.sum(axis=0)) + np.fill_diagonal(W, -W.sum(axis=0)) else: - W=np.array(W) + W = np.array(W) - self._W = 0.5*(W+W.T) - np.fill_diagonal(W,0) + self._W = 0.5 * (W + W.T) + np.fill_diagonal(W, 0) average_rate = avg_transition(W, self.Pi, gap_index=self.gap_index) - self._W = W/average_rate - self._mu *=average_rate + self._W = W / average_rate + self._mu *= average_rate self._eig() - @classmethod def custom(cls, mu=1.0, pi=None, W=None, **kwargs): """ @@ -430,7 +438,7 @@ class GTR(object): """ from .nuc_models import JC69, K80, F81, HKY85, T92, TN93 - from .aa_models import JTT92 + from .aa_models import JTT92 if model.lower() in ['jc', 'jc69', 'jukes-cantor', 'jukes-cantor69', 'jukescantor', 'jukescantor69']: model = JC69(**kwargs) @@ -447,13 +455,11 @@ class GTR(object): elif model.lower() in ['jtt', 'jtt92']: model = JTT92(**kwargs) else: - raise KeyError("The GTR model '{}' is not in the list of available models." - "".format(model)) + raise KeyError("The GTR model '{}' is not in the list of available models.".format(model)) model.mu = kwargs['mu'] if 'mu' in kwargs else 1.0 return model - @classmethod def random(cls, mu=1.0, alphabet='nuc', rng=None): """ @@ -473,16 +479,15 @@ class GTR(object): if rng is None: rng = np.random.default_rng() - alphabet=alphabets[alphabet] + alphabet = alphabets[alphabet] gtr = cls(alphabet) n = gtr.alphabet.shape[0] - pi = 1.0*rng.random(size=n) - W = 1.0*rng.random(size=(n,n)) # with gaps + pi = 1.0 * rng.random(size=n) + W = 1.0 * rng.random(size=(n, n)) # with gaps gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr - @classmethod def infer(cls, nij, Ti, root_state, fixed_pi=None, pc=1.0, gap_limit=0.01, **kwargs): r""" @@ -530,11 +535,12 @@ class GTR(object): """ from scipy import linalg as LA + gtr = cls(**kwargs) - gtr.logger("GTR: model inference ",1) + gtr.logger('GTR: model inference ', 1) dp = 1e-5 Nit = 40 - pc_mat = pc*np.ones_like(nij) + pc_mat = pc * np.ones_like(nij) np.fill_diagonal(pc_mat, 0.0) np.fill_diagonal(nij, 0.0) count = 0 @@ -543,47 +549,58 @@ class GTR(object): pi = np.ones_like(Ti) else: pi = np.copy(fixed_pi) - pi/=pi.sum() + pi /= pi.sum() W_ij = np.ones_like(nij) - mu = (nij.sum()+pc)/(Ti.sum()+pc) + mu = (nij.sum() + pc) / (Ti.sum() + pc) # if pi is fixed, this will immediately converge - while LA.norm(pi_old-pi) > dp and count < Nit: - gtr.logger(' '.join(map(str, ['GTR inference iteration',count,'change:',LA.norm(pi_old-pi)])), 3) + while LA.norm(pi_old - pi) > dp and count < Nit: + gtr.logger(' '.join(map(str, ['GTR inference iteration', count, 'change:', LA.norm(pi_old - pi)])), 3) count += 1 pi_old = np.copy(pi) - W_ij = (nij+nij.T+2*pc_mat)/mu/(np.outer(pi,Ti) + np.outer(Ti,pi) + ttconf.TINY_NUMBER + 2*pc_mat) + W_ij = ( + (nij + nij.T + 2 * pc_mat) + / mu + / (np.outer(pi, Ti) + np.outer(Ti, pi) + ttconf.TINY_NUMBER + 2 * pc_mat) + ) np.fill_diagonal(W_ij, 0) - scale_factor = avg_transition(W_ij,pi, gap_index=gtr.gap_index) + scale_factor = avg_transition(W_ij, pi, gap_index=gtr.gap_index) - W_ij = W_ij/scale_factor + W_ij = W_ij / scale_factor if fixed_pi is None: - pi = (np.sum(nij+pc_mat,axis=1)+root_state)/(ttconf.TINY_NUMBER + mu*np.dot(W_ij,Ti)+root_state.sum()+np.sum(pc_mat, axis=1)) + pi = (np.sum(nij + pc_mat, axis=1) + root_state) / ( + ttconf.TINY_NUMBER + mu * np.dot(W_ij, Ti) + root_state.sum() + np.sum(pc_mat, axis=1) + ) pi /= pi.sum() - mu = (nij.sum() + pc)/(np.sum(pi * (W_ij.dot(Ti)))+pc) + mu = (nij.sum() + pc) / (np.sum(pi * (W_ij.dot(Ti))) + pc) else: - mu = (nij.sum() + pc)/(np.sum(pi * (W_ij.dot(pi)))*Ti.sum() + pc) + mu = (nij.sum() + pc) / (np.sum(pi * (W_ij.dot(pi))) * Ti.sum() + pc) if count >= Nit: - gtr.logger('WARNING: maximum number of iterations has been reached in GTR inference',3, warn=True) - if LA.norm(pi_old-pi) > dp: - gtr.logger('the iterative scheme has not converged',3,warn=True) - elif np.abs(1-np.max(pi.sum(axis=0))) > dp: - gtr.logger('the iterative scheme has converged, but proper normalization was not reached',3,warn=True) + gtr.logger('WARNING: maximum number of iterations has been reached in GTR inference', 3, warn=True) + if LA.norm(pi_old - pi) > dp: + gtr.logger('the iterative scheme has not converged', 3, warn=True) + elif np.abs(1 - np.max(pi.sum(axis=0))) > dp: + gtr.logger('the iterative scheme has converged, but proper normalization was not reached', 3, warn=True) if gtr.gap_index is not None: - if pi[gtr.gap_index]<gap_limit: - gtr.logger('The model allows for gaps which are estimated to occur at a low fraction of %1.3e'%pi[gtr.gap_index]+ - ' this can potentially result in artificats.'+ - ' gap fraction will be set to %1.4f'%gap_limit,2,warn=True) + if pi[gtr.gap_index] < gap_limit: + gtr.logger( + 'The model allows for gaps which are estimated to occur at a low fraction of %1.3e' + % pi[gtr.gap_index] + + ' this can potentially result in artificats.' + + ' gap fraction will be set to %1.4f' % gap_limit, + 2, + warn=True, + ) pi[gtr.gap_index] = gap_limit pi /= pi.sum() gtr.assign_rates(mu=mu, W=W_ij, pi=pi) return gtr -######################################################################## -### prepare model -######################################################################## + ######################################################################## + ### prepare model + ######################################################################## def _eig(self): """ Perform eigendecompositon of the rate matrix and stores the left- and right- @@ -592,7 +609,6 @@ class GTR(object): """ self.eigenvals, self.v, self.v_inv = self._eig_single_site(self.W, self.Pi) - def _eig_single_site(self, W, p): """ Perform eigendecompositon of the rate matrix and stores the left- and right- @@ -601,21 +617,19 @@ class GTR(object): NOTE: this assumes the diagonal of W is all zeros """ # eigendecomposition of the rate matrix - assert np.abs(np.diag(W).sum())<1e-10 + assert np.abs(np.diag(W).sum()) < 1e-10 tmpp = np.sqrt(p) - symQ = W*np.outer(tmpp, tmpp) - np.fill_diagonal(symQ, -np.sum(W*p, axis=1)) + symQ = W * np.outer(tmpp, tmpp) + np.fill_diagonal(symQ, -np.sum(W * p, axis=1)) eigvals, eigvecs = np.linalg.eigh(symQ) - tmp_v = eigvecs.T*tmpp + tmp_v = eigvecs.T * tmpp one_norm = np.sum(np.abs(tmp_v), axis=1) - return eigvals, tmp_v.T/one_norm, (eigvecs*one_norm).T/tmpp - + return eigvals, tmp_v.T / one_norm, (eigvecs * one_norm).T / tmpp - def state_pair(self, seq_p, seq_ch, pattern_multiplicity=None, - ignore_gaps=False): - ''' + def state_pair(self, seq_p, seq_ch, pattern_multiplicity=None, ignore_gaps=False): + """ Make a compressed representation of a pair of sequences, only counting the number of times a particular pair of states (e.g. (A,T)) is observed in the aligned sequences of parent and child. @@ -648,54 +662,60 @@ class GTR(object): multiplicity : numpy array Number of times a particular pair is observed - ''' + """ if pattern_multiplicity is None: pattern_multiplicity = np.ones_like(seq_p, dtype=float) from collections import Counter + if seq_ch.shape != seq_p.shape: - raise ValueError("GTR.state_pair: Sequence lengths do not match!") + raise ValueError('GTR.state_pair: Sequence lengths do not match!') - if len(self.alphabet)<10: # for small alphabet, repeatedly check array for all state pairs + if len(self.alphabet) < 10: # for small alphabet, repeatedly check array for all state pairs pair_count = [] bool_seqs_p = [] bool_seqs_ch = [] - for seq, bs in [(seq_p,bool_seqs_p), (seq_ch, bool_seqs_ch)]: - for ni,nuc in enumerate(self.alphabet): - bs.append(seq==nuc) - - for n1,nuc1 in enumerate(self.alphabet): - if (self.gap_index is None) or (not ignore_gaps) or (n1!=self.gap_index): - for n2,nuc2 in enumerate(self.alphabet): - if (self.gap_index is None) or (not ignore_gaps) or (n2!=self.gap_index): - count = ((bool_seqs_p[n1]&bool_seqs_ch[n2])*pattern_multiplicity).sum() - if count: pair_count.append(((n1,n2), count)) - else: # enumerate state pairs of the sequence for large alphabets + for seq, bs in [(seq_p, bool_seqs_p), (seq_ch, bool_seqs_ch)]: + for ni, nuc in enumerate(self.alphabet): + bs.append(seq == nuc) + + for n1, nuc1 in enumerate(self.alphabet): + if (self.gap_index is None) or (not ignore_gaps) or (n1 != self.gap_index): + for n2, nuc2 in enumerate(self.alphabet): + if (self.gap_index is None) or (not ignore_gaps) or (n2 != self.gap_index): + count = ((bool_seqs_p[n1] & bool_seqs_ch[n2]) * pattern_multiplicity).sum() + if count: + pair_count.append(((n1, n2), count)) + else: # enumerate state pairs of the sequence for large alphabets num_seqs = [] - for seq in [seq_p, seq_ch]: # for each sequence (parent and child) construct a numerical sequence [0,5,3,1,2,3...] + for seq in [ + seq_p, + seq_ch, + ]: # for each sequence (parent and child) construct a numerical sequence [0,5,3,1,2,3...] tmp = np.ones_like(seq, dtype=int) - for ni,nuc in enumerate(self.alphabet): - tmp[seq==nuc] = ni # set each position corresponding to a state to the corresponding index + for ni, nuc in enumerate(self.alphabet): + tmp[seq == nuc] = ni # set each position corresponding to a state to the corresponding index num_seqs.append(tmp) pair_count = defaultdict(int) if ignore_gaps: # if gaps are ignored skip positions where one or the other sequence is gapped for i in range(len(seq_p)): - if self.gap_index!=num_seqs[0][i] and self.gap_index!=num_seqs[1][i]: - pair_count[(num_seqs[0][i],num_seqs[1][i])]+=pattern_multiplicity[i] - else: # otherwise, just count + if self.gap_index != num_seqs[0][i] and self.gap_index != num_seqs[1][i]: + pair_count[(num_seqs[0][i], num_seqs[1][i])] += pattern_multiplicity[i] + else: # otherwise, just count for i in range(len(seq_p)): - pair_count[(num_seqs[0][i],num_seqs[1][i])]+=pattern_multiplicity[i] + pair_count[(num_seqs[0][i], num_seqs[1][i])] += pattern_multiplicity[i] pair_count = pair_count.items() - return (np.array([x[0] for x in pair_count], dtype=int), # [(child_nuc, parent_nuc),()...] - np.array([x[1] for x in pair_count], dtype=int)) # multiplicity of each parent/child nuc pair - + return ( + np.array([x[0] for x in pair_count], dtype=int), # [(child_nuc, parent_nuc),()...] + np.array([x[1] for x in pair_count], dtype=int), + ) # multiplicity of each parent/child nuc pair -######################################################################## -### evolution functions -######################################################################## + ######################################################################## + ### evolution functions + ######################################################################## def prob_t_compressed(self, seq_pair, multiplicity, t, return_log=False): - ''' + """ Calculate the probability of observing a sequence pair at a distance t, for compressed sequences @@ -717,20 +737,18 @@ class GTR(object): return_log : bool Whether or not to exponentiate the result - ''' - if t<0: + """ + if t < 0: logP = -ttconf.BIG_NUMBER else: tmp_eQT = self.expQt(t) logQt = np.log(np.maximum(tmp_eQT, ttconf.SUPERTINY_NUMBER)) # logQt[~np.isfinite(logQt)] = -ttconf.BIG_NUMBER - logP = np.sum(logQt[seq_pair[:,1], seq_pair[:,0]]*multiplicity) + logP = np.sum(logQt[seq_pair[:, 1], seq_pair[:, 0]] * multiplicity) return logP if return_log else np.exp(logP) - - def prob_t(self, seq_p, seq_ch, t, pattern_multiplicity = None, - return_log=False, ignore_gaps=True): + def prob_t(self, seq_p, seq_ch, t, pattern_multiplicity=None, return_log=False, ignore_gaps=True): """ Compute the probability to observe seq_ch (child sequence) after time t starting from seq_p (parent sequence). @@ -762,13 +780,13 @@ class GTR(object): Resulting probability """ - seq_pair, multiplicity = self.state_pair(seq_p, seq_ch, - pattern_multiplicity=pattern_multiplicity, ignore_gaps=ignore_gaps) + seq_pair, multiplicity = self.state_pair( + seq_p, seq_ch, pattern_multiplicity=pattern_multiplicity, ignore_gaps=ignore_gaps + ) return self.prob_t_compressed(seq_pair, multiplicity, t, return_log=return_log) - def optimal_t(self, seq_p, seq_ch, pattern_multiplicity=None, ignore_gaps=False): - ''' + """ Find the optimal distance between the two sequences Parameters @@ -789,13 +807,12 @@ class GTR(object): ignore_gaps : bool If True, ignore gaps in distance calculations - ''' - seq_pair, multiplicity = self.state_pair(seq_p, seq_ch, - pattern_multiplicity = pattern_multiplicity, - ignore_gaps=ignore_gaps) + """ + seq_pair, multiplicity = self.state_pair( + seq_p, seq_ch, pattern_multiplicity=pattern_multiplicity, ignore_gaps=ignore_gaps + ) return self.optimal_t_compressed(seq_pair, multiplicity) - def optimal_t_compressed(self, seq_pair, multiplicity, profiles=False, tol=1e-10): """ Find the optimal distance between the two sequences represented as state_pairs @@ -846,48 +863,64 @@ class GTR(object): to be separated by the time t. """ if profiles: - res = -1.0*self.prob_t_profiles(seq_pair, multiplicity,t**2, return_log=True) - return res + np.exp(t**4/10000) + res = -1.0 * self.prob_t_profiles(seq_pair, multiplicity, t**2, return_log=True) + return res + np.exp(t**4 / 10000) else: - return -1.0*self.prob_t_compressed(seq_pair, multiplicity,t**2, return_log=True) + return -1.0 * self.prob_t_compressed(seq_pair, multiplicity, t**2, return_log=True) if profiles: - hamming_distance = 1-np.sum(multiplicity*np.sum(seq_pair[0]*seq_pair[1], axis=1))/np.sum(multiplicity) + hamming_distance = 1 - np.sum(multiplicity * np.sum(seq_pair[0] * seq_pair[1], axis=1)) / np.sum( + multiplicity + ) else: - hamming_distance = np.sum(multiplicity[seq_pair[:,1]!=seq_pair[:,0]])/np.sum(multiplicity) + hamming_distance = np.sum(multiplicity[seq_pair[:, 1] != seq_pair[:, 0]]) / np.sum(multiplicity) try: from scipy.optimize import minimize_scalar - opt = minimize_scalar(_neg_prob, - bracket=[-np.sqrt(ttconf.MAX_BRANCH_LENGTH), np.sqrt(hamming_distance), np.sqrt(ttconf.MAX_BRANCH_LENGTH)], - args=(seq_pair, multiplicity), tol=tol, method='brent') - new_len = opt["x"]**2 + + opt = minimize_scalar( + _neg_prob, + bracket=[ + -np.sqrt(ttconf.MAX_BRANCH_LENGTH), + np.sqrt(hamming_distance), + np.sqrt(ttconf.MAX_BRANCH_LENGTH), + ], + args=(seq_pair, multiplicity), + tol=tol, + method='brent', + ) + new_len = opt['x'] ** 2 if 'success' not in opt: opt['success'] = True - self.logger("WARNING: the optimization result does not contain a 'success' flag:"+str(opt),4, warn=True) + self.logger( + "WARNING: the optimization result does not contain a 'success' flag:" + str(opt), 4, warn=True + ) except ImportError: import scipy + print('legacy scipy', scipy.__version__) from scipy.optimize import fminbound - new_len = fminbound(_neg_prob, - -np.sqrt(ttconf.MAX_BRANCH_LENGTH),np.sqrt(ttconf.MAX_BRANCH_LENGTH), - args=(seq_pair, multiplicity)) + + new_len = fminbound( + _neg_prob, + -np.sqrt(ttconf.MAX_BRANCH_LENGTH), + np.sqrt(ttconf.MAX_BRANCH_LENGTH), + args=(seq_pair, multiplicity), + ) new_len = new_len**2 - opt={'success':True} + opt = {'success': True} - if new_len > .9 * ttconf.MAX_BRANCH_LENGTH: - self.logger("WARNING: GTR.optimal_t_compressed -- The branch length seems to be very long!", 4, warn=True) + if new_len > 0.9 * ttconf.MAX_BRANCH_LENGTH: + self.logger('WARNING: GTR.optimal_t_compressed -- The branch length seems to be very long!', 4, warn=True) - if opt["success"] != True: + if opt['success'] != True: # return hamming distance: number of state pairs where state differs/all pairs - new_len = hamming_distance + new_len = hamming_distance return new_len - - def prob_t_profiles(self, profile_pair, multiplicity, t, - return_log=False, ignore_gaps=True): - ''' + def prob_t_profiles(self, profile_pair, multiplicity, t, return_log=False, ignore_gaps=True): + """ Calculate the probability of observing a node pair at a distance t Parameters @@ -909,25 +942,26 @@ class GTR(object): return_log : bool Whether or not to exponentiate the result - ''' - if t<0: + """ + if t < 0: logP = -ttconf.BIG_NUMBER else: Qt = self.expQt(t) - if len(Qt.shape)==3: # site specific GTR model + if len(Qt.shape) == 3: # site specific GTR model res = np.einsum('ai,ija,aj->a', profile_pair[1], Qt, profile_pair[0]) else: res = np.einsum('ai,ij,aj->a', profile_pair[1], Qt, profile_pair[0]) - if ignore_gaps and (self.gap_index is not None): # calculate the probability that neither outgroup/node has a gap - non_gap_frac = (1-profile_pair[0][:,self.gap_index])*(1-profile_pair[1][:,self.gap_index]) + if ignore_gaps and ( + self.gap_index is not None + ): # calculate the probability that neither outgroup/node has a gap + non_gap_frac = (1 - profile_pair[0][:, self.gap_index]) * (1 - profile_pair[1][:, self.gap_index]) # weigh log LH by the non-gap probability - logP = np.sum(multiplicity*np.log(res+ttconf.SUPERTINY_NUMBER)*non_gap_frac) + logP = np.sum(multiplicity * np.log(res + ttconf.SUPERTINY_NUMBER) * non_gap_frac) else: - logP = np.sum(multiplicity*np.log(res+ttconf.SUPERTINY_NUMBER)) + logP = np.sum(multiplicity * np.log(res + ttconf.SUPERTINY_NUMBER)) return logP if return_log else np.exp(logP) - def propagate_profile(self, profile, t, return_log=False): """ Compute the probability of the sequence state of the parent @@ -960,7 +994,6 @@ class GTR(object): return np.log(res) if return_log else res - def evolve(self, profile, t, return_log=False): """ Compute the probability of the sequence state of the child @@ -991,7 +1024,6 @@ class GTR(object): res = profile.dot(Qt) return np.log(res) if return_log else res - def _exp_lt(self, t): """ Parameters @@ -1009,14 +1041,15 @@ class GTR(object): """ log_val = self.mu * t * self.eigenvals if any(i > 10 for i in log_val): - raise ValueError("Error in computing exp(Q * t): Q has positive eigenvalues or the branch length t is too large. " - "This is most likely caused by incorrect input data.") + raise ValueError( + 'Error in computing exp(Q * t): Q has positive eigenvalues or the branch length t is too large. ' + 'This is most likely caused by incorrect input data.' + ) return np.exp(log_val) - def expQt(self, t): - ''' + """ Parameters ---------- @@ -1028,28 +1061,25 @@ class GTR(object): expQt : numpy.array Matrix exponential of exo(Qt) - ''' - eLambdaT = np.diag(self._exp_lt(t)) # vector length = a - Qs = self.v.dot(eLambdaT.dot(self.v_inv)) # This is P(nuc1 | given nuc_2) - return np.maximum(0,Qs) - + """ + eLambdaT = np.diag(self._exp_lt(t)) # vector length = a + Qs = self.v.dot(eLambdaT.dot(self.v_inv)) # This is P(nuc1 | given nuc_2) + return np.maximum(0, Qs) def expQs(self, s): return self.expQt(s**2) - def expQsds(self, s): - r''' + r""" Returns ------- Qtds : Returns 2 V_{ij} \lambda_j s e^{\lambda_j s**2 } V^{-1}_{jk} This is the derivative of the branch probability with respect to s=\sqrt(t) - ''' - lambda_eLambdaT = np.diag(2.0*self._exp_lt(s**2)*self.eigenvals*s) + """ + lambda_eLambdaT = np.diag(2.0 * self._exp_lt(s**2) * self.eigenvals * s) return self.v.dot(lambda_eLambdaT.dot(self.v_inv)) - - def sequence_logLH(self,seq, pattern_multiplicity=None): + def sequence_logLH(self, seq, pattern_multiplicity=None): """ Returns the log-likelihood of sampling a sequence from equilibrium frequency. Expects a sequence as numpy array @@ -1067,20 +1097,21 @@ class GTR(object): """ if pattern_multiplicity is None: pattern_multiplicity = np.ones_like(seq, dtype=float) - return np.sum([np.sum((seq==state)*pattern_multiplicity*np.log(self.Pi[si])) - for si,state in enumerate(self.alphabet)]) + return np.sum( + [ + np.sum((seq == state) * pattern_multiplicity * np.log(self.Pi[si])) + for si, state in enumerate(self.alphabet) + ] + ) def average_rate(self): - return self.mu*avg_transition(self.W, self.Pi, gap_index=self.gap_index) + return self.mu * avg_transition(self.W, self.Pi, gap_index=self.gap_index) def save_to_npz(self, outfile): full_gtr = self.mu * np.dot(self.Pi, self.W) - desc=np.array(["GTR matrix description\n", "Substitution rate: " + str(self.mu)]) - np.savez(outfile, description=desc, - full_gtr=full_gtr, - char_dist=self.Pi, - flow_matrix=self.W) + desc = np.array(['GTR matrix description\n', 'Substitution rate: ' + str(self.mu)]) + np.savez(outfile, description=desc, full_gtr=full_gtr, char_dist=self.Pi, flow_matrix=self.W) -if __name__ == "__main__": +if __name__ == '__main__': pass diff --git a/treetime/gtr_site_specific.py b/treetime/gtr_site_specific.py index a3f19a7..76b6456 100644 --- a/treetime/gtr_site_specific.py +++ b/treetime/gtr_site_specific.py @@ -4,11 +4,13 @@ from . import config as ttconf from .seq_utils import alphabets, profile_maps, alphabet_synonyms from .gtr import GTR + class GTR_site_specific(GTR): """ Defines General-Time-Reversible model of character evolution that allows for different models at different sites in the alignment """ + def __init__(self, seq_len=1, approximate=True, **kwargs): """constructor for site specfic GTR models @@ -21,25 +23,23 @@ class GTR_site_specific(GTR): **kwargs Description """ - self.seq_len=seq_len + self.seq_len = seq_len self.approximate = approximate super(GTR_site_specific, self).__init__(**kwargs) - self.is_site_specific=True - + self.is_site_specific = True @property def Q(self): """function that return the product of the transition matrix - and the equilibrium frequencies to obtain the rate matrix - of the GTR model + and the equilibrium frequencies to obtain the rate matrix + of the GTR model """ tmp = np.einsum('ia,ij->ija', self.Pi, self.W) diag_vals = np.sum(tmp, axis=0) for x in range(tmp.shape[-1]): - np.fill_diagonal(tmp[:,:,x], -diag_vals[:,x]) + np.fill_diagonal(tmp[:, :, x], -diag_vals[:, x]) return tmp - def assign_rates(self, mu=1.0, pi=None, W=None): """ Overwrite the GTR model given the provided data @@ -57,55 +57,66 @@ class GTR_site_specific(GTR): Equilibrium frequencies """ - if not np.isscalar(mu) and pi is not None and len(pi.shape)==2: - if mu.shape[0]!=pi.shape[1]: - raise ValueError("GTR_site_specific: length of rate vector (got {}) and equilibrium frequency vector (got {}) must match!".format(mu.shape[0], pi.shape[1])) + if not np.isscalar(mu) and pi is not None and len(pi.shape) == 2: + if mu.shape[0] != pi.shape[1]: + raise ValueError( + 'GTR_site_specific: length of rate vector (got {}) and equilibrium frequency vector (got {}) must match!'.format( + mu.shape[0], pi.shape[1] + ) + ) n = len(self.alphabet) if np.isscalar(mu): - self._mu = mu*np.ones(self.seq_len) + self._mu = mu * np.ones(self.seq_len) else: self._mu = np.copy(mu) self.seq_len = mu.shape[0] - if pi is not None and pi.shape[0]==n and len(pi.shape)==2: + if pi is not None and pi.shape[0] == n and len(pi.shape) == 2: self.seq_len = pi.shape[1] Pi = np.copy(pi) else: if pi is not None: - if len(pi)==n: + if len(pi) == n: Pi = np.repeat([pi], self.seq_len, axis=0).T else: - raise ValueError("GTR_site_specific: length of equilibrium frequency vector (got {}) does not match alphabet length {}".format(len(pi), n)) + raise ValueError( + 'GTR_site_specific: length of equilibrium frequency vector (got {}) does not match alphabet length {}'.format( + len(pi), n + ) + ) else: - Pi = np.ones(shape=(n,self.seq_len)) - - self._Pi = Pi/np.sum(Pi, axis=0) - - if W is None or W.shape!=(n,n): - if (W is not None) and W.shape!=(n,n): - raise ValueError("GTR_site_specific: Size of substitution matrix (got {}) does not match alphabet length {}".format(W.shape, n)) - W = np.ones((n,n)) + Pi = np.ones(shape=(n, self.seq_len)) + + self._Pi = Pi / np.sum(Pi, axis=0) + + if W is None or W.shape != (n, n): + if (W is not None) and W.shape != (n, n): + raise ValueError( + 'GTR_site_specific: Size of substitution matrix (got {}) does not match alphabet length {}'.format( + W.shape, n + ) + ) + W = np.ones((n, n)) np.fill_diagonal(W, 0.0) - np.fill_diagonal(W, - W.sum(axis=0)) + np.fill_diagonal(W, -W.sum(axis=0)) else: - W=0.5*(np.copy(W)+np.copy(W).T) + W = 0.5 * (np.copy(W) + np.copy(W).T) - np.fill_diagonal(W,0) - average_rate = np.einsum('ia,ij,ja',self.Pi, W, self.Pi)/self.seq_len + np.fill_diagonal(W, 0) + average_rate = np.einsum('ia,ij,ja', self.Pi, W, self.Pi) / self.seq_len # average_rate = W.dot(avg_pi).dot(avg_pi) - self._W = W/average_rate - self._mu *=average_rate - + self._W = W / average_rate + self._mu *= average_rate - self.is_site_specific=True + self.is_site_specific = True self._eig() self._make_expQt_interpolator() - @classmethod - def random(cls, L=1, avg_mu=1.0, alphabet='nuc', pi_dirichlet_alpha=1, - W_dirichlet_alpha=3.0, mu_gamma_alpha=3.0, rng=None): + def random( + cls, L=1, avg_mu=1.0, alphabet='nuc', pi_dirichlet_alpha=1, W_dirichlet_alpha=3.0, mu_gamma_alpha=3.0, rng=None + ): """ Creates a random GTR model @@ -132,22 +143,22 @@ class GTR_site_specific(GTR): if rng is None: rng = np.random.default_rng() - alphabet=alphabets[alphabet] + alphabet = alphabets[alphabet] gtr = cls(alphabet=alphabet, seq_len=L) n = gtr.alphabet.shape[0] # Dirichlet distribution == l_1 normalized vector of samples of the Gamma distribution if pi_dirichlet_alpha: - pi = 1.0*rng.gamma(pi_dirichlet_alpha, size=(n,L)) + pi = 1.0 * rng.gamma(pi_dirichlet_alpha, size=(n, L)) else: - pi = np.ones((n,L)) + pi = np.ones((n, L)) pi /= pi.sum(axis=0) if W_dirichlet_alpha: - tmp = 1.0*rng.gamma(W_dirichlet_alpha, size=(n,n)) + tmp = 1.0 * rng.gamma(W_dirichlet_alpha, size=(n, n)) else: - tmp = np.ones((n,n)) - tmp = np.tril(tmp,k=-1) + tmp = np.ones((n, n)) + tmp = np.tril(tmp, k=-1) W = tmp + tmp.T if mu_gamma_alpha: @@ -156,11 +167,10 @@ class GTR_site_specific(GTR): mu = np.ones(L) gtr.assign_rates(mu=mu, pi=pi, W=W) - gtr.mu *= avg_mu/np.mean(gtr.average_rate()) + gtr.mu *= avg_mu / np.mean(gtr.average_rate()) return gtr - @classmethod def custom(cls, mu=1.0, pi=None, W=None, **kwargs): """ @@ -194,10 +204,8 @@ class GTR_site_specific(GTR): gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr - @classmethod - def infer(cls, sub_ija, T_ia, root_state, pc=1.0, - gap_limit=0.01, Nit=30, dp=1e-5, **kwargs): + def infer(cls, sub_ija, T_ia, root_state, pc=1.0, gap_limit=0.01, Nit=30, dp=1e-5, **kwargs): r""" Infer a GTR model by specifying the number of transitions and time spent in each character. The basic equation that is being solved is @@ -242,96 +250,102 @@ class GTR_site_specific(GTR): """ from scipy import linalg as LA + gtr = cls(**kwargs) - gtr.logger("GTR: model inference ",1) + gtr.logger('GTR: model inference ', 1) q = len(gtr.alphabet) L = sub_ija.shape[-1] n_iter = 0 n_ija = np.copy(sub_ija) - n_ija[range(q),range(q),:] = 0 + n_ija[range(q), range(q), :] = 0 n_ij = n_ija.sum(axis=-1) - m_ia = np.sum(n_ija,axis=1) + root_state + pc + m_ia = np.sum(n_ija, axis=1) + root_state + pc n_a = n_ija.sum(axis=1).sum(axis=0) + pc - Lambda = np.sum(root_state,axis=0) + q*pc - p_ia_old=np.zeros((q,L)) - p_ia = np.ones((q,L))/q + Lambda = np.sum(root_state, axis=0) + q * pc + p_ia_old = np.zeros((q, L)) + p_ia = np.ones((q, L)) / q mu_a = np.ones(L) - W_ij = np.ones((q,q)) - np.eye(q) + W_ij = np.ones((q, q)) - np.eye(q) - while (LA.norm(p_ia_old-p_ia)>dp) and n_iter<Nit: - gtr.logger(' '.join(map(str, ['GTR inference iteration',n_iter,'change:',LA.norm(p_ia_old-p_ia)])), 3) + while (LA.norm(p_ia_old - p_ia) > dp) and n_iter < Nit: + gtr.logger(' '.join(map(str, ['GTR inference iteration', n_iter, 'change:', LA.norm(p_ia_old - p_ia)])), 3) n_iter += 1 p_ia_old = np.copy(p_ia) - S_ij = np.einsum('a,ia,ja',mu_a, p_ia, T_ia) - W_ij = (n_ij + n_ij.T + pc)/(S_ij + S_ij.T + pc) + S_ij = np.einsum('a,ia,ja', mu_a, p_ia, T_ia) + W_ij = (n_ij + n_ij.T + pc) / (S_ij + S_ij.T + pc) avg_pi = p_ia.mean(axis=-1) average_rate = W_ij.dot(avg_pi).dot(avg_pi) # crude approx, will be fixed in assign rates - W_ij = W_ij/average_rate - mu_a *=average_rate + W_ij = W_ij / average_rate + mu_a *= average_rate - p_ia = m_ia/(mu_a*np.dot(W_ij,T_ia)+Lambda) - p_ia = p_ia/p_ia.sum(axis=0) - - mu_a = n_a/(pc+np.einsum('ia,ij,ja->a', p_ia, W_ij, T_ia)) + p_ia = m_ia / (mu_a * np.dot(W_ij, T_ia) + Lambda) + p_ia = p_ia / p_ia.sum(axis=0) + mu_a = n_a / (pc + np.einsum('ia,ij,ja->a', p_ia, W_ij, T_ia)) if n_iter >= Nit: - gtr.logger('WARNING: maximum number of iterations has been reached in GTR inference',3, warn=True) - if LA.norm(p_ia_old-p_ia) > dp: - gtr.logger('the iterative scheme has not converged',3,warn=True) + gtr.logger('WARNING: maximum number of iterations has been reached in GTR inference', 3, warn=True) + if LA.norm(p_ia_old - p_ia) > dp: + gtr.logger('the iterative scheme has not converged', 3, warn=True) if gtr.gap_index is not None: for p in range(p_ia.shape[-1]): - if p_ia[gtr.gap_index,p]<gap_limit: - gtr.logger('The model allows for gaps which are estimated to occur at a low fraction of %1.3e'%p_ia[gtr.gap_index,p]+ - '\n\t\tthis can potentially result in artifacts.'+ - '\n\t\tgap fraction will be set to %1.4f'%gap_limit,4,warn=True) - p_ia[gtr.gap_index,p] = gap_limit - p_ia[:,p] /= p_ia[:,p].sum() + if p_ia[gtr.gap_index, p] < gap_limit: + gtr.logger( + 'The model allows for gaps which are estimated to occur at a low fraction of %1.3e' + % p_ia[gtr.gap_index, p] + + '\n\t\tthis can potentially result in artifacts.' + + '\n\t\tgap fraction will be set to %1.4f' % gap_limit, + 4, + warn=True, + ) + p_ia[gtr.gap_index, p] = gap_limit + p_ia[:, p] /= p_ia[:, p].sum() gtr.assign_rates(mu=mu_a, W=W_ij, pi=p_ia) return gtr - def _eig(self): eigvals, vec, vec_inv = [], [], [] for pi in range(self.seq_len): - if len(self.W.shape)>2: - W = np.copy(self.W[:,:,pi]) + if len(self.W.shape) > 2: + W = np.copy(self.W[:, :, pi]) np.fill_diagonal(W, 0) - elif pi==0: + elif pi == 0: np.fill_diagonal(self.W, 0) - W=self.W + W = self.W - ev, evec, evec_inv = self._eig_single_site(W,self.Pi[:,pi]) + ev, evec, evec_inv = self._eig_single_site(W, self.Pi[:, pi]) eigvals.append(ev) vec.append(evec) vec_inv.append(evec_inv) self.eigenvals = np.array(eigvals).T - self.v = np.swapaxes(vec,0,-1) - self.v_inv = np.swapaxes(vec_inv, 0,-1) - + self.v = np.swapaxes(vec, 0, -1) + self.v_inv = np.swapaxes(vec_inv, 0, -1) def _make_expQt_interpolator(self): """Function that evaluates the exponentiated substitution matrix at multiple time points and constructs a linear interpolation object """ self.rate_scale = self.average_rate().mean() - t_grid = (1.0/self.rate_scale)*np.concatenate((np.linspace(0,.1,11)[:-1], - np.linspace(.1,1,21)[:-1], - np.linspace(1,5,21)[:-1], - np.linspace(5,10,11))) + t_grid = (1.0 / self.rate_scale) * np.concatenate( + ( + np.linspace(0, 0.1, 11)[:-1], + np.linspace(0.1, 1, 21)[:-1], + np.linspace(1, 5, 21)[:-1], + np.linspace(5, 10, 11), + ) + ) stacked_expQT = np.stack([self._expQt(t) for t in t_grid], axis=0) from scipy.interpolate import interp1d - self.expQt_interpolator = interp1d(t_grid, stacked_expQT, axis=0, - assume_sorted=True, copy=False, kind='linear') + self.expQt_interpolator = interp1d(t_grid, stacked_expQT, axis=0, assume_sorted=True, copy=False, kind='linear') def _expQt(self, t): """Raw numerical matrix exponentiation using the diagonalized matrix. @@ -347,53 +361,49 @@ class GTR_site_specific(GTR): np.array stack of matrices for each site """ - eLambdaT = np.exp(t*self.mu*self.eigenvals) + eLambdaT = np.exp(t * self.mu * self.eigenvals) return np.einsum('jia,ja,kja->ika', self.v, eLambdaT, self.v_inv) - def expQt(self, t): - if t*self.rate_scale<10 and self.approximate: + if t * self.rate_scale < 10 and self.approximate: return self.expQt_interpolator(t) else: return self._expQt(t) - def prop_t_compressed(self, seq_pair, multiplicity, t, return_log=False): - print("NOT IMPEMENTED") - + print('NOT IMPEMENTED') def propagate_profile(self, profile, t, return_log=False): """ - Compute the probability of the sequence state of the parent - at time (t+t0, backwards), given the sequence state of the - child (profile) at time t0. + Compute the probability of the sequence state of the parent + at time (t+t0, backwards), given the sequence state of the + child (profile) at time t0. - Parameters - ---------- + Parameters + ---------- - profile : numpy.array - Sequence profile. Shape = (L, a), - where L - sequence length, a - alphabet size. + profile : numpy.array + Sequence profile. Shape = (L, a), + where L - sequence length, a - alphabet size. - t : double - Time to propagate + t : double + Time to propagate - return_log: bool - If True, return log-probability + return_log: bool + If True, return log-probability - Returns - ------- -` - res : np.array - Profile of the sequence after time t in the past. - Shape = (L, a), where L - sequence length, a - alphabet size. + Returns + ------- + ` + res : np.array + Profile of the sequence after time t in the past. + Shape = (L, a), where L - sequence length, a - alphabet size. """ Qt = self.expQt(t) res = np.einsum('ai,ija->aj', profile, Qt) - return np.log(np.maximum(ttconf.TINY_NUMBER,res)) if return_log else np.maximum(0,res) - + return np.log(np.maximum(ttconf.TINY_NUMBER, res)) if return_log else np.maximum(0, res) def evolve(self, profile, t, return_log=False): """ @@ -426,9 +436,7 @@ class GTR_site_specific(GTR): return np.log(res) if return_log else res - - def prob_t(self, seq_p, seq_ch, t, pattern_multiplicity = None, - return_log=False, ignore_gaps=True): + def prob_t(self, seq_p, seq_ch, t, pattern_multiplicity=None, return_log=False, ignore_gaps=True): """ Compute the probability to observe seq_ch (child sequence) after time t starting from seq_p (parent sequence). @@ -460,29 +468,28 @@ class GTR_site_specific(GTR): Resulting probability """ - if t<0: + if t < 0: logP = -ttconf.BIG_NUMBER else: tmp_eQT = self.expQt(t) - bad_indices=(tmp_eQT==0) - logQt = np.log(tmp_eQT + ttconf.TINY_NUMBER*(bad_indices)) + bad_indices = tmp_eQT == 0 + logQt = np.log(tmp_eQT + ttconf.TINY_NUMBER * (bad_indices)) logQt[np.isnan(logQt) | np.isinf(logQt) | bad_indices] = -ttconf.BIG_NUMBER seq_indices_c = np.zeros(len(seq_ch), dtype=int) seq_indices_p = np.zeros(len(seq_p), dtype=int) for ai, a in enumerate(self.alphabet): - seq_indices_p[seq_p==a] = ai - seq_indices_c[seq_ch==a] = ai + seq_indices_p[seq_p == a] = ai + seq_indices_c[seq_ch == a] = ai - if len(logQt.shape)==2: - logP = np.sum(logQt[seq_indices_p, seq_indices_c]*pattern_multiplicity) + if len(logQt.shape) == 2: + logP = np.sum(logQt[seq_indices_p, seq_indices_c] * pattern_multiplicity) else: - logP = np.sum(logQt[seq_indices_p, seq_indices_c, np.arange(len(seq_ch))]*pattern_multiplicity) + logP = np.sum(logQt[seq_indices_p, seq_indices_c, np.arange(len(seq_ch))] * pattern_multiplicity) return logP if return_log else np.exp(logP) - def average_rate(self): - if self.Pi.shape[1]>1: - return np.einsum('a,ia,ij,ja->a',self.mu, self.Pi, self.W, self.Pi) + if self.Pi.shape[1] > 1: + return np.einsum('a,ia,ij,ja->a', self.mu, self.Pi, self.W, self.Pi) else: - return self.mu*np.einsum('ia,ij,ja->a',self.Pi, self.W, self.Pi) + return self.mu * np.einsum('ia,ij,ja->a', self.Pi, self.W, self.Pi) diff --git a/treetime/merger_models.py b/treetime/merger_models.py index aeaf57c..1ca7a8d 100644 --- a/treetime/merger_models.py +++ b/treetime/merger_models.py @@ -1,9 +1,11 @@ """ methods to calculate merger models for a time tree """ + import numpy as np import scipy.special as sf from scipy.interpolate import interp1d + try: from collections.abc import Iterable except ImportError: @@ -21,7 +23,7 @@ class Coalescent(object): """ def __init__(self, tree, Tc=0.001, logger=None, date2dist=None, n_branches_posterior=False): - ''' + """ Initialize :math:`k(t)` and :math:`Tc` functions Parameters @@ -34,7 +36,7 @@ class Coalescent(object): the number of lineages function :math:`k(t)`, False if current divergence times should be seen as fixed (default). Using the uncertainty should make :math:`k(t)` more smooth. - ''' + """ super(Coalescent, self).__init__() self.tree = tree @@ -43,14 +45,16 @@ class Coalescent(object): self.set_Tc(Tc) self.date2dist = date2dist if logger is None: + def f(*args): print(*args) + self.logger = f else: self.logger = logger def set_Tc(self, Tc, T=None): - ''' + """ initialize the merger model with a coalescent time and calculate the integral of the merger rate Parameters @@ -62,24 +66,23 @@ class Coalescent(object): an array of same shape as Tc that specifies the time pivots corresponding to Tc note that this array is ordered past to present corresponding to decreasing 'time before present' values - ''' + """ if isinstance(Tc, Iterable): - if len(Tc)==len(T): + if len(Tc) == len(T): x = np.concatenate(([ttconf.BIG_NUMBER], T, [-ttconf.BIG_NUMBER])) y = np.concatenate(([Tc[0]], Tc, [Tc[-1]])) - self.Tc = interp1d(x,y) + self.Tc = interp1d(x, y) else: - self.logger("need Tc values and Timepoints of equal length",2,warn=True) + self.logger('need Tc values and Timepoints of equal length', 2, warn=True) self.Tc = interp1d([-ttconf.BIG_NUMBER, ttconf.BIG_NUMBER], [1e-5, 1e-5]) else: - self.Tc = interp1d([-ttconf.BIG_NUMBER, ttconf.BIG_NUMBER], - [Tc+ttconf.TINY_NUMBER, Tc+ttconf.TINY_NUMBER]) + self.Tc = interp1d( + [-ttconf.BIG_NUMBER, ttconf.BIG_NUMBER], [Tc + ttconf.TINY_NUMBER, Tc + ttconf.TINY_NUMBER] + ) self.calc_integral_merger_rate() - - def calc_branch_count(self, posterior=False): - ''' + """ Calculates an interpolation object that maps time to the number of concurrent branches in the tree: :math:`k(t)` @@ -89,16 +92,17 @@ class Coalescent(object): If False use current best estimate of divergence times, else use posterior distributions of divergence times (If the marginal posterior time distribution of a node has been calculated this is used or approximated using the joint posterior time distribution) - ''' + """ ## Divide merger events into either smooth merger events where a posterior likelihood distribution is known or ## delta events where either a date constraint for that node exists or the likelihood distribution is unknown. ## For delta distributions the corresponding nbranches step function can be calculated faster as the nodes can be ## sorted by time and mergers added or subtracted from the previous time, for smooth distributions when a new merger ## event occurs the previous distribution must be evaluated at the corresponding position. - self.tree_events = sorted([(n.time_before_present, len(n.clades)-1) - for n in self.tree.find_clades() if not n.bad_branch], - key=lambda x:-x[0]) + self.tree_events = sorted( + [(n.time_before_present, len(n.clades) - 1) for n in self.tree.find_clades() if not n.bad_branch], + key=lambda x: -x[0], + ) tree_delta_events = [] tree_smooth_events = [] @@ -107,57 +111,58 @@ class Coalescent(object): tree_delta_events = self.tree_events else: y_power = np.array([-8, -4, -3, -2, 0, 2, 3, 4, 8]) - y_points= np.exp(y_power)/(1 + np.exp(y_power)) + y_points = np.exp(y_power) / (1 + np.exp(y_power)) for n in self.tree.find_clades(): - cdf_function=None + cdf_function = None # use cdf function if exists and not from a delta function if hasattr(n, 'marginal_inverse_cdf') and not n.marginal_pos_LH.is_delta: - cdf_function=n.marginal_inverse_cdf + cdf_function = n.marginal_inverse_cdf elif hasattr(n, 'joint_inverse_cdf') and (n.date_constraint is None or not n.date_constraint.is_delta): - cdf_function=n.joint_inverse_cdf + cdf_function = n.joint_inverse_cdf if cdf_function is not None: x_vals = np.concatenate([[-ttconf.BIG_NUMBER], cdf_function(y_points), [ttconf.BIG_NUMBER]]) - y_vals = np.concatenate([ [(len(n.clades)-1),(len(n.clades)-1)], (1-y_points[1:-1]), [0,0]]) - tree_smooth_events += [interp1d(x_vals, y_vals, kind="linear")] + y_vals = np.concatenate([[(len(n.clades) - 1), (len(n.clades) - 1)], (1 - y_points[1:-1]), [0, 0]]) + tree_smooth_events += [interp1d(x_vals, y_vals, kind='linear')] else: - tree_delta = [(n.time_before_present, len(n.clades)-1)] + tree_delta = [(n.time_before_present, len(n.clades) - 1)] tree_delta_events += tree_delta - tree_delta_events= sorted(tree_delta_events, key=lambda x:-x[0]) + tree_delta_events = sorted(tree_delta_events, key=lambda x: -x[0]) if tree_delta_events: # collapse multiple events at one time point into sum of changes from collections import defaultdict + dn_branch = defaultdict(int) - for (t, dn) in tree_delta_events: - dn_branch[t]+=dn - unique_mergers = np.array(sorted(dn_branch.items(), key = lambda x:-x[0])) + for t, dn in tree_delta_events: + dn_branch[t] += dn + unique_mergers = np.array(sorted(dn_branch.items(), key=lambda x: -x[0])) # calculate the branch count at each point summing the delta branch counts - nbranches_discrete = [[ttconf.BIG_NUMBER, 1], [unique_mergers[0,0]+ttconf.TINY_NUMBER, 1]] + nbranches_discrete = [[ttconf.BIG_NUMBER, 1], [unique_mergers[0, 0] + ttconf.TINY_NUMBER, 1]] for ti, (t, dn) in enumerate(unique_mergers[:-1]): - new_n = nbranches_discrete[-1][1]+dn - next_t = unique_mergers[ti+1,0]+ttconf.TINY_NUMBER + new_n = nbranches_discrete[-1][1] + dn + next_t = unique_mergers[ti + 1, 0] + ttconf.TINY_NUMBER nbranches_discrete.append([t, new_n]) nbranches_discrete.append([next_t, new_n]) - new_n += unique_mergers[-1,1] - nbranches_discrete.append([unique_mergers[ti+1,0], new_n]) + new_n += unique_mergers[-1, 1] + nbranches_discrete.append([unique_mergers[ti + 1, 0], new_n]) # pylint: disable=undefined-loop-variable nbranches_discrete.append([-ttconf.BIG_NUMBER, new_n]) - nbranches_discrete=np.array(nbranches_discrete) - nbranches_discrete = interp1d(nbranches_discrete[:,0], nbranches_discrete[:,1], kind='linear') + nbranches_discrete = np.array(nbranches_discrete) + nbranches_discrete = interp1d(nbranches_discrete[:, 0], nbranches_discrete[:, 1], kind='linear') if tree_smooth_events: # add all smooth events by evaluating at all unique x points x_tot = np.unique(np.concatenate([t.x for t in tree_smooth_events])) y_tot = np.array([t(x_tot) for t in tree_smooth_events]).sum(axis=0) - nbranches_smooth = interp1d(x_tot, y_tot +1, kind='linear') + nbranches_smooth = interp1d(x_tot, y_tot + 1, kind='linear') if tree_delta_events: # join smooth and delta merger events into one distribution object x_tot = np.unique(np.concatenate([nbranches_discrete.x, nbranches_smooth.x])) y_tot = nbranches_discrete(x_tot) + nbranches_smooth(x_tot) # if both delta and smooth event objects exist must remove the initial starting value so not double - self.nbranches = interp1d(x_tot, y_tot -1, kind='linear') + self.nbranches = interp1d(x_tot, y_tot - 1, kind='linear') else: self.nbranches = nbranches_smooth else: @@ -166,47 +171,49 @@ class Coalescent(object): self.tree_events = np.array(self.tree_events) def calc_integral_merger_rate(self): - ''' + """ calculates the integral :math:`int_0^t (k(t')-1)/2Tc(t') dt` and stores it as self.integral_merger_rate. This differences of this quantity evaluated at different times points are the cost of a branch. - ''' + """ # integrate the piecewise constant branch count function. tvals = np.unique(self.nbranches.x[1:-1]) rate = self.branch_merger_rate(tvals) - avg_rate = 0.5*(rate[1:] + rate[:-1]) - cost = np.concatenate(([0],np.cumsum(np.diff(tvals)*avg_rate))) + avg_rate = 0.5 * (rate[1:] + rate[:-1]) + cost = np.concatenate(([0], np.cumsum(np.diff(tvals) * avg_rate))) # make interpolation objects for the branch count and its integral # the latter is scaled by 0.5/Tc # need to add extra point at very large time before present to # prevent 'out of interpolation range' errors - self.integral_merger_rate = interp1d(np.concatenate(([-ttconf.BIG_NUMBER], tvals,[ttconf.BIG_NUMBER])), - np.concatenate(([cost[0]], cost,[cost[-1]])), kind='linear') + self.integral_merger_rate = interp1d( + np.concatenate(([-ttconf.BIG_NUMBER], tvals, [ttconf.BIG_NUMBER])), + np.concatenate(([cost[0]], cost, [cost[-1]])), + kind='linear', + ) def branch_merger_rate(self, t): - r''' + r""" rate at which one particular branch merges with any other branch at time t, in the Kingman model this is: :math:`\kappa(t) = (k(t)-1)/(2Tc(t))` - ''' + """ # note that we always have a positive merger rate by capping the # number of branches at 0.5 from below. in these regions, the # function should only be called if the tree changes. - return 0.5*np.maximum(0.5,self.nbranches(t)-1.0)/self.Tc(t) + return 0.5 * np.maximum(0.5, self.nbranches(t) - 1.0) / self.Tc(t) def total_merger_rate(self, t): - r''' + r""" rate at which any branch merges with any other branch at time t, in the Kingman model this is: :math:`\lambda(t) = k(t)(k(t)-1)/(2Tc(t))` - ''' + """ # note that we always have a positive merger rate by capping the # number of branches at 0.5 from below. in these regions, the # function should only be called if the tree changes. - nlineages = np.maximum(0.5,self.nbranches(t)-1.0) - return 0.5*nlineages*(nlineages+1)/self.Tc(t) - + nlineages = np.maximum(0.5, self.nbranches(t) - 1.0) + return 0.5 * nlineages * (nlineages + 1) / self.Tc(t) def cost(self, t_node, branch_length, multiplicity=2.0): - r''' + r""" returns the cost associated with a branch starting with divergence time t_node (:math:`t_n`) having a branch length :math:`\tau`. This is equivalent to the probability of there being no merger on that branch and a merger at the end of the branch, @@ -221,55 +228,58 @@ class Coalescent(object): branch length, determines when this branch merges with sister multiplicity: int 2 if merger is binary, higher if this is a polytomy - ''' - merger_time = t_node + np.maximum(0,branch_length) - return self.integral_merger_rate(merger_time) - self.integral_merger_rate(t_node)\ - - np.log(self.total_merger_rate(merger_time))*(multiplicity-1.0)/multiplicity - + """ + merger_time = t_node + np.maximum(0, branch_length) + return ( + self.integral_merger_rate(merger_time) + - self.integral_merger_rate(t_node) + - np.log(self.total_merger_rate(merger_time)) * (multiplicity - 1.0) / multiplicity + ) def node_contribution(self, node, t, multiplicity=None): - ''' + """ returns the contribution of node at time t to cost of merging branch that node is parent of - ''' + """ from treetime.node_interpolator import NodeInterpolator + if multiplicity is None: multiplicity = len(node.clades) # the number of mergers is 'number of children' - 1 multiplicity -= 1.0 - y = (self.integral_merger_rate(t) - np.log(self.total_merger_rate(t)))*multiplicity + y = (self.integral_merger_rate(t) - np.log(self.total_merger_rate(t))) * multiplicity return NodeInterpolator(t, y, is_log=True) - def total_LH(self): - LH = 0.0 #np.log(self.total_merger_rate([node.time_before_present for node in self.tree.get_nonterminals()])).sum() + LH = 0.0 # np.log(self.total_merger_rate([node.time_before_present for node in self.tree.get_nonterminals()])).sum() for node in self.tree.find_clades(): if node.up: LH -= self.cost(node.time_before_present, node.branch_length) return LH - def optimize_Tc(self): - ''' + """ determines the coalescent time scale Tc that optimizes the coalescent likelihood of the tree (product of the cost of coalescence of all nodes) - ''' + """ from scipy.optimize import minimize_scalar + initial_Tc = self.Tc + def cost(logTc): self.set_Tc(np.exp(logTc)) return -self.total_LH() sol = minimize_scalar(cost, bracket=[-20.0, 2.0], method='brent') - if "success" in sol and sol["success"]: + if 'success' in sol and sol['success']: self.set_Tc(np.exp(sol['x'])) else: - self.logger("merger_models:optimize_Tc: optimization of coalescent time scale failed: " + str(sol), 0, warn=True) + self.logger( + 'merger_models:optimize_Tc: optimization of coalescent time scale failed: ' + str(sol), 0, warn=True + ) self.set_Tc(initial_Tc.y, T=initial_Tc.x) - - def optimize_skyline(self, n_points=20, stiffness=2.0, method = 'SLSQP', - tol=0.03, regularization=10.0, **kwarks): - ''' + def optimize_skyline(self, n_points=20, stiffness=2.0, method='SLSQP', tol=0.03, regularization=10.0, **kwarks): + """ optimize the trajectory of the clock rate 1./T_c to maximize the coalescent likelihood, this is the product of the cost of coalescence of all nodes @@ -287,44 +297,48 @@ class Coalescent(object): cost of moving log(Tc) outsize of the range [-100,0] merger rate is measured in branch length units, no plausible rates should ever be outside this window - ''' - self.logger("Coalescent:optimize_skyline:... current LH: %f"%self.total_LH(),2) + """ + self.logger('Coalescent:optimize_skyline:... current LH: %f' % self.total_LH(), 2) from scipy.optimize import minimize + initial_Tc = self.Tc - tvals = np.linspace(self.tree_events[0,0], self.tree_events[-1,0], n_points) + tvals = np.linspace(self.tree_events[0, 0], self.tree_events[-1, 0], n_points) + def cost(logTc): # cap log Tc to avoid under or overflow and nan in logs self.set_Tc(np.exp(clip(logTc, -200, 100)), tvals) - neglogLH = -self.total_LH() + stiffness*np.sum(np.diff(logTc)**2) \ - + np.sum((logTc>0)*logTc)*regularization\ - - np.sum((logTc<-100)*logTc)*regularization + neglogLH = ( + -self.total_LH() + + stiffness * np.sum(np.diff(logTc) ** 2) + + np.sum((logTc > 0) * logTc) * regularization + - np.sum((logTc < -100) * logTc) * regularization + ) return neglogLH - sol = minimize(cost, np.ones_like(tvals)*np.log(self.Tc.y.mean()), method=method, tol=tol) - if "success" in sol and sol["success"]: + sol = minimize(cost, np.ones_like(tvals) * np.log(self.Tc.y.mean()), method=method, tol=tol) + if 'success' in sol and sol['success']: dlogTc = 0.1 opt_logTc = sol['x'] dcost = [] for ii in range(len(opt_logTc)): tmp = opt_logTc.copy() - tmp[ii]+=dlogTc + tmp[ii] += dlogTc cost_plus = cost(tmp) - tmp[ii]-=2*dlogTc + tmp[ii] -= 2 * dlogTc cost_minus = cost(tmp) dcost.append([cost_minus, cost_plus]) dcost = np.array(dcost) optimal_cost = cost(opt_logTc) - self.confidence = dlogTc/np.sqrt(np.abs(2*optimal_cost - dcost[:,0] - dcost[:,1])) - self.logger("Coalescent:optimize_skyline:...done. new LH: %f"%self.total_LH(),2) + self.confidence = dlogTc / np.sqrt(np.abs(2 * optimal_cost - dcost[:, 0] - dcost[:, 1])) + self.logger('Coalescent:optimize_skyline:...done. new LH: %f' % self.total_LH(), 2) else: self.set_Tc(initial_Tc.y, T=initial_Tc.x) self.confidence = [np.nan for i in initial_Tc.x] - self.logger("Coalescent:optimize_skyline:...failed:"+str(sol),0, warn=True) - + self.logger('Coalescent:optimize_skyline:...failed:' + str(sol), 0, warn=True) - def skyline_empirical(self, gen=1.0, n_points = 20): - ''' + def skyline_empirical(self, gen=1.0, n_points=20): + """ returns the skyline, i.e., an estimate of the inverse rate of coalesence. Here, the skyline is estimated from a sliding window average of the observed mergers, i.e., without reference to the coalescence likelihood. @@ -334,37 +348,40 @@ class Coalescent(object): gen: float number of generations per year n_points: int - ''' - merger_times = np.array(self.tree_events[self.tree_events[:,1]>0, 0]) - nlineages = self.nbranches(merger_times -ttconf.TINY_NUMBER) - expected_merger_density = nlineages*(nlineages-1)*0.5 + """ + merger_times = np.array(self.tree_events[self.tree_events[:, 1] > 0, 0]) + nlineages = self.nbranches(merger_times - ttconf.TINY_NUMBER) + expected_merger_density = nlineages * (nlineages - 1) * 0.5 nmergers = len(merger_times) et = merger_times - ev = 1.0/expected_merger_density + ev = 1.0 / expected_merger_density # reduce the window size if there are few events in the tree - if 2*n_points>len(expected_merger_density): - n_points = len(ev)//4 + if 2 * n_points > len(expected_merger_density): + n_points = len(ev) // 4 # smoothes with a sliding window over data points - avg = np.sum(ev)/np.abs(et[0]-et[-1]) - dt = et[0]-et[-1] - mid_points = np.concatenate(([et[0]-0.5*(et[1]-et[0])], - 0.5*(et[1:] + et[:-1]), - [et[-1]+0.5*(et[-1]-et[-2])])) + avg = np.sum(ev) / np.abs(et[0] - et[-1]) + dt = et[0] - et[-1] + mid_points = np.concatenate( + ([et[0] - 0.5 * (et[1] - et[0])], 0.5 * (et[1:] + et[:-1]), [et[-1] + 0.5 * (et[-1] - et[-2])]) + ) # this smoothes the ratio of expected and observed merger rate # epsilon is added to avoid division by 0 and to normalize Tc - epsilon= (1/n_points)*dt/nmergers - self.Tc_inv = interp1d(mid_points[n_points:-n_points], - [np.sum(ev[(et>=l)&(et<u)])/(u-l+epsilon) - for u,l in zip(mid_points[:-2*n_points],mid_points[2*n_points:])]) - - return interp1d(self.date2dist.to_numdate(self.Tc_inv.x), gen/self.date2dist.clock_rate/self.Tc_inv.y) + epsilon = (1 / n_points) * dt / nmergers + self.Tc_inv = interp1d( + mid_points[n_points:-n_points], + [ + np.sum(ev[(et >= l) & (et < u)]) / (u - l + epsilon) + for u, l in zip(mid_points[: -2 * n_points], mid_points[2 * n_points :]) + ], + ) + return interp1d(self.date2dist.to_numdate(self.Tc_inv.x), gen / self.date2dist.clock_rate / self.Tc_inv.y) def skyline_inferred(self, gen=1.0, confidence=False): - ''' + """ return the skyline, i.e., an estimate of the inverse rate of coalesence. This function merely returns the merger rate self.Tc that was set or estimated by other means. If it was determined using self.optimize_skyline, @@ -377,19 +394,16 @@ class Coalescent(object): hence this needs to be the inverse substitution rate per generation confidence: boolean, float False, or number of standard deviations of confidence intervals - ''' - if len(self.Tc.x)<=2: - print("no skyline has been inferred, returning constant population size") - return gen/self.date2dist.clock_rate*self.Tc.y[-1] - - skyline = interp1d(self.date2dist.to_numdate(self.Tc.x[1:-1]), gen/self.date2dist.clock_rate*self.Tc.y[1:-1]) + """ + if len(self.Tc.x) <= 2: + print('no skyline has been inferred, returning constant population size') + return gen / self.date2dist.clock_rate * self.Tc.y[-1] + + skyline = interp1d( + self.date2dist.to_numdate(self.Tc.x[1:-1]), gen / self.date2dist.clock_rate * self.Tc.y[1:-1] + ) if confidence and hasattr(self, 'confidence'): - conf = [skyline.y*np.exp(-confidence*self.confidence), skyline.y*np.exp(confidence*self.confidence)] + conf = [skyline.y * np.exp(-confidence * self.confidence), skyline.y * np.exp(confidence * self.confidence)] return skyline, conf else: return skyline, None - - - - - diff --git a/treetime/node_interpolator.py b/treetime/node_interpolator.py index 3ed44af..de290da 100644 --- a/treetime/node_interpolator.py +++ b/treetime/node_interpolator.py @@ -5,9 +5,9 @@ from .distribution import Distribution from .utils import clip from .config import FFT_FWHM_GRID_SIZE -def _convolution_integrand(t_val, f, g, - inverse_time=None, return_log=False): - ''' + +def _convolution_integrand(t_val, f, g, inverse_time=None, return_log=False): + """ Evaluates int_tau f(t+tau)*g(tau) or int_tau f(t-tau)g(tau) if inverse time is TRUE Parameters @@ -36,10 +36,10 @@ def _convolution_integrand(t_val, f, g, FG : Distribution The function to be integrated as Distribution object (interpolator) - ''' + """ if inverse_time is None: - raise Exception("Inverse time argument must be set!") + raise Exception('Inverse time argument must be set!') # determine integration boundaries: if inverse_time: @@ -49,44 +49,42 @@ def _convolution_integrand(t_val, f, g, tau_max = min(t_val - f.xmin, g.xmax) else: ## tau>g.xmin and t+tau>f.xmin - tau_min = max(f.xmin-t_val, g.xmin) + tau_min = max(f.xmin - t_val, g.xmin) ## tau<g.xmax and t+tau<f.xmax - tau_max = min(f.xmax-t_val, g.xmax) - #print(tau_min, tau_max) - + tau_max = min(f.xmax - t_val, g.xmax) + # print(tau_min, tau_max) if tau_max <= tau_min: if return_log: return ttconf.BIG_NUMBER else: - return 0.0 # functions do not overlap + return 0.0 # functions do not overlap else: # create the tau-grid for the interpolation object in the overlap region if inverse_time: - tau = np.concatenate((g.x, t_val-f.x,[tau_min,tau_max])) + tau = np.concatenate((g.x, t_val - f.x, [tau_min, tau_max])) else: - tau = np.concatenate((g.x, f.x-t_val,[tau_min,tau_max])) - tau = np.unique(clip(tau, tau_min-ttconf.TINY_NUMBER, tau_max+ttconf.TINY_NUMBER)) - if len(tau)<10: + tau = np.concatenate((g.x, f.x - t_val, [tau_min, tau_max])) + tau = np.unique(clip(tau, tau_min - ttconf.TINY_NUMBER, tau_max + ttconf.TINY_NUMBER)) + if len(tau) < 10: tau = np.linspace(tau_min, tau_max, 10) - if inverse_time: # add negative logarithms + if inverse_time: # add negative logarithms tnode = t_val - tau fg = f(tnode) + g(tau) else: fg = f(t_val + tau) + g(tau) # create the interpolation object on this grid - FG = Distribution(tau, fg, is_log=True, min_width = np.max([f.min_width, g.min_width]), - kind='linear', assume_sorted=True) + FG = Distribution( + tau, fg, is_log=True, min_width=np.max([f.min_width, g.min_width]), kind='linear', assume_sorted=True + ) return FG - def _max_of_integrand(t_val, f, g, inverse_time=None, return_log=False): - - ''' + """ Evaluates max_tau f(t+tau)*g(tau) or max_tau f(t-tau)g(tau) if inverse time is TRUE Parameters @@ -115,7 +113,7 @@ def _max_of_integrand(t_val, f, g, inverse_time=None, return_log=False): FG : Distribution The function to be integrated as Distribution object (interpolator) - ''' + """ # return log is always True FG = _convolution_integrand(t_val, f, g, inverse_time, return_log=True) @@ -125,25 +123,25 @@ def _max_of_integrand(t_val, f, g, inverse_time=None, return_log=False): else: X = FG.x[FG.y.argmin()] Y = FG.y.min() - res = [Y, X] + res = [Y, X] if not return_log: res[0] = np.exp(res[0]) return res -def _evaluate_convolution(t_val, f, g, n_integral = 100, inverse_time=None, return_log=False): + +def _evaluate_convolution(t_val, f, g, n_integral=100, inverse_time=None, return_log=False): """ Calculate convolution F(t) = int { f(tau)g(t-tau) } dtau """ FG = _convolution_integrand(t_val, f, g, inverse_time, return_log) - #integrate the interpolation object, return log, make neg_log - #print('FG:',FG.xmin, FG.xmax, FG(FG.xmin), FG(FG.xmax)) - if (return_log and FG == ttconf.BIG_NUMBER) or \ - (not return_log and FG == 0.0): # distributions do not overlap - res = ttconf.BIG_NUMBER # we integrate log funcitons + # integrate the interpolation object, return log, make neg_log + # print('FG:',FG.xmin, FG.xmax, FG(FG.xmin), FG(FG.xmax)) + if (return_log and FG == ttconf.BIG_NUMBER) or (not return_log and FG == 0.0): # distributions do not overlap + res = ttconf.BIG_NUMBER # we integrate log funcitons else: res = -FG.integrate(a=FG.xmin, b=FG.xmax, n=n_integral, return_log=True) @@ -153,7 +151,7 @@ def _evaluate_convolution(t_val, f, g, n_integral = 100, inverse_time=None, ret return np.exp(-res), -1 -class NodeInterpolator (Distribution): +class NodeInterpolator(Distribution): """ Node's position distribution function. This class extends the distribution class ind implements the convolution constructor. @@ -161,33 +159,44 @@ class NodeInterpolator (Distribution): @classmethod def convolve_fft(cls, node_interp, branch_interp, fft_grid_size=FFT_FWHM_GRID_SIZE, inverse_time=True): - - dt = max(branch_interp.one_mutation*0.005, min(node_interp.fwhm, branch_interp.fwhm)/fft_grid_size) + dt = max(branch_interp.one_mutation * 0.005, min(node_interp.fwhm, branch_interp.fwhm) / fft_grid_size) b_effsupport = branch_interp.effective_support n_effsupport = node_interp.effective_support - b_support_range = b_effsupport[1]-b_effsupport[0] - n_support_range = n_effsupport[1]-n_effsupport[0] + b_support_range = b_effsupport[1] - b_effsupport[0] + n_support_range = n_effsupport[1] - n_effsupport[0] # compare the support of the node distribution to the width of the branch length distribution - ratio = n_support_range/branch_interp.fwhm + ratio = n_support_range / branch_interp.fwhm - if ratio < 1.0/fft_grid_size and 4.0*dt > node_interp.fwhm: + if ratio < 1.0 / fft_grid_size and 4.0 * dt > node_interp.fwhm: ## node distribution is much narrower than the branch distribution, proceed as if # node distribution is a delta distribution with the peak 4 full-width-half-maxima # away from the nominal peak to avoid slicing the relevant range to zero - log_scale_node_interp = node_interp.integrate(return_log=True, a=node_interp.xmin,b=node_interp.xmax,n=max(100, len(node_interp.x))) #probability of node distribution + log_scale_node_interp = node_interp.integrate( + return_log=True, a=node_interp.xmin, b=node_interp.xmax, n=max(100, len(node_interp.x)) + ) # probability of node distribution if inverse_time: - x = branch_interp.x + max(n_effsupport[0], node_interp._peak_pos - 4.0*node_interp.fwhm) - dist = Distribution(x, branch_interp(x - node_interp._peak_pos) - log_scale_node_interp, - min_width=max(node_interp.min_width, branch_interp.min_width), is_log=True) + x = branch_interp.x + max(n_effsupport[0], node_interp._peak_pos - 4.0 * node_interp.fwhm) + dist = Distribution( + x, + branch_interp(x - node_interp._peak_pos) - log_scale_node_interp, + min_width=max(node_interp.min_width, branch_interp.min_width), + is_log=True, + ) else: - x = - branch_interp.x + min(n_effsupport[1], node_interp._peak_pos + 4.0*node_interp.fwhm) - dist = Distribution(x, branch_interp(branch_interp.x) - log_scale_node_interp, - min_width=max(node_interp.min_width, branch_interp.min_width), is_log=True) + x = -branch_interp.x + min(n_effsupport[1], node_interp._peak_pos + 4.0 * node_interp.fwhm) + dist = Distribution( + x, + branch_interp(branch_interp.x) - log_scale_node_interp, + min_width=max(node_interp.min_width, branch_interp.min_width), + is_log=True, + ) return dist - elif ratio > fft_grid_size and 4*dt > branch_interp.fwhm: - raise ValueError("ERROR: Unexpected behavior: branch distribution is much narrower than the node distribution.") + elif ratio > fft_grid_size and 4 * dt > branch_interp.fwhm: + raise ValueError( + 'ERROR: Unexpected behavior: branch distribution is much narrower than the node distribution.' + ) else: - tmax = 2*max(b_support_range, n_support_range) + tmax = 2 * max(b_support_range, n_support_range) Tb = np.arange(b_effsupport[0], b_effsupport[0] + tmax + dt, dt) if inverse_time: @@ -200,92 +209,111 @@ class NodeInterpolator (Distribution): Tmax = node_interp.xmax raw_len = len(Tb) - fft_len = 2*raw_len + fft_len = 2 * raw_len fftb = branch_interp.fft(Tb, n=fft_len) fftn = node_interp.fft(Tn, n=fft_len, inverse_time=inverse_time) if inverse_time: - fft_res = np.fft.irfft(fftb*fftn, fft_len)[:raw_len] + fft_res = np.fft.irfft(fftb * fftn, fft_len)[:raw_len] Tres = Tn + Tb[0] else: - fft_res = np.fft.irfft(fftb*fftn, fft_len)[::-1] + fft_res = np.fft.irfft(fftb * fftn, fft_len)[::-1] fft_res = fft_res[raw_len:] Tres = Tn - Tb[0] # determine region in which we can trust the FFT convolution and avoid # inaccuracies due to machine precision. 1e-13 seems robust - ind = fft_res>fft_res.max()*1e-13 + ind = fft_res > fft_res.max() * 1e-13 res = -np.log(fft_res[ind]) + branch_interp.peak_val + node_interp.peak_val - np.log(dt) Tres_cropped = Tres[ind] # extrapolate the tails exponentially: use margin last data points - margin = np.minimum(3, Tres_cropped.shape[0]//3) - if margin<1 or len(res)==0: - raise TreeTimeUnknownError("Error: Unexpected behavior detected in FFT function. " - "No valid points left after reducing to plausible region.\n\n" - "If you see this error please let us know by filling an issue at:\n" - "https://github.com/neherlab/treetime/issues") + margin = np.minimum(3, Tres_cropped.shape[0] // 3) + if margin < 1 or len(res) == 0: + raise TreeTimeUnknownError( + 'Error: Unexpected behavior detected in FFT function. ' + 'No valid points left after reducing to plausible region.\n\n' + 'If you see this error please let us know by filling an issue at:\n' + 'https://github.com/neherlab/treetime/issues' + ) else: - left_slope = (res[margin]-res[0])/(Tres_cropped[margin]-Tres_cropped[0]) - right_slope = (res[-1]-res[-margin-1])/(Tres_cropped[-1]-Tres_cropped[-margin-1]) + left_slope = (res[margin] - res[0]) / (Tres_cropped[margin] - Tres_cropped[0]) + right_slope = (res[-1] - res[-margin - 1]) / (Tres_cropped[-1] - Tres_cropped[-margin - 1]) # only extrapolate on the left when the slope is negative and we are not on the boundary - if Tmin<Tres_cropped[0] and left_slope<0: - Tleft = np.linspace(Tmin, Tres_cropped[0],10)[:-1] - res_left = res[0] + left_slope*(Tleft - Tres_cropped[0]) + if Tmin < Tres_cropped[0] and left_slope < 0: + Tleft = np.linspace(Tmin, Tres_cropped[0], 10)[:-1] + res_left = res[0] + left_slope * (Tleft - Tres_cropped[0]) else: Tleft, res_left = [], [] # only extrapolate on the right when the slope is positive and we are not on the boundary - if Tres_cropped[-1]<Tmax and right_slope>0: - Tright = np.linspace(Tres_cropped[-1], Tmax,10)[1:] - res_right = res[-1] + right_slope*(Tright - Tres_cropped[-1]) - else: #otherwise + if Tres_cropped[-1] < Tmax and right_slope > 0: + Tright = np.linspace(Tres_cropped[-1], Tmax, 10)[1:] + res_right = res[-1] + right_slope * (Tright - Tres_cropped[-1]) + else: # otherwise Tright, res_right = [], [] # instantiate the new interpolation object and return - return cls(np.concatenate((Tleft,Tres_cropped,Tright)), - np.concatenate((res_left, res, res_right)), - is_log=True, kind='linear', assume_sorted=True) + return cls( + np.concatenate((Tleft, Tres_cropped, Tright)), + np.concatenate((res_left, res, res_right)), + is_log=True, + kind='linear', + assume_sorted=True, + ) @classmethod - def convolve(cls, node_interp, branch_interp, max_or_integral='integral', - n_grid_points = ttconf.NODE_GRID_SIZE, n_integral=ttconf.N_INTEGRAL, - inverse_time=True, rel_tol=0.05, yc=10): - - r''' + def convolve( + cls, + node_interp, + branch_interp, + max_or_integral='integral', + n_grid_points=ttconf.NODE_GRID_SIZE, + n_integral=ttconf.N_INTEGRAL, + inverse_time=True, + rel_tol=0.05, + yc=10, + ): + r""" calculate H(t) = \int_tau f(t-tau)g(tau) if inverse_time=True H(t) = \int_tau f(t+tau)g(tau) if inverse_time=False This function determines the time points of the grid of the result to ensure an accurate approximation. - ''' + """ if max_or_integral not in ['max', 'integral']: - raise Exception("Max_or_integral expected to be 'max' or 'integral', got " - + str(max_or_integral) + " instead.") + raise Exception( + "Max_or_integral expected to be 'max' or 'integral', got " + str(max_or_integral) + ' instead.' + ) def conv_in_point(time_point): - - if max_or_integral == 'integral': # compute integral of the convolution - return _evaluate_convolution(time_point, node_interp, branch_interp, - n_integral=n_integral, return_log=True, - inverse_time = inverse_time) - - else: # compute max of the convolution - return _max_of_integrand(time_point, node_interp, branch_interp, - return_log=True, inverse_time = inverse_time) + if max_or_integral == 'integral': # compute integral of the convolution + return _evaluate_convolution( + time_point, + node_interp, + branch_interp, + n_integral=n_integral, + return_log=True, + inverse_time=inverse_time, + ) + + else: # compute max of the convolution + return _max_of_integrand( + time_point, node_interp, branch_interp, return_log=True, inverse_time=inverse_time + ) # estimate peak and width - joint_fwhm = (node_interp.fwhm + branch_interp.fwhm) - min_fwhm = min(node_interp.fwhm, branch_interp.fwhm) + joint_fwhm = node_interp.fwhm + branch_interp.fwhm + min_fwhm = min(node_interp.fwhm, branch_interp.fwhm) # determine support of the resulting convolution # in order to be positive, the flipped support of f, shifted by t and g need to overlap if inverse_time: new_peak_pos = node_interp.peak_pos + branch_interp.peak_pos - tmin = node_interp.xmin+branch_interp.xmin - tmax = node_interp.xmax+branch_interp.xmax + tmin = node_interp.xmin + branch_interp.xmin + tmax = node_interp.xmax + branch_interp.xmax else: new_peak_pos = node_interp.peak_pos - branch_interp.peak_pos tmin = node_interp.xmin - branch_interp.xmax @@ -293,40 +321,43 @@ class NodeInterpolator (Distribution): # make initial node grid consisting of linearly spaced points around # the center and quadratically spaced points at either end - n = n_grid_points//3 - center_width = 3*joint_fwhm - grid_center = new_peak_pos + np.linspace(-1, 1, n)*center_width + n = n_grid_points // 3 + center_width = 3 * joint_fwhm + grid_center = new_peak_pos + np.linspace(-1, 1, n) * center_width # add the right and left grid if it is needed - right_range = (tmax - grid_center[-1]) - if right_range>4*center_width: - grid_right = grid_center[-1] + right_range*(np.linspace(0, 1, n)**2.0) - elif right_range>0: # use linear grid the right_range is comparable to center_width - grid_right = grid_center[-1] + right_range*np.linspace(0,1, int(min(n,1+0.5*n*right_range/center_width))) + right_range = tmax - grid_center[-1] + if right_range > 4 * center_width: + grid_right = grid_center[-1] + right_range * (np.linspace(0, 1, n) ** 2.0) + elif right_range > 0: # use linear grid the right_range is comparable to center_width + grid_right = grid_center[-1] + right_range * np.linspace( + 0, 1, int(min(n, 1 + 0.5 * n * right_range / center_width)) + ) else: - grid_right =[] + grid_right = [] - left_range = grid_center[0]-tmin - if left_range>4*center_width: - grid_left = tmin + left_range*(np.linspace(0, 1, n)**2.0) - elif left_range>0: - grid_left = tmin + left_range*np.linspace(0,1, int(min(n,1+0.5*n*left_range/center_width))) + left_range = grid_center[0] - tmin + if left_range > 4 * center_width: + grid_left = tmin + left_range * (np.linspace(0, 1, n) ** 2.0) + elif left_range > 0: + grid_left = tmin + left_range * np.linspace(0, 1, int(min(n, 1 + 0.5 * n * left_range / center_width))) else: - grid_left =[] - + grid_left = [] - if tmin>-1: - grid_zero_left = tmin + (tmax-tmin)*np.linspace(0,0.01,11)**2 + if tmin > -1: + grid_zero_left = tmin + (tmax - tmin) * np.linspace(0, 0.01, 11) ** 2 else: grid_zero_left = [tmin] - if tmax<1: - grid_zero_right = tmax - (tmax-tmin)*np.linspace(0,0.01,11)**2 + if tmax < 1: + grid_zero_right = tmax - (tmax - tmin) * np.linspace(0, 0.01, 11) ** 2 else: grid_zero_right = [tmax] # make grid and calculate convolution - t_grid_0 = np.unique(np.concatenate([grid_zero_left, grid_left[:-1], grid_center, grid_right[1:], grid_zero_right])) - t_grid_0 = t_grid_0[(t_grid_0 > tmin-ttconf.TINY_NUMBER) & (t_grid_0 < tmax+ttconf.TINY_NUMBER)] + t_grid_0 = np.unique( + np.concatenate([grid_zero_left, grid_left[:-1], grid_center, grid_right[1:], grid_zero_right]) + ) + t_grid_0 = t_grid_0[(t_grid_0 > tmin - ttconf.TINY_NUMBER) & (t_grid_0 < tmax + ttconf.TINY_NUMBER)] # res0 - the values of the convolution (integral or max) # t_0 - the value, at which the res0 achieves maximum @@ -335,26 +366,36 @@ class NodeInterpolator (Distribution): # refine grid as necessary and add new points # calculate interpolation error at all internal points [2:-2] bc end points are sometime off scale - interp_error = np.abs(res_0[3:-1]+res_0[1:-3]-2*res_0[2:-2]) + interp_error = np.abs(res_0[3:-1] + res_0[1:-3] - 2 * res_0[2:-2]) # determine the number of extra points needed, criterion depends on distance from peak dy - dy = (res_0[2:-2]-res_0.min()) + dy = res_0[2:-2] - res_0.min() dx = np.diff(t_grid_0) - refine_factor = np.minimum(np.minimum(np.array(np.floor(np.sqrt(interp_error/(rel_tol*(1+(dy/yc)**4)))), dtype=int), - np.array(100*(dx[1:-2]+dx[2:-1])/min_fwhm, dtype=int)), 10) - - insert_point_idx = np.zeros(interp_error.shape[0]+1, dtype=int) + refine_factor = np.minimum( + np.minimum( + np.array(np.floor(np.sqrt(interp_error / (rel_tol * (1 + (dy / yc) ** 4)))), dtype=int), + np.array(100 * (dx[1:-2] + dx[2:-1]) / min_fwhm, dtype=int), + ), + 10, + ) + + insert_point_idx = np.zeros(interp_error.shape[0] + 1, dtype=int) insert_point_idx[1:] = refine_factor insert_point_idx[:-1] += refine_factor # add additional points if there are any to add if np.sum(insert_point_idx): - add_x = np.concatenate([np.linspace(t1,t2,n+2)[1:-1] for t1,t2,n in - zip(t_grid_0[1:-2], t_grid_0[2:-1], insert_point_idx) if n>0]) + add_x = np.concatenate( + [ + np.linspace(t1, t2, n + 2)[1:-1] + for t1, t2, n in zip(t_grid_0[1:-2], t_grid_0[2:-1], insert_point_idx) + if n > 0 + ] + ) # calculate convolution at these points add_y, add_t = np.array([conv_in_point(t_val) for t_val in add_x]).T t_grid_0 = np.concatenate((t_grid_0, add_x)) - res_0 = np.concatenate ((res_0, add_y)) - t_0 = np.concatenate ((t_0, add_t)) + res_0 = np.concatenate((res_0, add_y)) + t_0 = np.concatenate((t_0, add_t)) # instantiate the new interpolation object and return res_y = cls(t_grid_0, res_0, is_log=True, kind='linear') @@ -363,7 +404,6 @@ class NodeInterpolator (Distribution): # grid, which maximizes the convolution (for 'max' option), # or flat -1 distribution (for 'integral' option) # this grid is the optimal branch length - res_t = Distribution(t_grid_0, t_0, is_log=True, - min_width=node_interp.min_width, kind='linear') + res_t = Distribution(t_grid_0, t_0, is_log=True, min_width=node_interp.min_width, kind='linear') return res_y, res_t diff --git a/treetime/nuc_models.py b/treetime/nuc_models.py index c8aa4e3..083c43c 100644 --- a/treetime/nuc_models.py +++ b/treetime/nuc_models.py @@ -4,8 +4,9 @@ import numpy as np from .seq_utils import alphabets from .gtr import GTR + def get_alphabet(a): - if type(a)==str and a in alphabets: + if type(a) == str and a in alphabets: return alphabets[a] else: try: @@ -14,7 +15,7 @@ def get_alphabet(a): raise TypeError -def JC69 (mu=1.0, alphabet="nuc", **kwargs): +def JC69(mu=1.0, alphabet='nuc', **kwargs): """ Jukes-Cantor 1969 model. This model assumes equal concentrations of the nucleotides and equal transition rates between nucleotide states. @@ -37,12 +38,13 @@ def JC69 (mu=1.0, alphabet="nuc", **kwargs): """ num_chars = len(get_alphabet(alphabet)) - W, pi = np.ones((num_chars,num_chars)), np.ones(num_chars) + W, pi = np.ones((num_chars, num_chars)), np.ones(num_chars) gtr = GTR(alphabet=alphabet) gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr -def K80(mu=1., kappa=0.1, **kwargs): + +def K80(mu=1.0, kappa=0.1, **kwargs): """ Kimura 1980 model. Assumes equal concentrations across nucleotides, but allows different rates between transitions and transversions. The ratio @@ -63,13 +65,14 @@ def K80(mu=1., kappa=0.1, **kwargs): """ num_chars = len(alphabets['nuc_nogap']) - pi = np.ones(len(alphabets['nuc_nogap']), dtype=float)/len(alphabets['nuc_nogap']) + pi = np.ones(len(alphabets['nuc_nogap']), dtype=float) / len(alphabets['nuc_nogap']) W = _create_transversion_transition_W(kappa) - gtr = GTR(alphabet=alphabets['nuc_nogap']) + gtr = GTR(alphabet='nuc_nogap') gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr -def F81(mu=1.0, pi=None, alphabet="nuc", **kwargs): + +def F81(mu=1.0, pi=None, alphabet='nuc', **kwargs): """ Felsenstein 1981 model. Assumes non-equal concentrations across nucleotides, but the transition rate between all states is assumed to be equal. See @@ -95,22 +98,25 @@ def F81(mu=1.0, pi=None, alphabet="nuc", **kwargs): """ if pi is None: - pi=0.25*np.ones(4, dtype=float) + pi = 0.25 * np.ones(4, dtype=float) num_chars = len(get_alphabet(alphabet)) pi = np.array(pi, dtype=float) - if num_chars != len(pi) : - pi = np.ones((num_chars, ), dtype=float) - print ("GTR: Warning!The number of the characters in the alphabet does not match the " - "shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.") + if num_chars != len(pi): + pi = np.ones((num_chars,), dtype=float) + print( + 'GTR: Warning!The number of the characters in the alphabet does not match the ' + 'shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.' + ) - W = np.ones((num_chars,num_chars)) - pi /= (1.0 * np.sum(pi)) - gtr = GTR(alphabet=get_alphabet(alphabet)) + W = np.ones((num_chars, num_chars)) + pi /= 1.0 * np.sum(pi) + gtr = GTR(alphabet=alphabet) gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr + def HKY85(mu=1.0, pi=None, kappa=0.1, **kwargs): """ Hasegawa, Kishino and Yano 1985 model. Allows different concentrations of the @@ -136,19 +142,22 @@ def HKY85(mu=1.0, pi=None, kappa=0.1, **kwargs): """ if pi is None: - pi=0.25*np.ones(4, dtype=float) + pi = 0.25 * np.ones(4, dtype=float) num_chars = len(alphabets['nuc_nogap']) - if num_chars != pi.shape[0] : - pi = np.ones((num_chars, ), dtype=float) - print ("GTR: Warning!The number of the characters in the alphabet does not match the " - "shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.") + if num_chars != pi.shape[0]: + pi = np.ones((num_chars,), dtype=float) + print( + 'GTR: Warning!The number of the characters in the alphabet does not match the ' + 'shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.' + ) W = _create_transversion_transition_W(kappa) pi /= pi.sum() - gtr = GTR(alphabet=alphabets['nuc_nogap']) + gtr = GTR(alphabet='nuc_nogap') gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr + def T92(mu=1.0, pi_GC=0.5, kappa=0.1, **kwargs): """ Tamura 1992 model. Extending Kimura (1980) model for the case where a @@ -173,14 +182,15 @@ def T92(mu=1.0, pi_GC=0.5, kappa=0.1, **kwargs): W = _create_transversion_transition_W(kappa) # A C G T - if pi_GC >=1.: - raise ValueError("The relative GC content specified is larger than 1.0!") - pi = np.array([(1.-pi_GC)*0.5, pi_GC*0.5, pi_GC*0.5, (1-pi_GC)*0.5]) - gtr = GTR(alphabet=alphabets['nuc_nogap']) + if pi_GC >= 1.0: + raise ValueError('The relative GC content specified is larger than 1.0!') + pi = np.array([(1.0 - pi_GC) * 0.5, pi_GC * 0.5, pi_GC * 0.5, (1 - pi_GC) * 0.5]) + gtr = GTR(alphabet='nuc_nogap') gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr -def TN93(mu=1.0, kappa1=1., kappa2=1., pi=None, **kwargs): + +def TN93(mu=1.0, kappa1=1.0, kappa2=1.0, pi=None, **kwargs): """ Tamura and Nei 1993. The model distinguishes between the two different types of transition: (A <-> G) is allowed to have a different rate to (C<->T). @@ -209,33 +219,35 @@ def TN93(mu=1.0, kappa1=1., kappa2=1., pi=None, **kwargs): """ if pi is None: - pi=0.25*np.ones(4, dtype=float) - W = np.ones((4,4)) - W = np.array([ - [1, kappa1, 1, kappa1], - [kappa1, 1, kappa1, kappa2], - [1, kappa1, 1, kappa1], - [kappa1, kappa2, kappa1, 1]], dtype=float) - - pi /=pi.sum() - num_chars = len(alphabets['nuc_nogap']) - if num_chars != pi.shape[0] : - pi = np.ones((num_chars, ), dtype=float) - print ("GTR: Warning!The number of the characters in the alphabet does not match the " - "shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.") + pi = 0.25 * np.ones(4, dtype=float) + W = np.ones((4, 4)) + W = np.array( + [[1, kappa1, 1, kappa1], [kappa1, 1, kappa1, kappa2], [1, kappa1, 1, kappa1], [kappa1, kappa2, kappa1, 1]], + dtype=float, + ) - gtr = GTR(alphabet=alphabets['nuc']) + pi /= pi.sum() + num_chars = len(alphabets['nuc_nogap']) + if num_chars != pi.shape[0]: + pi = np.ones((num_chars,), dtype=float) + print( + 'GTR: Warning!The number of the characters in the alphabet does not match the ' + 'shape of the vector of equilibrium frequencies Pi -- assuming equal frequencies for all states.' + ) + + gtr = GTR(alphabet='nuc') gtr.assign_rates(mu=mu, pi=pi, W=W) return gtr + def _create_transversion_transition_W(kappa): """ Alphabet = [A, C, G, T] """ - W = np.ones((4,4)) - W[0, 2]=W[1, 3]=W[2, 0]=W[3,1]=kappa + W = np.ones((4, 4)) + W[0, 2] = W[1, 3] = W[2, 0] = W[3, 1] = kappa return W + if __name__ == '__main__': pass - diff --git a/treetime/seq_utils.py b/treetime/seq_utils.py index 1714fdd..f404553 100644 --- a/treetime/seq_utils.py +++ b/treetime/seq_utils.py @@ -2,30 +2,34 @@ import numpy as np from Bio import Seq, SeqRecord -alphabet_synonyms = {'nuc':'nuc', 'nucleotide':'nuc', 'aa':'aa', 'aminoacid':'aa', - 'nuc_nogap':'nuc_nogap', 'nucleotide_nogap':'nuc_nogap', - 'aa_nogap':'aa_nogap', 'aminoacid_nogap':'aa_nogap', - 'DNA':'nuc', 'DNA_nogap':'nuc_nogap'} +alphabet_synonyms = { + 'nuc': 'nuc', + 'nucleotide': 'nuc', + 'aa': 'aa', + 'aminoacid': 'aa', + 'nuc_nogap': 'nuc_nogap', + 'nucleotide_nogap': 'nuc_nogap', + 'aa_nogap': 'aa_nogap', + 'aminoacid_nogap': 'aa_nogap', + 'DNA': 'nuc', + 'DNA_nogap': 'nuc_nogap', +} +# fmt: off alphabets = { - "nuc": np.array(['A', 'C', 'G', 'T', '-']), - - "nuc_nogap":np.array(['A', 'C', 'G', 'T']), - - "aa": np.array(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', - 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', - 'W', 'Y', '*', '-']), - - "aa_nogap": np.array(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', - 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', - 'W', 'Y']) - } + 'nuc': np.array(['A', 'C', 'G', 'T', '-']), + 'nuc_nogap': np.array(['A', 'C', 'G', 'T']), + 'aa': np.array(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'Y', '*', '-']), + 'aa_nogap': np.array(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'Y']), +} +# fmt: on +# fmt: off profile_maps = { -'nuc':{ - 'A': np.array([1, 0, 0, 0, 0], dtype='float'), - 'C': np.array([0, 1, 0, 0, 0], dtype='float'), - 'G': np.array([0, 0, 1, 0, 0], dtype='float'), + 'nuc': { + 'A': np.array([1, 0, 0, 0, 0], dtype='float'), + 'C': np.array([0, 1, 0, 0, 0], dtype='float'), + 'G': np.array([0, 0, 1, 0, 0], dtype='float'), 'T': np.array([0, 0, 0, 1, 0], dtype='float'), '-': np.array([0, 0, 0, 0, 1], dtype='float'), 'N': np.array([1, 1, 1, 1, 1], dtype='float'), @@ -39,13 +43,13 @@ profile_maps = { 'D': np.array([1, 0, 1, 1, 0], dtype='float'), 'H': np.array([1, 1, 0, 1, 0], dtype='float'), 'B': np.array([0, 1, 1, 1, 0], dtype='float'), - 'V': np.array([1, 1, 1, 0, 0], dtype='float') + 'V': np.array([1, 1, 1, 0, 0], dtype='float') }, -'nuc_nogap':{ - 'A': np.array([1, 0, 0, 0], dtype='float'), - 'C': np.array([0, 1, 0, 0], dtype='float'), - 'G': np.array([0, 0, 1, 0], dtype='float'), + 'nuc_nogap': { + 'A': np.array([1, 0, 0, 0], dtype='float'), + 'C': np.array([0, 1, 0, 0], dtype='float'), + 'G': np.array([0, 0, 1, 0], dtype='float'), 'T': np.array([0, 0, 0, 1], dtype='float'), '-': np.array([1, 1, 1, 1], dtype='float'), # gaps are completely ignored in distance computations 'N': np.array([1, 1, 1, 1], dtype='float'), @@ -59,13 +63,13 @@ profile_maps = { 'D': np.array([1, 0, 1, 1], dtype='float'), 'H': np.array([1, 1, 0, 1], dtype='float'), 'B': np.array([0, 1, 1, 1], dtype='float'), - 'V': np.array([1, 1, 1, 0], dtype='float') + 'V': np.array([1, 1, 1, 0], dtype='float') }, -'aa':{ - 'A': np.array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Alanine Ala - 'C': np.array([0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Cysteine Cys - 'D': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Aspartic AciD Asp + 'aa': { + 'A': np.array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Alanine Ala + 'C': np.array([0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Cysteine Cys + 'D': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Aspartic AciD Asp 'E': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamic Acid Glu 'F': np.array([0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Phenylalanine Phe 'G': np.array([0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glycine Gly @@ -87,13 +91,13 @@ profile_maps = { '-': np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], dtype='float'), #gap 'X': np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype='float'), #not specified/any 'B': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Asparagine/Aspartic Acid Asx - 'Z': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamine/Glutamic Acid Glx + 'Z': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamine/Glutamic Acid Glx }, -'aa_nogap':{ - 'A': np.array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Alanine Ala - 'C': np.array([0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Cysteine Cys - 'D': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Aspartic AciD Asp + 'aa_nogap': { + 'A': np.array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Alanine Ala + 'C': np.array([0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Cysteine Cys + 'D': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Aspartic AciD Asp 'E': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamic Acid Glu 'F': np.array([0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Phenylalanine Phe 'G': np.array([0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Glycine Gly @@ -113,9 +117,10 @@ profile_maps = { 'Y': np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], dtype='float'), #Tyrosine Tyr 'X': np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype='float'), #not specified/any 'B': np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], dtype='float'), #Asparagine/Aspartic Acid Asx - 'Z': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamine/Glutamic Acid Glx + 'Z': np.array([0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype='float'), #Glutamine/Glutamic Acid Glx } } +# fmt: on def extend_profile(gtr, aln, logger=None): @@ -128,17 +133,17 @@ def extend_profile(gtr, aln, logger=None): if c not in gtr.profile_map: gtr.profile_map[c] = np.ones(gtr.n_states) if logger: - logger("WARNING: character %s is unknown. Treating it as missing information"%c,1,warn=True) + logger('WARNING: character %s is unknown. Treating it as missing information' % c, 1, warn=True) def guess_alphabet(aln): - total=0 + total = 0 nuc_count = 0 for seq in aln: total += len(seq) for n in np.array(list('acgtACGT-N')): - nuc_count += np.sum(seq==n) - if nuc_count>0.9*total: + nuc_count += np.sum(seq == n) + if nuc_count > 0.9 * total: return 'nuc' else: return 'aa' @@ -173,25 +178,26 @@ def seq2array(seq, word_length=1, convert_upper=False, fill_overhangs=False, amb elif isinstance(seq, SeqRecord.SeqRecord): seq_str = str(seq.seq) else: - raise TypeError("seq2array: sequence must be Bio.Seq, Bio.SeqRecord, or string. Got "+str(seq)) + raise TypeError('seq2array: sequence must be Bio.Seq, Bio.SeqRecord, or string. Got ' + str(seq)) if convert_upper: seq_str = seq_str.upper() - if word_length==1: + if word_length == 1: seq_array = np.array(list(seq_str)) else: - if len(seq_str)%word_length: - raise ValueError("sequence length has to be multiple of word length") - seq_array = np.array([seq_str[i*word_length:(i+1)*word_length] - for i in range(len(seq_str)/word_length)]) + if len(seq_str) % word_length: + raise ValueError('sequence length has to be multiple of word length') + seq_array = np.array( + [seq_str[i * word_length : (i + 1) * word_length] for i in range(len(seq_str) / word_length)] + ) # substitute overhanging unsequenced tails if fill_overhangs: gaps = np.where(seq_array != '-')[0] if len(gaps): - seq_array[:gaps[0]] = ambiguous - seq_array[gaps[-1]+1:] = ambiguous + seq_array[: gaps[0]] = ambiguous + seq_array[gaps[-1] + 1 :] = ambiguous else: seq_array[:] = ambiguous @@ -251,7 +257,7 @@ def prof2seq(profile, gtr, sample_from_prof=False, normalize=True, rng=None): rng = np.random.default_rng() # normalize profile such that probabilities at each site sum to one if normalize: - tmp_profile, pre=normalize_profile(profile, return_offset=False) + tmp_profile, pre = normalize_profile(profile, return_offset=False) else: tmp_profile = profile @@ -260,7 +266,7 @@ def prof2seq(profile, gtr, sample_from_prof=False, normalize=True, rng=None): if sample_from_prof: cumdis = tmp_profile.cumsum(axis=1).T randnum = rng.random(size=cumdis.shape[1]) - idx = np.argmax(cumdis>=randnum, axis=0) + idx = np.argmax(cumdis >= randnum, axis=0) else: idx = tmp_profile.argmax(axis=1) seq = gtr.alphabet[idx] # max LH over the alphabet @@ -270,7 +276,7 @@ def prof2seq(profile, gtr, sample_from_prof=False, normalize=True, rng=None): return seq, prof_values, idx -def normalize_profile(in_profile, log=False, return_offset = True): +def normalize_profile(in_profile, log=False, return_offset=True): """return a normalized version of a profile matrix Parameters @@ -295,6 +301,7 @@ def normalize_profile(in_profile, log=False, return_offset = True): tmp_prof = in_profile norm_vector = tmp_prof.sum(axis=1) - return (np.einsum('ai,a->ai',tmp_prof,1.0/norm_vector), - (np.log(norm_vector) + tmp_prefactor) if return_offset else None) - + return ( + np.einsum('ai,a->ai', tmp_prof, 1.0 / norm_vector), + (np.log(norm_vector) + tmp_prefactor) if return_offset else None, + ) diff --git a/treetime/seqgen.py b/treetime/seqgen.py index 0684870..3380abc 100644 --- a/treetime/seqgen.py +++ b/treetime/seqgen.py @@ -7,17 +7,15 @@ from .treeanc import TreeAnc class SeqGen(TreeAnc): - ''' + """ Evolve sequences along a given tree with a specific GTR model. This class inherits from TreeAnc. - ''' + """ def __init__(self, L, *args, **kwargs): - """Instantiate. Mandatory arguments are a the sequence length, tree and GTR model. - """ + """Instantiate. Mandatory arguments are a the sequence length, tree and GTR model.""" super(SeqGen, self).__init__(seq_len=L, compress=False, **kwargs) - def sample_from_profile(self, p): """returns a sequence sampled from a profile (column wise state probabilities) @@ -34,10 +32,9 @@ class SeqGen(TreeAnc): cum_p = p.cumsum(axis=1).T prand = self.rng.random(self.seq_len) - seq = self.gtr.alphabet[np.argmax(cum_p>prand, axis=0)] + seq = self.gtr.alphabet[np.argmax(cum_p > prand, axis=0)] return seq - def evolve(self, root_seq=None): """Evolve a root sequences along a tree. If no root sequences is provided, one will be sampled from the equilibrium @@ -53,10 +50,12 @@ class SeqGen(TreeAnc): if root_seq: self.tree.root.ancestral_sequence = seq2array(root_seq) else: - if len(self.gtr.Pi.shape)==2: + if len(self.gtr.Pi.shape) == 2: self.tree.root.ancestral_sequence = self.sample_from_profile(self.gtr.Pi.T) else: - self.tree.root.ancestral_sequence = self.sample_from_profile(np.repeat([self.gtr.Pi], self.seq_len, axis=0)) + self.tree.root.ancestral_sequence = self.sample_from_profile( + np.repeat([self.gtr.Pi], self.seq_len, axis=0) + ) # generate sequences in preorder for n in self.tree.get_nonterminals(order='preorder'): @@ -67,7 +66,6 @@ class SeqGen(TreeAnc): self.aln = self.get_aln() - def get_aln(self, internal=False): """assemble a multiple sequence alignment from the evolved sequences. Optionally in clude internal sequences @@ -88,8 +86,10 @@ class SeqGen(TreeAnc): tmp = [] for n in self.tree.find_clades(): if n.is_terminal() or internal: - tmp.append(SeqRecord.SeqRecord(id=n.name, name=n.name, description='', seq=Seq.Seq(''.join(n.ancestral_sequence.astype('U'))))) + tmp.append( + SeqRecord.SeqRecord( + id=n.name, name=n.name, description='', seq=Seq.Seq(''.join(n.ancestral_sequence.astype('U'))) + ) + ) return MultipleSeqAlignment(tmp) - - diff --git a/treetime/sequence_data.py b/treetime/sequence_data.py index 7f2fd21..73d39e2 100644 --- a/treetime/sequence_data.py +++ b/treetime/sequence_data.py @@ -7,10 +7,11 @@ from . import config as ttconf from . import MissingDataError from .seq_utils import seq2array, guess_alphabet, alphabets -string_types = [str] if sys.version_info[0]==3 else [str, unicode] + def simple_logger(*args, **kwargs): print(args) + class SequenceData(object): """docstring for SeqData @@ -55,9 +56,22 @@ class SequenceData(object): word_length : int length of state (typically 1 A,C,G,T, but could be 3 for codons) """ - def __init__(self, aln, ref=None, logger=None, convert_upper=True, - sequence_length=None, compress=True, word_length=1, sequence_type=None, - fill_overhangs=True, seq_multiplicity=None, ambiguous=None, **kwargs): + + def __init__( + self, + aln, + ref=None, + logger=None, + convert_upper=True, + sequence_length=None, + compress=True, + word_length=1, + sequence_type=None, + fill_overhangs=True, + seq_multiplicity=None, + ambiguous=None, + **kwargs, + ): """construct an sequence data object Parameters @@ -94,8 +108,12 @@ class SequenceData(object): self.is_sparse = None self.convert_upper = convert_upper self.compress = compress - self.seq_multiplicity = seq_multiplicity or {} # possibly a dict mapping sequences to their read cound/sample count - self.additional_constant_sites = kwargs['additional_constant_sites'] if 'additional_constant_sites' in kwargs else 0 + self.seq_multiplicity = ( + seq_multiplicity or {} + ) # possibly a dict mapping sequences to their read cound/sample count + self.additional_constant_sites = ( + kwargs['additional_constant_sites'] if 'additional_constant_sites' in kwargs else 0 + ) # if not specified, this will be set as the alignment_length or reference length self._full_length = None @@ -109,7 +127,6 @@ class SequenceData(object): self.ref = ref self.aln = aln - @property def aln(self): """ @@ -122,9 +139,8 @@ class SequenceData(object): """ return self._aln - @aln.setter - def aln(self,in_aln): + def aln(self, in_aln): """ Reads in the alignment (from a dict, MultipleSeqAlignment, or file, as necessary), sets tree-related parameters, and attaches sequences @@ -138,23 +154,24 @@ class SequenceData(object): """ # load alignment from file if necessary - from Bio.Align import MultipleSeqAlignment + self._aln, self.is_sparse = None, None if in_aln is None: return - elif type(in_aln) in [defaultdict, dict]: #if input is sparse (i.e. from VCF) + elif type(in_aln) in [defaultdict, dict]: # if input is sparse (i.e. from VCF) self._aln = in_aln self.is_sparse = True - elif type(in_aln) in string_types and isfile(in_aln): + elif isinstance(in_aln, str) and isfile(in_aln): if any([in_aln.lower().endswith(x) for x in ['.vcf', '.vcf.gz']]) and (self.ref is not None): from .vcf_utils import read_vcf + compress_seq = read_vcf(in_aln) in_aln = compress_seq['sequences'] else: for fmt in ['fasta', 'phylip-relaxed', 'nexus']: try: - in_aln=AlignIO.read(in_aln, fmt) + in_aln = AlignIO.read(in_aln, fmt) except: continue @@ -162,39 +179,52 @@ class SequenceData(object): # check whether the alignment is consistent with a nucleotide alignment. self._aln = {} for s in in_aln: - if s.id==s.name: - tmp_name = s.id + if s.id == s.name: + tmp_name = s.id elif '<unknown' in s.id: # use s.name if id is BioPython default (previous behavior) tmp_name = s.name - elif '<unknown' in s.name: # use s.id if s.name is BioPython default (change relative to previous, but what we want) + elif ( + '<unknown' in s.name + ): # use s.id if s.name is BioPython default (change relative to previous, but what we want) tmp_name = s.id else: tmp_name = s.name # otherwise use s.name (previous behavior) - self._aln[tmp_name] = seq2array(s, convert_upper=self.convert_upper, - fill_overhangs=self.fill_overhangs, ambiguous=self.ambiguous) + self._aln[tmp_name] = seq2array( + s, convert_upper=self.convert_upper, fill_overhangs=self.fill_overhangs, ambiguous=self.ambiguous + ) self.check_alphabet(list(self._aln.values())) self.is_sparse = False - self.logger("SequenceData: loaded alignment.",1) + self.logger('SequenceData: loaded alignment.', 1) elif type(in_aln) in [dict, defaultdict]: - self.logger("SequenceData: loaded sparse/vcf alignment.",1) + self.logger('SequenceData: loaded sparse/vcf alignment.', 1) self.check_alphabet([self.ref]) self.is_sparse = True self._aln = in_aln else: - raise MissingDataError("SequenceData: loading alignment failed... " + str(in_aln)) + raise MissingDataError('SequenceData: loading alignment failed... ' + str(in_aln)) if self.full_length: if self.is_sparse: - if self.full_length!=len(self.ref): - self.logger("SequenceData.aln: specified sequence length doesn't match reference length, ignoring sequence length.", 1, warn=True) + if self.full_length != len(self.ref): + self.logger( + "SequenceData.aln: specified sequence length doesn't match reference length, ignoring sequence length.", + 1, + warn=True, + ) self._full_length = len(self.ref) else: if self.full_length < in_aln.get_alignment_length(): - raise AttributeError("SequenceData.aln: specified sequence length is smaller than alignment length!") + raise AttributeError( + 'SequenceData.aln: specified sequence length is smaller than alignment length!' + ) elif self.full_length > in_aln.get_alignment_length(): - self.logger("SequenceData.aln: specified sequence length doesn't match alignment length. Treating difference as constant sites.", 2, warn=True) + self.logger( + "SequenceData.aln: specified sequence length doesn't match alignment length. Treating difference as constant sites.", + 2, + warn=True, + ) self.additional_constant_sites = max(0, self.full_length - in_aln.get_alignment_length()) else: if self.is_sparse: @@ -206,16 +236,13 @@ class SequenceData(object): self.make_compressed_alignment() - @property def full_length(self): - """length of the uncompressed sequence - """ + """length of the uncompressed sequence""" return self._full_length - @full_length.setter - def full_length(self,L): + def full_length(self, L): """set the length of the uncompressed sequence. its inverse 'one_mutation' is frequently used as a general length scale. This can't be changed once it is set. @@ -229,13 +256,12 @@ class SequenceData(object): if L: self._full_length = int(L) else: - self.logger("Alignment: one_mutation and sequence length can only be specified once!",1) + self.logger('Alignment: one_mutation and sequence length can only be specified once!', 1) @property def compressed_length(self): return self._compressed_length - @property def ref(self): """ @@ -244,7 +270,6 @@ class SequenceData(object): """ return self._ref - @ref.setter def ref(self, in_ref): """ @@ -253,18 +278,20 @@ class SequenceData(object): in_ref : file name, str, Bio.Seq.Seq, Bio.SeqRecord.SeqRecord reference sequence will read and stored a byte array """ - read_from_file=False + read_from_file = False if in_ref and isfile(in_ref): for fmt in ['fasta', 'genbank']: try: in_ref = SeqIO.read(in_ref, fmt) - self.logger("SequenceData: loaded reference sequence as %s format"%fmt,1) - read_from_file=True + self.logger('SequenceData: loaded reference sequence as %s format' % fmt, 1) + read_from_file = True break except: continue if not read_from_file: - raise TypeError('SequenceData.ref: reference sequence file %s could not be parsed, fasta and genbank formats are supported.') + raise TypeError( + 'SequenceData.ref: reference sequence file %s could not be parsed, fasta and genbank formats are supported.' + ) if in_ref: self._ref = seq2array(in_ref, fill_overhangs=False, word_length=self.word_length) @@ -276,21 +303,24 @@ class SequenceData(object): if mask is None: return self._multiplicity else: - return self._multiplicity*mask + return self._multiplicity * mask def check_alphabet(self, seqs): self.likely_alphabet = guess_alphabet(seqs) if self.sequence_type: - if self.likely_alphabet!=self.sequence_type: - if self.sequence_type=='nuc': - self.logger("POSSIBLE ERROR: This does not look like a nucleotide alignment!", 0, warn=True) - elif self.sequence_type=='aa': - self.logger("POSSIBLE ERROR: This looks like a nucleotide alignment, you indicated amino acids!", 0, warn=True) + if self.likely_alphabet != self.sequence_type: + if self.sequence_type == 'nuc': + self.logger('POSSIBLE ERROR: This does not look like a nucleotide alignment!', 0, warn=True) + elif self.sequence_type == 'aa': + self.logger( + 'POSSIBLE ERROR: This looks like a nucleotide alignment, you indicated amino acids!', + 0, + warn=True, + ) if self.ambiguous is None: - self.ambiguous = 'N' if self.likely_alphabet=='nuc' else 'X' - + self.ambiguous = 'N' if self.likely_alphabet == 'nuc' else 'X' def make_compressed_alignment(self): """ @@ -313,32 +343,33 @@ class SequenceData(object): sequences, L' - number of unique alignment patterns """ - if not self.compress: # + if not self.compress: # self._multiplicity = np.ones(self.full_length, dtype=float) self.full_to_compressed_sequence_map = np.arange(self.full_length) - self.compressed_to_full_sequence_map = {p:np.array([p]) for p in np.arange(self.full_length)} + self.compressed_to_full_sequence_map = {p: np.array([p]) for p in np.arange(self.full_length)} self._compressed_length = self._full_length self.compressed_alignment = self._aln return ttconf.SUCCESS - self.logger("SeqData: making compressed alignment...", 1) + self.logger('SeqData: making compressed alignment...', 1) # bind positions in full length sequence to that of the compressed (compressed) sequence self.full_to_compressed_sequence_map = np.zeros(self.full_length, dtype=int) # bind position in compressed sequence to the array of positions in full length sequence self.compressed_to_full_sequence_map = {} - #if alignment is sparse, don't iterate over all invarible sites. - #so pre-load alignment_patterns with the location of const sites! - #and get the sites that we want to iterate over only! + # if alignment is sparse, don't iterate over all invarible sites. + # so pre-load alignment_patterns with the location of const sites! + # and get the sites that we want to iterate over only! if self.is_sparse: from .vcf_utils import process_sparse_alignment + tmp = process_sparse_alignment(self.aln, self.ref, self.ambiguous) - compressed_aln_transpose = tmp["constant_columns"] - alignment_patterns = tmp["constant_patterns"] - variable_positions = tmp["variable_positions"] - self.inferred_const_sites = tmp["constant_up_to_ambiguous"] - self.nonref_positions = tmp["nonref_positions"] - else: # transpose real alignment, for ease of iteration + compressed_aln_transpose = tmp['constant_columns'] + alignment_patterns = tmp['constant_patterns'] + variable_positions = tmp['variable_positions'] + self.inferred_const_sites = tmp['constant_up_to_ambiguous'] + self.nonref_positions = tmp['nonref_positions'] + else: # transpose real alignment, for ease of iteration alignment_patterns = {} compressed_aln_transpose = [] aln_transpose = np.array([self.aln[k] for k in self.sequence_names]).T @@ -346,8 +377,9 @@ class SequenceData(object): for pi in variable_positions: if self.is_sparse: - pattern = np.array([self.aln[k][pi] if pi in self.aln[k] else self.ref[pi] - for k in self.sequence_names]) + pattern = np.array( + [self.aln[k][pi] if pi in self.aln[k] else self.ref[pi] for k in self.sequence_names] + ) else: # pylint: disable=unsubscriptable-object pattern = np.copy(aln_transpose[pi]) @@ -355,19 +387,19 @@ class SequenceData(object): # if the column contains only one state and ambiguous nucleotides, replace # those with the state in other strains right away unique_letters = list(np.unique(pattern)) - if len(unique_letters)==2 and self.ambiguous in unique_letters: - other = [c for c in unique_letters if c!=self.ambiguous][0] - #also replace in original pattern! + if len(unique_letters) == 2 and self.ambiguous in unique_letters: + other = [c for c in unique_letters if c != self.ambiguous][0] + # also replace in original pattern! pattern[pattern == self.ambiguous] = other unique_letters = [other] - str_pattern = "".join(pattern.astype('U')) + str_pattern = ''.join(pattern.astype('U')) # if there is a mutation in this column, give it its private pattern # this is required when sampling mutations from reconstructed profiles. # otherwise, all mutations corresponding to the same pattern will be coupled. # FIXME: this could be done more efficiently - if len(unique_letters)>1: - str_pattern += '_%d'%pi + if len(unique_letters) > 1: + str_pattern += '_%d' % pi # if the pattern is not yet seen, if str_pattern not in alignment_patterns: @@ -383,18 +415,23 @@ class SequenceData(object): # add constant alignment column not in the alignment. We don't know where they # are, so just add them to the end. First, determine sequence composition. if self.additional_constant_sites: - character_counts = {c:np.sum(aln_transpose==c) for c in alphabets[self.likely_alphabet+'_nogap'] - if c not in [self.ambiguous, '-']} + character_counts = { + c: np.sum(aln_transpose == c) + for c in alphabets[self.likely_alphabet + '_nogap'] + if c not in [self.ambiguous, '-'] + } total = np.sum(list(character_counts.values())) - additional_columns_per_character = [(c,int(np.round(self.additional_constant_sites*n/total))) - for c, n in character_counts.items()] + additional_columns_per_character = [ + (c, int(np.round(self.additional_constant_sites * n / total))) for c, n in character_counts.items() + ] columns_left = self.additional_constant_sites - pi = np.max(variable_positions)+1 - for c,n in additional_columns_per_character: - if c==additional_columns_per_character[-1][0]: # make sure all additions add up to the correct number to avoid rounding - n = columns_left - str_pattern = c*len(self.sequence_names) - pos_list = list(range(pi, pi+n)) + pi = np.max(variable_positions) + 1 + for c, n in additional_columns_per_character: + if c == additional_columns_per_character[-1][0]: + # make sure all additions add up to the correct number to avoid rounding + n = columns_left # noqa: PLW2901 + str_pattern = c * len(self.sequence_names) + pos_list = list(range(pi, pi + n)) if n: if str_pattern in alignment_patterns: alignment_patterns[str_pattern][1].extend(pos_list) @@ -404,31 +441,28 @@ class SequenceData(object): pi += n columns_left -= n - # count how many times each column is repeated in the real alignment self._multiplicity = np.zeros(len(alignment_patterns)) for p, pos in alignment_patterns.values(): - self._multiplicity[p]=len(pos) + self._multiplicity[p] = len(pos) # create the compressed alignment as a dictionary linking names to sequences tmp_compressed_alignment = np.array(compressed_aln_transpose).T # pylint: disable=unsubscriptable-object - self.compressed_alignment = {k: tmp_compressed_alignment[i] - for i,k in enumerate(self.sequence_names)} + self.compressed_alignment = {k: tmp_compressed_alignment[i] for i, k in enumerate(self.sequence_names)} # create map to compress a sequence for p, pos in alignment_patterns.values(): - self.full_to_compressed_sequence_map[np.array(pos)]=p + self.full_to_compressed_sequence_map[np.array(pos)] = p # create a map to reconstruct full sequence from the compressed (compressed) sequence for p, val in alignment_patterns.items(): - self.compressed_to_full_sequence_map[val[0]]=np.array(val[1], dtype=int) + self.compressed_to_full_sequence_map[val[0]] = np.array(val[1], dtype=int) - self.logger("SequenceData: constructed compressed alignment...", 1) + self.logger('SequenceData: constructed compressed alignment...', 1) self._compressed_length = len(self._multiplicity) return ttconf.SUCCESS - def full_to_sparse_sequence(self, sequence): """turn a sequence into a dictionary of differences from a reference sequence @@ -443,14 +477,15 @@ class SequenceData(object): dictionary of difference from reference """ if self.ref is None: - raise TypeError("SequenceData: sparse sequences can only be constructed when a reference sequence is defined") + raise TypeError( + 'SequenceData: sparse sequences can only be constructed when a reference sequence is defined' + ) if type(sequence) is not np.ndarray: aseq = seq2array(sequence, fill_overhangs=False) else: aseq = sequence - differences = np.where(self.ref!=aseq)[0] - return {p:aseq[p] for p in differences} - + differences = np.where(self.ref != aseq)[0] + return {p: aseq[p] for p in differences} def compressed_to_sparse_sequence(self, sequence): """turn a compressed sequence into a list of difference from a reference @@ -466,14 +501,15 @@ class SequenceData(object): dictionary of difference from reference """ if self.ref is None: - raise TypeError("SequenceData: sparse sequences can only be constructed when a reference sequence is defined") + raise TypeError( + 'SequenceData: sparse sequences can only be constructed when a reference sequence is defined' + ) compressed_nonref_positions = self.full_to_compressed_sequence_map[self.nonref_positions] compressed_nonref_values = sequence[compressed_nonref_positions] - mismatches = (compressed_nonref_values != self.ref[self.nonref_positions]) + mismatches = compressed_nonref_values != self.ref[self.nonref_positions] return dict(zip(self.nonref_positions[mismatches], compressed_nonref_values[mismatches])) - def compressed_to_full_sequence(self, sequence, include_additional_constant_sites=False, as_string=False): """expand a compressed sequence @@ -498,31 +534,35 @@ class SequenceData(object): tmp_seq = sequence[self.full_to_compressed_sequence_map[:L]] if as_string: - return "".join(tmp_seq.astype('U')) + return ''.join(tmp_seq.astype('U')) else: return tmp_seq def differences(self, seq1, seq2, seq1_compressed=True, seq2_compressed=True, mask=None): diffs = [] if self.is_sparse: - if seq1_compressed: seq1 = self.compressed_to_sparse_sequence(seq1) - if seq2_compressed: seq2 = self.compressed_to_sparse_sequence(seq2) + if seq1_compressed: + seq1 = self.compressed_to_sparse_sequence(seq1) + if seq2_compressed: + seq2 = self.compressed_to_sparse_sequence(seq2) for pos in set(seq1.keys()).union(seq2.keys()): ref_state = self.ref[pos] s1 = seq1.get(pos, ref_state) s2 = seq2.get(pos, ref_state) - if s1!=s2: - diffs.append((s1,pos,s2)) + if s1 != s2: + diffs.append((s1, pos, s2)) else: - if seq1_compressed: seq1 = self.compressed_to_full_sequence(seq1) - if seq2_compressed: seq2 = self.compressed_to_full_sequence(seq2) + if seq1_compressed: + seq1 = self.compressed_to_full_sequence(seq1) + if seq2_compressed: + seq2 = self.compressed_to_full_sequence(seq2) if mask is None: diff_pos = np.where(seq1 != seq2)[0] else: - diff_pos = np.where((seq1 != seq2)&(mask>0))[0] + diff_pos = np.where((seq1 != seq2) & (mask > 0))[0] for pos in diff_pos: diffs.append((seq1[pos], pos, seq2[pos])) - return sorted(diffs, key=lambda x:x[1]) + return sorted(diffs, key=lambda x: x[1]) diff --git a/treetime/treeanc.py b/treetime/treeanc.py index 5d641f8..1e6b0aa 100644 --- a/treetime/treeanc.py +++ b/treetime/treeanc.py @@ -4,24 +4,26 @@ import numpy as np from Bio import Phylo from Bio.Phylo.BaseTree import Clade from . import config as ttconf -from . import MissingDataError,UnknownMethodError +from . import MissingDataError, UnknownMethodError from .seq_utils import seq2prof, prof2seq, normalize_profile, extend_profile from .gtr import GTR from .gtr_site_specific import GTR_site_specific from .sequence_data import SequenceData + def compressed_sequence(node): if node.name in node.tt.data.compressed_alignment and (not node.tt.reconstructed_tip_sequences): return node.tt.data.compressed_alignment[node.name] elif hasattr(node, '_cseq'): return node._cseq - elif node.is_terminal(): # node without sequence when tip-reconstruction is off. + elif node.is_terminal(): # node without sequence when tip-reconstruction is off. return None elif hasattr(node, '_cseq'): return node._cseq else: raise ValueError('Ancestral sequences are not yet inferred') + def mutations(node): """ Get the mutations on a tree branch. Take compressed sequences from both sides @@ -31,13 +33,15 @@ def mutations(node): if node.up is None: return [] elif (not node.tt.reconstructed_tip_sequences) and node.name in node.tt.data.aln: - return node.tt.data.differences(node.up.cseq, node.tt.data.aln[node.name], seq2_compressed=False, mask=node.mask) + return node.tt.data.differences( + node.up.cseq, node.tt.data.aln[node.name], seq2_compressed=False, mask=node.mask + ) elif node.is_terminal() and (node.name not in node.tt.data.aln): return [] else: return node.tt.data.differences(node.up.cseq, node.cseq, mask=node.mask) -string_types = [str] if sys.version_info[0]==3 else [str, unicode] + Clade.sequence = property(lambda x: x.tt.sequence(x, as_string=False)) Clade.cseq = property(compressed_sequence) Clade.mutations = property(mutations) @@ -50,11 +54,25 @@ class TreeAnc(object): alignment, making ancestral state inference """ - def __init__(self, tree=None, aln=None, gtr=None, fill_overhangs=True, - ref=None, verbose = ttconf.VERBOSE, ignore_gaps=True, - convert_upper=True, seq_multiplicity=None, log=None, - compress=True, seq_len=None, ignore_missing_alns=False, - keep_node_order=False, rng_seed=None, **kwargs): + def __init__( + self, + tree=None, + aln=None, + gtr=None, + fill_overhangs=True, + ref=None, + verbose=ttconf.VERBOSE, + ignore_gaps=True, + convert_upper=True, + seq_multiplicity=None, + log=None, + compress=True, + seq_len=None, + ignore_missing_alns=False, + keep_node_order=False, + rng_seed=None, + **kwargs, + ): """ TreeAnc constructor. It prepares the tree, attaches sequences to the leaf nodes, and sets some configuration parameters. @@ -127,14 +145,14 @@ class TreeAnc(object): """ if tree is None: - raise TypeError("TreeAnc requires a tree!") + raise TypeError('TreeAnc requires a tree!') self.t_start = time.time() self.verbose = verbose self.log = log self.ok = False self.data = None self.log_messages = set() - self.logger("TreeAnc: set-up",1) + self.logger('TreeAnc: set-up', 1) self._internal_node_count = 0 self.use_mutation_length = False self.ignore_gaps = ignore_gaps @@ -146,7 +164,7 @@ class TreeAnc(object): self._tree = None self.tree = tree if tree is None: - raise MissingDataError("TreeAnc: tree loading failed! exiting") + raise MissingDataError('TreeAnc: tree loading failed! exiting') # set up GTR model self._gtr = None @@ -154,17 +172,23 @@ class TreeAnc(object): # set alignment and attach sequences to tree on success. # otherwise self.data.aln will be None - self.data = SequenceData(aln, ref=ref, logger=self.logger, compress=compress, - convert_upper=convert_upper, fill_overhangs=fill_overhangs, ambiguous=self.gtr.ambiguous, - sequence_length=seq_len) + self.data = SequenceData( + aln, + ref=ref, + logger=self.logger, + compress=compress, + convert_upper=convert_upper, + fill_overhangs=fill_overhangs, + ambiguous=self.gtr.ambiguous, + sequence_length=seq_len, + ) if self.gtr.is_site_specific and self.data.compress: - raise TypeError("TreeAnc: sequence compression and site specific gtr models are incompatible!" ) + raise TypeError('TreeAnc: sequence compression and site specific gtr models are incompatible!') if self.data.aln and self.tree: self._check_alignment_tree_gtr_consistency() - def logger(self, msg, level, warn=False, only_once=False): """ Print log message *msg* to stdout. @@ -189,20 +213,20 @@ class TreeAnc(object): self.log_messages.add(msg) - lw=80 - if level<self.verbose or (warn and level<=self.verbose): + lw = 80 + if level < self.verbose or (warn and level <= self.verbose): from textwrap import fill + dt = time.time() - self.t_start - outstr = '\n' if level<2 else '' - initial_indent = format(dt, '4.2f')+'\t' + level*'-' - subsequent_indent = " "*len(format(dt, '4.2f')) + "\t" + " "*level + outstr = '\n' if level < 2 else '' + initial_indent = format(dt, '4.2f') + '\t' + level * '-' + subsequent_indent = ' ' * len(format(dt, '4.2f')) + '\t' + ' ' * level outstr += fill(msg, width=lw, initial_indent=initial_indent, subsequent_indent=subsequent_indent) print(outstr, file=sys.stdout) - -#################################################################### -## SET-UP -#################################################################### + #################################################################### + ## SET-UP + #################################################################### @property def leaves_lookup(self): """ @@ -229,10 +253,9 @@ class TreeAnc(object): the new GTR object """ if not isinstance(value, (GTR, GTR_site_specific)): - raise TypeError("GTR instance expected") + raise TypeError('GTR instance expected') self._gtr = value - def set_gtr(self, in_gtr, **kwargs): """ Create new GTR model if needed, and set the model as an attribute of the @@ -255,30 +278,28 @@ class TreeAnc(object): self._gtr.logger = self.logger elif isinstance(in_gtr, (GTR, GTR_site_specific)): self._gtr = in_gtr - self._gtr.logger=self.logger + self._gtr.logger = self.logger else: self.logger("TreeAnc.gtr_setter: can't interpret GTR model", 1, warn=True) - raise TypeError("Cannot set GTR model in TreeAnc class: GTR or " - "string expected") + raise TypeError('Cannot set GTR model in TreeAnc class: GTR or string expected') if self._gtr.ambiguous is None: - self.fill_overhangs=False + self.fill_overhangs = False @property def aln(self): - ''' + """ :setter: Sets the alignment :getter: Returns the alignment - ''' + """ return self.data.aln @aln.setter - def aln(self,in_aln): - self.data.aln=in_aln + def aln(self, in_aln): + self.data.aln = in_aln if self.tree: self._check_alignment_tree_gtr_consistency() - @property def tree(self): """ @@ -291,31 +312,36 @@ class TreeAnc(object): """ return self._tree - @tree.setter def tree(self, in_tree): - ''' + """ assigns a tree to the internal self._tree variable. The tree is either loaded from file (if in_tree is str) or assigned (if in_tree is a Phylo.tree) - ''' + """ from os.path import isfile + self._tree = None if isinstance(in_tree, Phylo.BaseTree.Tree): self._tree = in_tree - elif type(in_tree) in string_types and isfile(in_tree): + elif isinstance(in_tree, str) and isfile(in_tree): try: - self._tree=Phylo.read(in_tree, 'newick') + self._tree = Phylo.read(in_tree, 'newick') except: fmt = in_tree.split('.')[-1] if fmt in ['nexus', 'nex']: - self._tree=Phylo.read(in_tree, 'nexus') + self._tree = Phylo.read(in_tree, 'nexus') else: - raise MissingDataError('TreeAnc: could not load tree, format needs to be nexus or newick! input was '+str(in_tree)) + raise MissingDataError( + 'TreeAnc: could not load tree, format needs to be nexus or newick! input was ' + str(in_tree) + ) else: - raise MissingDataError('TreeAnc: could not load tree! input was '+str(in_tree)) + raise MissingDataError('TreeAnc: could not load tree! input was ' + str(in_tree)) - if self._tree.count_terminals()<3: - raise MissingDataError('TreeAnc: tree in %s as only %d tips. Please check your tree!'%(str(in_tree), self._tree.count_terminals())) + if self._tree.count_terminals() < 3: + raise MissingDataError( + 'TreeAnc: tree in %s as only %d tips. Please check your tree!' + % (str(in_tree), self._tree.count_terminals()) + ) # remove all existing sequence attributes branch_length_warning = False @@ -323,15 +349,20 @@ class TreeAnc(object): node.branch_length = node.branch_length if node.branch_length else 0.0 if node.branch_length > ttconf.MAX_BRANCH_LENGTH: branch_length_warning = True - if hasattr(node, "_cseq"): - node.__delattr__("_cseq") + if hasattr(node, '_cseq'): + node.__delattr__('_cseq') node.original_length = node.branch_length node.mutation_length = node.branch_length if branch_length_warning: - self.logger("WARNING: TreeTime has detected branches that are longer than %d. " - "TreeTime requires trees where branch length is in units of average number " - "of nucleotide or protein substitutions per site. " - "Use on trees with longer branches is not recommended for ancestral sequence reconstruction."%(ttconf.MAX_BRANCH_LENGTH), 0, warn=True) + self.logger( + 'WARNING: TreeTime has detected branches that are longer than %d. ' + 'TreeTime requires trees where branch length is in units of average number ' + 'of nucleotide or protein substitutions per site. ' + 'Use on trees with longer branches is not recommended for ancestral sequence reconstruction.' + % (ttconf.MAX_BRANCH_LENGTH), + 0, + warn=True, + ) self.prepare_tree() if self.data: @@ -339,7 +370,6 @@ class TreeAnc(object): return ttconf.SUCCESS - @property def one_mutation(self): """ @@ -348,31 +378,32 @@ class TreeAnc(object): float inverse of the uncompressed sequence length - length scale for short branches """ - return 1.0/self.data.full_length if self.data.full_length else np.nan + return 1.0 / self.data.full_length if self.data.full_length else np.nan @one_mutation.setter - def one_mutation(self,om): - self.logger("TreeAnc: one_mutation can't be set",1) - + def one_mutation(self, om): + self.logger("TreeAnc: one_mutation can't be set", 1) @property def seq_len(self): return self.data.full_length - @property def sequence_length(self): return self.data.full_length - def _check_alignment_tree_gtr_consistency(self): - ''' + """ For each node of the tree, check whether there is a sequence available in the alignment and assign this sequence as a character array - ''' + """ if len(self.tree.get_terminals()) != len(self.data.aln): - self.logger(f"**WARNING: Number of tips in tree ({len(self.tree.get_terminals())}) differs from number of sequences in alignment ({len(self.data.aln)})**", 3, warn=True) - failed_leaves= 0 + self.logger( + f'**WARNING: Number of tips in tree ({len(self.tree.get_terminals())}) differs from number of sequences in alignment ({len(self.data.aln)})**', + 3, + warn=True, + ) + failed_leaves = 0 # loop over leaves and assign multiplicities of leaves (e.g. number of identical reads) for l in self.tree.get_terminals(): @@ -383,26 +414,35 @@ class TreeAnc(object): # loop over tree, and assign sequences for l in self.tree.find_clades(): - if hasattr(l, 'branch_state'): del l.branch_state + if hasattr(l, 'branch_state'): + del l.branch_state if l.name not in self.data.compressed_alignment and l.is_terminal(): - self.logger("***WARNING: TreeAnc._check_alignment_tree_gtr_consistency: NO SEQUENCE FOR LEAF: '%s'" % l.name, 0, warn=True) + self.logger( + "***WARNING: TreeAnc._check_alignment_tree_gtr_consistency: NO SEQUENCE FOR LEAF: '%s'" % l.name, + 0, + warn=True, + ) failed_leaves += 1 - if not self.ignore_missing_alns and failed_leaves > self.tree.count_terminals()/3: - raise MissingDataError("TreeAnc._check_alignment_tree_gtr_consistency: At least 30\\% terminal nodes cannot be assigned a sequence!\n" - "Are you sure the alignment belongs to the tree?") - else: # could not assign sequence for internal node - is OK + if not self.ignore_missing_alns and failed_leaves > self.tree.count_terminals() / 3: + raise MissingDataError( + 'TreeAnc._check_alignment_tree_gtr_consistency: At least 30\\% terminal nodes cannot be assigned a sequence!\n' + 'Are you sure the alignment belongs to the tree?' + ) + else: # could not assign sequence for internal node - is OK pass if failed_leaves: - self.logger("***WARNING: TreeAnc: %d nodes don't have a matching sequence in the alignment." - " POSSIBLE ERROR."%failed_leaves, 0, warn=True) + self.logger( + "***WARNING: TreeAnc: %d nodes don't have a matching sequence in the alignment." + ' POSSIBLE ERROR.' % failed_leaves, + 0, + warn=True, + ) # extend profile to contain additional unknown characters - extend_profile(self.gtr, [self.data.ref] if self.data.is_sparse - else self.data.aln.values(), logger=self.logger) + extend_profile(self.gtr, [self.data.ref] if self.data.is_sparse else self.data.aln.values(), logger=self.logger) self.ok = True - def prepare_tree(self): """ Set link to parent and calculate distance to root for all tree nodes. @@ -416,8 +456,7 @@ class TreeAnc(object): if not self.keep_node_order: self.tree.ladderize() self._prepare_nodes() - self._leaves_lookup = {node.name:node for node in self.tree.get_terminals()} - + self._leaves_lookup = {node.name: node for node in self.tree.get_terminals()} def _prepare_nodes(self): """ @@ -425,64 +464,58 @@ class TreeAnc(object): """ self.tree.root.up = None self.tree.root.tt = self - self.tree.root.bad_branch=self.tree.root.bad_branch if hasattr(self.tree.root, 'bad_branch') else False + self.tree.root.bad_branch = self.tree.root.bad_branch if hasattr(self.tree.root, 'bad_branch') else False name_set = {n.name for n in self.tree.find_clades() if n.name} internal_node_count = 0 - for clade in self.tree.get_nonterminals(order='preorder'): # parents first + for clade in self.tree.get_nonterminals(order='preorder'): # parents first if clade.name is None: - tmp = "NODE_" + format(internal_node_count, '07d') + tmp = 'NODE_' + format(internal_node_count, '07d') while tmp in name_set: internal_node_count += 1 - tmp = "NODE_" + format(internal_node_count, '07d') + tmp = 'NODE_' + format(internal_node_count, '07d') clade.name = tmp name_set.add(clade.name) - internal_node_count+=1 + internal_node_count += 1 for c in clade.clades: c.up = clade c.tt = self - - for clade in self.tree.find_clades(order='postorder'): # children first + for clade in self.tree.find_clades(order='postorder'): # children first if clade.is_terminal(): clade.bad_branch = clade.bad_branch if hasattr(clade, 'bad_branch') else False else: clade.bad_branch = all([c.bad_branch for c in clade]) - if not hasattr(clade, "mask"): + if not hasattr(clade, 'mask'): clade.mask = None self._calc_dist2root() self._internal_node_count = max(internal_node_count, self._internal_node_count) - def _calc_dist2root(self): """ For each node in the tree, set its root-to-node distance as dist2root attribute """ self.tree.root.dist2root = 0.0 - for clade in self.tree.get_nonterminals(order='preorder'): # parents first + for clade in self.tree.get_nonterminals(order='preorder'): # parents first for c in clade.clades: c.dist2root = clade.dist2root + c.mutation_length + #################################################################### + ## END SET-UP + #################################################################### + ################################################################### + ### ancestral reconstruction + ################################################################### + def reconstruct_anc(self, *args, **kwargs): + """Shortcut for :py:meth:`treetime.TreeAnc.infer_ancestral_sequences`""" + return self.infer_ancestral_sequences(*args, **kwargs) -#################################################################### -## END SET-UP -#################################################################### - - -################################################################### -### ancestral reconstruction -################################################################### - def reconstruct_anc(self,*args, **kwargs): - """Shortcut for :py:meth:`treetime.TreeAnc.infer_ancestral_sequences` - """ - return self.infer_ancestral_sequences(*args,**kwargs) - - - def infer_ancestral_sequences(self, method='probabilistic', infer_gtr=False, - marginal=False, reconstruct_tip_states=False, **kwargs): + def infer_ancestral_sequences( + self, method='probabilistic', infer_gtr=False, marginal=False, reconstruct_tip_states=False, **kwargs + ): """Reconstruct ancestral sequences Parameters @@ -508,9 +541,11 @@ class TreeAnc(object): """ if not self.ok: - raise MissingDataError("TreeAnc.infer_ancestral_sequences: ERROR, sequences or tree are missing") + raise MissingDataError('TreeAnc.infer_ancestral_sequences: ERROR, sequences or tree are missing') - self.logger("TreeAnc.infer_ancestral_sequences with method: %s, %s"%(method, 'marginal' if marginal else 'joint'), 1) + self.logger( + 'TreeAnc.infer_ancestral_sequences with method: %s, %s' % (method, 'marginal' if marginal else 'joint'), 1 + ) if method.lower() in ['ml', 'probabilistic']: if marginal: @@ -520,7 +555,11 @@ class TreeAnc(object): elif method.lower() in ['fitch', 'parsimony']: _ml_anc = self._fitch_anc else: - raise UnknownMethodError("Reconstruction method needs to be in ['ml', 'probabilistic', 'fitch', 'parsimony'], got '{}'".format(method)) + raise UnknownMethodError( + "Reconstruction method needs to be in ['ml', 'probabilistic', 'fitch', 'parsimony'], got '{}'".format( + method + ) + ) if infer_gtr: self.infer_gtr(marginal=marginal, **kwargs) @@ -530,10 +569,9 @@ class TreeAnc(object): return N_diff - -################################################################### -### FITCH -################################################################### + ################################################################### + ### FITCH + ################################################################### def _fitch_anc(self, **kwargs): """ Reconstruct ancestral states using Fitch's algorithm. It implements @@ -561,43 +599,43 @@ class TreeAnc(object): L = self.data.compressed_length - self.logger("TreeAnc._fitch_anc: Walking up the tree, creating the Fitch profiles",2) + self.logger('TreeAnc._fitch_anc: Walking up the tree, creating the Fitch profiles', 2) for node in self.tree.get_nonterminals(order='postorder'): node.state = [self._fitch_state(node, k) for k in range(L)] - ambs = [i for i in range(L) if len(self.tree.root.state[i])>1] + ambs = [i for i in range(L) if len(self.tree.root.state[i]) > 1] if len(ambs) > 0: for amb in ambs: - self.logger("Ambiguous state of the root sequence " - "in the position %d: %s, " - "choosing %s" % (amb, str(self.tree.root.state[amb]), - self.tree.root.state[amb][0]), 4) - self.tree.root._cseq = np.array([k[self.rng.integers(len(k)) if len(k)>1 else 0] - for k in self.tree.root.state]) - - - self.logger("TreeAnc._fitch_anc: Walking down the self.tree, generating sequences from the " - "Fitch profiles.", 2) + self.logger( + 'Ambiguous state of the root sequence ' + 'in the position %d: %s, ' + 'choosing %s' % (amb, str(self.tree.root.state[amb]), self.tree.root.state[amb][0]), + 4, + ) + self.tree.root._cseq = np.array( + [k[self.rng.integers(len(k)) if len(k) > 1 else 0] for k in self.tree.root.state] + ) + + self.logger('TreeAnc._fitch_anc: Walking down the self.tree, generating sequences from the Fitch profiles.', 2) N_diff = 0 for node in self.tree.get_nonterminals(order='preorder'): - if node.up != None: # not root - sequence = np.array([node.up._cseq[i] - if node.up._cseq[i] in node.state[i] - else node.state[i][0] for i in range(L)]) + if node.up != None: # not root + sequence = np.array( + [node.up._cseq[i] if node.up._cseq[i] in node.state[i] else node.state[i][0] for i in range(L)] + ) if self.sequence_reconstruction: - N_diff += (sequence!=node.cseq).sum() + N_diff += (sequence != node.cseq).sum() else: N_diff += L node._cseq = sequence - del node.state # no need to store Fitch states + del node.state # no need to store Fitch states self.sequence_reconstruction = 'parsimony' - self.logger("Done ancestral state reconstruction",3) + self.logger('Done ancestral state reconstruction', 3) return N_diff - def _fitch_state(self, node, pos): """ Determine the Fitch profile for a single character of the node's sequence. @@ -624,19 +662,19 @@ class TreeAnc(object): state = np.concatenate([k.state[pos] for k in node.clades]) return state - def _fitch_intersect(self, arrays): """ Find the intersection of any number of 1D arrays. Return the sorted, unique values that are in all of the input arrays. Adapted from numpy.lib.arraysetops.intersect1d """ + def pairwise_intersect(arr1, arr2): s2 = set(arr2) b3 = [val for val in arr1 if val in s2] return b3 - arrays = list(arrays) # allow assignment + arrays = list(arrays) # allow assignment N = len(arrays) while N > 1: arr1 = arrays.pop() @@ -647,11 +685,9 @@ class TreeAnc(object): return arrays[0] - - -################################################################### -### Maximum Likelihood -################################################################### + ################################################################### + ### Maximum Likelihood + ################################################################### def sequence_LH(self, pos=None, full_sequence=False): """return the likelihood of the observed sequences given the tree @@ -667,8 +703,8 @@ class TreeAnc(object): float likelihood """ - if not hasattr(self.tree, "total_sequence_LH"): - self.logger("TreeAnc.sequence_LH: you need to run marginal ancestral inference first!", 1) + if not hasattr(self.tree, 'total_sequence_LH'): + self.logger('TreeAnc.sequence_LH: you need to run marginal ancestral inference first!', 1) self.infer_ancestral_sequences(marginal=True) if pos is not None: if full_sequence: @@ -679,7 +715,6 @@ class TreeAnc(object): else: return self.tree.total_sequence_LH - def ancestral_likelihood(self): """ Calculate the likelihood of the given realization of the sequences in @@ -693,20 +728,20 @@ class TreeAnc(object): """ log_lh = np.zeros(self.data.multiplicity().shape[0]) for node in self.tree.find_clades(order='postorder'): - - if node.up is None: # root node + if node.up is None: # root node # 0-1 profile profile = seq2prof(node.cseq, self.gtr.profile_map) # get the probabilities to observe each nucleotide profile *= self.gtr.Pi profile = profile.sum(axis=1) - log_lh += np.log(profile) # product over all characters + log_lh += np.log(profile) # product over all characters continue t = node.branch_length - indices = np.array([(self.gtr.state_index[a], self.gtr.state_index[b]) - for a, b in zip(node.up.cseq, node.cseq)]) + indices = np.array( + [(self.gtr.state_index[a], self.gtr.state_index[b]) for a, b in zip(node.up.cseq, node.cseq)] + ) logQt = np.log(self.gtr.expQt(t)) lh = logQt[indices[:, 1], indices[:, 0]] @@ -720,13 +755,11 @@ class TreeAnc(object): The assigend values are to be used in the following ML analysis. """ if self.use_mutation_length: - return max(ttconf.MIN_BRANCH_LENGTH*self.one_mutation, node.mutation_length) + return max(ttconf.MIN_BRANCH_LENGTH * self.one_mutation, node.mutation_length) else: - return max(ttconf.MIN_BRANCH_LENGTH*self.one_mutation, node.branch_length) + return max(ttconf.MIN_BRANCH_LENGTH * self.one_mutation, node.branch_length) - - def _ml_anc_marginal(self, sample_from_profile=False, - reconstruct_tip_states=False, debug=False, **kwargs): + def _ml_anc_marginal(self, sample_from_profile=False, reconstruct_tip_states=False, debug=False, **kwargs): """ Perform marginal ML reconstruction of the ancestral states. In contrast to joint reconstructions, this needs to access the probabilities rather than only @@ -744,25 +777,26 @@ class TreeAnc(object): with the most likely definite character. Note that this will affect the mutations assigned to branches. """ - self.logger("TreeAnc._ml_anc_marginal: type of reconstruction: Marginal", 2) + self.logger('TreeAnc._ml_anc_marginal: type of reconstruction: Marginal', 2) self.postorder_traversal_marginal() # choose sequence characters from this profile. # treat root node differently to avoid piling up mutations on the longer branch - if sample_from_profile=='root': + if sample_from_profile == 'root': root_sample_from_profile = True other_sample_from_profile = False elif isinstance(sample_from_profile, bool): root_sample_from_profile = sample_from_profile other_sample_from_profile = sample_from_profile - self.total_LH_and_root_sequence(sample_from_profile=root_sample_from_profile, - assign_sequence=True) + self.total_LH_and_root_sequence(sample_from_profile=root_sample_from_profile, assign_sequence=True) # pylint: disable=possibly-used-before-assignment - N_diff = self.preorder_traversal_marginal(reconstruct_tip_states=reconstruct_tip_states, - sample_from_profile=other_sample_from_profile, - assign_sequence=True) - self.logger("TreeAnc._ml_anc_marginal: ...done", 3) + N_diff = self.preorder_traversal_marginal( + reconstruct_tip_states=reconstruct_tip_states, + sample_from_profile=other_sample_from_profile, # pylint: disable=possibly-used-before-assignment + assign_sequence=True, + ) + self.logger('TreeAnc._ml_anc_marginal: ...done', 3) self.reconstructed_tip_sequences = reconstruct_tip_states # do clean-up: @@ -777,109 +811,127 @@ class TreeAnc(object): self.sequence_reconstruction = 'marginal' return N_diff - def total_LH_and_root_sequence(self, sample_from_profile=False, assign_sequence=False): - self.logger("Computing root node sequence and total tree likelihood...",3) + self.logger('Computing root node sequence and total tree likelihood...', 3) # Msg to the root from the distant part (equ frequencies) - if len(self.gtr.Pi.shape)==1: + if len(self.gtr.Pi.shape) == 1: self.tree.root.marginal_outgroup_LH = np.repeat([self.gtr.Pi], self.data.compressed_length, axis=0) else: self.tree.root.marginal_outgroup_LH = np.copy(self.gtr.Pi.T) - self.tree.root.marginal_profile, pre = normalize_profile(self.tree.root.marginal_outgroup_LH*self.tree.root.marginal_subtree_LH) + self.tree.root.marginal_profile, pre = normalize_profile( + self.tree.root.marginal_outgroup_LH * self.tree.root.marginal_subtree_LH + ) marginal_LH_prefactor = self.tree.root.marginal_subtree_LH_prefactor + pre self.tree.sequence_LH = marginal_LH_prefactor - self.tree.total_sequence_LH = (self.tree.sequence_LH*self.data.multiplicity()).sum() + self.tree.total_sequence_LH = (self.tree.sequence_LH * self.data.multiplicity()).sum() self.tree.sequence_marginal_LH = self.tree.total_sequence_LH if assign_sequence: - seq, prof_vals, idxs = prof2seq(self.tree.root.marginal_profile, - self.gtr, sample_from_prof=sample_from_profile, - normalize=False, rng=self.rng) + seq, prof_vals, idxs = prof2seq( + self.tree.root.marginal_profile, + self.gtr, + sample_from_prof=sample_from_profile, + normalize=False, + rng=self.rng, + ) self.tree.root._cseq = seq - def postorder_traversal_marginal(self): L = self.data.compressed_length n_states = self.gtr.alphabet.shape[0] - self.logger("Attaching sequence profiles to leafs... ", 3) + self.logger('Attaching sequence profiles to leafs... ', 3) # set the leaves profiles. This doesn't ever need to be reassigned for leaves for leaf in self.tree.get_terminals(): - if not hasattr(leaf, "marginal_subtree_LH"): + if not hasattr(leaf, 'marginal_subtree_LH'): if leaf.name in self.data.compressed_alignment: leaf.marginal_subtree_LH = seq2prof(self.data.compressed_alignment[leaf.name], self.gtr.profile_map) else: leaf.marginal_subtree_LH = np.ones((L, n_states)) - if not hasattr(leaf, "marginal_subtree_LH_prefactor"): + if not hasattr(leaf, 'marginal_subtree_LH_prefactor'): leaf.marginal_subtree_LH_prefactor = np.zeros(L) - self.logger("Postorder: computing likelihoods... ", 3) + self.logger('Postorder: computing likelihoods... ', 3) # propagate leaves --> root, set the marginal-likelihood messages - for node in self.tree.get_nonterminals(order='postorder'): #leaves -> root + for node in self.tree.get_nonterminals(order='postorder'): # leaves -> root # regardless of what was before, set the profile to ones - tmp_log_subtree_LH = np.zeros((L,n_states), dtype=float) + tmp_log_subtree_LH = np.zeros((L, n_states), dtype=float) node.marginal_subtree_LH_prefactor = np.zeros(L, dtype=float) for ch in node.clades: if ch.mask is None: - ch.marginal_log_Lx = self.gtr.propagate_profile(ch.marginal_subtree_LH, - self._branch_length_to_gtr(ch), return_log=True) # raw prob to transfer prob up + ch.marginal_log_Lx = self.gtr.propagate_profile( + ch.marginal_subtree_LH, self._branch_length_to_gtr(ch), return_log=True + ) # raw prob to transfer prob up else: - ch.marginal_log_Lx = (self.gtr.propagate_profile(ch.marginal_subtree_LH, - self._branch_length_to_gtr(ch), return_log=True).T*ch.mask).T # raw prob to transfer prob up + ch.marginal_log_Lx = ( + self.gtr.propagate_profile( + ch.marginal_subtree_LH, self._branch_length_to_gtr(ch), return_log=True + ).T + * ch.mask + ).T # raw prob to transfer prob up tmp_log_subtree_LH += ch.marginal_log_Lx node.marginal_subtree_LH_prefactor += ch.marginal_subtree_LH_prefactor node.marginal_subtree_LH, offset = normalize_profile(tmp_log_subtree_LH, log=True) - node.marginal_subtree_LH_prefactor += offset # and store log-prefactor - + node.marginal_subtree_LH_prefactor += offset # and store log-prefactor - def preorder_traversal_marginal(self, reconstruct_tip_states=False, sample_from_profile=False, assign_sequence=False): - self.logger("Preorder: computing marginal profiles...",3) + def preorder_traversal_marginal( + self, reconstruct_tip_states=False, sample_from_profile=False, assign_sequence=False + ): + self.logger('Preorder: computing marginal profiles...', 3) # propagate root -->> leaves, reconstruct the internal node sequences # provided the upstream message + the message from the complementary subtree N_diff = 0 for node in self.tree.find_clades(order='preorder'): - if node.up is None: # skip if node is root + if node.up is None: # skip if node is root continue - if hasattr(node, 'branch_state'): del node.branch_state + if hasattr(node, 'branch_state'): + del node.branch_state # integrate the information coming from parents with the information # of all children my multiplying it to the prev computed profile - node.marginal_outgroup_LH, pre = normalize_profile(np.log(np.maximum(ttconf.TINY_NUMBER, node.up.marginal_profile)) - node.marginal_log_Lx, - log=True, return_offset=False) - if node.is_terminal() and (not reconstruct_tip_states): # skip remainder unless leaves are to be reconstructed + node.marginal_outgroup_LH, pre = normalize_profile( + np.log(np.maximum(ttconf.TINY_NUMBER, node.up.marginal_profile)) - node.marginal_log_Lx, + log=True, + return_offset=False, + ) + if node.is_terminal() and ( + not reconstruct_tip_states + ): # skip remainder unless leaves are to be reconstructed continue - tmp_msg_from_parent = self.gtr.evolve(node.marginal_outgroup_LH, - self._branch_length_to_gtr(node), return_log=False) + tmp_msg_from_parent = self.gtr.evolve( + node.marginal_outgroup_LH, self._branch_length_to_gtr(node), return_log=False + ) if node.mask is None: - node.marginal_profile, pre = normalize_profile(node.marginal_subtree_LH * tmp_msg_from_parent, return_offset=False) + node.marginal_profile, pre = normalize_profile( + node.marginal_subtree_LH * tmp_msg_from_parent, return_offset=False + ) else: - node.marginal_profile, pre = normalize_profile(node.marginal_subtree_LH * (node.mask*tmp_msg_from_parent.T + (1.0-node.mask)).T, - return_offset=False) + node.marginal_profile, pre = normalize_profile( + node.marginal_subtree_LH * (node.mask * tmp_msg_from_parent.T + (1.0 - node.mask)).T, + return_offset=False, + ) # choose sequence based maximal marginal LH. if assign_sequence: - seq, prof_vals, idxs = prof2seq(node.marginal_profile, self.gtr, - sample_from_prof=sample_from_profile, - normalize=False, rng=self.rng) + seq, prof_vals, idxs = prof2seq( + node.marginal_profile, self.gtr, sample_from_prof=sample_from_profile, normalize=False, rng=self.rng + ) if self.sequence_reconstruction: - N_diff += (seq!=node.cseq).sum() + N_diff += (seq != node.cseq).sum() else: N_diff += self.data.compressed_length - #assign new sequence + # assign new sequence node._cseq = seq return N_diff - - def _ml_anc_joint(self, sample_from_profile=False, - reconstruct_tip_states=False, debug=False, **kwargs): - + def _ml_anc_joint(self, sample_from_profile=False, reconstruct_tip_states=False, debug=False, **kwargs): """ Perform joint ML reconstruction of the ancestral states. In contrast to marginal reconstructions, this only needs to compare and multiply LH and @@ -897,18 +949,19 @@ class TreeAnc(object): assigned to branches. """ - N_diff = 0 # number of sites differ from perv reconstruction + N_diff = 0 # number of sites differ from perv reconstruction L = self.data.compressed_length n_states = self.gtr.alphabet.shape[0] - self.logger("TreeAnc._ml_anc_joint: type of reconstruction: Joint", 2) + self.logger('TreeAnc._ml_anc_joint: type of reconstruction: Joint', 2) - self.logger("TreeAnc._ml_anc_joint: Walking up the tree, computing likelihoods... ", 3) + self.logger('TreeAnc._ml_anc_joint: Walking up the tree, computing likelihoods... ', 3) # for the internal nodes, scan over all states j of this node, maximize the likelihood for node in self.tree.find_clades(order='postorder'): - if hasattr(node, 'branch_state'): del node.branch_state + if hasattr(node, 'branch_state'): + del node.branch_state if node.up is None: - node.joint_Cx=None # not needed for root + node.joint_Cx = None # not needed for root continue branch_len = self._branch_length_to_gtr(node) @@ -939,15 +992,17 @@ class TreeAnc(object): # get the best state of the current node # and compute the likelihood of this state # preallocate storage - node.joint_Lx = np.zeros((L, n_states)) # likelihood array + node.joint_Lx = np.zeros((L, n_states)) # likelihood array node.joint_Cx = np.zeros((L, n_states), dtype=np.uint16) # max LH indices for char_i, char in enumerate(self.gtr.alphabet): # Pij(i) * L_ch(i) for given parent state j # if the node has a mask, P_ij is uniformly 1 at masked positions as no info is propagated if node.mask is None: - msg_to_parent = (log_transitions[:,char_i].T + msg_from_children) + msg_to_parent = log_transitions[:, char_i].T + msg_from_children else: - msg_to_parent = ((log_transitions[:,char_i]*np.repeat([node.mask], self.gtr.n_states, axis=0).T) + msg_from_children) + msg_to_parent = ( + log_transitions[:, char_i] * np.repeat([node.mask], self.gtr.n_states, axis=0).T + ) + msg_from_children # For this parent state, choose the best state of the current node node.joint_Cx[:, char_i] = msg_to_parent.argmax(axis=1) @@ -958,34 +1013,37 @@ class TreeAnc(object): node.joint_Lx[:, char_i] *= node.mask # root node profile = likelihood of the total tree - msg_from_children = np.sum(np.stack([c.joint_Lx for c in self.tree.root], axis = 0), axis=0) + msg_from_children = np.sum(np.stack([c.joint_Lx for c in self.tree.root], axis=0), axis=0) # Pi(i) * Prod_ch Lch(i) self.tree.root.joint_Lx = msg_from_children + np.log(self.gtr.Pi).T normalized_profile = (self.tree.root.joint_Lx.T - self.tree.root.joint_Lx.max(axis=1)).T # choose sequence characters from this profile. # treat root node differently to avoid piling up mutations on the longer branch - if sample_from_profile=='root': + if sample_from_profile == 'root': root_sample_from_profile = True elif isinstance(sample_from_profile, bool): root_sample_from_profile = sample_from_profile - seq, anc_lh_vals, idxs = prof2seq(np.exp(normalized_profile), self.gtr, - sample_from_prof = root_sample_from_profile, - rng=self.rng) + seq, anc_lh_vals, idxs = prof2seq( + np.exp(normalized_profile), + self.gtr, + sample_from_prof=root_sample_from_profile, # pylint: disable=possibly-used-before-assignment + rng=self.rng, + ) # compute the likelihood of the most probable root sequence self.tree.sequence_LH = np.choose(idxs, self.tree.root.joint_Lx.T) - self.tree.sequence_joint_LH = (self.tree.sequence_LH*self.data.multiplicity()).sum() + self.tree.sequence_joint_LH = (self.tree.sequence_LH * self.data.multiplicity()).sum() self.tree.root._cseq = seq self.tree.root.seq_idx = idxs - self.logger("TreeAnc._ml_anc_joint: Walking down the tree, computing maximum likelihood sequences...",3) + self.logger('TreeAnc._ml_anc_joint: Walking down the tree, computing maximum likelihood sequences...', 3) # for each node, resolve the conditioning on the parent node nodes_to_reconstruct = self.tree.get_nonterminals(order='preorder') if reconstruct_tip_states: nodes_to_reconstruct += self.tree.get_terminals() - #TODO: Should we add tips without sequence here? + # TODO: Should we add tips without sequence here? for node in nodes_to_reconstruct: # root node has no mutations, everything else has been already set @@ -998,13 +1056,13 @@ class TreeAnc(object): # reconstruct seq, etc tmp_sequence = np.choose(node.seq_idx, self.gtr.alphabet) if self.sequence_reconstruction: - N_diff += (tmp_sequence!=node.cseq).sum() + N_diff += (tmp_sequence != node.cseq).sum() else: N_diff += L node._cseq = tmp_sequence - self.logger("TreeAnc._ml_anc_joint: ...done", 3) + self.logger('TreeAnc._ml_anc_joint: ...done', 3) self.reconstructed_tip_sequences = reconstruct_tip_states # do clean-up @@ -1012,7 +1070,7 @@ class TreeAnc(object): for node in self.tree.find_clades(order='preorder'): # Check for the likelihood matrix, since we might have cleaned # it up earlier. - if hasattr(node, "joint_Lx"): + if hasattr(node, 'joint_Lx'): del node.joint_Lx del node.joint_Cx if hasattr(node, 'seq_idx'): @@ -1021,10 +1079,9 @@ class TreeAnc(object): self.sequence_reconstruction = 'joint' return N_diff - -############################################################### -### sequence and mutation storing -############################################################### + ############################################################### + ### sequence and mutation storing + ############################################################### def get_branch_mutation_matrix(self, node, full_sequence=False): """uses results from marginal ancestral inference to return a joint distribution of the sequence states at both ends of the branch. @@ -1043,18 +1100,18 @@ class TreeAnc(object): numpy.array an Lxqxq stack of matrices (q=alphabet size, L (compressed)sequence length) """ - pp,pc = self.marginal_branch_profile(node) + pp, pc = self.marginal_branch_profile(node) # calculate pc_i [e^Qt]_ij pp_j for each site expQt = self.gtr.expQt(self._branch_length_to_gtr(node)) + ttconf.SUPERTINY_NUMBER - if len(expQt.shape)==3: # site specific model + if len(expQt.shape) == 3: # site specific model mut_matrix_stack = np.einsum('ai,aj,ija->aij', pc, pp, expQt) else: mut_matrix_stack = np.einsum('ai,aj,ij->aij', pc, pp, expQt) # normalize this distribution normalizer = mut_matrix_stack.sum(axis=2).sum(axis=1) - mut_matrix_stack = np.einsum('aij,a->aij', mut_matrix_stack, 1.0/normalizer) + mut_matrix_stack = np.einsum('aij,a->aij', mut_matrix_stack, 1.0 / normalizer) # expand to full sequence if requested if full_sequence: @@ -1062,9 +1119,8 @@ class TreeAnc(object): else: return mut_matrix_stack - def marginal_branch_profile(self, node): - ''' + """ calculate the marginal distribution of sequence states on both ends of the branch leading to node, @@ -1078,18 +1134,17 @@ class TreeAnc(object): pp, pc : Pair of vectors (profile parent, pp) and (profile child, pc) that are of shape (L,n) where L is sequence length and n is alphabet size. note that this correspond to the compressed sequences. - ''' + """ parent = node.up if parent is None: raise Exception("Branch profiles can't be calculated for the root!") if not hasattr(node, 'marginal_outgroup_LH'): - raise Exception("marginal ancestral inference needs to be performed first!") + raise Exception('marginal ancestral inference needs to be performed first!') pc = node.marginal_subtree_LH pp = node.marginal_outgroup_LH return pp, pc - def add_branch_state(self, node): """add a dictionary to the node containing tuples of state pairs and a list of their number across the branch @@ -1100,15 +1155,16 @@ class TreeAnc(object): attaces attribute :branch_state: """ seq_pairs, multiplicity = self.gtr.state_pair( - node.up.cseq, node.cseq, - pattern_multiplicity = self.data.multiplicity(mask=node.mask), - ignore_gaps = self.ignore_gaps) - node.branch_state = {'pair':seq_pairs, 'multiplicity':multiplicity} - - -################################################################### -### Branch length optimization -################################################################### + node.up.cseq, + node.cseq, + pattern_multiplicity=self.data.multiplicity(mask=node.mask), + ignore_gaps=self.ignore_gaps, + ) + node.branch_state = {'pair': seq_pairs, 'multiplicity': multiplicity} + + ################################################################### + ### Branch length optimization + ################################################################### def optimize_branch_len(self, **kwargs): """Deprecated in favor of 'optimize_branch_lengths_joint'""" return self.optimize_branch_lengths_joint(**kwargs) @@ -1142,32 +1198,42 @@ class TreeAnc(object): """ - self.logger("TreeAnc.optimize_branch_length: running branch length optimization using jointML ancestral sequences",1) + self.logger( + 'TreeAnc.optimize_branch_length: running branch length optimization using jointML ancestral sequences', 1 + ) if (self.tree is None) or (self.data.aln is None): - raise MissingDataError("TreeAnc.optimize_branch_length: ERROR, alignment or tree are missing.") + raise MissingDataError('TreeAnc.optimize_branch_length: ERROR, alignment or tree are missing.') store_old_dist = kwargs['store_old'] if 'store_old' in kwargs else False max_bl = 0 for node in self.tree.find_clades(order='postorder'): - if node.up is None: continue # this is the root + if node.up is None: + continue # this is the root if store_old_dist: node._old_length = node.branch_length - new_len = max(0,self.optimal_branch_length(node)) + new_len = max(0, self.optimal_branch_length(node)) - self.logger("Optimization results: old_len=%.4e, new_len=%.4e" - " Updating branch length..."%(node.branch_length, new_len), 5) + self.logger( + 'Optimization results: old_len=%.4e, new_len=%.4e' + ' Updating branch length...' % (node.branch_length, new_len), + 5, + ) node.branch_length = new_len - node.mutation_length=new_len + node.mutation_length = new_len max_bl = max(max_bl, new_len) - if max_bl>0.15: - self.logger("TreeAnc.optimize_branch_lengths_joint: THIS TREE HAS LONG BRANCHES." - " \n\t ****TreeTime's JOINT IS NOT DESIGNED TO OPTIMIZE LONG BRANCHES." - " \n\t ****PLEASE OPTIMIZE BRANCHES USING: " - " \n\t ****branch_length_mode='input' or 'marginal'", 0, warn=True) + if max_bl > 0.15: + self.logger( + 'TreeAnc.optimize_branch_lengths_joint: THIS TREE HAS LONG BRANCHES.' + " \n\t ****TreeTime's JOINT IS NOT DESIGNED TO OPTIMIZE LONG BRANCHES." + ' \n\t ****PLEASE OPTIMIZE BRANCHES USING: ' + " \n\t ****branch_length_mode='input' or 'marginal'", + 0, + warn=True, + ) # as branch lengths changed, the distance to root etc need to be recalculated self.tree.root.up = None @@ -1175,9 +1241,8 @@ class TreeAnc(object): self._prepare_nodes() return ttconf.SUCCESS - def optimal_branch_length(self, node): - ''' + """ Calculate optimal branch length given the sequences of node and parent Parameters @@ -1190,21 +1255,22 @@ class TreeAnc(object): new_len : float Optimal length of the given branch - ''' + """ if node.up is None: return self.one_mutation if not hasattr(node, 'branch_state'): if node.cseq is None and node.is_terminal(): - raise MissingDataError("TreeAnc.optimal_branch_length: terminal node alignments required; sequence is missing for leaf: '%s'. " - "Missing terminal sequences can be inferred from sister nodes by rerunning with `reconstruct_tip_states=True` or `--reconstruct-tip-states`" % node.name) + raise MissingDataError( + "TreeAnc.optimal_branch_length: terminal node alignments required; sequence is missing for leaf: '%s'. " + 'Missing terminal sequences can be inferred from sister nodes by rerunning with `reconstruct_tip_states=True` or `--reconstruct-tip-states`' + % node.name + ) self.add_branch_state(node) - return self.gtr.optimal_t_compressed(node.branch_state['pair'], - node.branch_state['multiplicity']) - + return self.gtr.optimal_t_compressed(node.branch_state['pair'], node.branch_state['multiplicity']) def optimal_marginal_branch_length(self, node, tol=1e-10): - ''' + """ calculate the marginal distribution of sequence states on both ends of the branch leading to node, @@ -1218,53 +1284,62 @@ class TreeAnc(object): branch_length : float branch length of the branch leading to the node. note: this can be unstable on iteration - ''' + """ if node.up is None: return self.one_mutation else: pp, pc = self.marginal_branch_profile(node) - return self.gtr.optimal_t_compressed((pp, pc), self.data.multiplicity(mask=node.mask), profiles=True, tol=tol) - + return self.gtr.optimal_t_compressed( + (pp, pc), self.data.multiplicity(mask=node.mask), profiles=True, tol=tol + ) - def optimize_tree_marginal(self, max_iter=10, infer_gtr=False, pc=1.0, damping=0.75, - LHtol=0.1, site_specific_gtr=False, **kwargs): + def optimize_tree_marginal( + self, max_iter=10, infer_gtr=False, pc=1.0, damping=0.75, LHtol=0.1, site_specific_gtr=False, **kwargs + ): self.infer_ancestral_sequences(marginal=True, **kwargs) oldLH = self.sequence_LH() - self.logger("TreeAnc.optimize_tree_marginal: initial, LH=%1.2f, total branch_length %1.4f"% - (oldLH, self.tree.total_branch_length()), 2) + self.logger( + 'TreeAnc.optimize_tree_marginal: initial, LH=%1.2f, total branch_length %1.4f' + % (oldLH, self.tree.total_branch_length()), + 2, + ) for i in range(max_iter): if infer_gtr: self.infer_gtr(site_specific=site_specific_gtr, marginal=True, normalized_rate=True, pc=pc) self.infer_ancestral_sequences(marginal=True, **kwargs) old_bl = self.tree.total_branch_length() - tol = 1e-8 + 0.01**(i+1) + tol = 1e-8 + 0.01 ** (i + 1) for n in self.tree.find_clades(): if n.up is None: continue - if n.up.up is None and len(n.up.clades)==2: + if n.up.up is None and len(n.up.clades) == 2: # children of a bifurcating root! n1, n2 = n.up.clades - total_bl = n1.branch_length+n2.branch_length - bl_ratio = n1.branch_length/total_bl + total_bl = n1.branch_length + n2.branch_length + bl_ratio = n1.branch_length / total_bl prof_c = n1.marginal_subtree_LH - prof_p = normalize_profile(n2.marginal_subtree_LH*self.tree.root.marginal_outgroup_LH)[0] + prof_p = normalize_profile(n2.marginal_subtree_LH * self.tree.root.marginal_outgroup_LH)[0] if n1.mask is None or n2.mask is None: - new_bl = self.gtr.optimal_t_compressed((prof_p, prof_c), self.data.multiplicity(), profiles=True, tol=tol) + new_bl = self.gtr.optimal_t_compressed( + (prof_p, prof_c), self.data.multiplicity(), profiles=True, tol=tol + ) else: - new_bl = self.gtr.optimal_t_compressed((prof_p, prof_c), self.data.multiplicity(mask=n1.mask*n2.mask), profiles=True, tol=tol) - update_val = new_bl*(1-damping**(i+1)) + total_bl*damping**(i+1) + new_bl = self.gtr.optimal_t_compressed( + (prof_p, prof_c), self.data.multiplicity(mask=n1.mask * n2.mask), profiles=True, tol=tol + ) + update_val = new_bl * (1 - damping ** (i + 1)) + total_bl * damping ** (i + 1) - n1.branch_length = update_val*bl_ratio - n2.branch_length = update_val*(1-bl_ratio) + n1.branch_length = update_val * bl_ratio + n2.branch_length = update_val * (1 - bl_ratio) n1.mutation_length = n1.branch_length n2.mutation_length = n2.branch_length else: new_val = self.optimal_marginal_branch_length(n, tol=tol) - update_val = new_val*(1-damping**(i+1)) + n.branch_length*damping**(i+1) + update_val = new_val * (1 - damping ** (i + 1)) + n.branch_length * damping ** (i + 1) n.branch_length = update_val n.mutation_length = n.branch_length @@ -1274,30 +1349,49 @@ class TreeAnc(object): deltaLH = LH - oldLH oldLH = LH dbl = self.tree.total_branch_length() - old_bl - self.logger("TreeAnc.optimize_tree_marginal: iteration %d, LH=%1.2f (%1.2f), delta branch_length=%1.4f, total branch_length %1.4f"% - (i, LH, deltaLH, dbl, self.tree.total_branch_length()), 2) - if deltaLH<LHtol: - self.logger("TreeAnc.optimize_tree_marginal: deltaLH=%f, stopping iteration."%deltaLH,1) + self.logger( + 'TreeAnc.optimize_tree_marginal: iteration %d, LH=%1.2f (%1.2f), delta branch_length=%1.4f, total branch_length %1.4f' + % (i, LH, deltaLH, dbl, self.tree.total_branch_length()), + 2, + ) + if deltaLH < LHtol: + self.logger('TreeAnc.optimize_tree_marginal: deltaLH=%f, stopping iteration.' % deltaLH, 1) break return ttconf.SUCCESS - - def optimize_sequences_and_branch_length(self,*args, **kwargs): + def optimize_sequences_and_branch_length(self, *args, **kwargs): """This method is a shortcut for :py:meth:`treetime.TreeAnc.optimize_tree` Deprecated in favor of 'optimize_tree' """ - self.logger("Deprecation warning: 'optimize_sequences_and_branch_length' will be removed and replaced by 'optimize_tree'!", 1, warn=True) - self.optimize_tree(*args,**kwargs) - - def optimize_seq_and_branch_len(self,*args, **kwargs): + self.logger( + "Deprecation warning: 'optimize_sequences_and_branch_length' will be removed and replaced by 'optimize_tree'!", + 1, + warn=True, + ) + self.optimize_tree(*args, **kwargs) + + def optimize_seq_and_branch_len(self, *args, **kwargs): """This method is a shortcut for :py:meth:`treetime.TreeAnc.optimize_tree` Deprecated in favor of 'optimize_tree' """ - self.logger("Deprecation warning: 'optimize_seq_and_branch_len' will be removed and replaced by 'optimize_tree'!", 1, warn=True) - self.optimize_tree(*args,**kwargs) - - def optimize_tree(self,prune_short=True, marginal_sequences=False, branch_length_mode='joint', - max_iter=5, infer_gtr=False, pc=1.0, method_anc='probabilistic', **kwargs): + self.logger( + "Deprecation warning: 'optimize_seq_and_branch_len' will be removed and replaced by 'optimize_tree'!", + 1, + warn=True, + ) + self.optimize_tree(*args, **kwargs) + + def optimize_tree( + self, + prune_short=True, + marginal_sequences=False, + branch_length_mode='joint', + max_iter=5, + infer_gtr=False, + pc=1.0, + method_anc='probabilistic', + **kwargs, + ): """ Iteratively set branch lengths and reconstruct ancestral sequences until the values of either former or latter do not change. The algorithm assumes @@ -1336,70 +1430,74 @@ class TreeAnc(object): Which method should be used to reconstruct ancestral sequences. Supported values are "parsimony", "fitch", "probabilistic" and "ml" """ - if branch_length_mode=='marginal': + if branch_length_mode == 'marginal': self.optimize_tree_marginal(max_iter=max_iter, infer_gtr=infer_gtr, pc=pc, **kwargs) if prune_short: self.prune_short_branches() return ttconf.SUCCESS - elif branch_length_mode=='input': - N_diff = self.reconstruct_anc(method=method_anc, infer_gtr=infer_gtr, pc=pc, - marginal=marginal_sequences, **kwargs) + elif branch_length_mode == 'input': + N_diff = self.reconstruct_anc( + method=method_anc, infer_gtr=infer_gtr, pc=pc, marginal=marginal_sequences, **kwargs + ) if prune_short: self.prune_short_branches() return ttconf.SUCCESS - elif branch_length_mode!='joint': - raise UnknownMethodError("TreeAnc.optimize_tree: `branch_length_mode` should be in ['marginal', 'joint', 'input']") - - self.logger("TreeAnc.optimize_tree: sequences...", 1) - N_diff = self.reconstruct_anc(method=method_anc, infer_gtr=infer_gtr, pc=pc, - marginal=marginal_sequences, **kwargs) + elif branch_length_mode != 'joint': + raise UnknownMethodError( + "TreeAnc.optimize_tree: `branch_length_mode` should be in ['marginal', 'joint', 'input']" + ) + + self.logger('TreeAnc.optimize_tree: sequences...', 1) + N_diff = self.reconstruct_anc( + method=method_anc, infer_gtr=infer_gtr, pc=pc, marginal=marginal_sequences, **kwargs + ) self.optimize_branch_lengths_joint(store_old=False) n = 0 - while n<max_iter: + while n < max_iter: n += 1 if prune_short: self.prune_short_branches() - N_diff = self.reconstruct_anc(method=method_anc, infer_gtr=False, - marginal=marginal_sequences, **kwargs) + N_diff = self.reconstruct_anc(method=method_anc, infer_gtr=False, marginal=marginal_sequences, **kwargs) - self.logger("TreeAnc.optimize_tree: Iteration %d." - " #Nuc changed since prev reconstructions: %d" %(n, N_diff), 2) + self.logger( + 'TreeAnc.optimize_tree: Iteration %d. #Nuc changed since prev reconstructions: %d' % (n, N_diff), 2 + ) if N_diff < 1: break self.optimize_branch_lengths_joint(store_old=False) - self.tree.unconstrained_sequence_LH = (self.tree.sequence_LH*self.data.multiplicity()).sum() - self._prepare_nodes() # fix dist2root and up-links after reconstruction - self.logger("TreeAnc.optimize_tree: Unconstrained sequence LH:%f" % self.tree.unconstrained_sequence_LH , 2) + self.tree.unconstrained_sequence_LH = (self.tree.sequence_LH * self.data.multiplicity()).sum() + self._prepare_nodes() # fix dist2root and up-links after reconstruction + self.logger('TreeAnc.optimize_tree: Unconstrained sequence LH:%f' % self.tree.unconstrained_sequence_LH, 2) return ttconf.SUCCESS - def prune_short_branches(self): """ If the branch length is less than the minimal value, remove the branch from the tree. **Requires** ancestral sequence reconstruction """ - self.logger("TreeAnc.prune_short_branches: pruning short branches (max prob at zero)...", 1) + self.logger('TreeAnc.prune_short_branches: pruning short branches (max prob at zero)...', 1) for node in self.tree.find_clades(): if node.up is None or node.is_terminal(): continue # probability of the two seqs separated by zero time is not zero - if ((node.branch_length<0.1*self.one_mutation) and - (self.gtr.prob_t(node.up._cseq, node._cseq, 0.0, - pattern_multiplicity=self.data.multiplicity(mask=node.mask)) > 0.1)): + if (node.branch_length < 0.1 * self.one_mutation) and ( + self.gtr.prob_t( + node.up._cseq, node._cseq, 0.0, pattern_multiplicity=self.data.multiplicity(mask=node.mask) + ) + > 0.1 + ): # re-assign the node children directly to its parent node.up.clades = [k for k in node.up.clades if k != node] + node.clades for clade in node.clades: clade.up = node.up - -##################################################################### -## GTR INFERENCE -##################################################################### - def infer_gtr(self, marginal=False, site_specific=False, normalized_rate=True, - fixed_pi=None, pc=5.0, **kwargs): + ##################################################################### + ## GTR INFERENCE + ##################################################################### + def infer_gtr(self, marginal=False, site_specific=False, normalized_rate=True, fixed_pi=None, pc=5.0, **kwargs): """ Calculates a GTR model given the multiple sequence alignment and the tree. It performs ancestral sequence inferrence (joint or marginal), followed by @@ -1437,13 +1535,13 @@ class TreeAnc(object): The inferred GTR model """ if site_specific and self.data.compress: - raise TypeError("TreeAnc.infer_gtr(): sequence compression and site specific GTR models are incompatible!" ) + raise TypeError('TreeAnc.infer_gtr(): sequence compression and site specific GTR models are incompatible!') if not self.ok: - raise MissingDataError("TreeAnc.infer_gtr: ERROR, sequences or tree are missing", 0) + raise MissingDataError('TreeAnc.infer_gtr: ERROR, sequences or tree are missing', 0) # if ancestral sequences are not in place, reconstruct them - if marginal and self.sequence_reconstruction!='marginal': + if marginal and self.sequence_reconstruction != 'marginal': self._ml_anc_marginal(**kwargs) elif not self.sequence_reconstruction: self._ml_anc_joint(**kwargs) @@ -1451,64 +1549,89 @@ class TreeAnc(object): n = self.gtr.n_states L = len(self.tree.root._cseq) # matrix of mutations n_{ij}: i = derived state, j=ancestral state - n_ija = np.zeros((n,n,L)) - T_ia = np.zeros((n,L)) + n_ija = np.zeros((n, n, L)) + T_ia = np.zeros((n, L)) - self.logger("TreeAnc.infer_gtr: counting mutations...", 2) + self.logger('TreeAnc.infer_gtr: counting mutations...', 2) for node in self.tree.get_nonterminals(): for c in node: if marginal: - mut_stack = np.transpose(self.get_branch_mutation_matrix(c, full_sequence=False), (1,2,0)) - T_ia += 0.5*self._branch_length_to_gtr(c) * mut_stack.sum(axis=0) * self.data.multiplicity(mask=c.mask) - T_ia += 0.5*self._branch_length_to_gtr(c) * mut_stack.sum(axis=1) * self.data.multiplicity(mask=c.mask) + mut_stack = np.transpose(self.get_branch_mutation_matrix(c, full_sequence=False), (1, 2, 0)) + T_ia += ( + 0.5 + * self._branch_length_to_gtr(c) + * mut_stack.sum(axis=0) + * self.data.multiplicity(mask=c.mask) + ) + T_ia += ( + 0.5 + * self._branch_length_to_gtr(c) + * mut_stack.sum(axis=1) + * self.data.multiplicity(mask=c.mask) + ) n_ija += mut_stack * self.data.multiplicity(mask=c.mask) else: - for a,pos, d in c.mutations: + for a, pos, d in c.mutations: try: - i,j = self.gtr.state_index[d], self.gtr.state_index[a] + i, j = self.gtr.state_index[d], self.gtr.state_index[a] except: # ambiguous positions continue cpos = self.data.full_to_compressed_sequence_map[pos] - n_ija[i,j,cpos]+=1 - T_ia[j,cpos] += 0.5*self._branch_length_to_gtr(c) - T_ia[i,cpos] -= 0.5*self._branch_length_to_gtr(c) + n_ija[i, j, cpos] += 1 + T_ia[j, cpos] += 0.5 * self._branch_length_to_gtr(c) + T_ia[i, cpos] -= 0.5 * self._branch_length_to_gtr(c) for i, nuc in enumerate(self.gtr.alphabet): cseq = c.cseq if cseq is not None: - ind = cseq==nuc - T_ia[i,ind] += self._branch_length_to_gtr(c)*self.data.multiplicity(mask=c.mask)[ind] + ind = cseq == nuc + T_ia[i, ind] += self._branch_length_to_gtr(c) * self.data.multiplicity(mask=c.mask)[ind] - self.logger("TreeAnc.infer_gtr: counting mutations...done", 3) + self.logger('TreeAnc.infer_gtr: counting mutations...done', 3) if site_specific: if marginal: root_state = self.tree.root.marginal_profile.T else: root_state = seq2prof(self.tree.root.cseq, self.gtr.profile_map).T - self._gtr = GTR_site_specific.infer(n_ija, T_ia, pc=pc, - root_state=root_state, logger=self.logger, - alphabet=self.gtr.alphabet, prof_map=self.gtr.profile_map) + self._gtr = GTR_site_specific.infer( + n_ija, + T_ia, + pc=pc, + root_state=root_state, + logger=self.logger, + alphabet=self.gtr.alphabet, + prof_map=self.gtr.profile_map, + ) else: - root_state = np.array([np.sum((self.tree.root.cseq==nuc)*self.data.multiplicity(mask=self.tree.root.mask)) - for nuc in self.gtr.alphabet]) + root_state = np.array( + [ + np.sum((self.tree.root.cseq == nuc) * self.data.multiplicity(mask=self.tree.root.mask)) + for nuc in self.gtr.alphabet + ] + ) n_ij = n_ija.sum(axis=-1) - self._gtr = GTR.infer(n_ij, T_ia.sum(axis=-1), root_state, fixed_pi=fixed_pi, pc=pc, - alphabet=self.gtr.alphabet, logger=self.logger, - prof_map = self.gtr.profile_map) + self._gtr = GTR.infer( + n_ij, + T_ia.sum(axis=-1), + root_state, + fixed_pi=fixed_pi, + pc=pc, + alphabet=self.gtr.alphabet, + logger=self.logger, + prof_map=self.gtr.profile_map, + ) if normalized_rate: - self.logger("TreeAnc.infer_gtr: setting overall rate to 1.0...", 2) + self.logger('TreeAnc.infer_gtr: setting overall rate to 1.0...', 2) if site_specific: self._gtr.mu /= self._gtr.average_rate().mean() else: - self._gtr.mu=1.0 + self._gtr.mu = 1.0 return self._gtr - - def infer_gtr_iterative(self, max_iter=10, site_specific=False, LHtol=0.1, - pc=1.0, normalized_rate=False): + def infer_gtr_iterative(self, max_iter=10, site_specific=False, LHtol=0.1, pc=1.0, normalized_rate=False): """infer GTR model by iteratively estimating ancestral sequences and the GTR model Parameters @@ -1534,21 +1657,22 @@ class TreeAnc(object): old_LH = self.sequence_LH() for i in range(max_iter): - self.infer_gtr(site_specific=site_specific, marginal=True, - normalized_rate=normalized_rate, pc=pc) + self.infer_gtr(site_specific=site_specific, marginal=True, normalized_rate=normalized_rate, pc=pc) self.infer_ancestral_sequences(marginal=True) - dp = np.abs(self.gtr.Pi - old_p).mean() if self.gtr.Pi.shape==old_p.shape else np.nan + dp = np.abs(self.gtr.Pi - old_p).mean() if self.gtr.Pi.shape == old_p.shape else np.nan deltaLH = self.sequence_LH() - old_LH old_p = np.copy(self.gtr.Pi) old_LH = self.sequence_LH() - self.logger("TreeAnc.infer_gtr_iterative: iteration %d, LH=%1.2f (%1.2f), deltaP=%1.4f"% - (i, old_LH, deltaLH, dp), 2) - if deltaLH<LHtol: - self.logger("TreeAnc.infer_gtr_iterative: deltaLH=%f, stopping iteration."%deltaLH,1) + self.logger( + 'TreeAnc.infer_gtr_iterative: iteration %d, LH=%1.2f (%1.2f), deltaP=%1.4f' % (i, old_LH, deltaLH, dp), + 2, + ) + if deltaLH < LHtol: + self.logger('TreeAnc.infer_gtr_iterative: deltaLH=%f, stopping iteration.' % deltaLH, 1) break return ttconf.SUCCESS @@ -1557,32 +1681,35 @@ class TreeAnc(object): likelihood of the sequence data. """ from scipy.optimize import minimize_scalar + def cost_func(sqrt_mu): self.gtr.mu = sqrt_mu**2 self.postorder_traversal_marginal() - self.total_LH_and_root_sequence(sample_from_profile=False, - assign_sequence=False) + self.total_LH_and_root_sequence(sample_from_profile=False, assign_sequence=False) return -self.sequence_LH() old_mu = self.gtr.mu try: - sol = minimize_scalar(cost_func, bracket=[0.01*np.sqrt(old_mu), np.sqrt(old_mu),100*np.sqrt(old_mu)], method='brent') + sol = minimize_scalar( + cost_func, bracket=[0.01 * np.sqrt(old_mu), np.sqrt(old_mu), 100 * np.sqrt(old_mu)], method='brent' + ) except: - self.gtr.mu=old_mu - self.logger('treeanc:optimize_gtr_rate: optimization failed, continuing with previous mu',1,warn=True) + self.gtr.mu = old_mu + self.logger('treeanc:optimize_gtr_rate: optimization failed, continuing with previous mu', 1, warn=True) return if sol['success']: - self.gtr.mu = sol['x']**2 - self.logger('treeanc:optimize_gtr_rate: optimization successful. Overall rate estimated to be %f'%self.gtr.mu,1) + self.gtr.mu = sol['x'] ** 2 + self.logger( + 'treeanc:optimize_gtr_rate: optimization successful. Overall rate estimated to be %f' % self.gtr.mu, 1 + ) else: - self.gtr.mu=old_mu - self.logger('treeanc:optimize_gtr_rate: optimization failed, continuing with previous mu',1,warn=True) - + self.gtr.mu = old_mu + self.logger('treeanc:optimize_gtr_rate: optimization failed, continuing with previous mu', 1, warn=True) -############################################################################### -### Utility functions -############################################################################### + ############################################################################### + ### Utility functions + ############################################################################### def get_reconstructed_alignment(self, reconstruct_tip_states=False): """ Get the multiple sequence alignment, including reconstructed sequences for @@ -1603,26 +1730,35 @@ class TreeAnc(object): from Bio.Align import MultipleSeqAlignment from Bio.Seq import Seq from Bio.SeqRecord import SeqRecord - self.logger("TreeAnc.get_reconstructed_alignment ...",2) + + self.logger('TreeAnc.get_reconstructed_alignment ...', 2) if (not self.sequence_reconstruction) or (reconstruct_tip_states != self.reconstructed_tip_sequences): - self.logger("TreeAnc.reconstructed_alignment... reconstruction not yet done",3) + self.logger('TreeAnc.reconstructed_alignment... reconstruction not yet done', 3) self.infer_ancestral_sequences(reconstruct_tip_states=reconstruct_tip_states) if self.data.is_sparse: - new_aln = {'sequences': {n.name: self.data.compressed_to_sparse_sequence(n.cseq) - for n in self.tree.find_clades()}} + new_aln = { + 'sequences': {n.name: self.data.compressed_to_sparse_sequence(n.cseq) for n in self.tree.find_clades()} + } new_aln['reference'] = self.data.ref new_aln['positions'] = self.data.nonref_positions new_aln['inferred_const_sites'] = self.data.inferred_const_sites else: - new_aln = MultipleSeqAlignment([SeqRecord(id=n.name, - seq=Seq(self.sequence(n, reconstructed=reconstruct_tip_states, - as_string=True, compressed=False)), description="") - for n in self.tree.find_clades()]) + new_aln = MultipleSeqAlignment( + [ + SeqRecord( + id=n.name, + seq=Seq( + self.sequence(n, reconstructed=reconstruct_tip_states, as_string=True, compressed=False) + ), + description='', + ) + for n in self.tree.find_clades() + ] + ) return new_aln - def sequence(self, node, reconstructed=False, as_string=True, compressed=False): """return the sequence of a node. @@ -1644,14 +1780,18 @@ class TreeAnc(object): str or np.array sequence of node """ - if type(node)==str: + if type(node) == str: if node in self.leaves_lookup: nodes = self.leaves_lookup else: - raise ValueError("TreeAnc.sequence accepts strings are argument only when the node is terminal and present in the leave lookup table") + raise ValueError( + 'TreeAnc.sequence accepts strings are argument only when the node is terminal and present in the leave lookup table' + ) if reconstructed and not self.reconstructed_tip_sequences: - raise ValueError("TreeAnc.sequence can only return reconstructed terminal nodes if TreeAnc.infer_ancestral_sequences was run with this the flag `reconstruct_tip_states`.") + raise ValueError( + 'TreeAnc.sequence can only return reconstructed terminal nodes if TreeAnc.infer_ancestral_sequences was run with this the flag `reconstruct_tip_states`.' + ) if compressed: if (not reconstructed) and (node.name in self.data.compressed_alignment): @@ -1664,18 +1804,28 @@ class TreeAnc(object): elif node.cseq is not None: tmp_seq = self.data.compressed_to_full_sequence(node.cseq, as_string=False) else: - tmp_seq = np.array([self.gtr.ambiguous or 'N']*self.sequence_length) - - return "".join(tmp_seq) if as_string else np.copy(tmp_seq) + tmp_seq = np.array([self.gtr.ambiguous or 'N'] * self.sequence_length) + return ''.join(tmp_seq) if as_string else np.copy(tmp_seq) def get_tree_dict(self, keep_var_ambigs=False): return self.get_reconstructed_alignment(reconstruct_tip_states=not keep_var_ambigs) - def recover_var_ambigs(self): - self.logger("TreeAnc: recover_var_ambigs: calls to recover_var_ambigs are no longer necessary since tip states are not inferred unless explicitly specified using `reconstruct_tip_states=True`.", 0, warn=True) + self.logger( + 'TreeAnc: recover_var_ambigs: calls to recover_var_ambigs are no longer necessary since tip states are not inferred unless explicitly specified using `reconstruct_tip_states=True`.', + 0, + warn=True, + ) if self.reconstructed_tip_sequences: - self.logger("Your code reconstructed tip states, please change the call of ancestral inference in your code",0, warn=True) + self.logger( + 'Your code reconstructed tip states, please change the call of ancestral inference in your code', + 0, + warn=True, + ) else: - self.logger("Your analysis did not reconstructed tip states, you can remove the call of `recover_var_ambigs`",0, warn=True) + self.logger( + 'Your analysis did not reconstructed tip states, you can remove the call of `recover_var_ambigs`', + 0, + warn=True, + ) diff --git a/treetime/treeregression.py b/treetime/treeregression.py index 4abe8c5..7d63c5c 100644 --- a/treetime/treeregression.py +++ b/treetime/treeregression.py @@ -1,7 +1,8 @@ import numpy as np from Bio import Phylo -tavgii, davgii, tsqii, dtavgii, dsqii, sii = 0,1,2,3,4,5 +tavgii, davgii, tsqii, dtavgii, dsqii, sii = 0, 1, 2, 3, 4, 5 + def base_regression(Q, slope=None): """ @@ -22,33 +23,39 @@ def base_regression(Q, slope=None): Description """ if np.isinf(Q).sum() or np.isnan(Q).sum(): - raise ValueError("Invalid values in input data!") + raise ValueError('Invalid values in input data!') if slope is None: - if (Q[tsqii] - Q[tavgii]**2/Q[sii])>0: - slope = (Q[dtavgii] - Q[tavgii]*Q[davgii]/Q[sii]) \ - /(Q[tsqii] - Q[tavgii]**2/Q[sii]) + if (Q[tsqii] - Q[tavgii] ** 2 / Q[sii]) > 0: + slope = (Q[dtavgii] - Q[tavgii] * Q[davgii] / Q[sii]) / (Q[tsqii] - Q[tavgii] ** 2 / Q[sii]) else: - raise ValueError("No variation in sampling dates! Please specify your clock rate explicitly.") - only_intercept=False + raise ValueError('No variation in sampling dates! Please specify your clock rate explicitly.') + only_intercept = False else: - only_intercept=True - - intercept = (Q[davgii] - Q[tavgii]*slope)/Q[sii] - if (Q[tsqii] - Q[tavgii]**2/Q[sii])>0: - chisq = 0.5*(Q[dsqii] - Q[davgii]**2/Q[sii] - (Q[dtavgii] - Q[davgii]*Q[tavgii]/Q[sii])**2/(Q[tsqii] - Q[tavgii]**2/Q[sii])) + only_intercept = True + + intercept = (Q[davgii] - Q[tavgii] * slope) / Q[sii] + if (Q[tsqii] - Q[tavgii] ** 2 / Q[sii]) > 0: + chisq = 0.5 * ( + Q[dsqii] + - Q[davgii] ** 2 / Q[sii] + - (Q[dtavgii] - Q[davgii] * Q[tavgii] / Q[sii]) ** 2 / (Q[tsqii] - Q[tavgii] ** 2 / Q[sii]) + ) else: - chisq = 0.5*(Q[dsqii] - Q[davgii]**2/Q[sii]) + chisq = 0.5 * (Q[dsqii] - Q[davgii] ** 2 / Q[sii]) if only_intercept: - return {'slope':slope, 'intercept':intercept, - 'chisq': chisq} + return {'slope': slope, 'intercept': intercept, 'chisq': chisq} estimator_hessian = np.array([[Q[tsqii], Q[tavgii]], [Q[tavgii], Q[sii]]]) - return {'slope':slope, 'intercept':intercept, - 'chisq':chisq, 'hessian':estimator_hessian, - 'cov':np.linalg.inv(estimator_hessian)} + return { + 'slope': slope, + 'intercept': intercept, + 'chisq': chisq, + 'hessian': estimator_hessian, + 'cov': np.linalg.inv(estimator_hessian), + } class TreeRegression(object): @@ -61,8 +68,8 @@ class TreeRegression(object): of the data under the assumptions that variance increase linearly along branches as well. """ - def __init__(self, tree_in, tip_value = None, - branch_value = None, branch_variance = None): + + def __init__(self, tree_in, tip_value=None, branch_value=None, branch_variance=None): """ Parameters ---------- @@ -93,29 +100,28 @@ class TreeRegression(object): n._ii = np.concatenate([c._ii for c in n]) n._ii.sort() for c in n: - c.up=n - total_bl+=c.branch_length - self.tree.root.up=None + c.up = n + total_bl += c.branch_length + self.tree.root.up = None self.N = self.tree.root._ii.shape[0] if tip_value is None: - self.tip_value = lambda x:np.mean(x.numdate) if x.is_terminal() else None + self.tip_value = lambda x: np.mean(x.numdate) if x.is_terminal() else None else: self.tip_value = tip_value if branch_value is None: - self.branch_value = lambda x:x.branch_length + self.branch_value = lambda x: x.branch_length else: self.branch_value = branch_value if branch_variance is None: # provide a default equal to the branch_length (Poisson) and add # a tenth of the average branch length to avoid numerical instabilities and division by 0. - self.branch_variance = lambda x:x.branch_length + 0.05*total_bl/self.N + self.branch_variance = lambda x: x.branch_length + 0.05 * total_bl / self.N else: self.branch_variance = branch_variance - def Cov(self): """ calculate the covariance matrix of the tips assuming variance @@ -135,7 +141,6 @@ class TreeRegression(object): M[np.meshgrid(n._ii, n._ii)] += self.branch_variance(n) return M - def CovInv(self): """ Inverse of the covariance matrix @@ -149,7 +154,6 @@ class TreeRegression(object): self.recurse(full_matrix=True) return self.tree.root.cinv - def recurse(self, full_matrix=False): """ recursion to calculate inverse covariance matrix @@ -161,7 +165,8 @@ class TreeRegression(object): """ for n in self.tree.get_nonterminals(order='postorder'): n_leaves = len(n._ii) - if full_matrix: M = np.zeros((n_leaves, n_leaves), dtype=float) + if full_matrix: + M = np.zeros((n_leaves, n_leaves), dtype=float) r = np.zeros(n_leaves, dtype=float) c_count = 0 for c in n: @@ -169,19 +174,21 @@ class TreeRegression(object): nc = len(c._ii) if c.is_terminal(): if full_matrix: - M[c_count, c_count] = 1.0/ssq - r[c_count] = 1.0/ssq + M[c_count, c_count] = 1.0 / ssq + r[c_count] = 1.0 / ssq else: if full_matrix: - M[c_count:c_count+nc, c_count:c_count+nc] = c.cinv - ssq*np.outer(c.r,c.r)/(1+ssq*c.s) - r[c_count:c_count+nc] = c.r/(1+ssq*c.s) + M[c_count : c_count + nc, c_count : c_count + nc] = c.cinv - ssq * np.outer(c.r, c.r) / ( + 1 + ssq * c.s + ) + r[c_count : c_count + nc] = c.r / (1 + ssq * c.s) c_count += nc - if full_matrix: n.cinv = M - n.r = r #M.sum(axis=1) + if full_matrix: + n.cinv = M + n.r = r # M.sum(axis=1) n.s = n.r.sum() - def _calculate_averages(self): """ calculate the weighted sums of the tip and branch values and @@ -194,16 +201,16 @@ class TreeRegression(object): bv = self.branch_value(c) var = self.branch_variance(c) Q += self.propagate_averages(c, tv, bv, var) - n.Q=Q + n.Q = Q for n in self.tree.find_clades(order='preorder'): O = np.zeros(6, dtype=float) - if n==self.tree.root: + if n == self.tree.root: n.Qtot = n.Q continue for c in n.up: - if c==n: + if c == n: continue tv = self.tip_value(c) @@ -211,7 +218,7 @@ class TreeRegression(object): var = self.branch_variance(c) O += self.propagate_averages(c, tv, bv, var) - if n.up!=self.tree.root: + if n.up != self.tree.root: c = n.up tv = self.tip_value(c) bv = self.branch_value(c) @@ -225,7 +232,6 @@ class TreeRegression(object): var = self.branch_variance(n) n.Qtot = n.Q + self.propagate_averages(n, tv, bv, var, outgroup=True) - def propagate_averages(self, n, tv, bv, var, outgroup=False): """ This function implements the propagation of the means, @@ -250,29 +256,28 @@ class TreeRegression(object): Q : (np.array) a vector of length 6 containing the updated quantities """ - if n.is_terminal() and outgroup==False: + if n.is_terminal() and outgroup == False: if tv is None or np.isinf(tv) or np.isnan(tv): res = np.array([0, 0, 0, 0, 0, 0]) - elif var==0: + elif var == 0: res = np.array([np.inf, np.inf, np.inf, np.inf, np.inf, np.inf]) else: - res = np.array([ - tv/var, - bv/var, - tv**2/var, - bv*tv/var, - bv**2/var, - 1.0/var], dtype=float) + res = np.array([tv / var, bv / var, tv**2 / var, bv * tv / var, bv**2 / var, 1.0 / var], dtype=float) else: tmpQ = n.O if outgroup else n.Q - denom = 1.0/(1+var*tmpQ[sii]) - res = np.array([ - tmpQ[tavgii]*denom, - (tmpQ[davgii] + bv*tmpQ[sii])*denom, - tmpQ[tsqii] - var*tmpQ[tavgii]**2*denom, - tmpQ[dtavgii] + tmpQ[tavgii]*bv - var*tmpQ[tavgii]*(tmpQ[davgii] + bv*tmpQ[sii])*denom, - tmpQ[dsqii] + 2*bv*tmpQ[davgii] + bv**2*tmpQ[sii] - var*(tmpQ[davgii]**2 + 2*bv*tmpQ[davgii]*tmpQ[sii] + bv**2*tmpQ[sii]**2)*denom, - tmpQ[sii]*denom] + denom = 1.0 / (1 + var * tmpQ[sii]) + res = np.array( + [ + tmpQ[tavgii] * denom, + (tmpQ[davgii] + bv * tmpQ[sii]) * denom, + tmpQ[tsqii] - var * tmpQ[tavgii] ** 2 * denom, + tmpQ[dtavgii] + tmpQ[tavgii] * bv - var * tmpQ[tavgii] * (tmpQ[davgii] + bv * tmpQ[sii]) * denom, + tmpQ[dsqii] + + 2 * bv * tmpQ[davgii] + + bv**2 * tmpQ[sii] + - var * (tmpQ[davgii] ** 2 + 2 * bv * tmpQ[davgii] * tmpQ[sii] + bv**2 * tmpQ[sii] ** 2) * denom, + tmpQ[sii] * denom, + ] ) return res @@ -286,14 +291,12 @@ class TreeRegression(object): r-value of the root-to-tip distance and time. independent of regression model, but dependent on root choice """ - self.tree.root._v=0 + self.tree.root._v = 0 for n in self.tree.get_nonterminals(order='preorder'): for c in n: c._v = n._v + self.branch_value(c) - raw = np.array([(self.tip_value(n), n._v) for n in self.tree.get_terminals() - if self.tip_value(n) is not None]) - return np.corrcoef(raw.T)[0,1] - + raw = np.array([(self.tip_value(n), n._v) for n in self.tree.get_terminals() if self.tip_value(n) is not None]) + return np.corrcoef(raw.T)[0, 1] def regression(self, slope=None): """regress tip values against branch values @@ -315,8 +318,6 @@ class TreeRegression(object): return clock_model - - def find_best_root(self, force_positive=True, slope=None): """ determine the position on the tree that minimizes the bilinear @@ -329,86 +330,89 @@ class TreeRegression(object): is to be split, and the regression parameters """ self._calculate_averages() - best_root = {"chisq": np.inf} + best_root = {'chisq': np.inf} for n in self.tree.find_clades(): - if n==self.tree.root: + if n == self.tree.root: continue tv = self.tip_value(n) bv = self.branch_value(n) var = self.branch_variance(n) x, chisq = self._optimal_root_along_branch(n, tv, bv, var, slope=slope) - if chisq<best_root["chisq"]: - tmpQ = self.propagate_averages(n, tv, bv*x, var*x) \ - + self.propagate_averages(n, tv, bv*(1-x), var*(1-x), outgroup=True) + if chisq < best_root['chisq']: + tmpQ = self.propagate_averages(n, tv, bv * x, var * x) + self.propagate_averages( + n, tv, bv * (1 - x), var * (1 - x), outgroup=True + ) reg = base_regression(tmpQ, slope=slope) - if reg["slope"]>=0 or (force_positive==False): - best_root = {"node":n, "split":x} + if reg['slope'] >= 0 or (force_positive == False): + best_root = {'node': n, 'split': x} best_root.update(reg) if 'node' not in best_root: - print(f"TreeRegression.find_best_root: No valid root found! force_positive={force_positive}") + print(f'TreeRegression.find_best_root: No valid root found! force_positive={force_positive}') return None if 'hessian' in best_root: # calculate differentials with respect to x deriv = [] - n = best_root["node"] + n = best_root['node'] tv = self.tip_value(n) bv = self.branch_value(n) var = self.branch_variance(n) for dx in [-0.001, 0.001]: # y needs to be bounded away from 0 and 1 to avoid division by 0 - y = min(0.9999, max(0.0001, best_root["split"]+dx)) - tmpQ = self.propagate_averages(n, tv, bv*y, var*y) \ - + self.propagate_averages(n, tv, bv*(1-y), var*(1-y), outgroup=True) + y = min(0.9999, max(0.0001, best_root['split'] + dx)) + tmpQ = self.propagate_averages(n, tv, bv * y, var * y) + self.propagate_averages( + n, tv, bv * (1 - y), var * (1 - y), outgroup=True + ) reg = base_regression(tmpQ, slope=slope) - deriv.append([y,reg['chisq'], tmpQ[tavgii], tmpQ[davgii]]) + deriv.append([y, reg['chisq'], tmpQ[tavgii], tmpQ[davgii]]) - estimator_hessian = np.zeros((3,3)) - estimator_hessian[:2,:2] = best_root['hessian'] - estimator_hessian[2,2] = (deriv[0][1] + deriv[1][1] - 2.0*best_root['chisq'])/(deriv[0][0] - deriv[1][0])**2 + estimator_hessian = np.zeros((3, 3)) + estimator_hessian[:2, :2] = best_root['hessian'] + estimator_hessian[2, 2] = (deriv[0][1] + deriv[1][1] - 2.0 * best_root['chisq']) / ( + deriv[0][0] - deriv[1][0] + ) ** 2 # estimator_hessian[2,0] = (deriv[0][2] - deriv[1][2])/(deriv[0][0] - deriv[1][0]) # estimator_hessian[2,1] = (deriv[0][3] - deriv[1][3])/(deriv[0][0] - deriv[1][0]) - estimator_hessian[0,2] = estimator_hessian[2,0] - estimator_hessian[1,2] = estimator_hessian[2,1] + estimator_hessian[0, 2] = estimator_hessian[2, 0] + estimator_hessian[1, 2] = estimator_hessian[2, 1] best_root['hessian'] = estimator_hessian best_root['cov'] = np.linalg.inv(estimator_hessian) return best_root - def _optimal_root_along_branch(self, n, tv, bv, var, slope=None): from scipy.optimize import minimize_scalar + def chisq(x): - tmpQ = self.propagate_averages(n, tv, bv*x, var*x) \ - + self.propagate_averages(n, tv, bv*(1-x), var*(1-x), outgroup=True) + tmpQ = self.propagate_averages(n, tv, bv * x, var * x) + self.propagate_averages( + n, tv, bv * (1 - x), var * (1 - x), outgroup=True + ) return base_regression(tmpQ, slope=slope)['chisq'] - if n.bad_branch or (n!=self.tree.root and n.up.bad_branch): + if n.bad_branch or (n != self.tree.root and n.up.bad_branch): return np.nan, np.inf - chisq_prox = np.inf if n.is_terminal() else base_regression(n.Qtot, slope=slope)['chisq'] - chisq_dist = np.inf if n==self.tree.root else base_regression(n.up.Qtot, slope=slope)['chisq'] + chisq_dist = np.inf if n == self.tree.root else base_regression(n.up.Qtot, slope=slope)['chisq'] - grid = np.linspace(0.001,0.999,6) + grid = np.linspace(0.001, 0.999, 6) chisq_grid = np.array([chisq(x) for x in grid]) min_chisq = chisq_grid.min() - if chisq_prox<=min_chisq: + if chisq_prox <= min_chisq: return 0.0, chisq_prox - elif chisq_dist<=min_chisq: + elif chisq_dist <= min_chisq: return 1.0, chisq_dist else: ii = np.argmin(chisq_grid) - bounds = (0 if ii==0 else grid[ii-1], 1.0 if ii==len(grid)-1 else grid[ii+1]) - sol = minimize_scalar(chisq, bounds=bounds, method="bounded", options={'xatol':1e-6}) - if sol["success"]: + bounds = (0 if ii == 0 else grid[ii - 1], 1.0 if ii == len(grid) - 1 else grid[ii + 1]) + sol = minimize_scalar(chisq, bounds=bounds, method='bounded', options={'xatol': 1e-6}) + if sol['success']: return sol['x'], sol['fun'] else: return np.nan, np.inf - def optimal_reroot(self, force_positive=True, slope=None, keep_node_order=False): """ determine the best root and reroot the tree to this value. @@ -432,13 +436,13 @@ class TreeRegression(object): """ best_root = self.find_best_root(force_positive=force_positive, slope=slope) if best_root is None: - raise ValueError("Rerooting failed!") - best_node = best_root["node"] + raise ValueError('Rerooting failed!') + best_node = best_root['node'] - x = best_root["split"] - if x<1e-5: + x = best_root['split'] + if x < 1e-5: new_node = best_node - elif x>1.0-1e-5: + elif x > 1.0 - 1e-5: new_node = best_node.up else: # create new node in the branch and root the tree to it @@ -447,11 +451,10 @@ class TreeRegression(object): # insert the new node in the middle of the branch # by simple re-wiring the links on the both sides of the branch # and fix the branch lengths - new_node.branch_length = best_node.branch_length*(1-x) + new_node.branch_length = best_node.branch_length * (1 - x) new_node.up = best_node.up new_node.clades = [best_node] - new_node.up.clades = [k if k!=best_node else new_node - for k in best_node.up.clades] + new_node.up.clades = [k if k != best_node else new_node for k in best_node.up.clades] best_node.branch_length *= x best_node.up = new_node @@ -463,12 +466,10 @@ class TreeRegression(object): self.tree.ladderize() for n in self.tree.get_nonterminals(order='postorder'): for c in n: - c.up=n + c.up = n return best_root - - def clock_plot(self, add_internal=False, ax=None, regression=None, - confidence=True, n_sigma = 2, fs=14): + def clock_plot(self, add_internal=False, ax=None, regression=None, confidence=True, n_sigma=2, fs=14): """Plot root-to-tip distance vs time as a basic time-tree diagnostic Parameters @@ -489,11 +490,12 @@ class TreeRegression(object): """ import matplotlib.pyplot as plt + if ax is None: plt.figure() - ax=plt.subplot(111) + ax = plt.subplot(111) - self.tree.root._v=0 + self.tree.root._v = 0 for n in self.tree.get_nonterminals(order='preorder'): for c in n: c._v = n._v + self.branch_value(c) @@ -504,68 +506,82 @@ class TreeRegression(object): # get values of terminals xi = np.array([self.tip_value(n) for n in tips]) yi = np.array([n._v for n in tips]) - ind = np.array([n.bad_branch if hasattr(n, 'bad_branch') else False for n in tips]) + ind = np.array([n.bad_branch if hasattr(n, 'bad_branch') else False for n in tips]) if add_internal: xi_int = np.array([n.numdate for n in internal]) yi_int = np.array([n._v for n in internal]) - ind_int = np.array([n.bad_branch if hasattr(n, 'bad_branch') else False for n in internal]) + ind_int = np.array([n.bad_branch if hasattr(n, 'bad_branch') else False for n in internal]) if regression: # plot regression line - t_mrca = -regression['intercept']/regression['slope'] + t_mrca = -regression['intercept'] / regression['slope'] if add_internal: time_span = np.max(xi_int[~ind_int]) - np.min(xi_int[~ind_int]) - x_vals = np.array([max(np.min(xi_int[~ind_int]), t_mrca) - 0.1*time_span, np.max(xi[~ind])+0.05*time_span]) + x_vals = np.array( + [max(np.min(xi_int[~ind_int]), t_mrca) - 0.1 * time_span, np.max(xi[~ind]) + 0.05 * time_span] + ) else: time_span = np.max(xi[~ind]) - np.min(xi[~ind]) - x_vals = np.array([max(np.min(xi[~ind]), t_mrca) - 0.1*time_span, np.max(xi[~ind]+0.05*time_span)]) + x_vals = np.array( + [max(np.min(xi[~ind]), t_mrca) - 0.1 * time_span, np.max(xi[~ind] + 0.05 * time_span)] + ) # plot confidence interval if confidence and 'cov' in regression: x_vals = np.linspace(x_vals[0], x_vals[1], 100) - y_vals = regression['slope']*x_vals + regression['intercept'] - dev = n_sigma*np.array([np.sqrt(regression['cov'][:2,:2].dot(np.array([x, 1])).dot(np.array([x,1]))) for x in x_vals]) - dev_slope = n_sigma*np.sqrt(regression['cov'][0,0]) - ax.fill_between(x_vals, y_vals-dev, y_vals+dev, alpha=0.2) - dp = np.array([regression['intercept']/regression['slope']**2,-1./regression['slope']]) - dev_rtt = n_sigma*np.sqrt(regression['cov'][:2,:2].dot(dp).dot(dp)) + y_vals = regression['slope'] * x_vals + regression['intercept'] + dev = n_sigma * np.array( + [np.sqrt(regression['cov'][:2, :2].dot(np.array([x, 1])).dot(np.array([x, 1]))) for x in x_vals] + ) + dev_slope = n_sigma * np.sqrt(regression['cov'][0, 0]) + ax.fill_between(x_vals, y_vals - dev, y_vals + dev, alpha=0.2) + dp = np.array([regression['intercept'] / regression['slope'] ** 2, -1.0 / regression['slope']]) + dev_rtt = n_sigma * np.sqrt(regression['cov'][:2, :2].dot(dp).dot(dp)) else: dev_rtt = None dev_slope = None - ax.plot(x_vals, regression['slope']*x_vals + regression['intercept'], - label = r"$y=\alpha + \beta t$"+"\n"+ - r"$\beta=$%1.2e"%(regression["slope"]) - + ("+/- %1.e"%dev_slope if dev_slope else "") + - "\nroot date: %1.1f"%(-regression['intercept']/regression['slope']) + - ("+/- %1.2f"%dev_rtt if dev_rtt else "")) - + ax.plot( + x_vals, + regression['slope'] * x_vals + regression['intercept'], + label=r'$y=\alpha + \beta t$' + + '\n' + + r'$\beta=$%1.2e' % (regression['slope']) + + ('+/- %1.e' % dev_slope if dev_slope else '') + + '\nroot date: %1.1f' % (-regression['intercept'] / regression['slope']) + + ('+/- %1.2f' % dev_rtt if dev_rtt else ''), + ) - ax.scatter(xi[~ind], yi[~ind], label=("tips" if add_internal else None)) + ax.scatter(xi[~ind], yi[~ind], label=('tips' if add_internal else None)) if ind.sum(): try: # note: this is treetime specific - tmp_x = np.array([np.mean(n.raw_date_constraint) if n.raw_date_constraint else None - for n in self.tree.get_terminals()]) - ax.scatter(tmp_x[ind], yi[ind], label="ignored tips", c='r') + tmp_x = np.array( + [ + np.mean(n.raw_date_constraint) if n.raw_date_constraint else None + for n in self.tree.get_terminals() + ] + ) + ax.scatter(tmp_x[ind], yi[ind], label='ignored tips', c='r') except: pass if add_internal: - ax.scatter(xi_int[~ind_int], yi_int[~ind_int], label="internal nodes") + ax.scatter(xi_int[~ind_int], yi_int[~ind_int], label='internal nodes') ax.set_ylabel('root-to-tip distance', fontsize=fs) ax.set_xlabel('date', fontsize=fs) ax.ticklabel_format(useOffset=False) - ax.tick_params(labelsize=fs*0.8) - ax.set_ylim([0, 1.1*np.max(yi)]) + ax.tick_params(labelsize=fs * 0.8) + ax.set_ylim([0, 1.1 * np.max(yi)]) plt.tight_layout() - plt.legend(fontsize=fs*0.8) + plt.legend(fontsize=fs * 0.8) if __name__ == '__main__': import matplotlib.pyplot as plt import time + plt.ion() # tree_file = '../data/H3N2_NA_allyears_NA.20.nwk' # date_file = '../data/H3N2_NA_allyears_NA.20.metadata.csv' @@ -573,33 +589,32 @@ if __name__ == '__main__': date_file = '../data/ebola.metadata.csv' T = Phylo.read(tree_file, 'newick') - #T.root_with_outgroup('A/Canterbury/58/2000|CY009150|09/05/2000|New_Zealand||H3N2/8-1416') + # T.root_with_outgroup('A/Canterbury/58/2000|CY009150|09/05/2000|New_Zealand||H3N2/8-1416') dates = {} with open(date_file, 'r', encoding='utf-8') as ifile: ifile.readline() for line in ifile: - if line[0]!='#': + if line[0] != '#': fields = line.strip().split(',') dates[fields[0]] = float(fields[1]) - for l in T.get_terminals(): l.numdate = dates[l.name] - branch_variance = lambda x:(x.branch_length+(0.0005 if x.is_terminal() else 0.0))/19000.0 - #branch_variance = lambda x:(x.branch_length+(0.005 if x.is_terminal() else 0.0))/1700.0 - #branch_variance = lambda x:1.0 if x.is_terminal() else 0.0 + branch_variance = lambda x: (x.branch_length + (0.0005 if x.is_terminal() else 0.0)) / 19000.0 + # branch_variance = lambda x:(x.branch_length+(0.005 if x.is_terminal() else 0.0))/1700.0 + # branch_variance = lambda x:1.0 if x.is_terminal() else 0.0 tstart = time.time() - mtc = TreeRegression(T, branch_variance = branch_variance) - print(time.time()-tstart) + mtc = TreeRegression(T, branch_variance=branch_variance) + print(time.time() - tstart) reg = mtc.optimal_reroot() - print(time.time()-tstart) + print(time.time() - tstart) print(reg) plt.figure() ti = [] rtt = [] - T.root.rtt=0 + T.root.rtt = 0 for n in T.get_nonterminals(order='preorder'): for c in n: c.rtt = n.rtt + c.branch_length @@ -610,5 +625,5 @@ if __name__ == '__main__': ti = np.array(ti) rtt = np.array(rtt) plt.plot(ti, rtt) - plt.plot(ti, reg["slope"]*ti + reg["intercept"]) + plt.plot(ti, reg['slope'] * ti + reg['intercept']) Phylo.draw(T) diff --git a/treetime/treetime.py b/treetime/treetime.py index 078fddf..78f6f4c 100644 --- a/treetime/treetime.py +++ b/treetime/treetime.py @@ -2,20 +2,24 @@ import numpy as np from scipy import optimize as sciopt from Bio import Phylo from . import config as ttconf -from . import MissingDataError,UnknownMethodError,NotReadyError,TreeTimeError, TreeTimeUnknownError +from . import MissingDataError, UnknownMethodError, NotReadyError, TreeTimeError, TreeTimeUnknownError from .utils import tree_layout from .clock_tree import ClockTree -rerooting_mechanisms = ["min_dev", "best", "least-squares"] -deprecated_rerooting_mechanisms = {"residual":"least-squares", "res":"least-squares", - "min_dev_ML": "min_dev", "ML":"least-squares"} +rerooting_mechanisms = ['min_dev', 'best', 'least-squares'] +deprecated_rerooting_mechanisms = { + 'residual': 'least-squares', + 'res': 'least-squares', + 'min_dev_ML': 'min_dev', + 'ML': 'least-squares', +} def reduce_time_marginal_argument(input_time_marginal): - ''' + """ This function maps deprecated arguments/terms for the timetree inference mode to recommended terms. - ''' + """ if input_time_marginal in [False, 'false', 'never']: return 'never' elif input_time_marginal in [True, 'always', 'true']: @@ -35,7 +39,7 @@ class TreeTime(ClockTree): using temporal information, and relaxed molecular clock models """ - def __init__(self, *args,**kwargs): + def __init__(self, *args, **kwargs): """ TreeTime constructor @@ -50,36 +54,54 @@ class TreeTime(ClockTree): """ super(TreeTime, self).__init__(*args, **kwargs) - def run(self, raise_uncaught_exceptions=False, **kwargs): import sys + try: return self._run(**kwargs) except TreeTimeError as err: if raise_uncaught_exceptions: raise err else: - print(f"ERROR: {err} \n", file=sys.stderr) + print(f'ERROR: {err} \n', file=sys.stderr) sys.exit(2) except BaseException as err: import traceback + print(traceback.format_exc(), file=sys.stderr) - print(f"ERROR: {err} \n ", file=sys.stderr) - print("ERROR in TreeTime.run: An error occurred which was not properly handled in TreeTime. If this error persists, please let us know " - "by filing a new issue including the original command and the error above at: https://github.com/neherlab/treetime/issues \n", file=sys.stderr) + print(f'ERROR: {err} \n ', file=sys.stderr) + print( + 'ERROR in TreeTime.run: An error occurred which was not properly handled in TreeTime. If this error persists, please let us know ' + 'by filing a new issue including the original command and the error above at: https://github.com/neherlab/treetime/issues \n', + file=sys.stderr, + ) if raise_uncaught_exceptions: raise TreeTimeUnknownError() from err else: sys.exit(2) - - def _run(self, root=None, infer_gtr=True, relaxed_clock=None, clock_filter_method='residual', - n_iqd = None, resolve_polytomies=True, max_iter=0, Tc=None, fixed_clock_rate=None, - time_marginal='never', sequence_marginal=False, branch_length_mode='auto', - vary_rate=False, use_covariation=False, tracelog_file=None, - method_anc = 'probabilistic', assign_gamma=None, stochastic_resolve=False, - **kwargs): - + def _run( + self, + root=None, + infer_gtr=True, + relaxed_clock=None, + clock_filter_method='residual', + n_iqd=None, + resolve_polytomies=True, + max_iter=0, + Tc=None, + fixed_clock_rate=None, + time_marginal='never', + sequence_marginal=False, + branch_length_mode='auto', + vary_rate=False, + use_covariation=False, + tracelog_file=None, + method_anc='probabilistic', + assign_gamma=None, + stochastic_resolve=False, + **kwargs, + ): """ Run TreeTime reconstruction. Based on the input parameters, it divides the analysis into semi-independent jobs and conquers them one-by-one, @@ -177,78 +199,88 @@ class TreeTime(ClockTree): """ # register the specified covaration mode - self.use_covariation = use_covariation or (vary_rate and (not type(vary_rate)==float)) + self.use_covariation = use_covariation or (vary_rate and (not type(vary_rate) == float)) if (self.tree is None) or (self.aln is None and self.data.full_length is None): - raise MissingDataError("TreeTime.run: ERROR, alignment or tree are missing") + raise MissingDataError('TreeTime.run: ERROR, alignment or tree are missing') if self.aln is None: - branch_length_mode='input' + branch_length_mode = 'input' self._set_branch_length_mode(branch_length_mode) # determine how to reconstruct and sample sequences - seq_kwargs = {"marginal_sequences":sequence_marginal or (self.branch_length_mode=='marginal'), - "branch_length_mode": self.branch_length_mode, - "sample_from_profile": "root", - "prune_short":kwargs.get("prune_short", True), - "reconstruct_tip_states":kwargs.get("reconstruct_tip_states", False)} - time_marginal_method = reduce_time_marginal_argument(time_marginal) ## for backward compatibility - tt_kwargs = {'clock_rate':fixed_clock_rate, - 'time_marginal':False if time_marginal_method in ['never', 'only-final', 'confidence-only'] else True} + seq_kwargs = { + 'marginal_sequences': sequence_marginal or (self.branch_length_mode == 'marginal'), + 'branch_length_mode': self.branch_length_mode, + 'sample_from_profile': 'root', + 'prune_short': kwargs.get('prune_short', True), + 'reconstruct_tip_states': kwargs.get('reconstruct_tip_states', False), + } + time_marginal_method = reduce_time_marginal_argument(time_marginal) ## for backward compatibility + tt_kwargs = { + 'clock_rate': fixed_clock_rate, + 'time_marginal': False if time_marginal_method in ['never', 'only-final', 'confidence-only'] else True, + } tt_kwargs.update(kwargs) seq_LH = 0 - if "fixed_pi" in kwargs: - seq_kwargs["fixed_pi"] = kwargs["fixed_pi"] - if "do_marginal" in kwargs: - time_marginal=kwargs["do_marginal"] + if 'fixed_pi' in kwargs: + seq_kwargs['fixed_pi'] = kwargs['fixed_pi'] + if 'do_marginal' in kwargs: + time_marginal = kwargs['do_marginal'] if assign_gamma and relaxed_clock: - raise UnknownMethodError("assign_gamma and relaxed clock are incompatible arguments") + raise UnknownMethodError('assign_gamma and relaxed clock are incompatible arguments') # initially, infer ancestral sequences and infer gtr model if desired - if self.branch_length_mode=='input': + if self.branch_length_mode == 'input': if self.aln: - self.infer_ancestral_sequences(infer_gtr=infer_gtr, marginal=seq_kwargs["marginal_sequences"], **seq_kwargs) - if seq_kwargs["prune_short"]: + self.infer_ancestral_sequences( + infer_gtr=infer_gtr, marginal=seq_kwargs['marginal_sequences'], **seq_kwargs + ) + if seq_kwargs['prune_short']: self.prune_short_branches() else: - self.optimize_tree(infer_gtr=infer_gtr, - max_iter=1, method_anc = method_anc, **seq_kwargs) + self.optimize_tree(infer_gtr=infer_gtr, max_iter=1, method_anc=method_anc, **seq_kwargs) # optionally reroot the tree either by oldest, best regression or with a specific leaf - if n_iqd or root=='clock_filter': - if "plot_rtt" in kwargs and kwargs["plot_rtt"]: - plot_rtt=True + if n_iqd or root == 'clock_filter': + if 'plot_rtt' in kwargs and kwargs['plot_rtt']: + plot_rtt = True else: - plot_rtt=False - reroot_mechanism = 'least-squares' if root=='clock_filter' else root - self.clock_filter(reroot=reroot_mechanism, method=clock_filter_method, - n_iqd=n_iqd, plot=plot_rtt, fixed_clock_rate=fixed_clock_rate) + plot_rtt = False + reroot_mechanism = 'least-squares' if root == 'clock_filter' else root + self.clock_filter( + reroot=reroot_mechanism, + method=clock_filter_method, + n_iqd=n_iqd, + plot=plot_rtt, + fixed_clock_rate=fixed_clock_rate, + ) elif root is not None: self.reroot(root=root, clock_rate=fixed_clock_rate) - if self.branch_length_mode=='input': + if self.branch_length_mode == 'input': if self.aln: - self.infer_ancestral_sequences(**seq_kwargs) + self.infer_ancestral_sequences(marginal=seq_kwargs['marginal_sequences'], **seq_kwargs) else: - self.optimize_tree(max_iter=1, method_anc = method_anc, **seq_kwargs) + self.optimize_tree(max_iter=1, method_anc=method_anc, **seq_kwargs) # infer time tree and optionally resolve polytomies - self.logger("###TreeTime.run: INITIAL ROUND",0) + self.logger('###TreeTime.run: INITIAL ROUND', 0) self.make_time_tree(**tt_kwargs) if self.aln: seq_LH = self.tree.sequence_marginal_LH if seq_kwargs['marginal_sequences'] else self.tree.sequence_joint_LH - self.LH =[[seq_LH, self.tree.positional_LH, 0]] + self.LH = [[seq_LH, self.tree.positional_LH, 0]] # if we reroot, repeat rerooting after initial clock-filter/time tree # re-optimize branch length, and update time tree if root is not None and max_iter: - self.reroot(root='least-squares' if root=='clock_filter' else root, clock_rate=fixed_clock_rate) - self.logger("###TreeTime.run: rerunning timetree after rerooting",0) + self.reroot(root='least-squares' if root == 'clock_filter' else root, clock_rate=fixed_clock_rate) + self.logger('###TreeTime.run: rerunning timetree after rerooting', 0) - if self.branch_length_mode!='input': - self.optimize_tree(max_iter=0, method_anc = method_anc,**seq_kwargs) + if self.branch_length_mode != 'input': + self.optimize_tree(max_iter=0, method_anc=method_anc, **seq_kwargs) self.make_time_tree(**tt_kwargs) @@ -259,20 +291,28 @@ class TreeTime(ClockTree): # Initialize the tracelog dict attribute self.trace_run = [] - self.trace_run.append(self.tracelog_run(niter=0, ndiff=0, n_resolved=0, - time_marginal = tt_kwargs['time_marginal'], - sequence_marginal = seq_kwargs['marginal_sequences'], Tc=None, tracelog=tracelog_file)) - - need_new_time_tree=False + self.trace_run.append( + self.tracelog_run( + niter=0, + ndiff=0, + n_resolved=0, + time_marginal=tt_kwargs['time_marginal'], + sequence_marginal=seq_kwargs['marginal_sequences'], + Tc=None, + tracelog=tracelog_file, + ) + ) + + need_new_time_tree = False while niter < max_iter: - self.logger("###TreeTime.run: ITERATION %d out of %d iterations"%(niter+1,max_iter),0) + self.logger('###TreeTime.run: ITERATION %d out of %d iterations' % (niter + 1, max_iter), 0) # add coalescent prior - tmpTc=None + tmpTc = None if Tc: - if Tc=='skyline' and niter<max_iter-1: - tmpTc='const' + if Tc == 'skyline' and niter < max_iter - 1: + tmpTc = 'const' else: - tmpTc=Tc + tmpTc = Tc self.add_coalescent_model(tmpTc, **kwargs) need_new_time_tree = True @@ -281,84 +321,115 @@ class TreeTime(ClockTree): self.relaxed_clock(**relaxed_clock) need_new_time_tree = True - n_resolved=0 + n_resolved = 0 if resolve_polytomies: # if polytomies are found, rerun the entire procedure n_resolved = self.resolve_polytomies(stochastic_resolve=stochastic_resolve) if n_resolved: - seq_kwargs['prune_short']=False + seq_kwargs['prune_short'] = False self.prepare_tree() - if self.branch_length_mode!='input': # otherwise reoptimize branch length while preserving branches without mutations - self.optimize_tree(max_iter=0, method_anc = method_anc,**seq_kwargs) + if ( + self.branch_length_mode != 'input' + ): # otherwise reoptimize branch length while preserving branches without mutations + self.optimize_tree(max_iter=0, method_anc=method_anc, **seq_kwargs) need_new_time_tree = True if assign_gamma and callable(assign_gamma): - self.logger("### assigning gamma",1) + self.logger('### assigning gamma', 1) assign_gamma(self.tree) need_new_time_tree = True if need_new_time_tree: self.make_time_tree(**tt_kwargs) if self.aln: - ndiff = self.infer_ancestral_sequences('ml',**seq_kwargs) - else: # no refinements, just iterate + ndiff = self.infer_ancestral_sequences( + 'ml', marginal=seq_kwargs['marginal_sequences'], **seq_kwargs + ) + else: # no refinements, just iterate if self.aln: - ndiff = self.infer_ancestral_sequences('ml',**seq_kwargs) + ndiff = self.infer_ancestral_sequences( + 'ml', marginal=seq_kwargs['marginal_sequences'], **seq_kwargs + ) self.make_time_tree(**tt_kwargs) self.tree.coalescent_joint_LH = self.merger_model.total_LH() if Tc else 0.0 if self.aln: - seq_LH = self.tree.sequence_marginal_LH if seq_kwargs['marginal_sequences'] else self.tree.sequence_joint_LH + seq_LH = ( + self.tree.sequence_marginal_LH if seq_kwargs['marginal_sequences'] else self.tree.sequence_joint_LH + ) self.LH.append([seq_LH, self.tree.positional_LH, self.tree.coalescent_joint_LH]) # Update the trace log - self.trace_run.append(self.tracelog_run(niter=niter+1, ndiff=ndiff, n_resolved=n_resolved, - time_marginal = tt_kwargs['time_marginal'], - sequence_marginal = seq_kwargs['marginal_sequences'], Tc=tmpTc, tracelog=tracelog_file)) - - niter+=1 - - if ndiff==0 and n_resolved==0 and Tc!='skyline': - self.logger("###TreeTime.run: CONVERGED",0) + self.trace_run.append( + self.tracelog_run( + niter=niter + 1, + ndiff=ndiff, + n_resolved=n_resolved, + time_marginal=tt_kwargs['time_marginal'], + sequence_marginal=seq_kwargs['marginal_sequences'], + Tc=tmpTc, + tracelog=tracelog_file, + ) + ) + + niter += 1 + + if ndiff == 0 and n_resolved == 0 and Tc != 'skyline': + self.logger('###TreeTime.run: CONVERGED', 0) break - # if the rate is too be varied and the rate estimate has a valid confidence interval # rerun the estimation for variations of the rate if vary_rate: - if type(vary_rate)==float: + if type(vary_rate) == float: self.calc_rate_susceptibility(rate_std=vary_rate, params=tt_kwargs) elif self.clock_model['valid_confidence']: self.calc_rate_susceptibility(params=tt_kwargs) else: - raise UnknownMethodError("TreeTime.run: rate variation for confidence estimation is not available. Either specify it explicitly, or estimate from root-to-tip regression.") + raise UnknownMethodError( + 'TreeTime.run: rate variation for confidence estimation is not available. Either specify it explicitly, or estimate from root-to-tip regression.' + ) # if marginal reconstruction requested, make one more round with marginal=True # this will set marginal_pos_LH, which to be used as error bar estimations if time_marginal_method in ['only-final', 'confidence-only']: - self.logger("###TreeTime.run: FINAL ROUND - confidence estimation via marginal reconstruction", 0) + self.logger('###TreeTime.run: FINAL ROUND - confidence estimation via marginal reconstruction', 0) tt_kwargs['time_marginal'] = True self.make_time_tree(**tt_kwargs) - self.trace_run.append(self.tracelog_run(niter=niter+1, ndiff=0, n_resolved=0, - time_marginal=tt_kwargs['time_marginal'], - sequence_marginal=seq_kwargs['marginal_sequences'], Tc=Tc, tracelog=tracelog_file)) + self.trace_run.append( + self.tracelog_run( + niter=niter + 1, + ndiff=0, + n_resolved=0, + time_marginal=tt_kwargs['time_marginal'], + sequence_marginal=seq_kwargs['marginal_sequences'], + Tc=Tc, + tracelog=tracelog_file, + ) + ) # explicitly print out which branches are bad and whose dates don't correspond to the input dates - bad_branches =[n for n in self.tree.get_terminals() - if n.bad_branch and n.raw_date_constraint] + bad_branches = [n for n in self.tree.get_terminals() if n.bad_branch and n.raw_date_constraint] if bad_branches: - self.logger("TreeTime: the following tips have been marked as outliers. Their date constraints were not used. " - "Please remove them from the tree. Their dates have been reset:",0,warn=True) + self.logger( + 'TreeTime: the following tips have been marked as outliers. Their date constraints were not used. ' + 'Please remove them from the tree. Their dates have been reset:', + 0, + warn=True, + ) for n in bad_branches: - self.logger("%s, input date: %s, apparent date: %1.2f"%(n.name, str(n.raw_date_constraint), n.numdate),0,warn=True) + self.logger( + '%s, input date: %s, apparent date: %1.2f' % (n.name, str(n.raw_date_constraint), n.numdate), + 0, + warn=True, + ) return ttconf.SUCCESS - def _set_branch_length_mode(self, branch_length_mode): - ''' + """ if branch_length mode is not explicitly set, set according to empirical branch length distribution in input tree @@ -368,25 +439,27 @@ class TreeTime(ClockTree): branch_length_mode : str, 'input', 'joint', 'marginal' if the maximal branch length in the tree is longer than 0.05, this will default to 'input'. Otherwise set to 'joint' - ''' + """ if branch_length_mode in ['joint', 'marginal', 'input']: self.branch_length_mode = branch_length_mode elif self.aln: bl_dis = [n.branch_length for n in self.tree.find_clades() if n.up] max_bl = np.max(bl_dis) - if max_bl>0.1: + if max_bl > 0.1: bl_mode = 'input' else: bl_mode = 'joint' - self.logger("TreeTime._set_branch_length_mode: maximum branch length is %1.3e, using branch length mode %s"%(max_bl, bl_mode),1) + self.logger( + 'TreeTime._set_branch_length_mode: maximum branch length is %1.3e, using branch length mode %s' + % (max_bl, bl_mode), + 1, + ) self.branch_length_mode = bl_mode else: self.branch_length_mode = 'input' - - def clock_filter(self, reroot='least-squares', method='residual', - n_iqd=None, plot=False, fixed_clock_rate=None): - r''' + def clock_filter(self, reroot='least-squares', method='residual', n_iqd=None, plot=False, fixed_clock_rate=None): + r""" Labels outlier branches that don't seem to follow a molecular clock and excludes them from subsequent molecular clock estimation and the timetree propagation. @@ -406,28 +479,36 @@ class TreeTime(ClockTree): plot : bool If True, plot the results - ''' + """ from .clock_filter_methods import residual_filter, local_filter + if n_iqd is None: n_iqd = ttconf.NIQD - if type(reroot) is list and len(reroot)==1: - reroot=str(reroot[0]) + if type(reroot) is list and len(reroot) == 1: + reroot = str(reroot[0]) if reroot: - self.reroot(root='least-squares' if reroot=='best' else reroot, - covariation=False, clock_rate=fixed_clock_rate) + self.reroot( + root='least-squares' if reroot == 'best' else reroot, covariation=False, clock_rate=fixed_clock_rate + ) else: self.get_clock_model(covariation=False, slope=fixed_clock_rate) - if method=='residual': + if method == 'residual': bad_branch_count = residual_filter(self, n_iqd) - elif method=='local': + elif method == 'local': bad_branch_count = local_filter(self, n_iqd) else: - raise ValueError(f"TreeTime.clock_filter: unknown clock-filter method '{method}'. Implemented methods are: 'residual', 'local'") - - if bad_branch_count>0.34*self.tree.count_terminals(): - self.logger("TreeTime.clock_filter: More than a third of leaves have been excluded by the clock filter. Please check your input data.", 0, warn=True) + raise ValueError( + f"TreeTime.clock_filter: unknown clock-filter method '{method}'. Implemented methods are: 'residual', 'local'" + ) + + if bad_branch_count > 0.34 * self.tree.count_terminals(): + self.logger( + 'TreeTime.clock_filter: More than a third of leaves have been excluded by the clock filter. Please check your input data.', + 0, + warn=True, + ) # reassign bad_branch flags to internal nodes self.prepare_tree() @@ -440,7 +521,6 @@ class TreeTime(ClockTree): return ttconf.SUCCESS - def plot_root_to_tip(self, add_internal=False, label=True, ax=None): """ Plot root-to-tip regression @@ -461,9 +541,7 @@ class TreeTime(ClockTree): cf = self.clock_model['valid_confidence'] else: cf = False - Treg.clock_plot(ax=ax, add_internal=add_internal, confidence=cf, n_sigma=1, - regression=self.clock_model) - + Treg.clock_plot(ax=ax, add_internal=add_internal, confidence=cf, n_sigma=1, regression=self.clock_model) def reroot(self, root='least-squares', force_positive=True, covariation=None, clock_rate=None): """ @@ -491,57 +569,65 @@ class TreeTime(ClockTree): covariation : bool account for covariation in root-to-tip regression """ - if type(root) is list and len(root)==1: - root=str(root[0]) + if type(root) is list and len(root) == 1: + root = str(root[0]) - if root=='best': - root='least-squares' + if root == 'best': + root = 'least-squares' use_cov = self.use_covariation if covariation is None else covariation - slope = 0.0 if type(root)==str and root.startswith('min_dev') else clock_rate + slope = 0.0 if type(root) == str and root.startswith('min_dev') else clock_rate old_root = self.tree.root - self.logger("TreeTime.reroot: with method or node: %s"%root,0) + self.logger('TreeTime.reroot: with method or node: %s' % root, 0) for n in self.tree.find_clades(): - n.branch_length=n.mutation_length + n.branch_length = n.mutation_length - if (type(root) is str) and \ - (root in rerooting_mechanisms or root in deprecated_rerooting_mechanisms): + if (type(root) is str) and (root in rerooting_mechanisms or root in deprecated_rerooting_mechanisms): if root in deprecated_rerooting_mechanisms: - if "ML" in root: - use_cov=True - self.logger('TreeTime.reroot: rerooting mechanisms %s has been renamed to %s' - %(root, deprecated_rerooting_mechanisms[root]), 1, warn=True) + if 'ML' in root: + use_cov = True + self.logger( + 'TreeTime.reroot: rerooting mechanisms %s has been renamed to %s' + % (root, deprecated_rerooting_mechanisms[root]), + 1, + warn=True, + ) root = deprecated_rerooting_mechanisms[root] - self.logger("TreeTime.reroot: rerooting will %s covariance and shared ancestry."%("account for" if use_cov else "ignore"),0) - new_root = self._find_best_root(covariation=use_cov, - slope = slope, - force_positive=force_positive and (not root.startswith('min_dev'))) + self.logger( + 'TreeTime.reroot: rerooting will %s covariance and shared ancestry.' + % ('account for' if use_cov else 'ignore'), + 0, + ) + new_root = self._find_best_root( + covariation=use_cov, slope=slope, force_positive=force_positive and (not root.startswith('min_dev')) + ) else: - if isinstance(root,Phylo.BaseTree.Clade): + if isinstance(root, Phylo.BaseTree.Clade): new_root = root elif isinstance(root, list): new_root = self.tree.common_ancestor(root) elif root in self._leaves_lookup: new_root = self._leaves_lookup[root] - elif root=='oldest': - new_root = sorted([n for n in self.tree.get_terminals() - if n.raw_date_constraint is not None], - key=lambda x:np.mean(x.raw_date_constraint))[0] + elif root == 'oldest': + new_root = sorted( + [n for n in self.tree.get_terminals() if n.raw_date_constraint is not None], + key=lambda x: np.mean(x.raw_date_constraint), + )[0] else: raise UnknownMethodError('TreeTime.reroot -- ERROR: unsupported rooting mechanisms or root not found') - #this forces a bifurcating root, as we want. Branch lengths will be reoptimized anyway. - #(Without outgroup_branch_length, gives a trifurcating root, but this will mean - #mutations may have to occur multiple times.) - self.tree.root_with_outgroup(new_root, outgroup_branch_length=new_root.branch_length/2) - self.tree.root.clades.sort(key = lambda x:x.count_terminals()) - self.get_clock_model(covariation=use_cov, slope = slope) - + # this forces a bifurcating root, as we want. Branch lengths will be reoptimized anyway. + # (Without outgroup_branch_length, gives a trifurcating root, but this will mean + # mutations may have to occur multiple times.) + self.tree.root_with_outgroup(new_root, outgroup_branch_length=new_root.branch_length / 2) + self.tree.root.clades.sort(key=lambda x: x.count_terminals()) + self.get_clock_model(covariation=use_cov, slope=slope) - self.logger("TreeTime.reroot: Tree was re-rooted to node " - +('new_node' if new_root.name is None else new_root.name), 2) + self.logger( + 'TreeTime.reroot: Tree was re-rooted to node ' + ('new_node' if new_root.name is None else new_root.name), 2 + ) self.tree.root.branch_length = self.one_mutation self.tree.root.clock_length = self.one_mutation @@ -563,9 +649,7 @@ class TreeTime(ClockTree): return new_root - - def resolve_polytomies(self, merge_compressed=False, resolution_threshold=0.05, - stochastic_resolve=False): + def resolve_polytomies(self, merge_compressed=False, resolution_threshold=0.05, stochastic_resolve=False): """ Resolve the polytomies on the tree. @@ -593,15 +677,20 @@ class TreeTime(ClockTree): The number of polytomies found """ - self.logger("TreeTime.resolve_polytomies: resolving multiple mergers...",1) - poly_found=0 + self.logger('TreeTime.resolve_polytomies: resolving multiple mergers...', 1) + poly_found = 0 if stochastic_resolve is False: - self.logger("DEPRECATION WARNING. TreeTime.resolve_polytomies: You are " - "resolving polytomies using the old 'greedy' mode. This is not " - "well suited for large polytomies. Stochastic resolution will " - "become the default in future versions. To switch now, rerun " - "with the flag `--stochastic-resolve`. To keep using the greedy method " - "in the future, run with `--greedy-resolve` ", 0, warn=True, only_once=True) + self.logger( + 'DEPRECATION WARNING. TreeTime.resolve_polytomies: You are ' + "resolving polytomies using the old 'greedy' mode. This is not " + 'well suited for large polytomies. Stochastic resolution will ' + 'become the default in future versions. To switch now, rerun ' + 'with the flag `--stochastic-resolve`. To keep using the greedy method ' + 'in the future, run with `--greedy-resolve` ', + 0, + warn=True, + only_once=True, + ) for n in self.tree.find_clades(): if len(n.clades) > 2: @@ -611,24 +700,21 @@ class TreeTime(ClockTree): else: self._poly(n, merge_compressed, resolution_threshold=resolution_threshold) - poly_found+=prior_n_clades - len(n.clades) + poly_found += prior_n_clades - len(n.clades) - obsolete_nodes = [n for n in self.tree.find_clades() - if len(n.clades)==1 and n.up is not None] + obsolete_nodes = [n for n in self.tree.find_clades() if len(n.clades) == 1 and n.up is not None] for node in obsolete_nodes: - self.logger('TreeTime.resolve_polytomies: remove obsolete node '+node.name,4) + self.logger('TreeTime.resolve_polytomies: remove obsolete node ' + node.name, 4) if node.up is not None: self.tree.collapse(node) if poly_found: - self.logger('TreeTime.resolve_polytomies: introduces %d new nodes'%poly_found,3) + self.logger('TreeTime.resolve_polytomies: introduces %d new nodes' % poly_found, 3) else: - self.logger('TreeTime.resolve_polytomies: No more polytomies to resolve',3) + self.logger('TreeTime.resolve_polytomies: No more polytomies to resolve', 3) return poly_found - def _poly(self, clade, merge_compressed, resolution_threshold): - """ Function to resolve polytomies for a given parent node. If the number of the direct descendants is less than three (not a polytomy), does @@ -641,7 +727,7 @@ class TreeTime(ClockTree): from .branch_len_interpolator import BranchLenInterpolator - zero_branch_slope = self.gtr.mu*self.data.full_length + zero_branch_slope = self.gtr.mu * self.data.full_length def _c_gain(t, n1, n2, parent): """ @@ -652,10 +738,14 @@ class TreeTime(ClockTree): entire procedure is not well suited for large polytomies. """ # old - new contributions of child branches - cg1 = n1.branch_length_interpolator._func(parent.time_before_present - n1.time_before_present) - n1.branch_length_interpolator._func(t - n1.time_before_present) - cg2 = n2.branch_length_interpolator._func(parent.time_before_present - n2.time_before_present) - n2.branch_length_interpolator._func(t - n2.time_before_present) + cg1 = n1.branch_length_interpolator._func( + parent.time_before_present - n1.time_before_present + ) - n1.branch_length_interpolator._func(t - n1.time_before_present) + cg2 = n2.branch_length_interpolator._func( + parent.time_before_present - n2.time_before_present + ) - n2.branch_length_interpolator._func(t - n2.time_before_present) # old - new contribution of additional branch (no old contribution) - cg_new = - zero_branch_slope * (parent.time_before_present - t) # loss in LH due to the new branch + cg_new = -zero_branch_slope * (parent.time_before_present - t) # loss in LH due to the new branch return -(cg2 + cg1 + cg_new) @@ -664,31 +754,37 @@ class TreeTime(ClockTree): cost gained if the two nodes would have been connected. """ try: - cg = sciopt.minimize_scalar(_c_gain, - bounds=[max(n1.time_before_present,n2.time_before_present), parent.time_before_present], - method='bounded',args=(n1,n2, parent), options={'xatol':1e-4*self.one_mutation}) - return cg['x'], - cg['fun'] + cg = sciopt.minimize_scalar( + _c_gain, + bounds=[max(n1.time_before_present, n2.time_before_present), parent.time_before_present], + method='bounded', + args=(n1, n2, parent), + options={'xatol': 1e-4 * self.one_mutation}, + ) + return cg['x'], -cg['fun'] except: - self.logger("TreeTime._poly.cost_gain: optimization of gain failed", 3, warn=True) + self.logger('TreeTime._poly.cost_gain: optimization of gain failed', 3, warn=True) return parent.time_before_present, 0.0 - def merge_nodes(source_arr, isall=False): - mergers = np.array([[cost_gain(n1,n2, clade) if i1<i2 else (0.0,-1.0) - for i1,n1 in enumerate(source_arr)] - for i2, n2 in enumerate(source_arr)]) + mergers = np.array( + [ + [cost_gain(n1, n2, clade) if i1 < i2 else (0.0, -1.0) for i1, n1 in enumerate(source_arr)] + for i2, n2 in enumerate(source_arr) + ] + ) LH = 0 while len(source_arr) > 1 + int(isall): # max possible gains of the cost when connecting the nodes: # this is only a rough approximation because it assumes the new node positions # to be optimal - new_positions = mergers[:,:,0] - cost_gains = mergers[:,:,1] + new_positions = mergers[:, :, 0] + cost_gains = mergers[:, :, 1] # set zero to large negative value and find optimal pair np.fill_diagonal(cost_gains, -1e11) - idxs = np.unravel_index(cost_gains.argmax(),cost_gains.shape) - if (idxs[0] == idxs[1]) or cost_gains.max()<resolution_threshold: - self.logger("TreeTime._poly.merge_nodes: node is not fully resolved "+clade.name,4) + idxs = np.unravel_index(cost_gains.argmax(), cost_gains.shape) + if (idxs[0] == idxs[1]) or cost_gains.max() < resolution_threshold: + self.logger('TreeTime._poly.merge_nodes: node is not fully resolved ' + clade.name, 4) return LH n1, n2 = source_arr[idxs[0]], source_arr[idxs[1]] @@ -698,14 +794,14 @@ class TreeTime(ClockTree): # fix positions and branch lengths new_node.time_before_present = new_positions[idxs] new_node.branch_length = clade.time_before_present - new_node.time_before_present - self.logger(f"TreeTime._poly.merge_nodes: merging {n1.name} and {n2.name}",4) - new_node.clades = [n1,n2] + self.logger(f'TreeTime._poly.merge_nodes: merging {n1.name} and {n2.name}', 4) + new_node.clades = [n1, n2] if n1.mask is None or n2.mask is None: new_node.mask = None new_node.mcc = None else: new_node.mask = n1.mask * n2.mask - new_node.mcc = n1.mcc if n1.mcc==n2.mcc else None + new_node.mcc = n1.mcc if n1.mcc == n2.mcc else None self.logger('TreeTime._poly.merge_nodes: assigning mcc to new node ' + new_node.mcc, 4) n1.branch_length = new_node.time_before_present - n1.time_before_present @@ -716,139 +812,159 @@ class TreeTime(ClockTree): new_node.tt = self n1.up = new_node n2.up = new_node - if hasattr(clade, "_cseq"): + if hasattr(clade, '_cseq'): new_node._cseq = clade._cseq self.add_branch_state(new_node) new_node.mutation_length = 0.0 - if self.branch_length_mode=='marginal': - new_node.marginal_subtree_LH = clade.marginal_subtree_LH + if self.branch_length_mode == 'marginal': + new_node.marginal_subtree_LH = clade.marginal_subtree_LH new_node.marginal_outgroup_LH = clade.marginal_outgroup_LH new_node.profile_pair = self.marginal_branch_profile(new_node) - new_node.branch_length_interpolator = BranchLenInterpolator(new_node, self.gtr, - pattern_multiplicity = self.data.multiplicity(mask=new_node.mask), min_width=self.min_width, - one_mutation=self.one_mutation, branch_length_mode=self.branch_length_mode, - n_grid_points = self.branch_grid_points) + new_node.branch_length_interpolator = BranchLenInterpolator( + new_node, + self.gtr, + pattern_multiplicity=self.data.multiplicity(mask=new_node.mask), + min_width=self.min_width, + one_mutation=self.one_mutation, + branch_length_mode=self.branch_length_mode, + n_grid_points=self.branch_grid_points, + ) clade.clades.remove(n1) clade.clades.remove(n2) clade.clades.append(new_node) - self.logger('TreeTime._poly.merge_nodes: creating new node as child of '+clade.name,3) - self.logger("TreeTime._poly.merge_nodes: Delta-LH = " + str(cost_gains[idxs].round(3)), 3) + self.logger('TreeTime._poly.merge_nodes: creating new node as child of ' + clade.name, 3) + self.logger('TreeTime._poly.merge_nodes: Delta-LH = ' + str(cost_gains[idxs].round(3)), 3) # and modify source_arr array for the next loop - if len(source_arr)>2: # if more than 3 nodes in polytomy, replace row/column + if len(source_arr) > 2: # if more than 3 nodes in polytomy, replace row/column for ii in np.sort(idxs)[::-1]: - tmp_ind = np.arange(mergers.shape[0])!=ii - mergers = mergers[tmp_ind].swapaxes(0,1) - mergers = mergers[tmp_ind].swapaxes(0,1) + tmp_ind = np.arange(mergers.shape[0]) != ii + mergers = mergers[tmp_ind].swapaxes(0, 1) + mergers = mergers[tmp_ind].swapaxes(0, 1) source_arr.remove(n1) source_arr.remove(n2) - new_gains = np.array([[cost_gain(n1,new_node, clade) for n1 in source_arr]]) - mergers = np.vstack((mergers, new_gains)).swapaxes(0,1) + new_gains = np.array([[cost_gain(n1, new_node, clade) for n1 in source_arr]]) + mergers = np.vstack((mergers, new_gains)).swapaxes(0, 1) source_arr.append(new_node) - new_gains = np.array([[cost_gain(n1,new_node, clade) for n1 in source_arr]]) - mergers = np.vstack((mergers, new_gains)).swapaxes(0,1) - else: # otherwise just recalculate matrix + new_gains = np.array([[cost_gain(n1, new_node, clade) for n1 in source_arr]]) + mergers = np.vstack((mergers, new_gains)).swapaxes(0, 1) + else: # otherwise just recalculate matrix source_arr.remove(n1) source_arr.remove(n2) source_arr.append(new_node) - mergers = np.array([[cost_gain(n1,n2, clade) for n1 in source_arr] - for n2 in source_arr]) + mergers = np.array([[cost_gain(n1, n2, clade) for n1 in source_arr] for n2 in source_arr]) return LH - stretched = [c for c in clade.clades if c.mutation_length < c.clock_length] + stretched = [c for c in clade.clades if c.mutation_length < c.clock_length] compressed = [c for c in clade.clades if c not in stretched] - if len(stretched)<2 and merge_compressed is False: + if len(stretched) < 2 and merge_compressed is False: return 0.0 - LH = merge_nodes(stretched, isall=len(stretched)==len(clade.clades)) - if merge_compressed and len(compressed)>1: - LH += merge_nodes(compressed, isall=len(compressed)==len(clade.clades)) + LH = merge_nodes(stretched, isall=len(stretched) == len(clade.clades)) + if merge_compressed and len(compressed) > 1: + LH += merge_nodes(compressed, isall=len(compressed) == len(clade.clades)) return LH - def generate_subtree(self, parent): from .branch_len_interpolator import BranchLenInterpolator + # use the random number generator of TreeTime exp_dis = self.rng.exponential L = self.data.full_length - mutation_rate = self.gtr.mu*L + mutation_rate = self.gtr.mu * L tmax = parent.time_before_present - branches_by_time = sorted(parent.clades, key=lambda x:x.time_before_present) + branches_by_time = sorted(parent.clades, key=lambda x: x.time_before_present) # calculate the mutations on branches leading to nodes from the mutation length # this excludes state chances to ambiguous states - mutations_per_branch = {b.name:round(b.mutation_length*L) for b in branches_by_time} + mutations_per_branch = {b.name: round(b.mutation_length * L) for b in branches_by_time} - branches_alive=branches_by_time[:1] + branches_alive = branches_by_time[:1] branches_to_come = branches_by_time[1:] t = branches_alive[-1].time_before_present - if t>=tmax: + if t >= tmax: # no time left -- keep everything as individual children. return # if there is no coalescent model, assume a rate that would typically coalesce all tips # in the time window between the latest and the parent node. - dummy_coalescent_rate = 2.0/(tmax-t) - self.logger(f"TreeTime.generate_subtree: node {parent.name} has {len(branches_by_time)} children." - +f" {len([b for b,k in mutations_per_branch.items() if k>0])} have mutations." - +f" The time window for coalescence is {tmax-t:1.4e}",3) + dummy_coalescent_rate = 2.0 / (tmax - t) + self.logger( + f'TreeTime.generate_subtree: node {parent.name} has {len(branches_by_time)} children.' + + f' {len([b for b, k in mutations_per_branch.items() if k > 0])} have mutations.' + + f' The time window for coalescence is {tmax - t:1.4e}', + 3, + ) # loop until time collides with the parent node or all but two branches have been dealt with # the remaining two would be the children of the parent - while len(branches_alive)+len(branches_to_come)>2 and t<tmax: - + while len(branches_alive) + len(branches_to_come) > 2 and t < tmax: # branches without mutations are ready to coalesce -- others have to mutate first - ready_to_coalesce = [b for b in branches_alive if mutations_per_branch.get(b.name,0)==0] + ready_to_coalesce = [b for b in branches_alive if mutations_per_branch.get(b.name, 0) == 0] if hasattr(self, 'merger_model') and (self.merger_model is not None): coalescent_rate = self.merger_model.branch_merger_rate(t) + mutation_rate else: - coalescent_rate = 0.5*len(ready_to_coalesce)*dummy_coalescent_rate + mutation_rate + coalescent_rate = 0.5 * len(ready_to_coalesce) * dummy_coalescent_rate + mutation_rate - total_mutations = np.sum([mutations_per_branch.get(b.name,0) for b in branches_alive]) + total_mutations = np.sum([mutations_per_branch.get(b.name, 0) for b in branches_alive]) n_branches_w_mutations = len(branches_alive) - len(ready_to_coalesce) # the probability of a branch without events is the sum of mutation and coalescent rates # branches with mutations can only mutate, the others only coalesce. This is due to the # conditioning for all branches being direct descendants of a polytomy. - total_mut_rate = mutation_rate*total_mutations + coalescent_rate*n_branches_w_mutations - total_coalescent_rate = max(0,(len(ready_to_coalesce)-1))*(coalescent_rate + mutation_rate) + total_mut_rate = mutation_rate * total_mutations + coalescent_rate * n_branches_w_mutations + total_coalescent_rate = max(0, (len(ready_to_coalesce) - 1)) * (coalescent_rate + mutation_rate) # just a single branch and no mutations --> advance to next branch - if (total_mut_rate + total_coalescent_rate)==0 and len(branches_to_come): + if (total_mut_rate + total_coalescent_rate) == 0 and len(branches_to_come): branches_alive.append(branches_to_come.pop(0)) t = branches_alive[-1].time_before_present continue # determine the next time step - total_rate_inv = 1.0/(total_mut_rate + total_coalescent_rate) + total_rate_inv = 1.0 / (total_mut_rate + total_coalescent_rate) dt = exp_dis(total_rate_inv) - t+=dt + t += dt # if the time advanced past the next branch in the branches_to_come list # add this branch to branches alive and re-renter the loop - if len(branches_to_come) and t>branches_to_come[0].time_before_present: - while len(branches_to_come) and t>branches_to_come[0].time_before_present: + if len(branches_to_come) and t > branches_to_come[0].time_before_present: + while len(branches_to_come) and t > branches_to_come[0].time_before_present: branches_alive.append(branches_to_come.pop(0)) # else mutate or coalesce else: # determine whether to mutate or coalesce p = self.rng.random() - mut_or_coal = p<total_mut_rate*total_rate_inv + mut_or_coal = p < total_mut_rate * total_rate_inv if mut_or_coal: + # pick a branch to remove a mutation from # transform p to be on a scale of 0 to total mutation - p /= total_mut_rate*total_rate_inv + p /= total_mut_rate * total_rate_inv p *= total_mutations # discount one mutation at a time until p<0, break and remove that mutation + branch_to_mutate = None for b in branches_alive: - p -= mutations_per_branch.get(b.name,0) - if p<0: break - mutations_per_branch[b.name] -= 1 + p -= mutations_per_branch.get(b.name, 0) + if p < 0: + branch_to_mutate = b + break + if branch_to_mutate is None: + # this should never happen (but recreate previous behavior for now) + # TODO: raise + self.logger( + 'TreeTime.generate_subtree: did not find a mutation to remove -- error in total_mutation count calculation', + 2, + ) + branch_to_mutate = branches_alive[-1] + + # remove a mutation from the branch + mutations_per_branch[branch_to_mutate.name] -= 1 else: # pick a pair to coalesce, make a new node. picks = self.rng.choice(len(ready_to_coalesce), size=2, replace=False) @@ -866,18 +982,27 @@ class TreeTime(ClockTree): new_node.mcc = None else: new_node.mask = n1.mask * n2.mask - new_node.mcc = n1.mcc if n1.mcc==n2.mcc else None + new_node.mcc = n1.mcc if n1.mcc == n2.mcc else None self.logger('TreeTime._poly.merge_nodes: assigning mcc to new node ' + new_node.mcc, 4) new_node.up = parent new_node.tt = self - if hasattr(parent, "_cseq"): + if hasattr(parent, '_cseq'): new_node._cseq = parent._cseq self.add_branch_state(new_node) - new_node.branch_length_interpolator = BranchLenInterpolator(new_node, self.gtr, - pattern_multiplicity = self.data.multiplicity(mask=new_node.mask), min_width=self.min_width, - one_mutation=self.one_mutation, branch_length_mode=self.branch_length_mode, - n_grid_points = self.branch_grid_points) - branches_alive = [b for b in branches_alive if b not in [n1,n2]] + [new_node] + if self.branch_length_mode == 'marginal': + new_node.marginal_subtree_LH = parent.marginal_subtree_LH + new_node.marginal_outgroup_LH = parent.marginal_outgroup_LH + new_node.profile_pair = self.marginal_branch_profile(new_node) + new_node.branch_length_interpolator = BranchLenInterpolator( + new_node, + self.gtr, + pattern_multiplicity=self.data.multiplicity(mask=new_node.mask), + min_width=self.min_width, + one_mutation=self.one_mutation, + branch_length_mode=self.branch_length_mode, + n_grid_points=self.branch_grid_points, + ) + branches_alive = [b for b in branches_alive if b not in [n1, n2]] + [new_node] remaining_branches = [] for b in branches_alive + branches_to_come: @@ -885,11 +1010,13 @@ class TreeTime(ClockTree): b.up = parent remaining_branches.append(b) - self.logger(f"TreeTime.generate_subtree: node {parent.name} was resolved from {len(branches_by_time)} to {len(remaining_branches)} children.",3) + self.logger( + f'TreeTime.generate_subtree: node {parent.name} was resolved from {len(branches_by_time)} to {len(remaining_branches)} children.', + 3, + ) # assign the remaining branches as new clades to the parent. parent.clades = remaining_branches - def print_lh(self, joint=True): """ Print the total likelihood of the tree given the constrained leaves @@ -911,15 +1038,16 @@ class TreeTime(ClockTree): s_lh = self.tree.sequence_marginal_LH c_lh = 0 - print ("### Tree Log-Likelihood ###\n" - " Sequence log-LH without constraints: \t%1.3f\n" - " Sequence log-LH with constraints: \t%1.3f\n" - " TreeTime sequence log-LH: \t%1.3f\n" - " Coalescent log-LH: \t%1.3f\n" - "#########################"%(u_lh, s_lh,t_lh, c_lh)) + print( + '### Tree Log-Likelihood ###\n' + ' Sequence log-LH without constraints: \t%1.3f\n' + ' Sequence log-LH with constraints: \t%1.3f\n' + ' TreeTime sequence log-LH: \t%1.3f\n' + ' Coalescent log-LH: \t%1.3f\n' + '#########################' % (u_lh, s_lh, t_lh, c_lh) + ) except: - print("ERROR. Did you run the corresponding inference (joint/marginal)?") - + print('ERROR. Did you run the corresponding inference (joint/marginal)?') def add_coalescent_model(self, Tc, n_branches_posterior=False, **kwargs): """Add a coalescent model to the tree and optionally optimze @@ -931,24 +1059,24 @@ class TreeTime(ClockTree): rate in molecular clock units, if its is a """ from .merger_models import Coalescent - self.logger('TreeTime.run: adding coalescent prior with Tc='+str(Tc),1) - self.merger_model = Coalescent(self.tree, date2dist=self.date2dist, - logger=self.logger, n_branches_posterior=n_branches_posterior) - if Tc=='skyline': # restrict skyline model optimization to last iteration + self.logger('TreeTime.run: adding coalescent prior with Tc=' + str(Tc), 1) + self.merger_model = Coalescent( + self.tree, date2dist=self.date2dist, logger=self.logger, n_branches_posterior=n_branches_posterior + ) + + if Tc == 'skyline': # restrict skyline model optimization to last iteration self.merger_model.optimize_skyline(**kwargs) - self.logger("optimized a skyline ", 2) + self.logger('optimized a skyline ', 2) else: if Tc in ['opt', 'const']: self.merger_model.optimize_Tc() - self.logger("optimized Tc to %f"%self.merger_model.Tc.y[0], 2) + self.logger('optimized Tc to %f' % self.merger_model.Tc.y[0], 2) else: try: self.merger_model.set_Tc(Tc) except: - self.logger("setting of coalescent time scale failed", 1, warn=True) - - + self.logger('setting of coalescent time scale failed', 1, warn=True) def relaxed_clock(self, slack=None, coupling=None, **kwargs): """ @@ -966,11 +1094,13 @@ class TreeTime(ClockTree): Maximum difference in substitution rates in sibling nodes """ - if slack is None: slack=ttconf.MU_ALPHA - if coupling is None: coupling=ttconf.MU_BETA - self.logger("TreeTime.relaxed_clock: slack=%f, coupling=%f"%(slack, coupling),2) + if slack is None: + slack = ttconf.MU_ALPHA + if coupling is None: + coupling = ttconf.MU_BETA + self.logger('TreeTime.relaxed_clock: slack=%f, coupling=%f' % (slack, coupling), 2) - c=1.0/self.one_mutation + c = 1.0 / self.one_mutation for node in self.tree.find_clades(order='postorder'): opt_len = node.mutation_length act_len = node.clock_length if hasattr(node, 'clock_length') else node.branch_length @@ -979,30 +1109,36 @@ class TreeTime(ClockTree): # stiffness is the expectation of the inverse variance of branch length (one_mutation/opt_len) # contact term: stiffness*(g*bl - bl_opt)^2 + slack(g-1)^2 = # (slack+bl^2) g^2 - 2 (bl*bl_opt+1) g + C= k2 g^2 + k1 g + C - node._k2 = slack + c*act_len**2/(opt_len+self.one_mutation) - node._k1 = -2*(c*act_len*opt_len/(opt_len+self.one_mutation) + slack) + node._k2 = slack + c * act_len**2 / (opt_len + self.one_mutation) + node._k1 = -2 * (c * act_len * opt_len / (opt_len + self.one_mutation) + slack) # coupling term: \sum_c coupling*(g-g_c)^2 + Cost_c(g_c|g) # given g, g_c needs to be optimal-> 2*coupling*(g-g_c) = 2*child.k2 g_c + child.k1 # hence g_c = (coupling*g - 0.5*child.k1)/(coupling+child.k2) # substituting yields for child in node.clades: - denom = coupling+child._k2 - node._k2 += coupling*(1.0-coupling/denom)**2 + child._k2*coupling**2/denom**2 - node._k1 += (coupling*(1.0-coupling/denom)*child._k1/denom \ - - coupling*child._k1*child._k2/denom**2 \ - + coupling*child._k1/denom) + denom = coupling + child._k2 + node._k2 += coupling * (1.0 - coupling / denom) ** 2 + child._k2 * coupling**2 / denom**2 + node._k1 += ( + coupling * (1.0 - coupling / denom) * child._k1 / denom + - coupling * child._k1 * child._k2 / denom**2 + + coupling * child._k1 / denom + ) for node in self.tree.find_clades(order='preorder'): if node.up is None: - node.gamma = max(0.1, -0.5*node._k1/node._k2) + node.gamma = max(0.1, -0.5 * node._k1 / node._k2) else: if node.up.up is None: g_up = node.up.gamma else: g_up = node.up.branch_length_interpolator.gamma - node.branch_length_interpolator.gamma = max(0.1,(coupling*g_up - 0.5*node._k1)/(coupling+node._k2)) + node.branch_length_interpolator.gamma = max( + 0.1, (coupling * g_up - 0.5 * node._k1) / (coupling + node._k2) + ) - def tracelog_run(self, niter=0, ndiff=0, n_resolved=0, time_marginal=False, sequence_marginal=False, Tc=None, tracelog=None): + def tracelog_run( + self, niter=0, ndiff=0, n_resolved=0, time_marginal=False, sequence_marginal=False, Tc=None, tracelog=None + ): """ Create a dictionary of parameters for the current iteration of the run function. @@ -1031,38 +1167,40 @@ class TreeTime(ClockTree): # Store the run parameters in a dictionary trace_dict = { - 'Sample' : niter, - 'ndiff' : ndiff, - 'n_resolved' : n_resolved, - 'seq_mode' : ('marginal' if sequence_marginal else 'joint') if self.aln else 'no sequences given', - 'seq_LH' : (self.tree.sequence_marginal_LH if sequence_marginal else self.tree.sequence_joint_LH) if self.aln else 0, - 'pos_mode' : 'marginal' if time_marginal else 'joint', - 'pos_LH' : self.tree.positional_LH, - 'coal_mode' : Tc, - 'coal_LH' : self.tree.coalescent_joint_LH, + 'Sample': niter, + 'ndiff': ndiff, + 'n_resolved': n_resolved, + 'seq_mode': ('marginal' if sequence_marginal else 'joint') if self.aln else 'no sequences given', + 'seq_LH': (self.tree.sequence_marginal_LH if sequence_marginal else self.tree.sequence_joint_LH) + if self.aln + else 0, + 'pos_mode': 'marginal' if time_marginal else 'joint', + 'pos_LH': self.tree.positional_LH, + 'coal_mode': Tc, + 'coal_LH': self.tree.coalescent_joint_LH, } # Write the current iteration to a file if tracelog: # Only on the initial round, write the headers if niter == 0: - with open(tracelog, "w") as outfile: - header = "\t".join(trace_dict.keys()) - outfile.write(header + "\n") + with open(tracelog, 'w') as outfile: + header = '\t'.join(trace_dict.keys()) + outfile.write(header + '\n') # Write the parameters - with open(tracelog, "a") as outfile: + with open(tracelog, 'a') as outfile: params_str = [str(p) for p in trace_dict.values()] - params = "\t".join(params_str) - outfile.write(params + "\n") + params = '\t'.join(params_str) + outfile.write(params + '\n') return trace_dict -############################################################################### -### rerooting -############################################################################### + ############################################################################### + ### rerooting + ############################################################################### def _find_best_root(self, covariation=True, force_positive=True, slope=None, **kwarks): - ''' + """ Determine the node that, when the tree is rooted on this node, results in the best regression of temporal constraints and root to tip distances. @@ -1078,16 +1216,18 @@ class TreeTime(ClockTree): force_positive : bool only accept positive evolutionary rate estimates when rerooting the tree - ''' + """ for n in self.tree.find_clades(): - n.branch_length=n.mutation_length - self.logger("TreeTime._find_best_root: searching for the best root position...",2) + n.branch_length = n.mutation_length + self.logger('TreeTime._find_best_root: searching for the best root position...', 2) Treg = self.setup_TreeRegression(covariation=covariation) - return Treg.optimal_reroot(force_positive=force_positive, slope=slope, keep_node_order=self.keep_node_order)['node'] + return Treg.optimal_reroot(force_positive=force_positive, slope=slope, keep_node_order=self.keep_node_order)[ + 'node' + ] -def plot_vs_years(tt, step = None, ax=None, confidence=None, ticks=True, selective_confidence=None, **kwargs): - ''' +def plot_vs_years(tt, step=None, ax=None, confidence=None, ticks=True, selective_confidence=None, **kwargs): + """ Converts branch length to years and plots the time tree on a time axis. Parameters @@ -1110,53 +1250,54 @@ def plot_vs_years(tt, step = None, ax=None, confidence=None, ticks=True, selecti **kwargs : dict Key word arguments that are passed down to Phylo.draw - ''' + """ import matplotlib.pyplot as plt + tt.branch_length_to_years() nleafs = tt.tree.count_terminals() if ax is None: - fig = plt.figure(figsize=(12,10)) + fig = plt.figure(figsize=(12, 10)) ax = plt.subplot(111) else: fig = None # draw tree - if "label_func" not in kwargs: - kwargs["label_func"] = lambda x:x.name if (x.is_terminal() and nleafs<30) else "" + if 'label_func' not in kwargs: + kwargs['label_func'] = lambda x: x.name if (x.is_terminal() and nleafs < 30) else '' Phylo.draw(tt.tree, axes=ax, do_show=False, **kwargs) offset = tt.tree.root.numdate - tt.tree.root.branch_length - date_range = np.max([n.numdate for n in tt.tree.get_terminals()])-offset + date_range = np.max([n.numdate for n in tt.tree.get_terminals()]) - offset # estimate year intervals if not explicitly specified - if step is None or (step>0 and date_range/step>100): - step = 10**np.floor(np.log10(date_range)) - if date_range/step<2: - step/=5 - elif date_range/step<5: - step/=2 - step = max(1.0/12,step) + if step is None or (step > 0 and date_range / step > 100): + step = 10 ** np.floor(np.log10(date_range)) + if date_range / step < 2: + step /= 5 + elif date_range / step < 5: + step /= 2 + step = max(1.0 / 12, step) # set axis labels if step: dtick = step - min_tick = step*(offset//step) - extra = dtick if dtick<date_range else dtick - tick_vals = np.arange(min_tick, min_tick+date_range+extra, dtick) + min_tick = step * (offset // step) + extra = dtick if dtick < date_range else dtick + tick_vals = np.arange(min_tick, min_tick + date_range + extra, dtick) xticks = tick_vals - offset else: xticks = ax.get_xticks() - dtick = xticks[1]-xticks[0] - shift = offset - dtick*(offset//dtick) + dtick = xticks[1] - xticks[0] + shift = offset - dtick * (offset // dtick) xticks -= shift - tick_vals = [x+offset-shift for x in xticks] + tick_vals = [x + offset - shift for x in xticks] ax.set_xticks(xticks) - if step>=1: - tick_labels = ["%d"%(int(x)) for x in tick_vals] + if step >= 1: + tick_labels = ['%d' % (int(x)) for x in tick_vals] else: - tick_labels = ["%1.2f"%(x) for x in tick_vals] - ax.set_xlim((0,date_range)) + tick_labels = ['%1.2f' % (x) for x in tick_vals] + ax.set_xlim((0, date_range)) ax.set_xticklabels(tick_labels) ax.set_xlabel('year') ax.set_ylabel('') @@ -1166,42 +1307,48 @@ def plot_vs_years(tt, step = None, ax=None, confidence=None, ticks=True, selecti ylim = ax.get_ylim() xlim = ax.get_xlim() from matplotlib.patches import Rectangle - for yi,year in enumerate(np.arange(np.floor(tick_vals[0]), tick_vals[-1]+.01, step)): + + for yi, year in enumerate(np.arange(np.floor(tick_vals[0]), tick_vals[-1] + 0.01, step)): pos = year - offset - r = Rectangle((pos, ylim[1]-5), - step, ylim[0]-ylim[1]+10, - facecolor=[0.88+0.04*(1+yi%2)] * 3, - edgecolor=[0.8,0.8,0.8]) + r = Rectangle( + (pos, ylim[1] - 5), + step, + ylim[0] - ylim[1] + 10, + facecolor=[0.88 + 0.04 * (1 + yi % 2)] * 3, + edgecolor=[0.8, 0.8, 0.8], + ) ax.add_patch(r) - if step>=1: - if year in tick_vals and pos>=xlim[0] and pos<=xlim[1] and ticks: - label_str = "%1.2f"%(step*(year//step)) if step<1 else str(int(year)) - ax.text(pos,ylim[0]-0.04*(ylim[1]-ylim[0]), label_str, - horizontalalignment='center') - if step>=1: + if step >= 1: + if year in tick_vals and pos >= xlim[0] and pos <= xlim[1] and ticks: + label_str = '%1.2f' % (step * (year // step)) if step < 1 else str(int(year)) + ax.text(pos, ylim[0] - 0.04 * (ylim[1] - ylim[0]), label_str, horizontalalignment='center') + if step >= 1: ax.set_axis_off() # add confidence intervals to the tree graph -- grey bars if confidence: tree_layout(tt.tree) - if not hasattr(tt.tree.root, "marginal_inverse_cdf"): - raise NotReadyError("marginal time tree reconstruction required for confidence intervals") + if not hasattr(tt.tree.root, 'marginal_inverse_cdf'): + raise NotReadyError('marginal time tree reconstruction required for confidence intervals') elif type(confidence) is float: cfunc = tt.get_max_posterior_region - elif len(confidence)==2: + elif len(confidence) == 2: cfunc = tt.get_confidence_interval else: - raise NotReadyError("confidence needs to be either a float (for max posterior region) or a two numbers specifying lower and upper bounds") + raise NotReadyError( + 'confidence needs to be either a float (for max posterior region) or a two numbers specifying lower and upper bounds' + ) for n in tt.tree.find_clades(): if not n.bad_branch and (selective_confidence is None or selective_confidence(n)): pos = cfunc(n, confidence) - ax.plot(pos-offset, np.ones(len(pos))*n.ypos, lw=3, c=(0.5,0.5,0.5)) + ax.plot(pos - offset, np.ones(len(pos)) * n.ypos, lw=3, c=(0.5, 0.5, 0.5)) return fig, ax + def treetime_to_newick(tt, outf): Phylo.write(tt.tree, outf, 'newick') -if __name__=="__main__": +if __name__ == '__main__': pass diff --git a/treetime/utils.py b/treetime/utils.py index d152025..5973655 100644 --- a/treetime/utils.py +++ b/treetime/utils.py @@ -1,17 +1,18 @@ -import os,sys +import os, sys import datetime import pandas as pd import numpy as np from . import TreeTimeError, MissingDataError + class DateConversion(object): """ Small container class to store parameters to convert between branch length as it is used in ML computations and the dates of the nodes. It is assumed that the conversion formula is 'length = k*date + b' """ - def __init__(self): + def __init__(self): self.clock_rate = 0 self.intercept = 0 self.chisq = 0 @@ -22,16 +23,16 @@ class DateConversion(object): def __str__(self): if self.cov is not None and self.valid_confidence: - dslope = np.sqrt(self.cov[0,0]) - outstr = ('Root-Tip-Regression:\n --rate:\t%1.3e +/- %1.2e (one std-dev)\n --chi^2:\t%1.2f\n --r^2: \t%1.2f\n' - %(self.clock_rate, dslope, self.chisq**2, self.r_val**2)) + dslope = np.sqrt(self.cov[0, 0]) + outstr = ( + 'Root-Tip-Regression:\n --rate:\t%1.3e +/- %1.2e (one std-dev)\n --chi^2:\t%1.2f\n --r^2: \t%1.2f\n' + % (self.clock_rate, dslope, self.chisq**2, self.r_val**2) + ) else: - outstr = ('Root-Tip-Regression:\n --rate:\t%1.3e\n --r^2: \t%1.2f\n' - %(self.clock_rate, self.r_val**2)) + outstr = 'Root-Tip-Regression:\n --rate:\t%1.3e\n --r^2: \t%1.2f\n' % (self.clock_rate, self.r_val**2) return outstr - @classmethod def from_regression(cls, clock_model): """ @@ -54,7 +55,6 @@ class DateConversion(object): dc.r_val = clock_model['r_val'] return dc - def get_branch_len(self, date1, date2): """ Compute branch length given the dates of the two nodes. @@ -78,14 +78,12 @@ class DateConversion(object): """ return abs(date1 - date2) * self.clock_rate - def get_time_before_present(self, numdate): """ Convert the numeric date to the branch-len scale """ return (numeric_date() - numdate) * abs(self.clock_rate) - def to_years(self, abs_t): """ Convert the time before present measured in branch length units to years @@ -93,28 +91,24 @@ class DateConversion(object): """ return abs_t / abs(self.clock_rate) - def to_numdate(self, tbp): """ Convert time before present measured in clock rate units to numeric calendar dates """ return numeric_date() - self.to_years(tbp) - def numdate_from_dist2root(self, d2r): """ estimate the numerical date based on the distance to root. -> crude dating of internal nodes """ - return (d2r-self.intercept)/self.clock_rate - + return (d2r - self.intercept) / self.clock_rate def clock_deviation(self, numdate, d2r): """ calculate the deviatio of the """ - return (self.numdate_from_dist2root(d2r) - numdate)*self.clock_rate - + return (self.numdate_from_dist2root(d2r) - numdate) * self.clock_rate def min_interp(interp_object): @@ -124,8 +118,14 @@ def min_interp(interp_object): try: return interp_object.x[interp_object(interp_object.x).argmin()] except Exception as e: - s = "Cannot find minimum of the interpolation object" + str(interp_object.x) + \ - "Minimal x: " + str(interp_object.x.min()) + "Maximal x: " + str(interp_object.x.max()) + s = ( + 'Cannot find minimum of the interpolation object' + + str(interp_object.x) + + 'Minimal x: ' + + str(interp_object.x.min()) + + 'Maximal x: ' + + str(interp_object.x.max()) + ) raise e @@ -133,12 +133,13 @@ def median_interp(interp_object): """ Find the median of the function represented as an interpolation object. """ - new_grid = np.sort(np.concatenate([interp_object.x[:-1] + 0.1*ii*np.diff(interp_object.x) - for ii in range(10)]).flatten()) + new_grid = np.sort( + np.concatenate([interp_object.x[:-1] + 0.1 * ii * np.diff(interp_object.x) for ii in range(10)]).flatten() + ) - tmp_prop = np.exp(-(interp_object(new_grid)-interp_object.y.min())) - tmp_cumsum = np.cumsum(0.5*(tmp_prop[1:]+tmp_prop[:-1])*np.diff(new_grid)) - median_index = min(len(tmp_cumsum)-3, max(2,np.searchsorted(tmp_cumsum, tmp_cumsum[-1]*0.5)+1)) + tmp_prop = np.exp(-(interp_object(new_grid) - interp_object.y.min())) + tmp_cumsum = np.cumsum(0.5 * (tmp_prop[1:] + tmp_prop[:-1]) * np.diff(new_grid)) + median_index = min(len(tmp_cumsum) - 3, max(2, np.searchsorted(tmp_cumsum, tmp_cumsum[-1] * 0.5) + 1)) return new_grid[median_index] @@ -160,7 +161,7 @@ def numeric_date(dt=None): days_in_year = 366 if isleap(dt.year) else 365 try: - res = dt.year + (dt.timetuple().tm_yday-0.5) / days_in_year + res = dt.year + (dt.timetuple().tm_yday - 0.5) / days_in_year except: res = None @@ -183,11 +184,12 @@ def datetime_from_numeric(numdate): datetime object """ from calendar import isleap + days_in_year = 366 if isleap(int(numdate)) else 365 # add a small number of the time elapsed in a year to avoid # unexpected behavior for values 1/365, 2/365, etc - days_elapsed = int(((numdate%1)+1e-10)*days_in_year) - date = datetime.datetime(int(numdate),1,1) + datetime.timedelta(days=days_elapsed) + days_elapsed = int(((numdate % 1) + 1e-10) * days_in_year) + date = datetime.datetime(int(numdate), 1, 1) + datetime.timedelta(days=days_elapsed) return date @@ -205,11 +207,11 @@ def datestring_from_numeric(numdate): date string YYYY-MM-DD """ try: - return datetime.datetime.strftime(datetime_from_numeric(numdate), "%Y-%m-%d") + return datetime.datetime.strftime(datetime_from_numeric(numdate), '%Y-%m-%d') except: year = int(np.floor(numdate)) - dt = datetime_from_numeric(1900+(numdate%1)) - return "%04d-%02d-%02d"%(year, dt.month, dt.day) + dt = datetime_from_numeric(1900 + (numdate % 1)) + return '%04d-%02d-%02d' % (year, dt.month, dt.day) def parse_dates(date_file, name_col=None, date_col=None): @@ -225,7 +227,7 @@ def parse_dates(date_file, name_col=None, date_col=None): name of column containing taxon names. If None, will use first column that contains 'name', 'strain', 'accession' date_col : str, optional - name of column containing taxon names. If None, will use + name of column containing taxon names. If None, will use a column that contains the substring 'date' Returns @@ -238,107 +240,109 @@ def parse_dates(date_file, name_col=None, date_col=None): Numeric date values are returned as float or a list of floats with 2 elements [min, max] if the date is ambiguous. """ - print("\nAttempting to parse dates...") + print('\nAttempting to parse dates...') dates = {} if not os.path.isfile(date_file): - print("\n\tERROR: file %s does not exist, exiting..."%date_file) + print('\n\tERROR: file %s does not exist, exiting...' % date_file) return dates # separator for the csv/tsv file. If csv, we'll strip extra whitespace around ',' full_sep = '\t' if date_file.endswith('.tsv') else r'\s*,\s*' - try: - # read the metadata file into pandas dataframe. - df = pd.read_csv(date_file, sep=full_sep, engine='python', dtype='str', index_col=False) - # check the metadata has strain names in the first column - # look for the column containing sampling dates - # We assume that the dates might be given either in human-readable format - # (e.g. ISO dates), or be already converted to the numeric format. - potential_date_columns = [] - potential_numdate_columns = [] - potential_index_columns = [] - # Scan the dataframe columns and find ones which likely to store the - # dates - for ci,col in enumerate(df.columns): - d = df.iloc[0,ci] - # strip quotation marks - if type(d)==str and d[0] in ['"', "'"] and d[-1] in ['"', "'"]: - for i,tmp_d in enumerate(df.iloc[:,ci]): - df.iloc[i,ci] = tmp_d.strip(d[0]) - if 'date' in col.lower(): - potential_date_columns.append((ci, col)) - if any([x==col.lower() for x in ['name', 'strain', 'accession']]): - potential_index_columns.append((ci, col)) - - if date_col and date_col not in df.columns: - raise MissingDataError("ERROR: specified column for dates does not exist. \n\tAvailable columns are: "\ - +", ".join(df.columns)+"\n\tYou specified '%s'"%date_col) - - if name_col and name_col not in df.columns: - raise MissingDataError("ERROR: specified column for the taxon name does not exist. \n\tAvailable columns are: "\ - +", ".join(df.columns)+"\n\tYou specified '%s'"%name_col) - - - dates = {} - # if a potential numeric date column was found, use it - # (use the first, if there are more than one) - if not (len(potential_index_columns) or name_col): - raise MissingDataError("ERROR: Cannot read metadata: need at least one column that contains the taxon labels." - " Looking for the first column that contains 'name', 'strain', or 'accession' in the header.") + # read the metadata file into pandas dataframe. + df = pd.read_csv(date_file, sep=full_sep, engine='python', dtype='str', index_col=False) + # check the metadata has strain names in the first column + # look for the column containing sampling dates + # We assume that the dates might be given either in human-readable format + # (e.g. ISO dates), or be already converted to the numeric format. + potential_date_columns = [] + potential_numdate_columns = [] + potential_index_columns = [] + # Scan the dataframe columns and find ones which likely to store the + # dates + for ci, col in enumerate(df.columns): + d = df.iloc[0, ci] + # strip quotation marks + if type(d) == str and d[0] in ['"', "'"] and d[-1] in ['"', "'"]: + for i, tmp_d in enumerate(df.iloc[:, ci]): + df.iloc[i, ci] = tmp_d.strip(d[0]) + if 'date' in col.lower(): + potential_date_columns.append((ci, col)) + if any([x == col.lower() for x in ['name', 'strain', 'accession']]): + potential_index_columns.append((ci, col)) + + if date_col and date_col not in df.columns: + raise MissingDataError( + 'ERROR: specified column for dates does not exist. \n\tAvailable columns are: ' + + ', '.join(df.columns) + + "\n\tYou specified '%s'" % date_col + ) + + if name_col and name_col not in df.columns: + raise MissingDataError( + 'ERROR: specified column for the taxon name does not exist. \n\tAvailable columns are: ' + + ', '.join(df.columns) + + "\n\tYou specified '%s'" % name_col + ) + + dates = {} + # if a potential numeric date column was found, use it + # (use the first, if there are more than one) + if not (len(potential_index_columns) or name_col): + raise MissingDataError( + 'ERROR: Cannot read metadata: need at least one column that contains the taxon labels.' + " Looking for the first column that contains 'name', 'strain', or 'accession' in the header." + ) + else: + # use the first column that is either 'name', 'strain', 'accession' + if name_col is None: + index_col = sorted(potential_index_columns)[0][1] else: - # use the first column that is either 'name', 'strain', 'accession' - if name_col is None: - index_col = sorted(potential_index_columns)[0][1] - else: - index_col = name_col - print("\tUsing column '%s' as name. This needs match the taxon names in the tree!!"%index_col) - - if len(potential_date_columns)>=1 or date_col: - #try to parse the csv file with dates in the idx column: - if date_col is None: - date_col = potential_date_columns[0][1] - - print("\tUsing column '%s' as date."%date_col) - for ri, row in df.iterrows(): - date_str = row.loc[date_col] - k = row.loc[index_col] - # try parsing as a float first - try: - if date_str: - dates[k] = float(date_str) - else: - dates[k] = None - continue - except ValueError: - # try whether the date string can be parsed as [2002.2:2004.3] - # to indicate general ambiguous ranges - if date_str[0]=='[' and date_str[-1]==']' and len(date_str[1:-1].split(':'))==2: - try: - dates[k] = [float(x) for x in date_str[1:-1].split(':')] - continue - except ValueError: - pass - # try date format parsing 2017-08-12 + index_col = name_col + print("\tUsing column '%s' as name. This needs match the taxon names in the tree!!" % index_col) + + if len(potential_date_columns) >= 1 or date_col: + # try to parse the csv file with dates in the idx column: + if date_col is None: + date_col = potential_date_columns[0][1] + + print("\tUsing column '%s' as date." % date_col) + for ri, row in df.iterrows(): + date_str = row.loc[date_col] + k = row.loc[index_col] + # try parsing as a float first + try: + if date_str: + dates[k] = float(date_str) + else: + dates[k] = None + continue + except ValueError: + # try whether the date string can be parsed as [2002.2:2004.3] + # to indicate general ambiguous ranges + if date_str[0] == '[' and date_str[-1] == ']' and len(date_str[1:-1].split(':')) == 2: try: - tmp_date = pd.to_datetime(date_str) - dates[k] = numeric_date(tmp_date) - except ValueError: # try ambiguous date format parsing 2017-XX-XX - lower, upper = ambiguous_date_to_date_range(date_str, '%Y-%m-%d') - if lower is not None: - dates[k] = [numeric_date(x) for x in [lower, upper]] + dates[k] = [float(x) for x in date_str[1:-1].split(':')] + continue + except ValueError: + pass + # try date format parsing 2017-08-12 + try: + tmp_date = pd.to_datetime(date_str) + dates[k] = numeric_date(tmp_date) + except ValueError: # try ambiguous date format parsing 2017-XX-XX + lower, upper = ambiguous_date_to_date_range(date_str, '%Y-%m-%d') + if lower is not None: + dates[k] = [numeric_date(x) for x in [lower, upper]] - else: - raise MissingDataError("ERROR: Metadata file has no column which looks like a sampling date!") + else: + raise MissingDataError('ERROR: Metadata file has no column which looks like a sampling date!') - if all(v is None for v in dates.values()): - raise MissingDataError("ERROR: Cannot parse dates correctly! Check date format.") - return dates - except TreeTimeError as err: - raise err - except: - raise + if all(v is None for v in dates.values()): + raise MissingDataError('ERROR: Cannot parse dates correctly! Check date format.') + return dates -def ambiguous_date_to_date_range(mydate, fmt="%Y-%m-%d", min_max_year=None): +def ambiguous_date_to_date_range(mydate, fmt='%Y-%m-%d', min_max_year=None): """parse an abiguous date such as 2017-XX-XX to [2017,2017.999] Parameters @@ -359,57 +363,58 @@ def ambiguous_date_to_date_range(mydate, fmt="%Y-%m-%d", min_max_year=None): min_date, max_date = {}, {} today = datetime.date.today() - for val, field in zip(mydate.split(sep), fmt.split(sep+'%')): + for val, field in zip(mydate.split(sep), fmt.split(sep + '%')): f = 'year' if 'y' in field.lower() else ('day' if 'd' in field.lower() else 'month') if 'XX' in val: - if f=='year': + if f == 'year': if min_max_year: - min_date[f]=min_max_year[0] - if len(min_max_year)>1: - max_date[f]=min_max_year[1] - elif len(min_max_year)==1: - max_date[f]=4000 #will be replaced by 'today' below. + min_date[f] = min_max_year[0] + if len(min_max_year) > 1: + max_date[f] = min_max_year[1] + elif len(min_max_year) == 1: + max_date[f] = 4000 # will be replaced by 'today' below. else: return None, None - elif f=='month': - min_date[f]=1 - max_date[f]=12 - elif f=='day': - min_date[f]=1 - max_date[f]=31 + elif f == 'month': + min_date[f] = 1 + max_date[f] = 12 + elif f == 'day': + min_date[f] = 1 + max_date[f] = 31 else: try: - min_date[f]=int(val) - max_date[f]=int(val) + min_date[f] = int(val) + max_date[f] = int(val) except ValueError: - print("Can't parse date string: "+mydate, file=sys.stderr) + print("Can't parse date string: " + mydate, file=sys.stderr) return None, None - max_date['day'] = min(max_date['day'], 31 if max_date['month'] in [1,3,5,7,8,10,12] - else 28 if max_date['month']==2 else 30) + max_date['day'] = min( + max_date['day'], 31 if max_date['month'] in [1, 3, 5, 7, 8, 10, 12] else 28 if max_date['month'] == 2 else 30 + ) lower_bound = datetime.date(year=min_date['year'], month=min_date['month'], day=min_date['day']) upper_bound = datetime.date(year=max_date['year'], month=max_date['month'], day=max_date['day']) - return (lower_bound, upper_bound if upper_bound<today else today) + return (lower_bound, upper_bound if upper_bound < today else today) def tree_layout(tree): - leaf_count=0 - for ni,node in enumerate(tree.find_clades(order="postorder")): + leaf_count = 0 + for ni, node in enumerate(tree.find_clades(order='postorder')): if node.is_terminal(): - leaf_count+=1 - node.ypos=leaf_count + leaf_count += 1 + node.ypos = leaf_count else: tmp = np.array([c.ypos for c in node]) - node.ypos=0.5*(np.max(tmp) + np.min(tmp)) + node.ypos = 0.5 * (np.max(tmp) + np.min(tmp)) -def tree_inference(aln_fname, tree_fname, tmp_dir=None, - methods = None, **kwargs): - import os,shutil +def tree_inference(aln_fname, tree_fname, tmp_dir=None, methods=None, **kwargs): + import os, shutil from Bio import Phylo + if methods is None: methods = ['iqtree', 'fasttree', 'raxml'] if not os.path.isfile(aln_fname): - print("alignment file does not exist") + print('alignment file does not exist') cwd = os.getcwd() if tmp_dir: @@ -417,23 +422,23 @@ def tree_inference(aln_fname, tree_fname, tmp_dir=None, try: os.makedirs(tmp_dir) except OSError as e: - print("Cannot create run_dir",e) + print('Cannot create run_dir', e) aln_fname_base = os.path.basename(aln_fname) - shutil.copyfile(aln_fname,os.path.join(tmp_dir, aln_fname_base)) + shutil.copyfile(aln_fname, os.path.join(tmp_dir, aln_fname_base)) aln_fname = aln_fname_base os.chdir(tmp_dir) for method in methods: T = None try: - if method.lower()=='iqtree': + if method.lower() == 'iqtree': T = build_newick_iqtree(aln_fname) - elif method.lower()=='fasttree': + elif method.lower() == 'fasttree': T = build_newick_fasttree(aln_fname, nuc=True) - elif method.lower()=='raxml': + elif method.lower() == 'raxml': T = build_newick_raxml(aln_fname) else: - print("Method not supported",method) + print('Method not supported', method) if T: break except: @@ -442,7 +447,7 @@ def tree_inference(aln_fname, tree_fname, tmp_dir=None, if T is None: msg = f"tree building failed. tried '{','.join(methods)}', but none worked" print(msg) - raise(TreeTimeError(msg)) + raise (TreeTimeError(msg)) else: Phylo.write(T, tree_fname, 'newick') @@ -450,30 +455,34 @@ def tree_inference(aln_fname, tree_fname, tmp_dir=None, def build_newick_fasttree(aln_fname, nuc=True): import os from Bio import Phylo - print("Building tree with fasttree") - tree_cmd = ["fasttree"] - if nuc: tree_cmd.append("-nt") - tree_cmd.extend([aln_fname,"1>","tmp.nwk", "2>", "fasttree_stderr"]) - os.system(" ".join(tree_cmd)) - return Phylo.read("tmp.nwk", 'newick') + print('Building tree with fasttree') + tree_cmd = ['fasttree'] + if nuc: + tree_cmd.append('-nt') + tree_cmd.extend([aln_fname, '1>', 'tmp.nwk', '2>', 'fasttree_stderr']) + os.system(' '.join(tree_cmd)) + return Phylo.read('tmp.nwk', 'newick') -def build_newick_raxml(aln_fname, nthreads=2, raxml_bin="raxml", **kwargs): - import shutil,os - print("Building tree with raxml") + +def build_newick_raxml(aln_fname, nthreads=2, raxml_bin='raxml', **kwargs): + import shutil, os + + print('Building tree with raxml') from Bio import Phylo, AlignIO - AlignIO.write(AlignIO.read(aln_fname, 'fasta'),"temp.phyx", "phylip-relaxed") - cmd = raxml_bin + " -f d -T " + str(nthreads) + " -m GTRCAT -c 25 -p 235813 -n tre -s temp.phyx" + + AlignIO.write(AlignIO.read(aln_fname, 'fasta'), 'temp.phyx', 'phylip-relaxed') + cmd = raxml_bin + ' -f d -T ' + str(nthreads) + ' -m GTRCAT -c 25 -p 235813 -n tre -s temp.phyx' os.system(cmd) - return Phylo.read('RAxML_bestTree.tre', "newick") + return Phylo.read('RAxML_bestTree.tre', 'newick') -def build_newick_iqtree(aln_fname, nthreads=2, iqtree_bin="iqtree", - iqmodel="HKY", **kwargs): +def build_newick_iqtree(aln_fname, nthreads=2, iqtree_bin='iqtree', iqmodel='HKY', **kwargs): import os from Bio import Phylo, AlignIO - print("Building tree with iqtree") + + print('Building tree with iqtree') aln = None for fmt in ['fasta', 'phylip-relaxed']: try: @@ -483,16 +492,16 @@ def build_newick_iqtree(aln_fname, nthreads=2, iqtree_bin="iqtree", continue if aln is None: - raise ValueError("failed to read alignment for tree building") + raise ValueError('failed to read alignment for tree building') - aln_file = "temp.fasta" + aln_file = 'temp.fasta' seq_names = set() for s in aln: - tmp = s.id + tmp = s.id for c, sub in zip('/|()', 'VWXY'): - tmp = tmp.replace(c, '_%s_%s_'%(sub,sub)) + tmp = tmp.replace(c, '_%s_%s_' % (sub, sub)) if tmp in seq_names: - print("A sequence with name {} already exists, skipping....".format(s.id)) + print('A sequence with name {} already exists, skipping....'.format(s.id)) continue s.id = tmp s.name = s.id @@ -501,29 +510,23 @@ def build_newick_iqtree(aln_fname, nthreads=2, iqtree_bin="iqtree", AlignIO.write(aln, aln_file, 'fasta') - fast_opts = [ - "-ninit", "2", - "-n", "2", - "-me", "0.05" - ] + fast_opts = ['-ninit', '2', '-n', '2', '-me', '0.05'] - call = ["iqtree"] + fast_opts +["-nt", str(nthreads), "-s", aln_file, "-m", iqmodel, - ">", "iqtree.log"] + call = ['iqtree'] + fast_opts + ['-nt', str(nthreads), '-s', aln_file, '-m', iqmodel, '>', 'iqtree.log'] - os.system(" ".join(call)) - T = Phylo.read(aln_file+".treefile", 'newick') + os.system(' '.join(call)) + T = Phylo.read(aln_file + '.treefile', 'newick') for n in T.get_terminals(): tmp = n.name for c, sub in zip('/|()', 'VWXY'): - tmp = tmp.replace('_%s_%s_'%(sub,sub), c) + tmp = tmp.replace('_%s_%s_' % (sub, sub), c) n.name = tmp return T + def clip(a, min_val, max_val): return np.maximum(min_val, np.minimum(a, max_val)) + if __name__ == '__main__': pass - - - diff --git a/treetime/vcf_utils.py b/treetime/vcf_utils.py index eb3c17d..dd321d5 100644 --- a/treetime/vcf_utils.py +++ b/treetime/vcf_utils.py @@ -6,6 +6,7 @@ from . import TreeTimeError ## Functions to read in and print out VCF files + def read_vcf(vcf_file, ref_file=None): """ Reads in a vcf/vcf.gz file and associated @@ -56,9 +57,10 @@ def read_vcf(vcf_file, ref_file=None): """ import re - ALT_CHARS = re.compile(r"^([ACGTNacgtn]+|\*|\.)$") # straight from the VCF 4.3 spec - #Programming Note: + ALT_CHARS = re.compile(r'^([ACGTNacgtn]+|\*|\.)$') # straight from the VCF 4.3 spec + + # Programming Note: # Note on VCF Format # ------------------- # 'Insertion where there are also deletions' (special handling) @@ -82,20 +84,20 @@ def read_vcf(vcf_file, ref_file=None): # REF ALT # A G - #define here, so that all sub-functions can access them + # define here, so that all sub-functions can access them sequences = defaultdict(dict) - insertions = defaultdict(dict) #Currently not used, but kept in case of future use. + insertions = defaultdict(dict) # Currently not used, but kept in case of future use. metadata = { - 'meta_lines': [], # all the VCF meta_lines, i.e. those starting with '##' (they are left unparsed) - 'chrom': None, # chromosome name (we only allow one) - 'ploidy': None, # ploidy count -- encoded in how the GT calls are formatted + 'meta_lines': [], # all the VCF meta_lines, i.e. those starting with '##' (they are left unparsed) + 'chrom': None, # chromosome name (we only allow one) + 'ploidy': None, # ploidy count -- encoded in how the GT calls are formatted } - #TreeTime handles 2-3 base ambig codes, this will allow that. - def getAmbigCode(bp1, bp2, bp3=""): - bps = [bp1,bp2,bp3] + # TreeTime handles 2-3 base ambig codes, this will allow that. + def getAmbigCode(bp1, bp2, bp3=''): + bps = [bp1, bp2, bp3] bps.sort() - key = "".join(bps) + key = ''.join(bps) return { 'CT': 'Y', @@ -107,93 +109,91 @@ def read_vcf(vcf_file, ref_file=None): 'AGT': 'D', 'ACG': 'V', 'ACT': 'H', - 'CGT': 'B' + 'CGT': 'B', }[key] - #Parses a 'normal' (not hetero or no-call) call depending if insertion+deletion, insertion, - #deletion, or single bp subsitution + # Parses a 'normal' (not hetero or no-call) call depending if insertion+deletion, insertion, + # deletion, or single bp subsitution def parse_homozygous_call(snps, ins, pos, ref, alt): - # Replace missing allele with N(s). See commentary in test_vcf.py::TestNoCallsOrMissing for more details - if alt=='*' or alt=='.': - alt = "N" * len(ref) + if alt == '*' or alt == '.': + alt = 'N' * len(ref) - #Insertion where there are also deletions (special handling) - if len(ref) > 1 and len(alt)>len(ref): + # Insertion where there are also deletions (special handling) + if len(ref) > 1 and len(alt) > len(ref): ## NOTE: the loop below contains a potential double-counting bug. For example, ## REF='TCG' ALT='TCAG' (Example 5.1.3 in the VCF 4.2 spec), then when i=2 - ## we'll add both snps[pos+2] = 'A' as well as ins[pos+2] = 'AG'. This has been + ## we'll add both snps[pos+2] = 'A' as well as ins[pos+2] = 'AG'. This has been ## detailed within `test_vcf.py`, as it may also be the expected way to encode ## insertions within TreeTime for i in range(len(ref)): - #if the pos doesn't match, store in sequences + # if the pos doesn't match, store in sequences if ref[i] != alt[i]: - snps[pos+i] = alt[i] - #if about to run out of ref, store rest: - if (i+1) >= len(ref): - ins[pos+i] = alt[i:] - #Deletion + snps[pos + i] = alt[i] + # if about to run out of ref, store rest: + if (i + 1) >= len(ref): + ins[pos + i] = alt[i:] + # Deletion elif len(ref) > 1: for i in range(len(ref)): - #if ref is longer than alt, these are deletion positions - if i+1 > len(alt): - snps[pos+i] = '-' - #if not, there may be mutations + # if ref is longer than alt, these are deletion positions + if i + 1 > len(alt): + snps[pos + i] = '-' + # if not, there may be mutations else: if ref[i] != alt[i]: - snps[pos+i] = alt[i] - #Insertion + single-base ref + snps[pos + i] = alt[i] + # Insertion + single-base ref elif len(alt) > 1: ins[pos] = alt # If the first base of the allele doesn't match the ref then we _also_ have a mutation - if ref[0]!=alt[0]: + if ref[0] != alt[0]: snps[pos] = alt[0] - #No indel + # No indel else: snps[pos] = alt - - #Parses a 'bad' (hetero or no-call) call depending on what it is - #TODO - consider the situation where the alternate allele base(s) is '*' (done for the homozygous case) + # Parses a 'bad' (hetero or no-call) call depending on what it is + # TODO - consider the situation where the alternate allele base(s) is '*' (done for the homozygous case) def parse_heterozygous_call(gen, snps, ins, pos, ref, ALT): - #Deletion + # Deletion # REF ALT Seq1 Seq2 Seq3 # GCC G 1/1 0/1 ./. # Seq1 (processed by parseCall, above) will become 'G--' # Seq2 will become 'GNN' # Seq3 will become 'GNN' if len(ref) > 1: - #Deleted part becomes Ns + # Deleted part becomes Ns if gen[0] == '0' or gen[0] == '.': - if gen[0] == '0': #if het, get first bp - alt = str(ALT[int(gen[2])-1]) - else: #if no-call, there is no alt, so just put Ns after 1st ref base + if gen[0] == '0': # if het, get first bp + alt = str(ALT[int(gen[2]) - 1]) + else: # if no-call, there is no alt, so just put Ns after 1st ref base alt = ref[0] for i in range(len(ref)): - #if ref is longer than alt, these are deletion positions - if i+1 > len(alt): - snps[pos+i] = 'N' - #if not, there may be mutations + # if ref is longer than alt, these are deletion positions + if i + 1 > len(alt): + snps[pos + i] = 'N' + # if not, there may be mutations else: if ref[i] != alt[i]: - snps[pos+i] = (alt[i] if alt[i] != '.' else 'N') #'.' = no-call + snps[pos + i] = alt[i] if alt[i] != '.' else 'N' #'.' = no-call - #If not deletion, need to know call type - #if het, see if proposed alt is 1bp mutation + # If not deletion, need to know call type + # if het, see if proposed alt is 1bp mutation elif gen[0] == '0': - alt = str(ALT[int(gen[2])-1]) - if len(alt)==1: - #alt = getAmbigCode(ref,alt) #if want to allow ambig - alt = 'N' #if you want to disregard ambig + alt = str(ALT[int(gen[2]) - 1]) + if len(alt) == 1: + # alt = getAmbigCode(ref,alt) #if want to allow ambig + alt = 'N' # if you want to disregard ambig snps[pos] = alt - #else a het-call insertion, so ignore. + # else a het-call insertion, so ignore. - #else it's a no-call; see if all alts have a length of 1 - #(meaning a simple 1bp mutation) - elif len(ALT)==len("".join(ALT)): + # else it's a no-call; see if all alts have a length of 1 + # (meaning a simple 1bp mutation) + elif len(ALT) == len(''.join(ALT)): alt = 'N' snps[pos] = alt - #else a no-call insertion, so ignore. + # else a no-call insertion, so ignore. def validate_alt(alt): """ @@ -205,66 +205,76 @@ def read_vcf(vcf_file, ref_file=None): The spec also states: > Tools processing VCF files are not required to preserve case in the allele String - + Return the uppercase allele bases (string) _or_ None if it fails validation """ if ALT_CHARS.match(alt): return alt.upper() else: - print(f"WARNING: Encountered invalid allele base(s) {alt!r}. Skipping...") + print(f'WARNING: Encountered invalid allele base(s) {alt!r}. Skipping...') return None - #House code is *much* faster than pyvcf because we don't care about all info - #about coverage, quality, counts, etc, which pyvcf goes to effort to parse - #(and it's not easy as there's no standard ordering). Custom code can completely - #ignore all of this. + # House code is *much* faster than pyvcf because we don't care about all info + # about coverage, quality, counts, etc, which pyvcf goes to effort to parse + # (and it's not easy as there's no standard ordering). Custom code can completely + # ignore all of this. from Bio import SeqIO - #Use different openers depending on whether compressed + # Use different openers depending on whether compressed opn = gzip.open if vcf_file.endswith(('.gz', '.GZ')) else open with opn(vcf_file, mode='rt') as f: - current_block = "meta-information" # The start of VCF files is the meta-information (lines starting with ##) - header,samps,nsamp=None,None,None + current_block = 'meta-information' # The start of VCF files is the meta-information (lines starting with ##) + header, samps, nsamp = None, None, None for line in f: - if line.startswith("##"): - if current_block!='meta-information': - raise TreeTimeError(f"Malformed VCF file {vcf_file!r} - all the meta-information (lines starting with ##) must appear at the top of the file.") + if line.startswith('##'): + if current_block != 'meta-information': + raise TreeTimeError( + f'Malformed VCF file {vcf_file!r} - all the meta-information (lines starting with ##) must appear at the top of the file.' + ) metadata['meta_lines'].append(line.strip()) - elif line[0]=='#': + elif line[0] == '#': mandatory_fields = ['CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT'] # Note that FORMAT isn't mandatory unless genotype data is present. But every VCF we deal with has genotype data - that's the point! - header_start = "#"+"\t".join(mandatory_fields) + header_start = '#' + '\t'.join(mandatory_fields) if not line.startswith(header_start): - raise TreeTimeError(f"Malformed VCF file {vcf_file!r} - the header line must start with {header_start}") - if current_block!='meta-information': - raise TreeTimeError(f"Malformed VCF file {vcf_file!r} - the header must immediately follow the meta-information lines") + raise TreeTimeError( + f'Malformed VCF file {vcf_file!r} - the header line must start with {header_start}' + ) + if current_block != 'meta-information': + raise TreeTimeError( + f'Malformed VCF file {vcf_file!r} - the header must immediately follow the meta-information lines' + ) current_block = 'header' header = line.strip().split('\t') - samps = [ x.strip() for x in header[9:] ] #ensure no leading/trailing spaces + samps = [x.strip() for x in header[9:]] # ensure no leading/trailing spaces nsamp = len(samps) for sample_name in samps: sequences[sample_name] = {} else: - if current_block!='header' and current_block!='data-lines': - raise TreeTimeError(f"Malformed VCF file {vcf_file!r} - the data lines must follow the header line") - current_block='data-lines' + if current_block != 'header' and current_block != 'data-lines': + raise TreeTimeError(f'Malformed VCF file {vcf_file!r} - the data lines must follow the header line') + current_block = 'data-lines' l = line.strip() - if not l: # empty line + if not l: # empty line continue dat = l.split('\t') chrom = str(dat[0]) if metadata['chrom'] is None: metadata['chrom'] = chrom - elif metadata['chrom']!=chrom: - raise TreeTimeError(f"The VCF file {vcf_file!r} contains multiple chromosomes. TreeTime can not yet handle this.") - pos = int(dat[1])-1 # Convert VCF 1-based to python 0-based + elif metadata['chrom'] != chrom: + raise TreeTimeError( + f'The VCF file {vcf_file!r} contains multiple chromosomes. TreeTime can not yet handle this.' + ) + pos = int(dat[1]) - 1 # Convert VCF 1-based to python 0-based REF = dat[3] - ALT = dat[4].split(',') # List of alternate alleles (strings) + ALT = dat[4].split(',') # List of alternate alleles (strings) calls = np.array(dat[9:]) - if len(calls)!=nsamp: - raise TreeTimeError(f"Malformed VCF file {vcf_file!r} - the data lines have different number of calls than the number of samples defined in the header") + if len(calls) != nsamp: + raise TreeTimeError( + f'Malformed VCF file {vcf_file!r} - the data lines have different number of calls than the number of samples defined in the header' + ) # Treetime only parses GT FORMAT calls. Moreover, "the first sub-field must always be the genotype (GT) if it is present" # according to the VCF 4.2 spec. Note that if the VCF is for one sample only then the format's a bit different, but this'll @@ -274,15 +284,15 @@ def read_vcf(vcf_file, ref_file=None): if not FORMAT.startswith('GT'): continue - #get samples that differ from Ref at this site + # get samples that differ from Ref at this site for sname, sa in zip(samps, calls): - gt = sa.split(':')[0] # May be multiple colon-separated subfields, depending on the line's FORMAT + gt = sa.split(':')[0] # May be multiple colon-separated subfields, depending on the line's FORMAT # `gt` is the "index" of the alternate allele for this sample. # The format of `gt` is quite varied: # For haploid genomes, it's simply a single int _or_ "." (meaning a call cannot be made at the locus) # For polyploid genomes, the format is a list of {int, '.'} separated by "/" or "|" - # ('/' means unphased, '|' means phased). + # ('/' means unphased, '|' means phased). # If the index is an int, it's the 1-based (!) lookup index for ALT # Split on the valid separators - if haploid, then we'll get a list len=1 @@ -290,41 +300,45 @@ def read_vcf(vcf_file, ref_file=None): ploidy = len(gts) if metadata['ploidy'] is None: metadata['ploidy'] = ploidy - elif metadata['ploidy']!=ploidy: - raise TreeTimeError(f"The VCF file {vcf_file!r} had genotype calls of ploidy {metadata['ploidy']} but sample {sname!r} has a genotype of ploidy {ploidy}") + elif metadata['ploidy'] != ploidy: + raise TreeTimeError( + f'The VCF file {vcf_file!r} had genotype calls of ploidy {metadata["ploidy"]} but sample {sname!r} has a genotype of ploidy {ploidy}' + ) # if polyploid, but homozygous, then treat as if haploid - if ploidy>1 and len(set(gts))==1: + if ploidy > 1 and len(set(gts)) == 1: gt = gts[0] - if gt.isdigit(): # haploid, and a call has been made (i.e. it's not gt='.') + if gt.isdigit(): # haploid, and a call has been made (i.e. it's not gt='.') gt = int(gt) - if gt==0: - continue # reference allele! - alt = validate_alt(ALT[gt-1]) # gt is the 1-based lookup, but ALT is 0-indexed + if gt == 0: + continue # reference allele! + alt = validate_alt(ALT[gt - 1]) # gt is the 1-based lookup, but ALT is 0-indexed if alt: - parse_homozygous_call(sequences[sname],insertions[sname], pos, REF, alt) + parse_homozygous_call(sequences[sname], insertions[sname], pos, REF, alt) continue - if gt=='.': # haploid "call cannot be made" identifier - replace REF with N(s) + if gt == '.': # haploid "call cannot be made" identifier - replace REF with N(s) for i in range(len(REF)): - sequences[sname][pos+i] = 'N' + sequences[sname][pos + i] = 'N' continue # ---- heterozygous polyploid call ---- - parse_heterozygous_call(gt, sequences[sname],insertions[sname], pos, REF, ALT) + parse_heterozygous_call(gt, sequences[sname], insertions[sname], pos, REF, ALT) - #Gather all variable positions - #NOTE: this does not consider positions of insertions + # Gather all variable positions + # NOTE: this does not consider positions of insertions positions = set() for seq, muts in sequences.items(): positions.update(muts.keys()) num_insertions = sum([len(list(ins.keys())) for ins in insertions.values()]) - if len(positions)==0 and num_insertions==0: - raise TreeTimeError(f"VCF file {vcf_file!r} has no data-lines which we could extract genotype information from!") + if len(positions) == 0 and num_insertions == 0: + raise TreeTimeError( + f'VCF file {vcf_file!r} has no data-lines which we could extract genotype information from!' + ) - #One or more seqs are same as ref! (No non-ref calls) So haven't been 'seen' yet + # One or more seqs are same as ref! (No non-ref calls) So haven't been 'seen' yet if nsamp > len(sequences): missings = set(samps).difference(sequences.keys()) for s in missings: @@ -332,21 +346,23 @@ def read_vcf(vcf_file, ref_file=None): if ref_file: refSeq = SeqIO.read(ref_file, format='fasta') - refSeq = refSeq.upper() #convert to uppercase to avoid unknown chars later + refSeq = refSeq.upper() # convert to uppercase to avoid unknown chars later refSeqStr = str(refSeq.seq) else: refSeqStr = None - compress_seq = {'reference':refSeqStr, - 'sequences': sequences, - 'insertions': insertions, - 'positions': sorted(positions), - 'metadata': metadata} + compress_seq = { + 'reference': refSeqStr, + 'sequences': sequences, + 'insertions': insertions, + 'positions': sorted(positions), + 'metadata': metadata, + } return compress_seq -def write_vcf(tree_dict, file_name, mask=None):#, compress=False): +def write_vcf(tree_dict, file_name, mask=None): # , compress=False): """ Writes out a VCF-style file (which seems to be minimally handleable by vcftools and pyvcf) of the alignment. This is created from a dict @@ -379,32 +395,32 @@ def write_vcf(tree_dict, file_name, mask=None):#, compress=False): Calls at these positions will be skipped """ -# Programming Logic Note: -# -# For a sequence like: -# Pos 1 2 3 4 5 6 -# Ref A C T T A C -# Seq1 A C - - - G -# -# In a dict it is stored: -# Seq1:{3:'-', 4:'-', 5:'-', 6:'G'} (Numbering from 1 for simplicity) -# -# In a VCF it needs to be: -# POS REF ALT Seq1 -# 2 CTTA C 1/1 -# 6 C G 1/1 -# -# If a position is deleted (pos 3), need to get invariable position preceeding it -# -# However, in alternative case, the base before a deletion is mutant, so need to check -# that next position isn't a deletion (as otherwise won't be found until after the -# current single bp mutation is written out) -# -# When deleted position found, need to gather up all adjacent mutant positions with deletions, -# but not include adjacent mutant positions that aren't deletions (pos 6) -# -# Don't run off the 'end' of the position list if deletion is the last thing to be included -# in the VCF file + # Programming Logic Note: + # + # For a sequence like: + # Pos 1 2 3 4 5 6 + # Ref A C T T A C + # Seq1 A C - - - G + # + # In a dict it is stored: + # Seq1:{3:'-', 4:'-', 5:'-', 6:'G'} (Numbering from 1 for simplicity) + # + # In a VCF it needs to be: + # POS REF ALT Seq1 + # 2 CTTA C 1/1 + # 6 C G 1/1 + # + # If a position is deleted (pos 3), need to get invariable position preceeding it + # + # However, in alternative case, the base before a deletion is mutant, so need to check + # that next position isn't a deletion (as otherwise won't be found until after the + # current single bp mutation is written out) + # + # When deleted position found, need to gather up all adjacent mutant positions with deletions, + # but not include adjacent mutant positions that aren't deletions (pos 6) + # + # Don't run off the 'end' of the position list if deletion is the last thing to be included + # in the VCF file sequences = tree_dict['sequences'] ref = tree_dict['reference'] @@ -424,19 +440,19 @@ def write_vcf(tree_dict, file_name, mask=None):#, compress=False): alleles[posn] = np.zeros(num_samples, dtype='U') alleles[posn][idx] = allele # fill in reference - for posn,bases in alleles.items(): - bases[bases==''] = ref[posn] + for posn, bases in alleles.items(): + bases[bases == ''] = ref[posn] def handleDeletions(i, pi, pos, ref, delete, pattern): refb = ref[pi] - if delete: #Need to get the position before - i-=1 #As we'll next go to this position again - pi-=1 - pos = pi+1 + if delete: # Need to get the position before + i -= 1 # As we'll next go to this position again + pi -= 1 + pos = pi + 1 refb = ref[pi] - #re-get pattern + # re-get pattern pattern = [] - for k,v in sequences.items(): + for k, v in sequences.items(): try: pattern.append(sequences[k][pi]) except KeyError: @@ -446,39 +462,39 @@ def write_vcf(tree_dict, file_name, mask=None):#, compress=False): sites = [] sites.append(pattern) - #Gather all positions affected by deletion - but don't run off end of position list - while (i+1) < len(positions) and positions[i+1] == pi+1: - i+=1 + # Gather all positions affected by deletion - but don't run off end of position list + while (i + 1) < len(positions) and positions[i + 1] == pi + 1: + i += 1 pi = positions[i] pattern = [] - for k,v in sequences.items(): + for k, v in sequences.items(): try: pattern.append(sequences[k][pi]) except KeyError: pattern.append(ref[pi]) pattern = np.array(pattern).astype('U') - #Stops 'greedy' behaviour from adding mutations adjacent to deletions - if any(pattern == '-'): #if part of deletion, append + # Stops 'greedy' behaviour from adding mutations adjacent to deletions + if any(pattern == '-'): # if part of deletion, append sites.append(pattern) - refb = refb+ref[pi] - else: #this is another mutation next to the deletion! - i-=1 #don't append, break this loop + refb = refb + ref[pi] + else: # this is another mutation next to the deletion! + i -= 1 # don't append, break this loop - #Rotate them into 'calls' + # Rotate them into 'calls' align = np.asarray(sites).T - #Get rid of '-', and put '0' for calls that match ref - #Only removes trailing '-'. This breaks VCF convension, but the standard - #VCF way of handling this* is really complicated, and the situation is rare. - #(*deletions and mutations at the same locations) + # Get rid of '-', and put '0' for calls that match ref + # Only removes trailing '-'. This breaks VCF convension, but the standard + # VCF way of handling this* is really complicated, and the situation is rare. + # (*deletions and mutations at the same locations) fullpat = [] for pt in align: - gp = len(pt)-1 + gp = len(pt) - 1 while pt[gp] == '-': pt[gp] = '' - gp-=1 - pat = "".join(pt) + gp -= 1 + pat = ''.join(pt) if pat == refb: fullpat.append('0') else: @@ -488,138 +504,156 @@ def write_vcf(tree_dict, file_name, mask=None):#, compress=False): return i, pi, pos, refb, pattern - - #prepare the header of the VCF & write out - header=["#CHROM","POS","ID","REF","ALT","QUAL","FILTER","INFO","FORMAT"]+sample_names + # prepare the header of the VCF & write out + header = ['#CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', 'FORMAT'] + sample_names opn = gzip.open if file_name.endswith(('.gz', '.GZ')) else open out_file = opn(file_name, 'w') - out_file.write( "##fileformat=VCFv4.2\n"+ - "##source=NextStrain\n"+ - "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">\n"+ - f"##contig=<ID={chrom_name}>\n") - out_file.write("\t".join(header)+"\n") + out_file.write( + '##fileformat=VCFv4.2\n' + + '##source=NextStrain\n' + + '##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">\n' + + f'##contig=<ID={chrom_name}>\n' + ) + out_file.write('\t'.join(header) + '\n') vcfWrite = [] errorPositions = [] explainedErrors = 0 mask_skip_count = 0 - #Why so basic? Because we sometimes have to back up a position! - i=0 + # Why so basic? Because we sometimes have to back up a position! + i = 0 while i < len(positions): - #Get the 'pattern' of all calls at this position. - #Look out specifically for current (this pos) or upcoming (next pos) deletions - #But also distinguish these two, as handled differently. + # Get the 'pattern' of all calls at this position. + # Look out specifically for current (this pos) or upcoming (next pos) deletions + # But also distinguish these two, as handled differently. pi = positions[i] - pos = pi+1 #change numbering to match VCF, not python, for output - refb = ref[pi] #reference base at this position - delete = False #deletion at this position - need to grab previous base (invariable) - deleteGroup = False #deletion at next position (mutation at this pos) - do not need to get prev base + pos = pi + 1 # change numbering to match VCF, not python, for output + refb = ref[pi] # reference base at this position + delete = False # deletion at this position - need to grab previous base (invariable) + deleteGroup = False # deletion at next position (mutation at this pos) - do not need to get prev base if mask and mask[pi] == '1': - mask_skip_count+=1 - i+=1 + mask_skip_count += 1 + i += 1 continue # patterns will be empty if there was no variation in the sequences pattern = alleles[pi] if pi in alleles else np.array([]).astype('U') - pattern2 = alleles[pi+1] if pi+1 in alleles else np.array([]).astype('U') + pattern2 = alleles[pi + 1] if pi + 1 in alleles else np.array([]).astype('U') - #If a deletion here, need to gather up all bases, and position before + # If a deletion here, need to gather up all bases, and position before if any(pattern == '-'): if pos != 1: deleteGroup = True delete = True else: - #If theres a deletion in 1st pos, VCF files do not handle this well. - #Proceed keeping it as '-' for alt (violates VCF), but warn user to check output. - #(This is rare) - print(fill("WARNING: You have a deletion in the first position of your" - " alignment. VCF format does not handle this well. Please check" - " the output to ensure it is correct.")) + # If theres a deletion in 1st pos, VCF files do not handle this well. + # Proceed keeping it as '-' for alt (violates VCF), but warn user to check output. + # (This is rare) + print( + fill( + 'WARNING: You have a deletion in the first position of your' + ' alignment. VCF format does not handle this well. Please check' + ' the output to ensure it is correct.' + ) + ) else: - #If a deletion in next pos, need to gather up all bases + # If a deletion in next pos, need to gather up all bases if any(pattern2 == '-'): deleteGroup = True - #If deletion, treat affected bases as 1 'call': + # If deletion, treat affected bases as 1 'call': if delete or deleteGroup: - if pattern.size==0: # no variation in sequences + if pattern.size == 0: # no variation in sequences pattern = np.full(num_samples, refb, dtype='U') i, pi, pos, refb, pattern = handleDeletions(i, pi, pos, ref, delete, pattern) - #If no deletion, replace ref with '0' which means the reference base is unchanged + # If no deletion, replace ref with '0' which means the reference base is unchanged else: - pattern[pattern==refb] = '0' + pattern[pattern == refb] = '0' - #Get the list of ALTs - minus any '0' which are unchanged reference sequences! + # Get the list of ALTs - minus any '0' which are unchanged reference sequences! uniques = np.unique(pattern) - uniques = uniques[np.where(uniques!='0')] + uniques = uniques[np.where(uniques != '0')] - #Convert bases to the number that matches the ALT - j=1 + # Convert bases to the number that matches the ALT + j = 1 for u in uniques: - pattern[np.where(pattern==u)[0]] = str(j) - j+=1 + pattern[np.where(pattern == u)[0]] = str(j) + j += 1 - #What if there's no variation at a variable site?? - #This can happen when sites are modified by TreeTime - see below. - #We don't print to VCF (because no variation!) - any_variation = len(uniques)!=0 + # What if there's no variation at a variable site?? + # This can happen when sites are modified by TreeTime - see below. + # We don't print to VCF (because no variation!) + any_variation = len(uniques) != 0 if not any_variation: - #If we expect it (it was made constant by TreeTime), it's fine. + # If we expect it (it was made constant by TreeTime), it's fine. if pi in inferred_const_sites: explainedErrors += 1 else: - #If we don't expect, raise an error + # If we don't expect, raise an error errorPositions.append(str(pi)) - #Write it out - Increment positions by 1 so it's in VCF numbering - #If no longer variable, and explained, don't write it out + # Write it out - Increment positions by 1 so it's in VCF numbering + # If no longer variable, and explained, don't write it out if any_variation: - calls = [ "/".join([j]*ploidy) for j in pattern ] - output = [chrom_name, str(pos), ".", refb, ",".join(uniques), ".", "PASS", ".", "GT"] + calls - vcfWrite.append("\t".join(output)) + calls = ['/'.join([j] * ploidy) for j in pattern] + output = [chrom_name, str(pos), '.', refb, ','.join(uniques), '.', 'PASS', '.', 'GT'] + calls + vcfWrite.append('\t'.join(output)) - i+=1 + i += 1 - #Note: The number of 'inferred_const_sites' passed back by TreeTime will often be longer - #than the number of 'site that were made constant' that prints below. This is because given the site: + # Note: The number of 'inferred_const_sites' passed back by TreeTime will often be longer + # than the number of 'site that were made constant' that prints below. This is because given the site: # Ref Alt Seq # G A AANAA - #This will be converted to 'AAAAA' and listed as an 'inferred_const_sites'. However, for VCF - #purposes, because the site is 'variant' against the ref, it is variant, as expected, and so - #won't be counted in the below list, which is only sites removed from the VCF. + # This will be converted to 'AAAAA' and listed as an 'inferred_const_sites'. However, for VCF + # purposes, because the site is 'variant' against the ref, it is variant, as expected, and so + # won't be counted in the below list, which is only sites removed from the VCF. if mask_skip_count: - print(f"{mask_skip_count} positions were skipped due to the provided mask") + print(f'{mask_skip_count} positions were skipped due to the provided mask') if 'inferred_const_sites' in tree_dict and explainedErrors != 0: - print(fill("Sites that were constant except for ambiguous bases were made" + - " constant by TreeTime. This happened {} times. These sites are".format(explainedErrors) + - " now excluded from the VCF.")) + print( + fill( + 'Sites that were constant except for ambiguous bases were made' + + ' constant by TreeTime. This happened {} times. These sites are'.format(explainedErrors) + + ' now excluded from the VCF.' + ) + ) if len(errorPositions) != 0: - print ("\n***WARNING: vcf_utils.py") - print(fill("\n{} sites were found that had no alternative bases.".format(str(len(errorPositions)))+ - " If this data has been run through TreeTime and contains ambiguous bases," - " try calling get_tree_dict with var_ambigs=True to see if this clears the error.")) - print(fill("\nAlternative causes:" - "\n- Not all sequences in your alignment are in the tree" - " (if you are running TreeTime via commandline this is most likely)" - "\n- In TreeTime, can be caused by overwriting variants in tips with small branch lengths (debug)" - "\n\nThese are the positions affected (numbering starts at 0):")) - print(fill(", ".join(errorPositions))) - - out_file.write("\n".join(vcfWrite)) + print('\n***WARNING: vcf_utils.py') + print( + fill( + '\n{} sites were found that had no alternative bases.'.format(str(len(errorPositions))) + + ' If this data has been run through TreeTime and contains ambiguous bases,' + ' try calling get_tree_dict with var_ambigs=True to see if this clears the error.' + ) + ) + print( + fill( + '\nAlternative causes:' + '\n- Not all sequences in your alignment are in the tree' + ' (if you are running TreeTime via commandline this is most likely)' + '\n- In TreeTime, can be caused by overwriting variants in tips with small branch lengths (debug)' + '\n\nThese are the positions affected (numbering starts at 0):' + ) + ) + print(fill(', '.join(errorPositions))) + + out_file.write('\n'.join(vcfWrite)) out_file.close() def process_sparse_alignment(aln, ref, ambiguous_char): return process_alignment_dictionary(aln, ref, ambiguous_char) + def process_alignment_dictionary(aln, ref, ambiguous_char): """ prepare the dictionary specifying differences from a reference sequence @@ -648,7 +682,7 @@ def process_alignment_dictionary(aln, ref, ambiguous_char): nseq = len(aln) inv_map = defaultdict(list) - for k,v in aln.items(): + for k, v in aln.items(): for pos, bs in v.items(): inv_map[pos].append(bs) @@ -659,27 +693,27 @@ def process_alignment_dictionary(aln, ref, ambiguous_char): nonref_alleles = [] ambiguous_const = [] variable_pos = [] - for pos, bs in inv_map.items(): #loop over positions and patterns + for pos, bs in inv_map.items(): # loop over positions and patterns bases = list(np.unique(bs)) - if len(bs) == nseq: #every sequence is different from reference - if (len(bases)<=2 and ambiguous_char in bases) or len(bases)==1: + if len(bs) == nseq: # every sequence is different from reference + if (len(bases) <= 2 and ambiguous_char in bases) or len(bases) == 1: # all sequences different from reference, but only one state # (other than ambiguous_char) in column nonref_const.append(pos) - if len(bases)==1: + if len(bases) == 1: nonref_alleles.append(bases[0]) else: - nonref_alleles.append([x for x in bases if x!=ambiguous_char][0]) + nonref_alleles.append([x for x in bases if x != ambiguous_char][0]) - if ambiguous_char in bases: #keep track of sites 'made constant' + if ambiguous_char in bases: # keep track of sites 'made constant' constant_up_to_ambiguous.append(pos) else: # at least two non-reference alleles variable_pos.append(pos) - else: # not every sequence different from reference - if len(bases)==1 and bases[0]==ambiguous_char: + else: # not every sequence different from reference + if len(bases) == 1 and bases[0] == ambiguous_char: ambiguous_const.append(pos) - constant_up_to_ambiguous.append(pos) #keep track of sites 'made constant' + constant_up_to_ambiguous.append(pos) # keep track of sites 'made constant' else: # at least one non ambiguous non-reference allele not in # every sequence @@ -697,19 +731,19 @@ def process_alignment_dictionary(aln, ref, ambiguous_char): constant_columns = [] constant_patterns = {} for base in states: - if base==ambiguous_char: + if base == ambiguous_char: continue p = np.repeat(base, nseq) - pos = list(np.where(refMod==base)[0]) - #if the alignment doesn't have a const site of this base, don't add! (ex: no '----' site!) + pos = list(np.where(refMod == base)[0]) + # if the alignment doesn't have a const site of this base, don't add! (ex: no '----' site!) if len(pos): - constant_patterns["".join(p.astype('U'))] = [len(constant_columns), pos] + constant_patterns[''.join(p.astype('U'))] = [len(constant_columns), pos] constant_columns.append(p) - return {"constant_columns": constant_columns, - "constant_patterns": constant_patterns, - "variable_positions": variable_pos, - "nonref_positions": nonref_positions, - "constant_up_to_ambiguous": constant_up_to_ambiguous} - - + return { + 'constant_columns': constant_columns, + 'constant_patterns': constant_patterns, + 'variable_positions': variable_pos, + 'nonref_positions': nonref_positions, + 'constant_up_to_ambiguous': constant_up_to_ambiguous, + } diff --git a/treetime/wrappers.py b/treetime/wrappers.py index 1073d90..591e282 100644 --- a/treetime/wrappers.py +++ b/treetime/wrappers.py @@ -10,15 +10,16 @@ from . import TreeTimeError, MissingDataError, UnknownMethodError from .treetime import reduce_time_marginal_argument from .CLI_io import * + def assure_tree(params, tmp_dir='treetime_tmp'): """ Function that attempts to load a tree and build it from the alignment if no tree is provided. """ if params.tree is None: - params.tree = os.path.basename(params.aln)+'.nwk' - print("No tree given: inferring tree") - utils.tree_inference(params.aln, params.tree, tmp_dir = tmp_dir) + params.tree = os.path.basename(params.aln) + '.nwk' + print('No tree given: inferring tree') + utils.tree_inference(params.aln, params.tree, tmp_dir=tmp_dir) if os.path.isdir(tmp_dir): shutil.rmtree(tmp_dir) @@ -27,7 +28,7 @@ def assure_tree(params, tmp_dir='treetime_tmp'): tt = TreeAnc(params.tree) except (ValueError, TreeTimeError, MissingDataError) as e: print(e) - print("Tree loading/building failed.") + print('Tree loading/building failed.') return 1 return 0 @@ -41,13 +42,15 @@ def create_gtr(params): custom_gtr = params.custom_gtr if custom_gtr: if model not in ['custom', 'infer']: - print(f'Warning: you specified a GTR model `{model}` and a custom gtr path `{custom_gtr}`. TreeTime will load the custom model and ignore the parameter `--gtr {model}`.') + print( + f'Warning: you specified a GTR model `{model}` and a custom gtr path `{custom_gtr}`. TreeTime will load the custom model and ignore the parameter `--gtr {model}`.' + ) if os.path.isfile(custom_gtr): gtr = GTR.from_file(custom_gtr) params.gtr = 'custom' return gtr else: - raise ValueError(f"File with custom GTR model `{custom_gtr}` does not exist!") + raise ValueError(f'File with custom GTR model `{custom_gtr}` does not exist!') if model == 'infer': gtr = GTR.standard('jc', alphabet='aa' if params.aa else 'nuc') @@ -57,7 +60,8 @@ def create_gtr(params): if gtr_params is not None: for param in gtr_params: keyval = param.split('=') - if len(keyval)!=2: continue + if len(keyval) != 2: + continue if keyval[0] in ['pis', 'pi', 'Pi', 'Pis']: keyval[0] = 'pi' keyval[1] = list(map(float, keyval[1].split(','))) @@ -65,15 +69,25 @@ def create_gtr(params): keyval[1] = float(keyval[1]) kwargs[keyval[0]] = keyval[1] else: - print ("GTR params are not specified. Creating GTR model with default parameters") + print('GTR params are not specified. Creating GTR model with default parameters') gtr = GTR.standard(model, **kwargs) except KeyError as e: - print("\nUNKNOWN SUBSTITUTION MODEL\n") + print('\nUNKNOWN SUBSTITUTION MODEL\n') raise e return gtr + +def save_molecular_clock(outdir, date2dist, suffix=''): + fname = outdir + f'molecular_clock{suffix}.txt' + with open(fname, 'w', encoding='utf-8') as ofile: + ofile.write(str(date2dist) + '\n') + print('\n--- molecular clock model (saved as %s):\n' % fname) + print(date2dist) + return fname + + def scan_homoplasies(params): """ the function implementing treetime homoplasies @@ -86,16 +100,15 @@ def scan_homoplasies(params): ########################################################################### ### READ IN VCF ########################################################################### - #sets ref and fixed_pi to None if not VCF + # sets ref and fixed_pi to None if not VCF aln, ref, fixed_pi = read_if_vcf(params) is_vcf = True if ref is not None else False ########################################################################### ### ANCESTRAL RECONSTRUCTION ########################################################################### - treeanc = TreeAnc(params.tree, aln=aln, ref=ref, gtr=gtr, verbose=1, - fill_overhangs=True, rng_seed=params.rng_seed) - if treeanc.aln is None: # if alignment didn't load, exit + treeanc = TreeAnc(params.tree, aln=aln, ref=ref, gtr=gtr, verbose=1, fill_overhangs=True, rng_seed=params.rng_seed) + if treeanc.aln is None: # if alignment didn't load, exit return 1 if is_vcf: @@ -105,18 +118,17 @@ def scan_homoplasies(params): N_seq = len(treeanc.aln) N_tree = treeanc.tree.count_terminals() - if params.rescale!=1.0: + if params.rescale != 1.0: for n in treeanc.tree.find_clades(): n.branch_length *= params.rescale n.mutation_length = n.branch_length - print("read alignment from file %s with %d sequences of length %d"%(params.aln,N_seq,L)) - print("read tree from file %s with %d leaves"%(params.tree,N_tree)) - print("\ninferring ancestral sequences...") + print('read alignment from file %s with %d sequences of length %d' % (params.aln, N_seq, L)) + print('read tree from file %s with %d leaves' % (params.tree, N_tree)) + print('\ninferring ancestral sequences...') - ndiff = treeanc.infer_ancestral_sequences('ml', infer_gtr=params.gtr=='infer', - marginal=False, fixed_pi=fixed_pi) - print("...done.") + ndiff = treeanc.infer_ancestral_sequences('ml', infer_gtr=params.gtr == 'infer', marginal=False, fixed_pi=fixed_pi) + print('...done.') if is_vcf: treeanc.recover_var_ambigs() @@ -126,6 +138,7 @@ def scan_homoplasies(params): ########################################################################### from collections import defaultdict from scipy.stats import poisson + offset = 0 if params.zero_based else 1 if params.drms: @@ -141,32 +154,33 @@ def scan_homoplasies(params): continue if len(n.mutations): - for (a,pos, d) in n.mutations: - if '-' not in [a,d] and 'N' not in [a,d]: - mutations[(a,pos+offset,d)].append(n) - positions[pos+offset].append(n) + for a, pos, d in n.mutations: + if '-' not in [a, d] and 'N' not in [a, d]: + mutations[(a, pos + offset, d)].append(n) + positions[pos + offset].append(n) if n.is_terminal(): - for (a,pos, d) in n.mutations: - if '-' not in [a,d] and 'N' not in [a,d]: - terminal_mutations[(a,pos+offset,d)].append(n) + for a, pos, d in n.mutations: + if '-' not in [a, d] and 'N' not in [a, d]: + terminal_mutations[(a, pos + offset, d)].append(n) # gather homoplasic mutations by strain mutation_by_strain = defaultdict(list) for n in treeanc.tree.get_terminals(): - for a,pos,d in n.mutations: - if pos+offset in positions and len(positions[pos+offset])>1: - if '-' not in [a,d] and 'N' not in [a,d]: - mutation_by_strain[n.name].append([(a,pos+offset,d), len(positions[pos])]) - + for a, pos, d in n.mutations: + if pos + offset in positions and len(positions[pos + offset]) > 1: + if '-' not in [a, d] and 'N' not in [a, d]: + mutation_by_strain[n.name].append([(a, pos + offset, d), len(positions[pos])]) # total_branch_length is the expected number of substitutions # corrected_branch_length is the expected number of observable substitutions # (probability of an odd number of substitutions at a particular site) total_branch_length = treeanc.tree.total_branch_length() - corrected_branch_length = np.sum([np.exp(-x.branch_length)*np.sinh(x.branch_length) - for x in treeanc.tree.find_clades()]) - corrected_terminal_branch_length = np.sum([np.exp(-x.branch_length)*np.sinh(x.branch_length) - for x in treeanc.tree.get_terminals()]) + corrected_branch_length = np.sum( + [np.exp(-x.branch_length) * np.sinh(x.branch_length) for x in treeanc.tree.find_clades()] + ) + corrected_terminal_branch_length = np.sum( + [np.exp(-x.branch_length) * np.sinh(x.branch_length) for x in treeanc.tree.get_terminals()] + ) # make histograms and sum mutations in different categories multiplicities = np.bincount([len(x) for x in mutations.values()]) @@ -176,78 +190,114 @@ def scan_homoplasies(params): terminal_mutation_count = np.sum([len(x) for x in terminal_mutations.values()]) multiplicities_positions = np.bincount([len(x) for x in positions.values()]) - multiplicities_positions[0] = L - np.sum(multiplicities_positions) + multiplicities_positions[0] = L - np.sum(multiplicities_positions) # pylint: disable=unsupported-assignment-operation ########################################################################### ### Output the distribution of times particular mutations are observed ########################################################################### - print("\nThe TOTAL tree length is %1.3e and %d mutations were observed." - %(total_branch_length,total_mutations)) - print("Of these %d mutations,"%total_mutations - +"".join(['\n\t - %d occur %d times'%(n,mi) - for mi,n in enumerate(multiplicities) if n])) + print(f'\nThe TOTAL tree length is {total_branch_length:1.3e} and {total_mutations:d} mutations were observed.') + print( + 'Of these %d mutations,' % total_mutations + + ''.join([f'\n\t - {n:d} occur {mi:d} times' for mi, n in enumerate(multiplicities) if n]) + ) # additional optional output this for terminal mutations only if params.detailed: - print("\nThe TERMINAL branch length is %1.3e and %d mutations were observed." - %(corrected_terminal_branch_length,terminal_mutation_count)) - print("Of these %d mutations,"%terminal_mutation_count - +"".join(['\n\t - %d occur %d times'%(n,mi) - for mi,n in enumerate(multiplicities_terminal) if n])) - + print( + f'\nThe TERMINAL branch length is {corrected_terminal_branch_length:1.3e} and {terminal_mutation_count:d} mutations were observed.' + ) + print( + 'Of these %d mutations,' % terminal_mutation_count + + ''.join(['\n\t - %d occur %d times' % (n, mi) for mi, n in enumerate(multiplicities_terminal) if n]) + ) ########################################################################### ### Output the distribution of times mutations at particular positions are observed ########################################################################### - print("\nOf the %d positions in the genome,"%L - +"".join(['\n\t - %d were hit %d times (expected %1.2f)'%(n,mi,L*poisson.pmf(mi,1.0*total_mutations/L)) - for mi,n in enumerate(multiplicities_positions) if n])) - + print( + '\nOf the %d positions in the genome,' % L + + ''.join( + [ + '\n\t - %d were hit %d times (expected %1.2f)' % (n, mi, L * poisson.pmf(mi, 1.0 * total_mutations / L)) + for mi, n in enumerate(multiplicities_positions) + if n + ] + ) + ) # compare that distribution to a Poisson distribution with the same mean - p = poisson.pmf(np.arange(3*len(multiplicities_positions)),1.0*total_mutations/L) - print("\nlog-likelihood difference to Poisson distribution with same mean: %1.3e"%( - - L*np.sum(p*np.log(p+1e-100)) - + np.sum(multiplicities_positions*np.log(p[:len(multiplicities_positions)]+1e-100)))) - + p = poisson.pmf(np.arange(3 * len(multiplicities_positions)), 1.0 * total_mutations / L) + print( + '\nlog-likelihood difference to Poisson distribution with same mean: %1.3e' + % ( + -L * np.sum(p * np.log(p + 1e-100)) + + np.sum(multiplicities_positions * np.log(p[: len(multiplicities_positions)] + 1e-100)) + ) + ) ########################################################################### ### Output the mutations that are observed most often ########################################################################### if params.drms: - print("\n\nThe ten most homoplasic mutations are:\n\tmut\tmultiplicity\tDRM details (gene drug AAmut)") - mutations_sorted = sorted(mutations.items(), key=lambda x:len(x[1])-0.1*x[0][1]/L, reverse=True) - for mut, val in mutations_sorted[:params.n]: - if len(val)>1: - print("\t%s%d%s\t%d\t%s"%(mut[0], mut[1], mut[2], len(val), - " ".join([drms[mut[1]]['gene'], drms[mut[1]]['drug'], drms[mut[1]]['alt_base'][mut[2]]]) if mut[1] in drms else "")) + print('\n\nThe ten most homoplasic mutations are:\n\tmut\tmultiplicity\tDRM details (gene drug AAmut)') + mutations_sorted = sorted(mutations.items(), key=lambda x: len(x[1]) - 0.1 * x[0][1] / L, reverse=True) + for mut, val in mutations_sorted[: params.n]: + if len(val) > 1: + print( + '\t%s%d%s\t%d\t%s' + % ( + mut[0], + mut[1], + mut[2], + len(val), + ' '.join([drms[mut[1]]['gene'], drms[mut[1]]['drug'], drms[mut[1]]['alt_base'][mut[2]]]) + if mut[1] in drms + else '', + ) + ) else: break else: - print("\n\nThe ten most homoplasic mutations are:\n\tmut\tmultiplicity") - mutations_sorted = sorted(mutations.items(), key=lambda x:len(x[1])-0.1*x[0][1]/L, reverse=True) - for mut, val in mutations_sorted[:params.n]: - if len(val)>1: - print("\t%s%d%s\t%d"%(mut[0], mut[1], mut[2], len(val))) + print('\n\nThe ten most homoplasic mutations are:\n\tmut\tmultiplicity') + mutations_sorted = sorted(mutations.items(), key=lambda x: len(x[1]) - 0.1 * x[0][1] / L, reverse=True) + for mut, val in mutations_sorted[: params.n]: + if len(val) > 1: + print('\t%s%d%s\t%d' % (mut[0], mut[1], mut[2], len(val))) else: break # optional output specifically for mutations on terminal branches if params.detailed: if params.drms: - print("\n\nThe ten most homoplasic mutation on terminal branches are:\n\tmut\tmultiplicity\tDRM details (gene drug AAmut)") - terminal_mutations_sorted = sorted(terminal_mutations.items(), key=lambda x:len(x[1])-0.1*x[0][1]/L, reverse=True) - for mut, val in terminal_mutations_sorted[:params.n]: - if len(val)>1: - print("\t%s%d%s\t%d\t%s"%(mut[0], mut[1], mut[2], len(val), - " ".join([drms[mut[1]]['gene'], drms[mut[1]]['drug'], drms[mut[1]]['alt_base'][mut[2]]]) if mut[1] in drms else "")) + print( + '\n\nThe ten most homoplasic mutation on terminal branches are:\n\tmut\tmultiplicity\tDRM details (gene drug AAmut)' + ) + terminal_mutations_sorted = sorted( + terminal_mutations.items(), key=lambda x: len(x[1]) - 0.1 * x[0][1] / L, reverse=True + ) + for mut, val in terminal_mutations_sorted[: params.n]: + if len(val) > 1: + print( + '\t%s%d%s\t%d\t%s' + % ( + mut[0], + mut[1], + mut[2], + len(val), + ' '.join([drms[mut[1]]['gene'], drms[mut[1]]['drug'], drms[mut[1]]['alt_base'][mut[2]]]) + if mut[1] in drms + else '', + ) + ) else: break else: - print("\n\nThe ten most homoplasic mutation on terminal branches are:\n\tmut\tmultiplicity") - terminal_mutations_sorted = sorted(terminal_mutations.items(), key=lambda x:len(x[1])-0.1*x[0][1]/L, reverse=True) - for mut, val in terminal_mutations_sorted[:params.n]: - if len(val)>1: - print("\t%s%d%s\t%d"%(mut[0], mut[1], mut[2], len(val))) + print('\n\nThe ten most homoplasic mutation on terminal branches are:\n\tmut\tmultiplicity') + terminal_mutations_sorted = sorted( + terminal_mutations.items(), key=lambda x: len(x[1]) - 0.1 * x[0][1] / L, reverse=True + ) + for mut, val in terminal_mutations_sorted[: params.n]: + if len(val) > 1: + print('\t%s%d%s\t%d' % (mut[0], mut[1], mut[2], len(val))) else: break @@ -257,22 +307,25 @@ def scan_homoplasies(params): # TODO: add statistical criterion if params.detailed: if params.drms: - print("\n\nTaxons that carry positions that mutated elsewhere in the tree:\n\ttaxon name\t#of homoplasic mutations\t# DRM") - mutation_by_strain_sorted = sorted(mutation_by_strain.items(), key=lambda x:len(x[1]), reverse=True) - for name, val in mutation_by_strain_sorted[:params.n]: + print( + '\n\nTaxons that carry positions that mutated elsewhere in the tree:\n\ttaxon name\t#of homoplasic mutations\t# DRM' + ) + mutation_by_strain_sorted = sorted(mutation_by_strain.items(), key=lambda x: len(x[1]), reverse=True) + for name, val in mutation_by_strain_sorted[: params.n]: if len(val): - print("\t%s\t%d\t%d"%(name, len(val), - len([mut for mut,l in val if mut[1] in drms]))) + print('\t%s\t%d\t%d' % (name, len(val), len([mut for mut, l in val if mut[1] in drms]))) else: - print("\n\nTaxons that carry positions that mutated elsewhere in the tree:\n\ttaxon name\t#of homoplasic mutations") - mutation_by_strain_sorted = sorted(mutation_by_strain.items(), key=lambda x:len(x[1]), reverse=True) - for name, val in mutation_by_strain_sorted[:params.n]: + print( + '\n\nTaxons that carry positions that mutated elsewhere in the tree:\n\ttaxon name\t#of homoplasic mutations' + ) + mutation_by_strain_sorted = sorted(mutation_by_strain.items(), key=lambda x: len(x[1]), reverse=True) + for name, val in mutation_by_strain_sorted[: params.n]: if len(val): - print("\t%s\t%d"%(name, len(val))) - + print('\t%s\t%d' % (name, len(val))) return 0 + def arg_time_trees(params): """ This function takes command line arguments and runs treetime @@ -280,23 +333,37 @@ def arg_time_trees(params): """ from .arg import parse_arg, setup_arg - arg_params = parse_arg(params.trees[0], params.trees[1], - params.alignments[0], params.alignments[1], params.mccs, - fill_overhangs=not params.keep_overhangs) + arg_params = parse_arg( + params.trees[0], + params.trees[1], + params.alignments[0], + params.alignments[1], + params.mccs, + fill_overhangs=not params.keep_overhangs, + ) dates = utils.parse_dates(params.dates, date_col=params.date_column, name_col=params.name_column) root = None if params.keep_root else params.reroot - for i,(tree,mask) in enumerate(zip(arg_params['trees'], arg_params['masks'])): + for i, (tree, mask) in enumerate(zip(arg_params['trees'], arg_params['masks'])): outdir = get_outdir(params, f'_ARG-treetime') gtr = create_gtr(params) - tt = setup_arg(tree, arg_params['alignment'], arg_params['combined_mask'], mask, dates, arg_params['MCCs'], - gtr=gtr, verbose=params.verbose, fill_overhangs=not params.keep_overhangs, - fixed_clock_rate = params.clock_rate, reroot=root) - - run_timetree(tt, params, outdir, tree_suffix=f"_{i+1}", prune_short=False, method_anc=params.method_anc) + tt = setup_arg( + tree, + arg_params['alignment'], + arg_params['combined_mask'], + mask, + dates, + arg_params['MCCs'], + gtr=gtr, + verbose=params.verbose, + fill_overhangs=not params.keep_overhangs, + fixed_clock_rate=params.clock_rate, + reroot=root, + ) + run_timetree(tt, params, outdir, tree_suffix=f'_{i + 1}', prune_short=False, method_anc=params.method_anc) def timetree(params): @@ -304,12 +371,12 @@ def timetree(params): this function implements the regular treetime time tree estimation """ dates = utils.parse_dates(params.dates, date_col=params.date_column, name_col=params.name_column) - if len(dates)==0: - print("No valid dates -- exiting.") + if len(dates) == 0: + print('No valid dates -- exiting.') return 1 if assure_tree(params, tmp_dir='timetree_tmp'): - print("No tree -- exiting.") + print('No tree -- exiting.') return 1 outdir = get_outdir(params, '_treetime') @@ -324,36 +391,44 @@ def timetree(params): print("one of arguments '--aln' and '--sequence-length' is required.", file=sys.stderr) return 1 - myTree = TreeTime(dates=dates, tree=params.tree, ref=ref, - aln=aln, gtr=gtr, seq_len=params.sequence_length, - verbose=params.verbose, fill_overhangs=not params.keep_overhangs, - branch_length_mode = params.branch_length_mode, rng_seed=params.rng_seed) + myTree = TreeTime( + dates=dates, + tree=params.tree, + ref=ref, + aln=aln, + gtr=gtr, + seq_len=params.sequence_length, + verbose=params.verbose, + fill_overhangs=not params.keep_overhangs, + branch_length_mode=params.branch_length_mode, + rng_seed=params.rng_seed, + ) return run_timetree(myTree, params, outdir) def run_timetree(myTree, params, outdir, tree_suffix='', prune_short=True, method_anc='probabilistic'): - ''' + """ this function abstracts the time tree estimation that is used for regular treetime inference and for arg time tree inference. - ''' + """ ########################################################################### ### READ IN VCF ########################################################################### - #sets ref and fixed_pi to None if not VCF + # sets ref and fixed_pi to None if not VCF aln, ref, fixed_pi = read_if_vcf(params) is_vcf = True if ref is not None else False branch_length_mode = params.branch_length_mode - #variable-site-only trees can have big branch lengths, the auto setting won't work. + # variable-site-only trees can have big branch lengths, the auto setting won't work. if is_vcf or (params.aln and params.sequence_length): if branch_length_mode == 'auto': branch_length_mode = 'joint' - infer_gtr = params.gtr=='infer' + infer_gtr = params.gtr == 'infer' - myTree.tip_slack=params.tip_slack + myTree.tip_slack = params.tip_slack if not myTree.one_mutation: - print("TreeTime setup failed, exiting") + print('TreeTime setup failed, exiting') return 1 # coalescent model options @@ -363,20 +438,24 @@ def run_timetree(myTree, params, outdir, tree_suffix='', prune_short=True, metho if params.coalescent in ['opt', 'const', 'skyline']: coalescent = params.coalescent else: - raise TreeTimeError("unknown coalescent model specification, has to be either " - "a float, 'opt', 'const' or 'skyline' -- exiting") + raise TreeTimeError( + 'unknown coalescent model specification, has to be either ' + "a float, 'opt', 'const' or 'skyline' -- exiting" + ) # coalescent rates faster than the time to one mutation can lead to numerical issues - if type(coalescent)==float and coalescent>0 and coalescent<myTree.one_mutation: - raise TreeTimeError(f"coalescent time scale is too low, should be at least distance" - f" corresponding to one mutation {myTree.one_mutation:1.3e}") - + if type(coalescent) == float and coalescent > 0 and coalescent < myTree.one_mutation: + raise TreeTimeError( + f'coalescent time scale is too low, should be at least distance' + f' corresponding to one mutation {myTree.one_mutation:1.3e}' + ) n_branches_posterior = params.n_branches_posterior if hasattr(params, 'stochastic_resolve'): stochastic_resolve = params.stochastic_resolve - else: stochastic_resolve = False + else: + stochastic_resolve = False # determine whether confidence intervals are to be computed and how the # uncertainty in the rate estimate should be treated @@ -386,9 +465,13 @@ def run_timetree(myTree, params, outdir, tree_suffix='', prune_short=True, metho elif params.confidence and params.covariation: vary_rate = True elif params.confidence: - print(fill("Outside of covariation aware mode TreeTime cannot estimate confidence intervals " + print( + fill( + 'Outside of covariation aware mode TreeTime cannot estimate confidence intervals ' "without specified standard deviation of the clock rate.Please specify '--clock-std-dev' " - "or rerun with '--covariation'. Will proceed without confidence estimation")) + "or rerun with '--covariation'. Will proceed without confidence estimation" + ) + ) vary_rate = False calc_confidence = False else: @@ -396,91 +479,113 @@ def run_timetree(myTree, params, outdir, tree_suffix='', prune_short=True, metho if params.relax is None: relaxed_clock_params = None - elif params.relax==[]: - relaxed_clock_params=True - elif len(params.relax)==2: - relaxed_clock_params={'slack':params.relax[0], 'coupling':params.relax[1]} + elif params.relax == []: + relaxed_clock_params = True + elif len(params.relax) == 2: + relaxed_clock_params = {'slack': params.relax[0], 'coupling': params.relax[1]} time_marginal = reduce_time_marginal_argument(params.time_marginal) # RUN root = None if params.keep_root else params.reroot try: - success = myTree.run(root=root, relaxed_clock=relaxed_clock_params, - resolve_polytomies=(not params.keep_polytomies), - stochastic_resolve = stochastic_resolve, - Tc=coalescent, max_iter=params.max_iter, - fixed_clock_rate=params.clock_rate, - n_iqd=params.clock_filter, clock_filter_method=params.clock_filter_method, - time_marginal="confidence-only" if (calc_confidence and time_marginal=='never') else time_marginal, - vary_rate = vary_rate, - branch_length_mode = branch_length_mode, - reconstruct_tip_states=params.reconstruct_tip_states, - n_points=params.n_skyline, n_branches_posterior = n_branches_posterior, - fixed_pi=fixed_pi, prune_short=prune_short, - use_covariation=params.covariation, method_anc=method_anc, - tracelog_file=os.path.join(outdir, f"trace_run{tree_suffix}.log")) + success = myTree.run( + root=root, + relaxed_clock=relaxed_clock_params, # pylint: disable=possibly-used-before-assignment + resolve_polytomies=(not params.keep_polytomies), + stochastic_resolve=stochastic_resolve, + Tc=coalescent, + max_iter=params.max_iter, + fixed_clock_rate=params.clock_rate, + n_iqd=params.clock_filter, + clock_filter_method=params.clock_filter_method, + time_marginal='confidence-only' if (calc_confidence and time_marginal == 'never') else time_marginal, + vary_rate=vary_rate, + branch_length_mode=branch_length_mode, + reconstruct_tip_states=params.reconstruct_tip_states, + n_points=params.n_skyline, + n_branches_posterior=n_branches_posterior, + fixed_pi=fixed_pi, + prune_short=prune_short, + use_covariation=params.covariation, + method_anc=method_anc, + tracelog_file=os.path.join(outdir, f'trace_run{tree_suffix}.log'), + ) except TreeTimeError as e: - print("\nTreeTime run FAILED: please check above for errors and/or rerun with --verbose 4.\n") + print('\nTreeTime run FAILED: please check above for errors and/or rerun with --verbose 4.\n') raise e ########################################################################### ### OUTPUT and saving of results ########################################################################### if infer_gtr: - fname = outdir+f'sequence_evolution_model{tree_suffix}.txt' + fname = outdir + f'sequence_evolution_model{tree_suffix}.txt' with open(fname, 'w', encoding='utf-8') as ofile: - ofile.write(str(myTree.gtr)+'\n') - print('\nInferred sequence evolution model (saved as %s):'%fname) + ofile.write(str(myTree.gtr) + '\n') + print('\nInferred sequence evolution model (saved as %s):' % fname) print(myTree.gtr) - fname = outdir+f'molecular_clock{tree_suffix}.txt' - with open(fname, 'w', encoding='utf-8') as ofile: - ofile.write(str(myTree.date2dist)+'\n') - print('\nInferred sequence evolution model (saved as %s):'%fname) - print(myTree.date2dist) + save_molecular_clock(outdir, myTree.date2dist, suffix=tree_suffix) basename = get_basename(params, outdir) if coalescent in ['skyline', 'opt', 'const']: - print("Inferred coalescent model") - if coalescent=='skyline': - print_save_plot_skyline(myTree, plot=basename+'skyline.pdf', save=basename+'skyline.tsv', screen=True, gen=params.gen_per_year) + print('Inferred coalescent model') + if coalescent == 'skyline': + print_save_plot_skyline( + myTree, + plot=basename + 'skyline.pdf', + save=basename + 'skyline.tsv', + screen=True, + gen=params.gen_per_year, + ) else: Tc = myTree.merger_model.Tc.y[0] - print(" --T_c: \t %1.2e \toptimized inverse merger rate in units of substitutions"%Tc) - print(" --T_c: \t %1.2e \toptimized inverse merger rate in years"%(Tc/myTree.date2dist.clock_rate)) - print(" --N_e: \t %1.2e \tcorresponding 'effective population size' assuming %1.2e gen/year\n"%(Tc/myTree.date2dist.clock_rate*params.gen_per_year, params.gen_per_year)) + print(' --T_c: \t %1.2e \toptimized inverse merger rate in units of substitutions' % Tc) + print(' --T_c: \t %1.2e \toptimized inverse merger rate in years' % (Tc / myTree.date2dist.clock_rate)) + print( + " --N_e: \t %1.2e \tcorresponding 'effective population size' assuming %1.2e gen/year\n" + % (Tc / myTree.date2dist.clock_rate * params.gen_per_year, params.gen_per_year) + ) # plot ##IMPORTANT: after this point the functions not only plot the tree but also modify the branch length import matplotlib.pyplot as plt from .treetime import plot_vs_years + leaf_count = myTree.tree.count_terminals() - label_func = lambda x: (x.name if x.is_terminal() and ((leaf_count<30 - and (not params.no_tip_labels)) - or params.tip_labels) else '') + label_func = lambda x: ( + x.name if x.is_terminal() and ((leaf_count < 30 and (not params.no_tip_labels)) or params.tip_labels) else '' + ) - plot_vs_years(myTree, show_confidence=False, label_func=label_func, - confidence=0.9 if calc_confidence else None) - tree_fname = (outdir + params.plot_tree[:-4]+tree_suffix+params.plot_tree[-4:]) + plot_vs_years(myTree, show_confidence=False, label_func=label_func, confidence=0.9 if calc_confidence else None) + tree_fname = outdir + params.plot_tree[:-4] + tree_suffix + params.plot_tree[-4:] plt.savefig(tree_fname) - print("--- saved tree as \n\t %s\n"%tree_fname) + print('--- saved tree as \n\t %s\n' % tree_fname) - plot_rtt(myTree, outdir + params.plot_rtt[:-4]+tree_suffix+params.plot_rtt[-4:]) + plot_rtt(myTree, outdir + params.plot_rtt[:-4] + tree_suffix + params.plot_rtt[-4:]) if params.relax: - fname = outdir+'substitution_rates.tsv' - print("--- wrote branch specific rates to\n\t %s\n"%fname) + fname = outdir + 'substitution_rates.tsv' + print('--- wrote branch specific rates to\n\t %s\n' % fname) with open(fname, 'w', encoding='utf-8') as fh: - fh.write("#node\tclock_length\tmutation_length\trate\tfold_change\n") - for n in myTree.tree.find_clades(order="preorder"): - if n==myTree.tree.root: + fh.write('#node\tclock_length\tmutation_length\trate\tfold_change\n') + for n in myTree.tree.find_clades(order='preorder'): + if n == myTree.tree.root: continue g = n.branch_length_interpolator.gamma - fh.write("%s\t%1.3e\t%1.3e\t%1.3e\t%1.2f\n"%(n.name, n.clock_length, n.mutation_length, myTree.date2dist.clock_rate*g, g)) - - export_sequences_and_tree(myTree, basename, is_vcf, params.zero_based, - timetree=True, confidence=calc_confidence, - reconstruct_tip_states=params.reconstruct_tip_states, - tree_suffix=tree_suffix) + fh.write( + '%s\t%1.3e\t%1.3e\t%1.3e\t%1.2f\n' + % (n.name, n.clock_length, n.mutation_length, myTree.date2dist.clock_rate * g, g) + ) + + export_sequences_and_tree( + myTree, + basename, + is_vcf, + params.zero_based, + timetree=True, + confidence=calc_confidence, + reconstruct_tip_states=params.reconstruct_tip_states, + tree_suffix=tree_suffix, + ) return 0 @@ -502,39 +607,65 @@ def ancestral_reconstruction(params): ########################################################################### ### READ IN VCF ########################################################################### - #sets ref and fixed_pi to None if not VCF + # sets ref and fixed_pi to None if not VCF aln, ref, fixed_pi = read_if_vcf(params) is_vcf = True if ref is not None else False - treeanc = TreeAnc(params.tree, aln=aln, ref=ref, gtr=gtr, verbose=1, - fill_overhangs=not params.keep_overhangs, rng_seed=params.rng_seed) + treeanc = TreeAnc( + params.tree, + aln=aln, + ref=ref, + gtr=gtr, + verbose=1, + fill_overhangs=not params.keep_overhangs, + rng_seed=params.rng_seed, + ) try: - ndiff = treeanc.infer_ancestral_sequences('ml', infer_gtr=params.gtr=='infer', - marginal=params.marginal, fixed_pi=fixed_pi, - reconstruct_tip_states=params.reconstruct_tip_states) + ndiff = treeanc.infer_ancestral_sequences( + 'ml', + infer_gtr=params.gtr == 'infer', + marginal=params.marginal, + fixed_pi=fixed_pi, + reconstruct_tip_states=params.reconstruct_tip_states, + ) except TreeTimeError as e: - print("\nAncestral reconstruction failed, please see above for error messages and/or rerun with --verbose 4\n") + print('\nAncestral reconstruction failed, please see above for error messages and/or rerun with --verbose 4\n') raise e ########################################################################### ### OUTPUT and saving of results ########################################################################### - if params.gtr=='infer': - fname = outdir+'sequence_evolution_model.txt' + if params.gtr == 'infer': + fname = outdir + 'sequence_evolution_model.txt' with open(fname, 'w', encoding='utf-8') as ofile: - ofile.write(str(treeanc.gtr)+'\n') - print('\nInferred sequence evolution model (saved as %s):'%fname) + ofile.write(str(treeanc.gtr) + '\n') + print('\nInferred sequence evolution model (saved as %s):' % fname) print(treeanc.gtr) - export_sequences_and_tree(treeanc, basename, is_vcf, params.zero_based, - report_ambiguous=params.report_ambiguous, - reconstruct_tip_states=params.reconstruct_tip_states) + export_sequences_and_tree( + treeanc, + basename, + is_vcf, + params.zero_based, + report_ambiguous=params.report_ambiguous, + reconstruct_tip_states=params.reconstruct_tip_states, + ) return 0 -def reconstruct_discrete_traits(tree, traits, missing_data='?', pc=1.0, sampling_bias_correction=None, - weights=None, verbose=0, iterations=5, rng_seed=None): + +def reconstruct_discrete_traits( + tree, + traits, + missing_data='?', + pc=1.0, + sampling_bias_correction=None, + weights=None, + verbose=0, + iterations=5, + rng_seed=None, +): """take a set of discrete states associated with tips of a tree and reconstruct their ancestral states along with a GTR model that approximately maximizes the likelihood of the states on the tree. @@ -580,14 +711,13 @@ def reconstruct_discrete_traits(tree, traits, missing_data='?', pc=1.0, sampling n_observed_states = len(unique_states) # load weights from file and convert to dict if supplied as string - if type(weights)==str: + if type(weights) == str: try: - tmp_weights = pd.read_csv(weights, sep='\t' if weights[-3:]=='tsv' else ',', - skipinitialspace=True) - weight_dict = {row[0]:row[1] for ri,row in tmp_weights.iterrows() if not np.isnan(row[1])} + tmp_weights = pd.read_csv(weights, sep='\t' if weights[-3:] == 'tsv' else ',', skipinitialspace=True) + weight_dict = {row.iloc[0]: row.iloc[1] for ri, row in tmp_weights.iterrows() if not np.isnan(row.iloc[1])} except: - raise ValueError("Loading of weights file '%s' failed!"%weights) - elif type(weights)==dict: + raise ValueError("Loading of weights file '%s' failed!" % weights) + elif type(weights) == dict: weight_dict = weights else: weight_dict = None @@ -597,68 +727,79 @@ def reconstruct_discrete_traits(tree, traits, missing_data='?', pc=1.0, sampling unique_states.update(weight_dict.keys()) missing_weights = [c for c in unique_states if c not in weight_dict and c is not missing_data] if len(missing_weights): - print("Missing weights for values: " + ", ".join(missing_weights)) + print('Missing weights for values: ' + ', '.join(missing_weights)) - if len(missing_weights)>0.5*n_observed_states: - print("More than half of discrete states missing from the weights file") - print("Weights read from file are:", weights) - raise MissingDataError("More than half of discrete states missing from the weights file") + if len(missing_weights) > 0.5 * n_observed_states: + print('More than half of discrete states missing from the weights file') + print('Weights read from file are:', weights) + raise MissingDataError('More than half of discrete states missing from the weights file') - unique_states=sorted(unique_states) + unique_states = sorted(unique_states) # make a map from states (excluding missing data) to characters in the alphabet # note that gap character '-' is chr(45) and will never be included here - reverse_alphabet = {state:chr(65+i) for i,state in enumerate(unique_states) if state!=missing_data} + reverse_alphabet = {state: chr(65 + i) for i, state in enumerate(unique_states) if state != missing_data} alphabet = list(reverse_alphabet.values()) # construct a look up from alphabet character to states - letter_to_state = {v:k for k,v in reverse_alphabet.items()} + letter_to_state = {v: k for k, v in reverse_alphabet.items()} # construct the vector with weights to be used as equilibrium frequency if weight_dict is not None: mean_weight = np.mean(list(weight_dict.values())) - weights = np.array([weight_dict[letter_to_state[c]] if letter_to_state[c] in weight_dict else mean_weight - for c in alphabet], dtype=float) - weights/=weights.sum() + weights = np.array( + [weight_dict[letter_to_state[c]] if letter_to_state[c] in weight_dict else mean_weight for c in alphabet], + dtype=float, + ) + weights /= weights.sum() # consistency checks - if len(alphabet)<2: + if len(alphabet) < 2: print("mugration: only one or zero states found -- this doesn't make any sense", file=sys.stderr) return None, None, None n_states = len(alphabet) - missing_char = chr(65+n_states) - reverse_alphabet[missing_data]=missing_char - letter_to_state[missing_char]=missing_data + missing_char = chr(65 + n_states) + reverse_alphabet[missing_data] = missing_char + letter_to_state[missing_char] = missing_data ########################################################################### ### construct gtr model ########################################################################### # set up dummy matrix - W = np.ones((n_states,n_states), dtype=float) + W = np.ones((n_states, n_states), dtype=float) - mugration_GTR = GTR.custom(pi = weights, W=W, alphabet = np.array(alphabet)) + mugration_GTR = GTR.custom(pi=weights, W=W, alphabet=np.array(alphabet)) mugration_GTR.profile_map[missing_char] = np.ones(n_states) - mugration_GTR.ambiguous=missing_char - + mugration_GTR.ambiguous = missing_char ########################################################################### ### set up treeanc ########################################################################### - treeanc = TreeAnc(tree, gtr=mugration_GTR, verbose=verbose, ref='A', - convert_upper=False, one_mutation=0.001, rng_seed=rng_seed) + treeanc = TreeAnc( + tree, gtr=mugration_GTR, verbose=verbose, ref='A', convert_upper=False, one_mutation=0.001, rng_seed=rng_seed + ) treeanc.use_mutation_length = False - pseudo_seqs = {n.name: {0:reverse_alphabet[traits[n.name]] if n.name in traits else missing_char} - for n in treeanc.tree.get_terminals()} - valid_seq = np.array([s[0]!=missing_char for s in pseudo_seqs.values()]) - print("Assigned discrete traits to %d out of %d taxa.\n"%(np.sum(valid_seq),len(valid_seq))) + pseudo_seqs = { + n.name: {0: reverse_alphabet[traits[n.name]] if n.name in traits else missing_char} + for n in treeanc.tree.get_terminals() + } + valid_seq = np.array([s[0] != missing_char for s in pseudo_seqs.values()]) + print(f'Assigned discrete traits to {np.sum(valid_seq):d} out of {len(valid_seq):d} taxa.\n') treeanc.aln = pseudo_seqs try: - ndiff = treeanc.infer_ancestral_sequences(method='ml', infer_gtr=True, - store_compressed=False, pc=pc, marginal=True, normalized_rate=False, - fixed_pi=weights, reconstruct_tip_states=True) + ndiff = treeanc.infer_ancestral_sequences( + method='ml', + infer_gtr=True, + store_compressed=False, + pc=pc, + marginal=True, + normalized_rate=False, + fixed_pi=weights, + reconstruct_tip_states=True, + ) treeanc.optimize_gtr_rate() except TreeTimeError as e: - print("\nAncestral reconstruction failed, please see above for error messages and/or rerun with --verbose 4\n") + print('\nAncestral reconstruction failed, please see above for error messages and/or rerun with --verbose 4\n') raise e for i in range(iterations): @@ -668,9 +809,9 @@ def reconstruct_discrete_traits(tree, traits, missing_data='?', pc=1.0, sampling if sampling_bias_correction: treeanc.gtr.mu *= sampling_bias_correction - treeanc.infer_ancestral_sequences(infer_gtr=False, store_compressed=False, - marginal=True, normalized_rate=False, - reconstruct_tip_states=True) + treeanc.infer_ancestral_sequences( + infer_gtr=False, store_compressed=False, marginal=True, normalized_rate=False, reconstruct_tip_states=True + ) return treeanc, letter_to_state, reverse_alphabet @@ -684,10 +825,9 @@ def mugration(params): ### Parse states ########################################################################### if os.path.isfile(params.states): - states = pd.read_csv(params.states, sep='\t' if params.states[-3:]=='tsv' else ',', - skipinitialspace=True) + states = pd.read_csv(params.states, sep='\t' if params.states[-3:] == 'tsv' else ',', skipinitialspace=True) else: - print("file with states does not exist") + print('file with states does not exist') return 1 outdir = get_outdir(params, '_mugration') @@ -696,76 +836,90 @@ def mugration(params): if params.name_column in states.columns: taxon_name = params.name_column else: - print("Error: specified column '%s' for taxon name not found in meta data file with columns: "%params.name_column + " ".join(states.columns)) + print( + "Error: specified column '%s' for taxon name not found in meta data file with columns: " + % params.name_column + + ' '.join(states.columns) + ) return 1 - elif 'name' in states.columns: taxon_name = 'name' - elif 'strain' in states.columns: taxon_name = 'strain' - elif 'accession' in states.columns: taxon_name = 'accession' + elif 'name' in states.columns: + taxon_name = 'name' + elif 'strain' in states.columns: + taxon_name = 'strain' + elif 'accession' in states.columns: + taxon_name = 'accession' else: taxon_name = states.columns[0] - print("Using column '%s' as taxon name. This needs to match the taxa in the tree!"%taxon_name) + print("Using column '%s' as taxon name. This needs to match the taxa in the tree!" % taxon_name) if params.attribute: if params.attribute in states.columns: attr = params.attribute else: - print("The specified attribute was not found in the metadata file "+params.states, file=sys.stderr) - print("Available columns are: "+", ".join(states.columns), file=sys.stderr) + print('The specified attribute was not found in the metadata file ' + params.states, file=sys.stderr) + print('Available columns are: ' + ', '.join(states.columns), file=sys.stderr) return 1 else: attr = states.columns[1] - print("Attribute for mugration inference was not specified. Using "+attr, file=sys.stderr) - - leaf_to_attr = {x[taxon_name]:str(x[attr]) for xi, x in states.iterrows() - if x[attr]!=params.missing_data and x[attr]} - - mug, letter_to_state, reverse_alphabet = reconstruct_discrete_traits(params.tree, leaf_to_attr, - missing_data=params.missing_data, pc=params.pc, - sampling_bias_correction=params.sampling_bias_correction, - verbose=params.verbose, weights=params.weights, rng_seed=params.rng_seed) + print('Attribute for mugration inference was not specified. Using ' + attr, file=sys.stderr) + + leaf_to_attr = { + x[taxon_name]: str(x[attr]) for xi, x in states.iterrows() if x[attr] != params.missing_data and x[attr] + } + + mug, letter_to_state, reverse_alphabet = reconstruct_discrete_traits( + params.tree, + leaf_to_attr, + missing_data=params.missing_data, + pc=params.pc, + sampling_bias_correction=params.sampling_bias_correction, + verbose=params.verbose, + weights=params.weights, + rng_seed=params.rng_seed, + ) if mug is None: - print("Mugration inference failed, check error messages above and your input data.") + print('Mugration inference failed, check error messages above and your input data.') return 1 unique_states = sorted(letter_to_state.values()) ########################################################################### ### output ########################################################################### - print("\nCompleted mugration model inference of attribute '%s' for"%attr,params.tree) + print("\nCompleted mugration model inference of attribute '%s' for" % attr, params.tree) basename = get_basename(params, outdir) gtr_name = basename + 'GTR.txt' with open(gtr_name, 'w', encoding='utf-8') as ofile: ofile.write('Character to attribute mapping:\n') for state in unique_states: - ofile.write(' %s: %s\n'%(reverse_alphabet[state], state)) - ofile.write('\n\n'+str(mug.gtr)+'\n') - print("\nSaved inferred mugration model as:", gtr_name) + ofile.write(' %s: %s\n' % (reverse_alphabet[state], state)) + ofile.write('\n\n' + str(mug.gtr) + '\n') + print('\nSaved inferred mugration model as:', gtr_name) terminal_count = 0 for n in mug.tree.find_clades(): - n.confidence=None + n.confidence = None # due to a bug in older versions of biopython that truncated filenames in nexus export # we truncate them by hand and make them unique. - if n.is_terminal() and len(n.name)>40 and bioversion<"1.69": - n.name = n.name[:35]+'_%03d'%terminal_count - terminal_count+=1 - n.comment= '&%s="'%attr + letter_to_state[n.cseq[0]] +'"' + if n.is_terminal() and len(n.name) > 40 and bioversion < '1.69': + n.name = n.name[:35] + '_%03d' % terminal_count + terminal_count += 1 + n.comment = '&%s="' % attr + letter_to_state[n.cseq[0]] + '"' if params.confidence: - conf_name = basename+'confidence.csv' + conf_name = basename + 'confidence.csv' with open(conf_name, 'w', encoding='utf-8') as ofile: - ofile.write('#name, '+', '.join(mug.gtr.alphabet)+'\n') + ofile.write('#name, ' + ', '.join(mug.gtr.alphabet) + '\n') for n in mug.tree.find_clades(): - ofile.write(n.name + ', '+', '.join([str(x) for x in n.marginal_profile[0]])+'\n') - print("Saved table with ancestral state confidences as:", conf_name) + ofile.write(n.name + ', ' + ', '.join([str(x) for x in n.marginal_profile[0]]) + '\n') + print('Saved table with ancestral state confidences as:', conf_name) # write tree to file - outtree_name = basename+'annotated_tree.nexus' + outtree_name = basename + 'annotated_tree.nexus' Phylo.write(mug.tree, outtree_name, 'nexus') - print("Saved annotated tree as:", outtree_name) - print("---Done!\n") + print('Saved annotated tree as:', outtree_name) + print('---Done!\n') return 0 @@ -778,7 +932,7 @@ def estimate_clock_model(params): if assure_tree(params, tmp_dir='clock_model_tmp'): return 1 dates = utils.parse_dates(params.dates, date_col=params.date_column, name_col=params.name_column) - if len(dates)==0: + if len(dates) == 0: return 1 outdir = get_outdir(params, '_clock') @@ -786,7 +940,7 @@ def estimate_clock_model(params): ########################################################################### ### READ IN VCF ########################################################################### - #sets ref and fixed_pi to None if not VCF + # sets ref and fixed_pi to None if not VCF aln, ref, fixed_pi = read_if_vcf(params) is_vcf = True if ref is not None else False @@ -799,35 +953,42 @@ def estimate_clock_model(params): basename = get_basename(params, outdir) try: - myTree = TreeTime(dates=dates, tree=params.tree, aln=aln, gtr='JC69', - verbose=params.verbose, seq_len=params.sequence_length, - ref=ref, rng_seed=params.rng_seed) + myTree = TreeTime( + dates=dates, + tree=params.tree, + aln=aln, + gtr='JC69', + verbose=params.verbose, + seq_len=params.sequence_length, + ref=ref, + rng_seed=params.rng_seed, + ) except TreeTimeError as e: - print("\nTreeTime setup failed. Please see above for error messages and/or rerun with --verbose 4\n") + print('\nTreeTime setup failed. Please see above for error messages and/or rerun with --verbose 4\n') raise e - myTree.tip_slack=params.tip_slack + myTree.tip_slack = params.tip_slack if params.clock_filter: n_bad = [n.name for n in myTree.tree.get_terminals() if n.bad_branch] - myTree.clock_filter(n_iqd=params.clock_filter, reroot=params.reroot or 'least-squares', - method=params.clock_filter_method) + myTree.clock_filter( + n_iqd=params.clock_filter, reroot=params.reroot or 'least-squares', method=params.clock_filter_method + ) n_bad_after = [n.name for n in myTree.tree.get_terminals() if n.bad_branch] - if len(n_bad_after)>len(n_bad): - print("The following leaves don't follow a loose clock and " - "will be ignored in rate estimation:\n\t" - +"\n\t".join(set(n_bad_after).difference(n_bad))) + if len(n_bad_after) > len(n_bad): + print( + "The following leaves don't follow a loose clock and " + 'will be ignored in rate estimation:\n\t' + '\n\t'.join(set(n_bad_after).difference(n_bad)) + ) if not params.keep_root: # reroot to optimal root, this assigns clock_model to myTree - if params.covariation: # this requires branch length estimates - myTree.run(root="least-squares", max_iter=0, - use_covariation=params.covariation) + if params.covariation: # this requires branch length estimates + myTree.run(root='least-squares', max_iter=0, use_covariation=params.covariation) try: - res = myTree.reroot(params.reroot, - force_positive=not params.allow_negative_rate) + res = myTree.reroot(params.reroot, force_positive=not params.allow_negative_rate) except UnknownMethodError as e: - print("ERROR: unknown root or rooting mechanism!") + print('ERROR: unknown root or rooting mechanism!') raise e myTree.get_clock_model(covariation=params.covariation) @@ -835,68 +996,78 @@ def estimate_clock_model(params): myTree.get_clock_model(covariation=params.covariation) d2d = utils.DateConversion.from_regression(myTree.clock_model) - print('\n',d2d) - print(fill('The R^2 value indicates the fraction of variation in' - 'root-to-tip distance explained by the sampling times.' - 'Higher values corresponds more clock-like behavior (max 1.0).')+'\n') - print(fill('The rate is the slope of the best fit of the date to' - 'the root-to-tip distance and provides an estimate of' - 'the substitution rate. The rate needs to be positive!' - 'Negative rates suggest an inappropriate root.')+'\n') + save_molecular_clock(outdir, d2d) + print( + fill( + 'The R^2 value indicates the fraction of variation in' + 'root-to-tip distance explained by the sampling times.' + 'Higher values corresponds more clock-like behavior (max 1.0).' + ) + + '\n' + ) + + print( + fill( + 'The rate is the slope of the best fit of the date to' + 'the root-to-tip distance and provides an estimate of' + 'the substitution rate. The rate needs to be positive!' + 'Negative rates suggest an inappropriate root.' + ) + + '\n' + ) print('\nThe estimated rate and tree correspond to a root date:') if params.covariation: reg = myTree.clock_model - dp = np.array([reg['intercept']/reg['slope']**2,-1./reg['slope']]) - droot = np.sqrt(reg['cov'][:2,:2].dot(dp).dot(dp)) - print('\n--- root-date:\t %3.2f +/- %1.2f (one std-dev)\n\n'%(-d2d.intercept/d2d.clock_rate, droot)) + dp = np.array([reg['intercept'] / reg['slope'] ** 2, -1.0 / reg['slope']]) + droot = np.sqrt(reg['cov'][:2, :2].dot(dp).dot(dp)) + print('\n--- root-date:\t %3.2f +/- %1.2f (one std-dev)\n\n' % (-d2d.intercept / d2d.clock_rate, droot)) else: - print('\n--- root-date:\t %3.2f\n\n'%(-d2d.intercept/d2d.clock_rate)) + print('\n--- root-date:\t %3.2f\n\n' % (-d2d.intercept / d2d.clock_rate)) if hasattr(myTree, 'outliers') and myTree.outliers is not None: - print("--- saved detected outliers as " + basename + 'outliers.tsv') + print('--- saved detected outliers as ' + basename + 'outliers.tsv') myTree.outliers.to_csv(basename + 'outliers.tsv', sep='\t') if hasattr(myTree, 'outliers') and myTree.outliers is not None and params.prune_outliers: for outlier in myTree.outliers.index: - print("removing ", outlier) + print('removing ', outlier) myTree.tree.prune(outlier) if not params.keep_root: # write rerooted tree to file - outtree_name = basename+'rerooted.newick' + outtree_name = basename + 'rerooted.newick' elif params.prune_outliers: - outtree_name = basename+'pruned.newick' + outtree_name = basename + 'pruned.newick' else: - outtree_name = basename+'.output.newick' + outtree_name = basename + '.output.newick' Phylo.write(myTree.tree, outtree_name, 'newick') - print("--- new tree written to \n\t%s\n"%outtree_name) + print('--- new tree written to \n\t%s\n' % outtree_name) - table_fname = basename+'rtt.csv' + table_fname = basename + 'rtt.csv' with open(table_fname, 'w', encoding='utf-8') as ofile: ofile.write("#Dates of nodes that didn't have a specified date are inferred from the root-to-tip regression.\n") - ofile.write("name, date, root-to-tip distance, clock-deviation\n") + ofile.write('name, date, root-to-tip distance, clock-deviation\n') for n in myTree.tree.get_terminals(): - if hasattr(n, "raw_date_constraint") and (n.raw_date_constraint is not None): + if hasattr(n, 'raw_date_constraint') and (n.raw_date_constraint is not None): clock_deviation = d2d.clock_deviation(np.mean(n.raw_date_constraint), n.dist2root) if np.isscalar(n.raw_date_constraint): tmp_str = str(n.raw_date_constraint) elif len(n.raw_date_constraint): - tmp_str = str(n.raw_date_constraint[0])+'-'+str(n.raw_date_constraint[1]) + tmp_str = str(n.raw_date_constraint[0]) + '-' + str(n.raw_date_constraint[1]) else: tmp_str = '' - ofile.write("%s, %s, %f, %f\n"%(n.name, tmp_str, n.dist2root, clock_deviation)) + ofile.write('%s, %s, %f, %f\n' % (n.name, tmp_str, n.dist2root, clock_deviation)) else: - ofile.write("%s, %f, %f, 0.0\n"%(n.name, d2d.numdate_from_dist2root(n.dist2root), n.dist2root)) + ofile.write('%s, %f, %f, 0.0\n' % (n.name, d2d.numdate_from_dist2root(n.dist2root), n.dist2root)) for n in myTree.tree.get_nonterminals(order='preorder'): - ofile.write("%s, %f, %f, 0.0\n"%(n.name, d2d.numdate_from_dist2root(n.dist2root), n.dist2root)) - print("--- wrote dates and root-to-tip distances to \n\t%s\n"%table_fname) - + ofile.write('%s, %f, %f, 0.0\n' % (n.name, d2d.numdate_from_dist2root(n.dist2root), n.dist2root)) + print('--- wrote dates and root-to-tip distances to \n\t%s\n' % table_fname) ########################################################################### ### PLOT AND SAVE RESULT ########################################################################### - plot_rtt(myTree, outdir+params.plot_rtt) + plot_rtt(myTree, outdir + params.plot_rtt) return 0 |
