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 ADDED
@@ -0,0 +1,9 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from .tree import TreeNode as _TreeNode
4
+
5
+ TreeNode = _TreeNode
6
+ """Alias for :py:class:`bdms.tree.TreeNode`."""
7
+
8
+ Tree = _TreeNode
9
+ """Alias for :py:class:`bdms.tree.TreeNode`."""
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
+ [![build and test](https://github.com/WSDeWitt/BDMS/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/WSDeWitt/BDMS/actions/workflows/build-and-test.yml)
22
+ [![docs build and deploy](https://github.com/WSDeWitt/BDMS/actions/workflows/docs-build-and-deploy.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.21.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.