bdms-sim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdms/__init__.py +9 -0
- bdms/mutators.py +232 -0
- bdms/poisson.py +305 -0
- bdms/tree.py +646 -0
- bdms/utils.py +129 -0
- bdms_sim-0.2.0.dist-info/METADATA +27 -0
- bdms_sim-0.2.0.dist-info/RECORD +9 -0
- bdms_sim-0.2.0.dist-info/WHEEL +4 -0
- bdms_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
bdms/__init__.py
ADDED
bdms/mutators.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
r"""Mutation effects generators.
|
|
2
|
+
|
|
3
|
+
Abstract base class for defining generic mutation effect generators (i.e.
|
|
4
|
+
:math:`\mathcal{p}(x\mid x')`), with arbitrary :py:class:`ete3.TreeNode` attribute
|
|
5
|
+
dependence. Some concrete child classes are included.
|
|
6
|
+
|
|
7
|
+
These classes are used to define mutation effects for simulations with
|
|
8
|
+
:py:class:`bdms.TreeNode.evolve`.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
|
|
12
|
+
>>> import bdms
|
|
13
|
+
|
|
14
|
+
Define a discrete mutation model.
|
|
15
|
+
|
|
16
|
+
>>> mutator = bdms.mutators.DiscreteMutator(
|
|
17
|
+
... state_space=["a", "b", "c"],
|
|
18
|
+
... transition_matrix=[[0.0, 0.5, 0.5],
|
|
19
|
+
... [0.5, 0.0, 0.5],
|
|
20
|
+
... [0.5, 0.5, 0.0]],
|
|
21
|
+
... )
|
|
22
|
+
|
|
23
|
+
Mutate a node.
|
|
24
|
+
|
|
25
|
+
>>> node = bdms.TreeNode(state="a")
|
|
26
|
+
>>> mutator.mutate(node, seed=0)
|
|
27
|
+
>>> node.state
|
|
28
|
+
'c'
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
from abc import ABC, abstractmethod
|
|
33
|
+
from typing import Any
|
|
34
|
+
from collections.abc import Sequence, Callable, Hashable
|
|
35
|
+
import numpy as np
|
|
36
|
+
import scipy.stats as stats
|
|
37
|
+
import ete3
|
|
38
|
+
|
|
39
|
+
# NOTE: sphinx is currently unable to present this in condensed form when the
|
|
40
|
+
# sphinx_autodoc_typehints extension is enabled
|
|
41
|
+
# TODO: use ArrayLike in various phenotype/time methods (current float types)
|
|
42
|
+
# once it is available in a stable release
|
|
43
|
+
# from numpy.typing import ArrayLike
|
|
44
|
+
from numpy.typing import NDArray
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Mutator(ABC):
|
|
48
|
+
r"""Abstract base class for mutators that mutate a specified
|
|
49
|
+
:py:class:`ete3.TreeNode` node attribute.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
attr: Node attribute to mutate.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, attr: str = "state", *args: Any, **kwargs: Any) -> None:
|
|
56
|
+
self.attr = attr
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def mutate(
|
|
60
|
+
self,
|
|
61
|
+
node: ete3.TreeNode,
|
|
62
|
+
seed: int | np.random.Generator | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
r"""Mutate a :py:class:`bdms.TreeNode` object in place.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
node: A :py:class:`bdms.TreeNode` to mutate.
|
|
68
|
+
seed: A seed to initialize the random number generation. If ``None``, then
|
|
69
|
+
fresh, unpredictable entropy will be pulled from the OS. If an
|
|
70
|
+
``int``, then it will be used to derive the initial state. If a
|
|
71
|
+
:py:class:`numpy.random.Generator`, then it will be used directly.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def prob(self, attr1: float, attr2: float, log: bool = False) -> float:
|
|
76
|
+
r"""Probability of mutating from ``attr1`` to ``attr2``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
attr1: The initial attribute value.
|
|
80
|
+
attr2: The final attribute value.
|
|
81
|
+
log: If ``True``, return the log-probability.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Mutation probability (or log probability).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
keyval_strs = (
|
|
89
|
+
f"{key}={value}"
|
|
90
|
+
for key, value in vars(self).items()
|
|
91
|
+
if not key.startswith("_")
|
|
92
|
+
)
|
|
93
|
+
return f"{self.__class__.__name__}({', '.join(keyval_strs)})"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GaussianMutator(Mutator):
|
|
97
|
+
r"""Gaussian mutation effects on a specified attribute.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
shift: Mean shift wrt current attribute value.
|
|
101
|
+
scale: Standard deviation of mutation effect.
|
|
102
|
+
attr: Node attribute to mutate.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
shift: float = 0.0,
|
|
108
|
+
scale: float = 1.0,
|
|
109
|
+
attr: str = "state",
|
|
110
|
+
):
|
|
111
|
+
super().__init__(attr=attr)
|
|
112
|
+
self.shift = shift
|
|
113
|
+
self.scale = scale
|
|
114
|
+
self._distribution = stats.norm(loc=self.shift, scale=self.scale)
|
|
115
|
+
|
|
116
|
+
def mutate(
|
|
117
|
+
self,
|
|
118
|
+
node: ete3.TreeNode,
|
|
119
|
+
seed: int | np.random.Generator | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
current_state = getattr(node, self.attr)
|
|
122
|
+
Δ = self._distribution.rvs(random_state=seed)
|
|
123
|
+
setattr(node, self.attr, current_state + Δ)
|
|
124
|
+
|
|
125
|
+
def prob(self, attr1: float, attr2: float, log: bool = False) -> float:
|
|
126
|
+
Δ = np.asarray(attr2) - np.asarray(attr1)
|
|
127
|
+
if log:
|
|
128
|
+
return self._distribution.logpdf(Δ) # type:ignore
|
|
129
|
+
return self._distribution.pdf(Δ) # type:ignore
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class KdeMutator(Mutator):
|
|
133
|
+
r"""Gaussian kernel density estimator (KDE) for mutation effect on a specified
|
|
134
|
+
attribute.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
data: Data to fit the KDE to.
|
|
138
|
+
attr: Node attribute to mutate.
|
|
139
|
+
bw_method: KDE bandwidth (see :py:class:`scipy.stats.gaussian_kde`).
|
|
140
|
+
weights: Weights of data points (see :py:class:`scipy.stats.gaussian_kde`).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
data: NDArray[np.float64],
|
|
146
|
+
attr: str = "state",
|
|
147
|
+
bw_method: str | float | Callable[..., float] | None = None,
|
|
148
|
+
weights: NDArray[np.float64] | None = None,
|
|
149
|
+
):
|
|
150
|
+
super().__init__(attr=attr)
|
|
151
|
+
self._distribution = stats.gaussian_kde(data, bw_method, weights)
|
|
152
|
+
|
|
153
|
+
def mutate(
|
|
154
|
+
self,
|
|
155
|
+
node: ete3.TreeNode,
|
|
156
|
+
seed: int | np.random.Generator | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
current_state = getattr(node, self.attr)
|
|
159
|
+
Δ = self._distribution.resample(size=1, seed=seed)[0, 0]
|
|
160
|
+
setattr(node, self.attr, current_state + Δ)
|
|
161
|
+
|
|
162
|
+
def prob(self, attr1: float, attr2: float, log: bool = False) -> float:
|
|
163
|
+
Δ = np.asarray(attr2) - np.asarray(attr1)
|
|
164
|
+
if log:
|
|
165
|
+
return self._distribution.logpdf(Δ)
|
|
166
|
+
return self._distribution.pdf(Δ)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DiscreteMutator(Mutator):
|
|
170
|
+
r"""Mutations on a discrete space with a stochastic matrix.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
state_space: hashable state values.
|
|
174
|
+
transition_matrix: Right-stochastic matrix, where column and row orders match
|
|
175
|
+
the order of `state_space`.
|
|
176
|
+
attr: Node attribute to mutate.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
state_space: Sequence[Hashable],
|
|
182
|
+
transition_matrix: NDArray[np.float64],
|
|
183
|
+
attr: str = "state",
|
|
184
|
+
):
|
|
185
|
+
transition_matrix = np.asarray(transition_matrix, dtype=float)
|
|
186
|
+
if not (
|
|
187
|
+
transition_matrix.ndim == 2
|
|
188
|
+
and transition_matrix.shape[0]
|
|
189
|
+
== transition_matrix.shape[1]
|
|
190
|
+
== len(state_space)
|
|
191
|
+
):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Transition matrix shape {transition_matrix.shape} "
|
|
194
|
+
f"does not match state space size {state_space}"
|
|
195
|
+
)
|
|
196
|
+
if (
|
|
197
|
+
np.any(transition_matrix < 0)
|
|
198
|
+
or np.any(transition_matrix > 1)
|
|
199
|
+
or not np.allclose(transition_matrix.sum(axis=1), 1)
|
|
200
|
+
):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Transition matrix {transition_matrix} is not a valid stochastic"
|
|
203
|
+
" matrix."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
super().__init__(attr=attr)
|
|
207
|
+
|
|
208
|
+
self.state_space: dict[Any, int] = {
|
|
209
|
+
state: index for index, state in enumerate(state_space)
|
|
210
|
+
}
|
|
211
|
+
"""Mapping from state values to their indices in the transition matrix."""
|
|
212
|
+
self.transition_matrix = transition_matrix
|
|
213
|
+
|
|
214
|
+
def mutate(
|
|
215
|
+
self,
|
|
216
|
+
node: ete3.TreeNode,
|
|
217
|
+
seed: int | np.random.Generator | None = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
rng = np.random.default_rng(seed)
|
|
220
|
+
transition_probs = self.transition_matrix[
|
|
221
|
+
self.state_space[getattr(node, self.attr)], :
|
|
222
|
+
]
|
|
223
|
+
new_value = rng.choice(list(self.state_space.keys()), p=transition_probs)
|
|
224
|
+
setattr(node, self.attr, new_value)
|
|
225
|
+
|
|
226
|
+
def prob(self, attr1: float, attr2: float, log: bool = False) -> float:
|
|
227
|
+
result = self.transition_matrix[
|
|
228
|
+
self.state_space[attr1], self.state_space[attr2]
|
|
229
|
+
]
|
|
230
|
+
if log:
|
|
231
|
+
result = np.log(result)
|
|
232
|
+
return result
|
bdms/poisson.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
r"""Abstract base classes for defining general Poisson point processes on
|
|
2
|
+
:py:class:`bdms.TreeNode` state spaces. Several concrete child classes are included.
|
|
3
|
+
|
|
4
|
+
These classes are used to define rate-driven processes---such as birth, death, and
|
|
5
|
+
mutation---for simulations with :py:class:`bdms.TreeNode.evolve`.
|
|
6
|
+
|
|
7
|
+
Example
|
|
8
|
+
-------
|
|
9
|
+
|
|
10
|
+
>>> import bdms
|
|
11
|
+
|
|
12
|
+
Define a two-state process.
|
|
13
|
+
|
|
14
|
+
>>> poisson_process = bdms.poisson.DiscreteProcess({"a": 1.0, "b": 2.0})
|
|
15
|
+
|
|
16
|
+
Sample waiting times for each state.
|
|
17
|
+
|
|
18
|
+
>>> for state in poisson_process.rates: # doctest: +ELLIPSIS
|
|
19
|
+
... print(state, poisson_process.waiting_time_rv(state, 0.0, seed=0))
|
|
20
|
+
a 0.6799...
|
|
21
|
+
b 0.3399...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
from typing import Any, Hashable, TYPE_CHECKING
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
import scipy.integrate as integrate
|
|
29
|
+
import numpy as np
|
|
30
|
+
import scipy.optimize as optimize
|
|
31
|
+
|
|
32
|
+
# imports that are only used for type hints
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
import bdms
|
|
35
|
+
|
|
36
|
+
# NOTE: sphinx is currently unable to present this in condensed form when the
|
|
37
|
+
# sphinx_autodoc_typehints extension is enabled
|
|
38
|
+
# TODO: use ArrayLike in various phenotype/time methods (current float types)
|
|
39
|
+
# once it is available in a stable release
|
|
40
|
+
# from numpy.typing import ArrayLike
|
|
41
|
+
from numpy.typing import NDArray
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Process(ABC):
|
|
45
|
+
r"""Abstract base class for Poisson point processes on :py:class:`bdms.TreeNode`
|
|
46
|
+
attributes.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
attr: The name of the :py:class:`bdms.TreeNode` attribute to access.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def __init__(self, attr: str = "state") -> None:
|
|
54
|
+
self.attr = attr
|
|
55
|
+
|
|
56
|
+
def __call__(self, node: bdms.TreeNode) -> NDArray[np.float64]:
|
|
57
|
+
r"""Call ``self`` to evaluate the Poisson intensity at a tree node.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
node: The node whose state is accessed to evaluate the process.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The Poisson intensity at the node.
|
|
64
|
+
"""
|
|
65
|
+
return self.λ(getattr(node, self.attr), node.t)
|
|
66
|
+
|
|
67
|
+
# @abstractmethod
|
|
68
|
+
# def __add__(self, other: Process) -> Process:
|
|
69
|
+
# r"""The superposition of this Poisson process with another.
|
|
70
|
+
|
|
71
|
+
# Args:
|
|
72
|
+
# other: A Poisson process to add.
|
|
73
|
+
# """
|
|
74
|
+
|
|
75
|
+
# @abstractmethod
|
|
76
|
+
# def __rmul__(self, scaling: float):
|
|
77
|
+
# r"""Multiply a Poisson process by a constant, returning a process with a
|
|
78
|
+
# rescaled intensity measure."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def λ(self, x: Hashable, t: float) -> NDArray[np.float64]:
|
|
82
|
+
r"""The Poisson intensity :math:`\lambda(x, t)` for state :math:`x` at time
|
|
83
|
+
:math:`t`.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
x: State to evaluate Poisson intensity at.
|
|
87
|
+
t: Time to evaluate Poisson intensity at (``0.0`` corresponds to
|
|
88
|
+
the root). This only has an effect if the process is time-inhomogeneous.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The Poisson intensity :math:`\lambda(x, t)`.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def Λ(self, x: Hashable, t: float, Δt: float) -> NDArray[np.float64]:
|
|
96
|
+
r"""Evaluate the Poisson intensity measure of state :math:`x` and time interval
|
|
97
|
+
:math:`[t, t+Δt)`, defined as.
|
|
98
|
+
|
|
99
|
+
.. math:: \Lambda(x, t, Δt) = \int_{t}^{t+Δt} \lambda(x, s)ds,
|
|
100
|
+
|
|
101
|
+
This is needed for sampling waiting times and evaluating the log probability
|
|
102
|
+
density function of waiting times.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
x: State to evaluate Poisson intensity measure at.
|
|
106
|
+
t: Start time.
|
|
107
|
+
Δt: Time interval duration.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The Poisson intensity measure :math:`\Lambda(x, t, \Delta t)`.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def Λ_inv(self, x: Hashable, t: float, τ: float) -> NDArray[np.float64]:
|
|
115
|
+
r"""Evaluate the inverse function wrt :math:`\Delta t` of :py:meth:`Process.Λ`,
|
|
116
|
+
:math:`\Lambda_t^{-1}(x, t, \tau)`, such that :math:`\Lambda_t^{-1}(x, t,
|
|
117
|
+
\Lambda(x, t, t+\Delta t)) = \Delta t`. This is needed for sampling waiting
|
|
118
|
+
times. Note that :math:`\Lambda_t^{-1}` is well-defined iff :math:`\lambda(x, t)
|
|
119
|
+
> 0`.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
x: State.
|
|
123
|
+
t: Start time of the interval.
|
|
124
|
+
τ: Poisson intensity measure of the interval.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The inverse Poisson intensity measure :math:`\Lambda_t^{-1}(x, t, \tau)`.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def waiting_time_rv(
|
|
131
|
+
self,
|
|
132
|
+
x: Hashable,
|
|
133
|
+
t: float,
|
|
134
|
+
rate_multiplier: float = 1.0,
|
|
135
|
+
seed: int | np.random.Generator | None = None,
|
|
136
|
+
) -> NDArray[np.float64]:
|
|
137
|
+
r"""Sample the waiting time :math:`\Delta t` until the first event, given the
|
|
138
|
+
process on state :math:`x` starting at time :math:`t`.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
x: State.
|
|
142
|
+
t: Time at which to start waiting.
|
|
143
|
+
rate_multiplier: A constant by which to multiply the Poisson intensity
|
|
144
|
+
seed: A seed to initialize the random number generation. If ``None``, then
|
|
145
|
+
fresh, unpredictable entropy will be pulled from the OS. If an
|
|
146
|
+
``int``, then it will be used to derive the initial state. If a
|
|
147
|
+
:py:class:`numpy.random.Generator`, then it will be used directly.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Waiting time :math:`\Delta t`.
|
|
151
|
+
"""
|
|
152
|
+
rng = np.random.default_rng(seed)
|
|
153
|
+
return self.Λ_inv(x, t, rng.exponential(scale=1 / rate_multiplier))
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
keyval_strs = (
|
|
157
|
+
f"{key}={value}"
|
|
158
|
+
for key, value in vars(self).items()
|
|
159
|
+
if not key.startswith("_")
|
|
160
|
+
)
|
|
161
|
+
return f"{self.__class__.__name__}({', '.join(keyval_strs)})"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class HomogeneousProcess(Process):
|
|
165
|
+
r"""Abstract base class for homogenous Poisson processes."""
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def λ_homogeneous(
|
|
169
|
+
self, x: Hashable | Sequence[Hashable] | NDArray[Any]
|
|
170
|
+
) -> NDArray[np.float64]:
|
|
171
|
+
r"""Evaluate homogeneous Poisson intensity :math:`\lambda(x)` for state
|
|
172
|
+
:math:`x`.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
x: State to evaluate Poisson intensity at.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The Poisson intensity :math:`\lambda(x)`.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# def __add__(self, other: Process) -> Process:
|
|
182
|
+
|
|
183
|
+
# def __rmul__(self, scaling: float):
|
|
184
|
+
|
|
185
|
+
def λ(self, x: Hashable, t: float) -> NDArray[np.float64]:
|
|
186
|
+
return self.λ_homogeneous(x)
|
|
187
|
+
|
|
188
|
+
def Λ(self, x: Hashable, t: float, Δt: float) -> NDArray[np.float64]:
|
|
189
|
+
return self.λ_homogeneous(x) * Δt
|
|
190
|
+
|
|
191
|
+
# @np.errstate(divide="ignore")
|
|
192
|
+
# NOTE: the above suppresses warnings, but is slow!
|
|
193
|
+
# We instead test for zero.
|
|
194
|
+
def Λ_inv(self, x: Hashable, t: float, τ: float) -> NDArray[np.float64]:
|
|
195
|
+
rate = self.λ_homogeneous(x)
|
|
196
|
+
if rate == 0:
|
|
197
|
+
return np.full_like(x, np.inf, dtype=float)
|
|
198
|
+
|
|
199
|
+
return τ / rate
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ConstantProcess(HomogeneousProcess):
|
|
203
|
+
r"""A process with a specified constant rate (independent of state).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
value: Constant rate.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, value: float = 1.0):
|
|
210
|
+
super().__init__()
|
|
211
|
+
self.value = value
|
|
212
|
+
|
|
213
|
+
def λ_homogeneous(
|
|
214
|
+
self, x: Hashable | Sequence[Hashable] | NDArray[Any]
|
|
215
|
+
) -> NDArray[np.float64]:
|
|
216
|
+
return self.value * np.ones_like(x)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class DiscreteProcess(HomogeneousProcess):
|
|
220
|
+
r"""A homogeneous process at each of :math:`d` states.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
rates: Rates for each state.
|
|
224
|
+
attr: The name of the :py:class:`bdms.TreeNode` attribute to access. This
|
|
225
|
+
should take a discrete set of values.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self, rates: Mapping[Hashable, float] | Sequence[float], attr: str = "state"
|
|
230
|
+
):
|
|
231
|
+
super().__init__(attr=attr)
|
|
232
|
+
self.rates = rates
|
|
233
|
+
|
|
234
|
+
def λ_homogeneous(
|
|
235
|
+
self, x: Hashable | Sequence[Hashable] | NDArray[Any]
|
|
236
|
+
) -> NDArray[np.float64]:
|
|
237
|
+
if isinstance(x, Hashable):
|
|
238
|
+
return self.rates[x] # type:ignore
|
|
239
|
+
return np.array([self.rates[xi] for xi in x])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class InhomogeneousProcess(Process):
|
|
243
|
+
r"""Abstract base class for homogenous Poisson processes.
|
|
244
|
+
|
|
245
|
+
Default implementations of :py:meth:`Λ` and :py:meth:`Λ_inv` use quadrature and
|
|
246
|
+
root-finding, respectively.
|
|
247
|
+
You may wish to override these methods in a child clasee for better performance,
|
|
248
|
+
if analytical forms are available.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
attr: The name of the :py:class:`bdms.TreeNode` attribute to access.
|
|
252
|
+
quad_kwargs: Quadrature convergence arguments passed to
|
|
253
|
+
:py:func:`scipy.integrate.quad`.
|
|
254
|
+
root_kwargs: Root-finding convergence arguments passed to
|
|
255
|
+
:py:func:`scipy.optimize.root_scalar`.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
attr: str = "state",
|
|
261
|
+
quad_kwargs: dict[str, Any] = {},
|
|
262
|
+
root_kwargs: dict[str, Any] = {},
|
|
263
|
+
):
|
|
264
|
+
super().__init__(attr=attr)
|
|
265
|
+
self.quad_kwargs = quad_kwargs
|
|
266
|
+
self.root_kwargs = root_kwargs
|
|
267
|
+
|
|
268
|
+
@abstractmethod
|
|
269
|
+
def λ_inhomogeneous(self, x: Hashable, t: float) -> NDArray[np.float64]:
|
|
270
|
+
r"""Evaluate inhomogeneous Poisson intensity :math:`\lambda(x, t)` given state
|
|
271
|
+
:math:`x`.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
x: Attribute value to evaluate Poisson intensity at.
|
|
275
|
+
t: Time at which to evaluate Poisson intensity.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The Poisson intensity :math:`\lambda(x, t)`.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def λ(self, x: Hashable, t: float) -> NDArray[np.float64]:
|
|
282
|
+
return self.λ_inhomogeneous(x, t)
|
|
283
|
+
|
|
284
|
+
def Λ(self, x: Hashable, t: float, Δt: float) -> NDArray[np.float64]:
|
|
285
|
+
return integrate.quad(lambda Δt: self.λ(x, t + Δt), 0, Δt, **self.quad_kwargs)[
|
|
286
|
+
0
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
def Λ_inv(self, x: Hashable, t: float, τ: float) -> NDArray[np.float64]:
|
|
290
|
+
# NOTE: we log transform to ensure non-negative values
|
|
291
|
+
def f(logΔt: float):
|
|
292
|
+
return self.Λ(x, t, np.exp(logΔt)) - τ
|
|
293
|
+
|
|
294
|
+
def fprime(logΔt: float):
|
|
295
|
+
return self.λ(x, t + np.exp(logΔt)) * np.exp(logΔt)
|
|
296
|
+
|
|
297
|
+
sol = optimize.root_scalar(
|
|
298
|
+
f=f,
|
|
299
|
+
fprime=fprime,
|
|
300
|
+
x0=np.log(τ / self.λ(x, t)), # initial guess based on rate at time t
|
|
301
|
+
**self.root_kwargs,
|
|
302
|
+
)
|
|
303
|
+
if not sol.converged:
|
|
304
|
+
raise RuntimeError(f"Root-finding failed to converge:\n{sol}")
|
|
305
|
+
return np.exp(sol.root)
|
bdms/tree.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
r"""Birth-death-mutation-sampling (BDMS) process simulation.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
|
|
5
|
+
>>> import bdms
|
|
6
|
+
|
|
7
|
+
Initialize a tree with a single root node.
|
|
8
|
+
|
|
9
|
+
>>> tree = bdms.Tree()
|
|
10
|
+
>>> print(tree)
|
|
11
|
+
<BLANKLINE>
|
|
12
|
+
--0
|
|
13
|
+
|
|
14
|
+
Evolve the tree for a one time unit with default parameters.
|
|
15
|
+
|
|
16
|
+
>>> tree.evolve(1.0, seed=0)
|
|
17
|
+
>>> print(tree)
|
|
18
|
+
<BLANKLINE>
|
|
19
|
+
/-2
|
|
20
|
+
|
|
|
21
|
+
-- /-| /-6
|
|
22
|
+
| /-|
|
|
23
|
+
\-| \-7
|
|
24
|
+
|
|
|
25
|
+
\-5
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
import ete3
|
|
30
|
+
from ete3.coretype.tree import TreeError as ete_TreeError
|
|
31
|
+
from bdms import mutators, poisson, utils
|
|
32
|
+
import numpy as np
|
|
33
|
+
from typing import Any, Literal, Hashable
|
|
34
|
+
from collections.abc import Mapping, Iterator, Generator
|
|
35
|
+
import itertools
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
import copy
|
|
38
|
+
import math
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TreeError(ete_TreeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TreeNode(ete3.Tree):
|
|
46
|
+
r"""A tree generated by a BDMS process. Subclasses :py:class:`ete3.TreeNode`.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
t: Time of this node.
|
|
50
|
+
state: State of this node.
|
|
51
|
+
state_attr: Name of the node attribute to store the state in.
|
|
52
|
+
kwargs: Keyword arguments passed to :py:class:`ete3.TreeNode` initializer.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_BIRTH_EVENT = "birth"
|
|
56
|
+
_DEATH_EVENT = "death"
|
|
57
|
+
_MUTATION_EVENT = "mutation"
|
|
58
|
+
_SURVIVAL_EVENT = "survival"
|
|
59
|
+
_SAMPLING_EVENT = "sampling"
|
|
60
|
+
|
|
61
|
+
_OFFSPRING_NUMBER = 2
|
|
62
|
+
|
|
63
|
+
_name_generator: Iterator[int] = itertools.count()
|
|
64
|
+
|
|
65
|
+
_mutation_face = ete3.AttrFace(
|
|
66
|
+
"n_mutations",
|
|
67
|
+
fsize=6,
|
|
68
|
+
ftype="Arial",
|
|
69
|
+
fgcolor="black",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
t: float = 0,
|
|
75
|
+
state: Hashable = None,
|
|
76
|
+
state_attr: str = "state",
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> None:
|
|
79
|
+
if "dist" not in kwargs:
|
|
80
|
+
kwargs["dist"] = 0
|
|
81
|
+
if "name" not in kwargs:
|
|
82
|
+
TreeNode._name_generator = itertools.count()
|
|
83
|
+
self.name: int = next(self._name_generator)
|
|
84
|
+
kwargs["name"] = self.name
|
|
85
|
+
super().__init__(**kwargs)
|
|
86
|
+
self.t: float = t
|
|
87
|
+
"""Time of the node."""
|
|
88
|
+
self.state_attr: str = state_attr
|
|
89
|
+
"""Name of the node attribute to store the state in."""
|
|
90
|
+
setattr(self, state_attr, state)
|
|
91
|
+
self.event: str | None = None
|
|
92
|
+
"""Event at this node."""
|
|
93
|
+
self.n_mutations: int = 0
|
|
94
|
+
"""Number of mutations on the branch above this node (zero unless the tree has
|
|
95
|
+
been pruned above this node, removing mutation event nodes)."""
|
|
96
|
+
self._sampled: bool = False
|
|
97
|
+
"""Whether sampling has been run on this tree.
|
|
98
|
+
|
|
99
|
+
(Not whether the node has been sampled as part of this sampling process).
|
|
100
|
+
"""
|
|
101
|
+
self._pruned: bool = False
|
|
102
|
+
"""Whether pruning has been run on this tree."""
|
|
103
|
+
self._mutations_removed: bool = False
|
|
104
|
+
"""Whether unifurcating mutation event nodes have been removed from this
|
|
105
|
+
tree."""
|
|
106
|
+
|
|
107
|
+
def _birth_outcome(
|
|
108
|
+
self, birth_mutations: bool, mutator: mutators.Mutator, rng: np.random.Generator
|
|
109
|
+
) -> Generator[TreeNode, None, None]:
|
|
110
|
+
r"""Generate the outcome of a birth event at this node.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
birth_mutations: Flag to indicate whether mutations should occur at birth.
|
|
114
|
+
mutator: Generator of mutation effects if ``birth_mutations=True``.
|
|
115
|
+
rng: Random number generator.
|
|
116
|
+
|
|
117
|
+
Yields:
|
|
118
|
+
The child nodes, or mutated grandchild nodes if ``birth_mutations=True``.
|
|
119
|
+
"""
|
|
120
|
+
assert self.event == self._BIRTH_EVENT
|
|
121
|
+
for _ in range(self._OFFSPRING_NUMBER):
|
|
122
|
+
child = TreeNode(
|
|
123
|
+
t=self.t,
|
|
124
|
+
dist=0,
|
|
125
|
+
name=next(self._name_generator),
|
|
126
|
+
)
|
|
127
|
+
setattr(child, mutator.attr, copy.copy(getattr(self, mutator.attr)))
|
|
128
|
+
if birth_mutations:
|
|
129
|
+
child.event = self._MUTATION_EVENT
|
|
130
|
+
mutator.mutate(child, seed=rng)
|
|
131
|
+
grandchild = TreeNode(
|
|
132
|
+
t=child.t,
|
|
133
|
+
dist=0,
|
|
134
|
+
name=next(self._name_generator),
|
|
135
|
+
)
|
|
136
|
+
setattr(
|
|
137
|
+
grandchild, mutator.attr, copy.copy(getattr(child, mutator.attr))
|
|
138
|
+
)
|
|
139
|
+
child.add_child(grandchild)
|
|
140
|
+
yield grandchild
|
|
141
|
+
else:
|
|
142
|
+
yield child
|
|
143
|
+
self.add_child(child)
|
|
144
|
+
|
|
145
|
+
def _mutation_outcome(
|
|
146
|
+
self, mutator: mutators.Mutator, rng: np.random.Generator
|
|
147
|
+
) -> Generator[TreeNode, None, None]:
|
|
148
|
+
r"""Generate the outcome of a mutation event at this node.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
mutator: Generator of mutation effects.
|
|
152
|
+
rng: Random number generator.
|
|
153
|
+
|
|
154
|
+
Yields:
|
|
155
|
+
The mutated child node.
|
|
156
|
+
"""
|
|
157
|
+
assert self.event == self._MUTATION_EVENT
|
|
158
|
+
mutator.mutate(self, seed=rng)
|
|
159
|
+
child = TreeNode(
|
|
160
|
+
t=self.t,
|
|
161
|
+
dist=0,
|
|
162
|
+
name=next(self._name_generator),
|
|
163
|
+
)
|
|
164
|
+
setattr(child, mutator.attr, copy.copy(getattr(self, mutator.attr)))
|
|
165
|
+
self.add_child(child)
|
|
166
|
+
yield child
|
|
167
|
+
|
|
168
|
+
def evolve(
|
|
169
|
+
self,
|
|
170
|
+
t: float,
|
|
171
|
+
birth_process: poisson.Process = poisson.ConstantProcess(1),
|
|
172
|
+
death_process: poisson.Process = poisson.ConstantProcess(0),
|
|
173
|
+
mutation_process: poisson.Process = poisson.ConstantProcess(0),
|
|
174
|
+
mutator: mutators.Mutator = mutators.DiscreteMutator((None,), np.array([[1]])),
|
|
175
|
+
birth_mutations: bool = False,
|
|
176
|
+
min_survivors: int = 1,
|
|
177
|
+
capacity: int = 1000,
|
|
178
|
+
capacity_method: Literal["birth", "death", "hard"] | None = None,
|
|
179
|
+
init_population: int = 1,
|
|
180
|
+
seed: int | np.random.Generator | None = None,
|
|
181
|
+
verbose: bool = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
r"""Evolve for time :math:`\Delta t`.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
t: Evolve for a duration of :math:`t` time units.
|
|
187
|
+
birth_process: Birth process function.
|
|
188
|
+
death_process: Death process function.
|
|
189
|
+
mutation_process: Mutation process function.
|
|
190
|
+
mutator: Generator of mutation effects at mutation events
|
|
191
|
+
(and on offspring of birth events if ``birth_mutations=True``).
|
|
192
|
+
birth_mutations: Flag to indicate whether mutations should occur at birth.
|
|
193
|
+
min_survivors: Minimum number of survivors. If the simulation finishes with
|
|
194
|
+
fewer than this number of survivors, then a
|
|
195
|
+
:py:class:`TreeError` is raised.
|
|
196
|
+
capacity: Population carrying capacity.
|
|
197
|
+
capacity_method: Method to enforce population carrying capacity. If
|
|
198
|
+
``None``, then a :py:class:`TreeError` is raised if
|
|
199
|
+
the population exceeds the carrying capacity.
|
|
200
|
+
If ``"birth"``, then the birth rate is logistically
|
|
201
|
+
modulated such that the process is critical when the
|
|
202
|
+
population is at carrying capacity.
|
|
203
|
+
If ``"death"``, then the death rate is logistically
|
|
204
|
+
modulated such that the process is critical when the
|
|
205
|
+
population is at carrying capacity.
|
|
206
|
+
If ``"hard"``, then a random individual is chosen to
|
|
207
|
+
die whenever a birth event results in carrying capacity
|
|
208
|
+
being exceeded.
|
|
209
|
+
init_population: Initial population size.
|
|
210
|
+
seed: A seed to initialize the random number generation.
|
|
211
|
+
If ``None``, then fresh, unpredictable entropy will be pulled from
|
|
212
|
+
the OS.
|
|
213
|
+
If an ``int``, then it will be used to derive the initial state.
|
|
214
|
+
If a :py:class:`numpy.random.Generator`, then it will be used
|
|
215
|
+
directly.
|
|
216
|
+
verbose: Flag to indicate whether to print progress information.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
TreeError: If the tree is not a root node, or if the tree has already
|
|
220
|
+
evolved, or if the number of survivors is less than
|
|
221
|
+
``min_survivors``, or if the population exceeds ``capacity``
|
|
222
|
+
with ``capacity_method=None``.
|
|
223
|
+
ValueError: If ``init_population`` is greater than ``capacity`` or if
|
|
224
|
+
the event processes refer to different node attributes.
|
|
225
|
+
"""
|
|
226
|
+
if not self.is_root():
|
|
227
|
+
raise TreeError("Cannot evolve a non-root node")
|
|
228
|
+
if self.children:
|
|
229
|
+
raise TreeError(
|
|
230
|
+
"tree has already evolved at node "
|
|
231
|
+
f"{self.name} with {len(self.children)} descendant lineages"
|
|
232
|
+
)
|
|
233
|
+
if init_population > capacity:
|
|
234
|
+
raise ValueError(f"{init_population=} must be less than {capacity=}")
|
|
235
|
+
if birth_process.attr != death_process.attr != mutation_process.attr:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"Event processes must refer to the same node attribute. Got: "
|
|
238
|
+
f"{birth_process.attr=}, {death_process.attr=}, "
|
|
239
|
+
f"{mutation_process.attr=}"
|
|
240
|
+
)
|
|
241
|
+
state_attr = birth_process.attr
|
|
242
|
+
if not hasattr(self, state_attr):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"node {self.name} does not have attribute {state_attr} "
|
|
245
|
+
"required by mutator"
|
|
246
|
+
)
|
|
247
|
+
# NOTE: this may go later when we revise how mutators work
|
|
248
|
+
if mutator.attr != state_attr:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"mutator attribute {mutator.attr} does not match state attribute "
|
|
251
|
+
f"{state_attr}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
rng = np.random.default_rng(seed)
|
|
255
|
+
|
|
256
|
+
if verbose:
|
|
257
|
+
|
|
258
|
+
def print_progress(current_time: float, n_nodes_active: int):
|
|
259
|
+
print(f"t={current_time:.3f}, n={n_nodes_active}", end=" \r")
|
|
260
|
+
|
|
261
|
+
else:
|
|
262
|
+
|
|
263
|
+
def print_progress(current_time: float, n_nodes_active: int):
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
current_time = self.t
|
|
267
|
+
end_time = self.t + t
|
|
268
|
+
processes = {
|
|
269
|
+
self._BIRTH_EVENT: birth_process,
|
|
270
|
+
self._DEATH_EVENT: death_process,
|
|
271
|
+
self._MUTATION_EVENT: mutation_process,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# initialize population
|
|
275
|
+
names_nodes_all: dict[int, TreeNode] = dict()
|
|
276
|
+
names_active = utils.RandomizedSet()
|
|
277
|
+
state_names_active: defaultdict[str, utils.RandomizedSet] = defaultdict(
|
|
278
|
+
utils.RandomizedSet
|
|
279
|
+
)
|
|
280
|
+
total_birth_rate = 0.0
|
|
281
|
+
total_death_rate = 0.0
|
|
282
|
+
for _ in range(init_population):
|
|
283
|
+
start_node = TreeNode(
|
|
284
|
+
t=self.t,
|
|
285
|
+
dist=0,
|
|
286
|
+
name=next(self._name_generator),
|
|
287
|
+
)
|
|
288
|
+
setattr(start_node, state_attr, copy.copy(getattr(self, state_attr)))
|
|
289
|
+
self.add_child(start_node)
|
|
290
|
+
names_nodes_all[start_node.name] = start_node
|
|
291
|
+
names_active.add(start_node.name)
|
|
292
|
+
state_names_active[getattr(start_node, state_attr)].add(start_node.name)
|
|
293
|
+
total_birth_rate += birth_process(start_node)
|
|
294
|
+
total_death_rate += death_process(start_node)
|
|
295
|
+
|
|
296
|
+
# initialize rate multipliers, which are used to modulate
|
|
297
|
+
# rates in accordance with the carrying capacity
|
|
298
|
+
rate_multipliers: dict[str, float] = {
|
|
299
|
+
self._BIRTH_EVENT: 1.0,
|
|
300
|
+
self._DEATH_EVENT: 1.0,
|
|
301
|
+
self._MUTATION_EVENT: 1.0,
|
|
302
|
+
}
|
|
303
|
+
while len(names_active) > 0:
|
|
304
|
+
if capacity_method == "birth":
|
|
305
|
+
rate_multipliers[self._BIRTH_EVENT] = ( # type:ignore
|
|
306
|
+
total_death_rate / total_birth_rate
|
|
307
|
+
) ** (len(names_active) / capacity)
|
|
308
|
+
elif capacity_method == "death":
|
|
309
|
+
rate_multipliers[self._DEATH_EVENT] = ( # type:ignore
|
|
310
|
+
total_birth_rate / total_death_rate
|
|
311
|
+
) ** (len(names_active) / capacity)
|
|
312
|
+
elif capacity_method == "hard":
|
|
313
|
+
if len(names_active) > capacity:
|
|
314
|
+
node_to_die_name: int = names_active.choice(rng)
|
|
315
|
+
node_to_die = names_nodes_all[node_to_die_name]
|
|
316
|
+
assert node_to_die.up is not None
|
|
317
|
+
node_to_die.dist = current_time - node_to_die.up.t
|
|
318
|
+
node_to_die.event = self._DEATH_EVENT
|
|
319
|
+
node_to_die.t = current_time
|
|
320
|
+
names_active.remove(node_to_die_name)
|
|
321
|
+
state_names_active[getattr(node_to_die, state_attr)].remove(
|
|
322
|
+
node_to_die.name
|
|
323
|
+
)
|
|
324
|
+
total_birth_rate -= birth_process(node_to_die)
|
|
325
|
+
total_death_rate -= death_process(node_to_die)
|
|
326
|
+
elif capacity_method is None:
|
|
327
|
+
if len(names_active) > capacity:
|
|
328
|
+
self._aborted_evolve_cleanup()
|
|
329
|
+
if verbose:
|
|
330
|
+
print()
|
|
331
|
+
raise TreeError(f"{capacity=} exceeded at time={current_time}")
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(f"{capacity_method=} not recognized")
|
|
334
|
+
waiting_time, event, state = min(
|
|
335
|
+
(
|
|
336
|
+
processes[event].waiting_time_rv(
|
|
337
|
+
state,
|
|
338
|
+
current_time,
|
|
339
|
+
rate_multiplier=rate_multipliers[event]
|
|
340
|
+
* len(state_names_active[state]),
|
|
341
|
+
seed=rng,
|
|
342
|
+
),
|
|
343
|
+
event,
|
|
344
|
+
state,
|
|
345
|
+
)
|
|
346
|
+
for event in processes
|
|
347
|
+
for state in state_names_active
|
|
348
|
+
if len(state_names_active[state]) > 0
|
|
349
|
+
)
|
|
350
|
+
Δt = float(np.minimum(waiting_time, end_time - current_time))
|
|
351
|
+
current_time += Δt
|
|
352
|
+
if current_time < end_time:
|
|
353
|
+
event_node_name = state_names_active[state].choice(rng)
|
|
354
|
+
event_node = names_nodes_all[event_node_name]
|
|
355
|
+
event_node.t = current_time
|
|
356
|
+
assert event_node.up is not None
|
|
357
|
+
event_node.dist = current_time - event_node.up.t
|
|
358
|
+
event_node.event = event
|
|
359
|
+
names_active.remove(event_node_name)
|
|
360
|
+
state_names_active[getattr(event_node, state_attr)].remove(
|
|
361
|
+
event_node.name
|
|
362
|
+
)
|
|
363
|
+
total_birth_rate -= birth_process(event_node)
|
|
364
|
+
total_death_rate -= death_process(event_node)
|
|
365
|
+
if event_node.event == self._DEATH_EVENT:
|
|
366
|
+
new_nodes = ()
|
|
367
|
+
elif event_node.event == self._BIRTH_EVENT:
|
|
368
|
+
new_nodes = event_node._birth_outcome(birth_mutations, mutator, rng)
|
|
369
|
+
elif event_node.event == self._MUTATION_EVENT:
|
|
370
|
+
new_nodes = event_node._mutation_outcome(mutator, rng)
|
|
371
|
+
else:
|
|
372
|
+
raise ValueError(f"invalid event {event_node.event}")
|
|
373
|
+
for new_node in new_nodes:
|
|
374
|
+
names_nodes_all[new_node.name] = new_node
|
|
375
|
+
names_active.add(new_node.name)
|
|
376
|
+
state_names_active[getattr(new_node, state_attr)].add(new_node.name)
|
|
377
|
+
total_birth_rate += birth_process(new_node)
|
|
378
|
+
total_death_rate += death_process(new_node)
|
|
379
|
+
print_progress(float(current_time), len(names_active))
|
|
380
|
+
else:
|
|
381
|
+
# deal with possible loss of significance
|
|
382
|
+
if current_time > end_time:
|
|
383
|
+
# NOTE: math.isclose is much faster than np.isclose
|
|
384
|
+
assert math.isclose(
|
|
385
|
+
current_time, end_time
|
|
386
|
+
), f"{current_time=}, {end_time=}"
|
|
387
|
+
current_time = end_time
|
|
388
|
+
print_progress(float(current_time), len(names_active))
|
|
389
|
+
for node_name in reversed(names_active):
|
|
390
|
+
node = names_nodes_all[node_name]
|
|
391
|
+
node.t = current_time
|
|
392
|
+
assert node.up is not None
|
|
393
|
+
node.dist = current_time - node.up.t
|
|
394
|
+
node.event = self._SURVIVAL_EVENT
|
|
395
|
+
names_active.remove(node_name)
|
|
396
|
+
state_names_active[getattr(node, state_attr)].remove(node.name)
|
|
397
|
+
assert len(names_active) == 0
|
|
398
|
+
assert not any(
|
|
399
|
+
len(state_names_active[state]) > 0 for state in state_names_active
|
|
400
|
+
)
|
|
401
|
+
if verbose:
|
|
402
|
+
print()
|
|
403
|
+
n_survivors = sum(leaf.event == self._SURVIVAL_EVENT for leaf in self)
|
|
404
|
+
if n_survivors < min_survivors:
|
|
405
|
+
self._aborted_evolve_cleanup()
|
|
406
|
+
raise TreeError(
|
|
407
|
+
f"number of survivors {n_survivors} is less than {min_survivors=}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _aborted_evolve_cleanup(self) -> None:
|
|
411
|
+
"""Remove any children added to the root node during an aborted evolution
|
|
412
|
+
attempt, and reset the node name generator."""
|
|
413
|
+
for child in self.children.copy():
|
|
414
|
+
child.detach()
|
|
415
|
+
del child
|
|
416
|
+
TreeNode._name_generator = itertools.count(start=self.name + 1)
|
|
417
|
+
|
|
418
|
+
def sample_survivors(
|
|
419
|
+
self,
|
|
420
|
+
n: int | None = None,
|
|
421
|
+
p: float | None = 1.0,
|
|
422
|
+
seed: int | np.random.Generator | None = None,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Choose :math:`n` survivor leaves from the tree, or each survivor leaf with
|
|
425
|
+
probability :math:`p`, to mark as sampled (via the event attribute).
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
n: Number of leaves to sample.
|
|
429
|
+
p: Probability of sampling a leaf.
|
|
430
|
+
seed: A seed to initialize the random number generation. If ``None``, then
|
|
431
|
+
fresh, unpredictable entropy will be pulled from the OS. If an
|
|
432
|
+
``int``, then it will be used to derive the initial state. If a
|
|
433
|
+
:py:class:`numpy.random.Generator`, then it will be used directly.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
ValueError: If the tree has already been sampled below this node, or if
|
|
437
|
+
neither ``n`` nor ``p`` is specified.
|
|
438
|
+
"""
|
|
439
|
+
if self._sampled:
|
|
440
|
+
raise ValueError(f"tree has already been sampled below node {self.name}")
|
|
441
|
+
rng = np.random.default_rng(seed)
|
|
442
|
+
surviving_leaves: list[TreeNode] = [
|
|
443
|
+
leaf for leaf in self if leaf.event == self._SURVIVAL_EVENT
|
|
444
|
+
]
|
|
445
|
+
if n is not None:
|
|
446
|
+
for leaf in rng.choice(
|
|
447
|
+
surviving_leaves, size=n, replace=False # type:ignore
|
|
448
|
+
):
|
|
449
|
+
leaf.event = self._SAMPLING_EVENT
|
|
450
|
+
elif p is not None:
|
|
451
|
+
for leaf in surviving_leaves:
|
|
452
|
+
if rng.choice(range(2), p=(1 - p, p)):
|
|
453
|
+
leaf.event = self._SAMPLING_EVENT
|
|
454
|
+
else:
|
|
455
|
+
raise ValueError("must specify either n or p")
|
|
456
|
+
for node in self.traverse(): # type:ignore
|
|
457
|
+
node._sampled = True
|
|
458
|
+
|
|
459
|
+
# NOTE: this could be generalized to take an ordered array-valued t,
|
|
460
|
+
# and made efficient via ordered traversal
|
|
461
|
+
def slice(self, t: float, attr: str = "x") -> list[Any]:
|
|
462
|
+
r"""
|
|
463
|
+
Args:
|
|
464
|
+
t: Slice the tree at time :math:`t`.
|
|
465
|
+
attr: Attribute to extract from slice.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
List of attribute ``attr`` values at time :math:`t` for all lineages alive
|
|
469
|
+
at that time.
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
ValueError: If the tree has not evolved or has already been pruned below
|
|
473
|
+
this node, or if the tree has not been sampled below this node,
|
|
474
|
+
or if ``t`` is before the root time or after the tree end time.
|
|
475
|
+
"""
|
|
476
|
+
if self._pruned:
|
|
477
|
+
raise ValueError("Cannot slice a pruned tree")
|
|
478
|
+
if not self.children:
|
|
479
|
+
raise ValueError("Cannot slice an unevolved tree")
|
|
480
|
+
if t < self.t:
|
|
481
|
+
raise ValueError(f"Cannot slice at time {t} before root time {self.t}")
|
|
482
|
+
tree_end_time = max(node.t for node in self)
|
|
483
|
+
if t > tree_end_time:
|
|
484
|
+
raise ValueError(
|
|
485
|
+
f"cannot slice at time {t} after tree end time {tree_end_time}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if self.t == t:
|
|
489
|
+
return [getattr(self, attr)]
|
|
490
|
+
|
|
491
|
+
def is_leaf_fn(node: TreeNode):
|
|
492
|
+
assert node.up is not None
|
|
493
|
+
return node.t >= t and node.up.t < t
|
|
494
|
+
|
|
495
|
+
return [
|
|
496
|
+
(getattr(node, attr) if node.t == t else getattr(node.up, attr))
|
|
497
|
+
for node in self.iter_leaves(is_leaf_fn=is_leaf_fn)
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
def prune_unsampled(self) -> None:
|
|
501
|
+
r"""Prune the tree to the subtree subtending the sampled leaves, removing
|
|
502
|
+
unobserved subtrees.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValueError: If the tree has not been sampled below this node, or if the
|
|
506
|
+
tree has already been pruned below this node.
|
|
507
|
+
TreeError: If no leaves were sampled.
|
|
508
|
+
"""
|
|
509
|
+
if self._pruned:
|
|
510
|
+
raise ValueError(f"tree has already been pruned below node {self.name}")
|
|
511
|
+
if not self._sampled:
|
|
512
|
+
raise ValueError(f"tree has not been sampled below node {self.name}")
|
|
513
|
+
|
|
514
|
+
event_cache: dict[TreeNode, set[str]] = self.get_cached_content(
|
|
515
|
+
store_attr="event"
|
|
516
|
+
)
|
|
517
|
+
if self._SAMPLING_EVENT not in event_cache[self]:
|
|
518
|
+
raise TreeError("cannot prune because no leaves were sampled")
|
|
519
|
+
|
|
520
|
+
def is_leaf_fn(node: TreeNode):
|
|
521
|
+
return self._SAMPLING_EVENT not in event_cache[node]
|
|
522
|
+
|
|
523
|
+
for node in self.iter_leaves(is_leaf_fn=is_leaf_fn):
|
|
524
|
+
parent = node.up
|
|
525
|
+
assert parent is not None
|
|
526
|
+
parent.remove_child(node)
|
|
527
|
+
assert parent.event == self._BIRTH_EVENT or parent.is_root()
|
|
528
|
+
parent.delete(
|
|
529
|
+
prevent_nondicotomic=False,
|
|
530
|
+
preserve_branch_length=True,
|
|
531
|
+
)
|
|
532
|
+
for node in self.traverse(): # type:ignore
|
|
533
|
+
node._pruned = True
|
|
534
|
+
|
|
535
|
+
def remove_mutation_events(self) -> None:
|
|
536
|
+
r"""Remove unifurcating mutation event nodes, preserving branch length, and
|
|
537
|
+
annotate mutation counts in child node ``n_mutations`` attribute.
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ValueError: If the tree has not been pruned below this node with
|
|
541
|
+
:py:meth:`prune`.
|
|
542
|
+
"""
|
|
543
|
+
if not self._pruned:
|
|
544
|
+
raise ValueError(f"tree has not been pruned below node {self.name}")
|
|
545
|
+
for node in self.traverse(strategy="postorder"): # type:ignore
|
|
546
|
+
if node.event == self._MUTATION_EVENT:
|
|
547
|
+
assert len(node.children) == 1
|
|
548
|
+
node.children[0].n_mutations += 1
|
|
549
|
+
node.delete(
|
|
550
|
+
prevent_nondicotomic=False,
|
|
551
|
+
preserve_branch_length=True,
|
|
552
|
+
)
|
|
553
|
+
for node in self.traverse(): # type:ignore
|
|
554
|
+
node._mutations_removed = True
|
|
555
|
+
|
|
556
|
+
def render( # type:ignore
|
|
557
|
+
self,
|
|
558
|
+
file_name: str,
|
|
559
|
+
color_by: str | None = "state",
|
|
560
|
+
color_map: Mapping[Any, str] | None = None,
|
|
561
|
+
mode: Literal["c", "r"] = "r",
|
|
562
|
+
scale: float | None = None,
|
|
563
|
+
**kwargs: Any,
|
|
564
|
+
) -> Any:
|
|
565
|
+
r"""A thin wrapper around :py:meth:`ete3.TreeNode.render` that adds some custom
|
|
566
|
+
decoration and a color bar. See also ETE's tree rendering `tutorial`_ and linked
|
|
567
|
+
API docs pages there.
|
|
568
|
+
|
|
569
|
+
.. _tutorial: http://etetoolkit.org/docs/latest/tutorial/tutorial_drawing.html
|
|
570
|
+
|
|
571
|
+
If tree is not pruned (or is pruned without removing mutation events), then
|
|
572
|
+
branches are colored according to the attribute specified by ``color_by``,
|
|
573
|
+
extinct lineages are indicated as dotted branches, unsampled non-extint
|
|
574
|
+
lineages are indicated as solid branches, and sampled lineages are indicated
|
|
575
|
+
as thick solid branches. Sampled leaves are indicated with a circle.
|
|
576
|
+
|
|
577
|
+
If tree is pruned without retaining mutation events, then nodes are colored
|
|
578
|
+
according to the attribute specified by ``color_by``, branches are annotated
|
|
579
|
+
above with branch length (in black text) and below with number of mutations
|
|
580
|
+
(in green text).
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
file_name: Filename to save the rendered tree to. Use ``"%%inline"`` to
|
|
584
|
+
render inline in a notebook.
|
|
585
|
+
color_by: If not ``None``, color tree by this numerical node attribute.
|
|
586
|
+
color_map: mapping from node attribute values to color names or hex codes.
|
|
587
|
+
mode: ``"c"`` for circular tree, ``"r"`` for rectangular tree.
|
|
588
|
+
scale: Scale branch lengths by this factor (ignored if a ``tree_style`` is
|
|
589
|
+
passed in ``kwargs``). If ``None``, then the scale is chosen
|
|
590
|
+
automatically.
|
|
591
|
+
kwargs: Keyword arguments to pass to :py:meth:`ete3.TreeNode.render`.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
The return value of :py:meth:`ete3.TreeNode.render`.
|
|
595
|
+
"""
|
|
596
|
+
if "tree_style" not in kwargs:
|
|
597
|
+
kwargs["tree_style"] = ete3.TreeStyle()
|
|
598
|
+
kwargs["tree_style"].show_leaf_name = False
|
|
599
|
+
kwargs["tree_style"].show_scale = False
|
|
600
|
+
kwargs["tree_style"].rotation = 90
|
|
601
|
+
kwargs["tree_style"].mode = mode
|
|
602
|
+
kwargs["tree_style"].scale = scale # type:ignore
|
|
603
|
+
|
|
604
|
+
event_cache: dict[TreeNode, set[str]] = self.get_cached_content(
|
|
605
|
+
store_attr="event", leaves_only=False
|
|
606
|
+
)
|
|
607
|
+
if (not self._pruned) or (not self._mutations_removed):
|
|
608
|
+
for node in self.traverse(): # type:ignore
|
|
609
|
+
nstyle = ete3.NodeStyle()
|
|
610
|
+
if (
|
|
611
|
+
self._SURVIVAL_EVENT not in event_cache[node]
|
|
612
|
+
and self._SAMPLING_EVENT not in event_cache[node]
|
|
613
|
+
):
|
|
614
|
+
nstyle["hz_line_type"] = 1
|
|
615
|
+
nstyle["vt_line_type"] = 1
|
|
616
|
+
nstyle["hz_line_width"] = 0
|
|
617
|
+
nstyle["vt_line_width"] = 0
|
|
618
|
+
elif self._SAMPLING_EVENT not in event_cache[node]:
|
|
619
|
+
nstyle["hz_line_width"] = 1
|
|
620
|
+
nstyle["vt_line_width"] = 1
|
|
621
|
+
else:
|
|
622
|
+
nstyle["hz_line_width"] = 2
|
|
623
|
+
nstyle["vt_line_width"] = 2
|
|
624
|
+
if color_map is not None:
|
|
625
|
+
assert color_by is not None
|
|
626
|
+
nstyle["vt_line_color"] = color_map[getattr(node, color_by)]
|
|
627
|
+
if not node.is_root():
|
|
628
|
+
assert node.up is not None
|
|
629
|
+
nstyle["hz_line_color"] = color_map[getattr(node.up, color_by)]
|
|
630
|
+
nstyle["fgcolor"] = color_map[getattr(node, color_by)]
|
|
631
|
+
if node.event == self._SAMPLING_EVENT:
|
|
632
|
+
nstyle["size"] = 2
|
|
633
|
+
else:
|
|
634
|
+
nstyle["size"] = 0
|
|
635
|
+
node.set_style(nstyle)
|
|
636
|
+
else:
|
|
637
|
+
for node in self.traverse(): # type:ignore
|
|
638
|
+
nstyle = ete3.NodeStyle()
|
|
639
|
+
if color_map is not None:
|
|
640
|
+
assert color_by is not None
|
|
641
|
+
nstyle["fgcolor"] = color_map[getattr(node, color_by)]
|
|
642
|
+
if not node.is_root() and not getattr(node.faces, "branch-bottom"):
|
|
643
|
+
node.add_face(self._mutation_face, 0, position="branch-bottom")
|
|
644
|
+
node.set_style(nstyle)
|
|
645
|
+
|
|
646
|
+
return super().render(file_name, **kwargs)
|
bdms/utils.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
r"""Miscellaneous utilities needed by the rest of the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Hashable, Iterable
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RandomizedSet:
|
|
9
|
+
r"""A set-like data structure that supports random sampling with constant time
|
|
10
|
+
complexity.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
|
|
14
|
+
>>> import bdms
|
|
15
|
+
|
|
16
|
+
Initialize with any iterable of hashable items.
|
|
17
|
+
|
|
18
|
+
>>> rs = bdms.utils.RandomizedSet("abc")
|
|
19
|
+
>>> rs
|
|
20
|
+
RandomizedSet('a', 'b', 'c')
|
|
21
|
+
>>> len(rs)
|
|
22
|
+
3
|
|
23
|
+
|
|
24
|
+
Add an item.
|
|
25
|
+
|
|
26
|
+
>>> rs.add('d')
|
|
27
|
+
>>> rs
|
|
28
|
+
RandomizedSet('a', 'b', 'c', 'd')
|
|
29
|
+
>>> len(rs)
|
|
30
|
+
4
|
|
31
|
+
|
|
32
|
+
Choose a random item.
|
|
33
|
+
|
|
34
|
+
>>> rs.choice(seed=0)
|
|
35
|
+
'd'
|
|
36
|
+
|
|
37
|
+
Remove an item.
|
|
38
|
+
|
|
39
|
+
>>> rs.remove('a')
|
|
40
|
+
>>> rs
|
|
41
|
+
RandomizedSet('d', 'b', 'c')
|
|
42
|
+
|
|
43
|
+
Iterate over the items.
|
|
44
|
+
|
|
45
|
+
>>> for item in rs:
|
|
46
|
+
... print(item)
|
|
47
|
+
d
|
|
48
|
+
b
|
|
49
|
+
c
|
|
50
|
+
|
|
51
|
+
Reverse iterate over the items.
|
|
52
|
+
|
|
53
|
+
>>> for item in reversed(rs):
|
|
54
|
+
... print(item)
|
|
55
|
+
c
|
|
56
|
+
b
|
|
57
|
+
d
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
items: Items to initialize the set with.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, items: Iterable[Hashable] = ()):
|
|
64
|
+
self._item_to_idx: dict[Any, int] = {}
|
|
65
|
+
self._idx_to_item: list[Any] = []
|
|
66
|
+
for item in items:
|
|
67
|
+
self.add(item)
|
|
68
|
+
|
|
69
|
+
def add(self, item: Hashable):
|
|
70
|
+
r"""Add an item to the set.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
item: The item to add.
|
|
74
|
+
"""
|
|
75
|
+
if item in self._item_to_idx:
|
|
76
|
+
return
|
|
77
|
+
self._item_to_idx[item] = len(self._idx_to_item)
|
|
78
|
+
self._idx_to_item.append(item)
|
|
79
|
+
|
|
80
|
+
def remove(self, item: Hashable):
|
|
81
|
+
r"""Remove an item from the set.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
item: The item to remove.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
KeyError: If the item is not in the set.
|
|
88
|
+
"""
|
|
89
|
+
if item not in self._item_to_idx:
|
|
90
|
+
raise KeyError(item)
|
|
91
|
+
# Swap the element with the last element
|
|
92
|
+
last_item = self._idx_to_item[len(self) - 1]
|
|
93
|
+
del_idx = self._item_to_idx[item]
|
|
94
|
+
self._item_to_idx[last_item] = del_idx
|
|
95
|
+
self._idx_to_item[del_idx] = last_item
|
|
96
|
+
# Remove the last element
|
|
97
|
+
del self._item_to_idx[item]
|
|
98
|
+
del self._idx_to_item[-1]
|
|
99
|
+
|
|
100
|
+
def choice(self, seed: int | np.random.Generator | None = None) -> Any:
|
|
101
|
+
r"""Randomly sample an item from the set.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
seed: A seed to initialize the random number generation.
|
|
105
|
+
If ``None``, then fresh, unpredictable entropy will be pulled from
|
|
106
|
+
the OS.
|
|
107
|
+
If an ``int``, then it will be used to derive the initial state.
|
|
108
|
+
If a :py:class:`numpy.random.Generator`, then it will be used
|
|
109
|
+
directly.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The sampled item.
|
|
113
|
+
"""
|
|
114
|
+
rng = np.random.default_rng(seed)
|
|
115
|
+
return self._idx_to_item[rng.choice(len(self))]
|
|
116
|
+
|
|
117
|
+
def __len__(self) -> int:
|
|
118
|
+
return len(self._idx_to_item)
|
|
119
|
+
|
|
120
|
+
def __iter__(self):
|
|
121
|
+
for idx in range(len(self)):
|
|
122
|
+
yield self._idx_to_item[idx]
|
|
123
|
+
|
|
124
|
+
def __reversed__(self):
|
|
125
|
+
for idx in reversed(range(len(self))):
|
|
126
|
+
yield self._idx_to_item[idx]
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return f"{self.__class__.__name__}({', '.join(map(repr, self))})"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bdms-sim
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Tree simulator for birth-death-mutation-sampling (BDMS) processes
|
|
5
|
+
Project-URL: Homepage, https://github.com/WSDeWitt/BDMS
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/WSDeWitt/BDMS/issues
|
|
7
|
+
Project-URL: Documentation, https://wsdewitt.github.io/BDMS
|
|
8
|
+
Author-email: Thanasi Bakis <abakis@uci.edu>, Michael Celentano <mcelentano@berkeley.edu>, William DeWitt <wsdewitt@gmail.com>, Frederick Matsen <ematsen@gmail.com>, Volodymyr Minin <yss@berkeley.edu>, Sebastian Prillo <sprillo@berkeley.edu>, Duncan Ralph <dkralph@gmail.com>, Yoonseo Song <ysong07@cs.washington.edu>, Yun Song <yss@berkeley.edu>
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: ete3
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: pyqt5
|
|
18
|
+
Requires-Dist: scipy
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
[](https://github.com/WSDeWitt/BDMS/actions/workflows/build-and-test.yml)
|
|
22
|
+
[](https://github.com/WSDeWitt/BDMS/actions/workflows/docs-build-and-deploy.yml)
|
|
23
|
+
|
|
24
|
+
# BDMS
|
|
25
|
+
Tree simulator for birth-death-mutation-sampling (BDMS) processes
|
|
26
|
+
|
|
27
|
+
Documentation: https://wsdewitt.github.io/BDMS
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
bdms/__init__.py,sha256=EwSpi9WqG-wLhsBVM3eLUgxru4xW9gRVZvuxH28TXHE,199
|
|
2
|
+
bdms/mutators.py,sha256=ZQT-sDVQU3GdveiAoHr2r8KVg8XQS38_dkes03lhZEE,7531
|
|
3
|
+
bdms/poisson.py,sha256=5DOmdnU-xe4MyrdSk-Sw_AnIyW0RTPbMIQNhfC_6404,10283
|
|
4
|
+
bdms/tree.py,sha256=zxtP5TGbcWiedTinFWeWit6vfOwdlS9-GOmmCoRy21Y,27342
|
|
5
|
+
bdms/utils.py,sha256=fQ0-GkuyyAmNUDmYFh9m6ea9LQGhwR61PL-YBfj3BlQ,3338
|
|
6
|
+
bdms_sim-0.2.0.dist-info/METADATA,sha256=G4EgQTux33IfdsP3muQv_7yG6rywl0pR-_mGnAnc-GM,1484
|
|
7
|
+
bdms_sim-0.2.0.dist-info/WHEEL,sha256=mRYSEL3Ih6g5a_CVMIcwiF__0Ae4_gLYh01YFNwiq1k,87
|
|
8
|
+
bdms_sim-0.2.0.dist-info/licenses/LICENSE,sha256=dwM9ik7qVMz2cdFxgoc9J3LbygD9AuBUch32nUAQEO8,1071
|
|
9
|
+
bdms_sim-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 William DeWitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|