forestpd 0.1.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.
Files changed (45) hide show
  1. forestpd/__init__.py +65 -0
  2. forestpd/_evaluate.py +113 -0
  3. forestpd/_validate.py +278 -0
  4. forestpd/algo/__init__.py +45 -0
  5. forestpd/algo/_profile_kernel.py +244 -0
  6. forestpd/algo/_signature_kernel.py +353 -0
  7. forestpd/algo/component_dp.py +336 -0
  8. forestpd/algo/convolve.py +157 -0
  9. forestpd/algo/local.py +203 -0
  10. forestpd/algo/local_solver.py +444 -0
  11. forestpd/algo/profile.py +1178 -0
  12. forestpd/algo/solver.py +929 -0
  13. forestpd/algo/survival.py +285 -0
  14. forestpd/algo/witness.py +186 -0
  15. forestpd/api.py +235 -0
  16. forestpd/cluster/__init__.py +19 -0
  17. forestpd/cluster/_kernels.py +354 -0
  18. forestpd/cluster/base.py +127 -0
  19. forestpd/cluster/common.py +763 -0
  20. forestpd/core/__init__.py +15 -0
  21. forestpd/core/taxa.py +176 -0
  22. forestpd/core/tree.py +633 -0
  23. forestpd/exceptions.py +72 -0
  24. forestpd/instance.py +671 -0
  25. forestpd/io/__init__.py +18 -0
  26. forestpd/io/_newick_kernel.py +132 -0
  27. forestpd/io/_splits_kernel.py +193 -0
  28. forestpd/io/convert.py +450 -0
  29. forestpd/io/newick.py +996 -0
  30. forestpd/io/pz.py +195 -0
  31. forestpd/io/splits.py +241 -0
  32. forestpd/maf/__init__.py +28 -0
  33. forestpd/maf/base.py +136 -0
  34. forestpd/maf/embedding.py +273 -0
  35. forestpd/maf/heuristic.py +494 -0
  36. forestpd/maf/maffe.py +325 -0
  37. forestpd/maf/partition.py +345 -0
  38. forestpd/maf/tiered.py +96 -0
  39. forestpd/py.typed +0 -0
  40. forestpd/utils.py +157 -0
  41. forestpd-0.1.0.dist-info/METADATA +122 -0
  42. forestpd-0.1.0.dist-info/RECORD +45 -0
  43. forestpd-0.1.0.dist-info/WHEEL +5 -0
  44. forestpd-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. forestpd-0.1.0.dist-info/top_level.txt +1 -0
forestpd/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ forestpd — maximizing phylogenetic diversity under phylogenetic and extinction uncertainty.
3
+
4
+ Solves MaxMPD: given unrooted weighted trees on a shared taxon set, per-tree weights ``w_j``,
5
+ integer taxon costs and a budget ``B``, choose ``S`` with ``c(S) <= B`` maximizing
6
+ ``sum_j w_j PD_{T_j}(S)`` — or, with per-taxon survival probabilities, its expected value over the
7
+ taxa that survive. Given a maximum agreement forest (MAF) of size ``k``, it solves MaxMPD in
8
+ ``O(2^k * n * B^2)`` time, where ``n`` is the total size of the trees. ``k`` is taken per common
9
+ cluster, so it measures how much the trees disagree locally.
10
+
11
+ The public entry points are :class:`MaxMPDInstance` (build it once, then call
12
+ :meth:`~MaxMPDInstance.optimize`, :meth:`~MaxMPDInstance.optimize_many` and
13
+ :meth:`~MaxMPDInstance.evaluate_pd`) and the one-call functions :func:`optimize_maxmpd`,
14
+ :func:`optimize_many_maxmpd` and :func:`evaluate_maxmpd`. The pipeline has three stages —
15
+ :mod:`forestpd.cluster`, :mod:`forestpd.maf`, :mod:`forestpd.algo` — described in ``README.md``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ from .api import evaluate_maxmpd, optimize_many_maxmpd, optimize_maxmpd
23
+ from .core import PhyloTree, TaxonRegistry
24
+ from .exceptions import (
25
+ ForestPDError,
26
+ ForestPDImportError,
27
+ ForestPDInputError,
28
+ ForestPDValueError,
29
+ ForestPDWarning,
30
+ )
31
+ from .instance import MaxMPDInstance, MaxMPDResult, MaxMPDStructure
32
+ from . import io, utils
33
+ from .algo.convolve import HAVE_NUMBA as _HAVE_NUMBA
34
+
35
+ if not _HAVE_NUMBA: # pragma: no cover - depends on the environment
36
+ import warnings as _warnings
37
+
38
+ _warnings.warn(
39
+ "Numba could not be imported, so forestpd runs its NumPy/Python fallbacks: the same "
40
+ "results, but several times slower. Install numba (a dependency of forestpd) for full speed.",
41
+ ForestPDWarning,
42
+ stacklevel=2,
43
+ )
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ # Public API
48
+ "MaxMPDInstance",
49
+ "optimize_maxmpd",
50
+ "optimize_many_maxmpd",
51
+ "evaluate_maxmpd",
52
+ "MaxMPDResult",
53
+ "MaxMPDStructure",
54
+ "io",
55
+ "utils",
56
+ # Core types
57
+ "PhyloTree",
58
+ "TaxonRegistry",
59
+ # Exceptions
60
+ "ForestPDError",
61
+ "ForestPDValueError",
62
+ "ForestPDInputError",
63
+ "ForestPDImportError",
64
+ "ForestPDWarning",
65
+ ]
forestpd/_evaluate.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Fast evaluation of the objective of a taxon set: the trees as one weighted split table.
3
+
4
+ The objective of a protected set ``S`` is a sum over all edges of all trees of
5
+ ``w_j * len_j(e) * P(one side keeps a survivor) * P(the other side does)``, where a side holding a
6
+ taxon of ``S`` keeps one surely and otherwise with probability ``1 - prod (1 - a(x))`` over its
7
+ taxa. Both probabilities depend on the edge only through its split, so the edges are grouped by
8
+ split once (:func:`forestpd.io.splits.split_system`, compiled) and each distinct split carries its
9
+ summed weight ``W_s``. Each split is stored by its smaller side, as a sparse row of taxon ids.
10
+
11
+ Scoring a set is then two sparse sums per split, independent of the number of trees: the protected
12
+ taxa on the stored side (the other side has the rest), and the summed ``log(1 - a(x))`` of its
13
+ unprotected taxa (the other side has the rest of the total). This regroups the per-edge sum of
14
+ the per-edge definition exactly; without survival probabilities it is the split
15
+ diversity of ``S``, i.e. its weighted PD.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Sequence
21
+
22
+ import numpy as np
23
+
24
+ from .core.tree import PhyloTree
25
+ from .io import _splits_kernel
26
+ from .io import splits as _splits
27
+
28
+
29
+ class SplitTable:
30
+ """
31
+ The weighted splits of a set of trees, ready to score taxon sets.
32
+
33
+ Parameters
34
+ ----------
35
+ trees : Sequence[PhyloTree]
36
+ The trees, all on leaves ``0..n_taxa-1``.
37
+ weights : numpy.ndarray
38
+ ``float64[t]``; the per-tree weights.
39
+ log_loss : numpy.ndarray
40
+ ``float64[n_taxa]``; ``log(1 - a(x))`` per taxon.
41
+
42
+ Examples
43
+ --------
44
+ >>> from forestpd.core.tree import PhyloTree
45
+ >>> t = PhyloTree.from_edges(6, [(0, 4), (1, 4), (4, 5), (2, 5), (3, 5)], [1.0] * 5,
46
+ ... {i: i for i in range(4)})
47
+ >>> table = SplitTable([t, t], np.array([1.0, 0.5]), np.zeros(4))
48
+ >>> table.value([0, 2]) # the path 0 - 4 - 5 - 2, in both trees: 3 + 1.5
49
+ 4.5
50
+ """
51
+
52
+ def __init__(self, trees: Sequence[PhyloTree], weights: np.ndarray, log_loss: np.ndarray):
53
+ n_taxa = trees[0].n_leaves
54
+ members, offsets, split_weights = _splits._flat_split_system(trees, weights)
55
+ if _splits.USE_COMPILED and _splits_kernel.HAVE_NUMBA:
56
+ members, offsets = _splits_kernel.smaller_sides(members, offsets, n_taxa)
57
+ else:
58
+ members, offsets = _smaller_sides(members, offsets, n_taxa)
59
+ self.n_taxa = n_taxa
60
+ self.members = members
61
+ self.weights = split_weights
62
+ self.log_loss = np.asarray(log_loss, dtype=np.float64)
63
+ # Rows are nonempty (every side holds a taxon), so reduceat over these starts is exact.
64
+ self.starts = offsets[:-1]
65
+
66
+ def value(self, protected: Sequence[int]) -> float:
67
+ """
68
+ The objective of protecting the taxa ``protected`` (taxon ids).
69
+
70
+ Parameters
71
+ ----------
72
+ protected : Sequence[int]
73
+ Distinct taxon ids.
74
+
75
+ Returns
76
+ -------
77
+ float
78
+ ``sum_s W_s P(side A keeps a survivor) P(side B does)``.
79
+ """
80
+ if self.weights.size == 0:
81
+ return 0.0
82
+ is_protected = np.zeros(self.n_taxa, dtype=bool)
83
+ is_protected[np.asarray(list(protected), dtype=np.int64)] = True
84
+ n_protected = int(is_protected.sum())
85
+ unprotected_loss = np.where(is_protected, 0.0, self.log_loss)
86
+ total_loss = float(unprotected_loss.sum())
87
+
88
+ on_side = is_protected[self.members]
89
+ protected_a = np.add.reduceat(on_side.astype(np.int64), self.starts)
90
+ loss_a = np.add.reduceat(unprotected_loss[self.members], self.starts)
91
+ protected_b = n_protected - protected_a
92
+ loss_b = total_loss - loss_a
93
+ keeps_a = np.where(protected_a > 0, 1.0, 0.0 - np.expm1(loss_a))
94
+ keeps_b = np.where(protected_b > 0, 1.0, 0.0 - np.expm1(loss_b))
95
+ return float(np.dot(self.weights, keeps_a * keeps_b))
96
+
97
+
98
+ def _smaller_sides(
99
+ members: np.ndarray, offsets: np.ndarray, n_taxa: int
100
+ ) -> tuple[np.ndarray, np.ndarray]:
101
+ """The NumPy version of :func:`forestpd.io._splits_kernel.smaller_sides`."""
102
+ sides = []
103
+ everyone = np.arange(n_taxa)
104
+ for r in range(offsets.size - 1):
105
+ side = members[offsets[r] : offsets[r + 1]]
106
+ sides.append(side if 2 * side.size <= n_taxa else np.setdiff1d(everyone, side))
107
+ out_offsets = np.zeros(len(sides) + 1, dtype=np.int64)
108
+ np.cumsum([side.size for side in sides], out=out_offsets[1:])
109
+ flat = np.concatenate(sides) if sides else np.empty(0, dtype=np.int64)
110
+ return flat.astype(np.int64), out_offsets
111
+
112
+
113
+ __all__ = ["SplitTable"]
forestpd/_validate.py ADDED
@@ -0,0 +1,278 @@
1
+ """
2
+ Validation of the instance data: per-tree weights, taxon costs, survival probabilities and budgets.
3
+
4
+ Shared by :class:`~forestpd.instance.MaxMPDInstance`, :mod:`forestpd.utils` and
5
+ :mod:`forestpd.io.splits`; every function raises :class:`~forestpd.exceptions.ForestPDInputError`
6
+ on invalid input.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Iterable, Mapping, Sequence
13
+
14
+ import numpy as np
15
+
16
+ from .core.taxa import TaxonRegistry
17
+ from .core.tree import PhyloTree, has_degree_two
18
+ from .exceptions import ForestPDInputError
19
+ from .io.newick import ROOT_LABEL
20
+
21
+
22
+ def weights_to_array(
23
+ weights: Sequence[float] | Mapping[int, float] | None, n_trees: int
24
+ ) -> np.ndarray:
25
+ """
26
+ Validate per-tree weights ``w_j`` and return them as an array (no rescaling).
27
+
28
+ Parameters
29
+ ----------
30
+ weights : Sequence[float] | Mapping[int, float] | None
31
+ Per-tree weights, either aligned with the trees or keyed by tree index (missing indices
32
+ weigh ``1.0``). ``None`` means all ``1.0``.
33
+ n_trees : int
34
+ Number of trees ``t``.
35
+
36
+ Returns
37
+ -------
38
+ numpy.ndarray
39
+ ``float64[t]`` array of weights.
40
+
41
+ Raises
42
+ ------
43
+ ForestPDInputError
44
+ If a weight is negative or non-finite, a mapping key is not a tree index, or the sequence
45
+ length is wrong.
46
+ """
47
+ if weights is None:
48
+ return np.ones(n_trees, dtype=np.float64)
49
+ if isinstance(weights, Mapping):
50
+ for key in weights:
51
+ if (
52
+ isinstance(key, (bool, np.bool_))
53
+ or not isinstance(key, (int, np.integer))
54
+ or not 0 <= key < n_trees
55
+ ):
56
+ raise ForestPDInputError(
57
+ f"Weight given for tree {key!r}, but trees are indexed 0..{n_trees - 1}"
58
+ )
59
+ values = [float(weights.get(j, 1.0)) for j in range(n_trees)]
60
+ else:
61
+ values = [float(w) for w in weights]
62
+ if len(values) != n_trees:
63
+ raise ForestPDInputError(f"Expected {n_trees} weights, got {len(values)}")
64
+ for j, w in enumerate(values):
65
+ if not math.isfinite(w) or w < 0:
66
+ raise ForestPDInputError(
67
+ f"Weight for tree {j} must be finite and non-negative, got {w}"
68
+ )
69
+ return np.asarray(values, dtype=np.float64)
70
+
71
+
72
+ def costs_to_array(
73
+ registry: TaxonRegistry, costs: Mapping[str, int] | Sequence[int] | None
74
+ ) -> np.ndarray:
75
+ """
76
+ Validate taxon costs and return them as an array indexed by taxon id.
77
+
78
+ Parameters
79
+ ----------
80
+ registry : TaxonRegistry
81
+ The taxon registry fixing the id of each label.
82
+ costs : Mapping[str, int] | Sequence[int] | None
83
+ Integer cost per taxon label (missing labels cost 1), or a sequence of costs indexed by
84
+ taxon id. ``None`` means unit costs.
85
+
86
+ Returns
87
+ -------
88
+ numpy.ndarray
89
+ ``int64[n]`` array of costs, indexed by taxon id.
90
+
91
+ Raises
92
+ ------
93
+ ForestPDInputError
94
+ If a cost is given for an unknown label, is not a positive integer, or a sequence
95
+ has the wrong length.
96
+ """
97
+ result = np.ones(registry.n, dtype=np.int64)
98
+ if costs is None:
99
+ return result
100
+ if isinstance(costs, Mapping):
101
+ items = [
102
+ (label, registry.id_of(label) if _user_label(label, registry) else -1, cost)
103
+ for label, cost in costs.items()
104
+ ]
105
+ else:
106
+ values = list(costs)
107
+ if len(values) != registry.n:
108
+ raise ForestPDInputError(f"Expected {registry.n} costs, got {len(values)}")
109
+ items = [(registry.label_of(i), i, cost) for i, cost in enumerate(values)]
110
+ for label, taxon, cost in items:
111
+ if taxon < 0:
112
+ raise ForestPDInputError(f"Cost given for unknown taxon: {label!r}")
113
+ if isinstance(cost, (bool, np.bool_)) or not isinstance(cost, (int, np.integer)):
114
+ raise ForestPDInputError(
115
+ f"Cost for taxon {label!r} must be an integer, got {type(cost).__name__}"
116
+ )
117
+ if cost < 1:
118
+ raise ForestPDInputError(f"Cost for taxon {label!r} must be at least 1, got {cost}")
119
+ result[taxon] = cost
120
+ return result
121
+
122
+
123
+ def survival_to_array(
124
+ registry: TaxonRegistry, survival: Mapping[str, float] | Sequence[float] | None
125
+ ) -> np.ndarray:
126
+ """
127
+ Validate survival probabilities and return them as an array indexed by taxon id.
128
+
129
+ Parameters
130
+ ----------
131
+ registry : TaxonRegistry
132
+ The taxon registry fixing the id of each label.
133
+ survival : Mapping[str, float] | Sequence[float] | None
134
+ Probability per taxon label (missing labels have ``0``), or a sequence indexed by taxon
135
+ id. ``None`` means ``0`` for every taxon.
136
+
137
+ Returns
138
+ -------
139
+ numpy.ndarray
140
+ ``float64[n]`` array of probabilities, indexed by taxon id.
141
+
142
+ Raises
143
+ ------
144
+ ForestPDInputError
145
+ If a probability is given for an unknown label, is not a number in ``[0, 1)``, or a
146
+ sequence has the wrong length.
147
+ """
148
+ result = np.zeros(registry.n, dtype=np.float64)
149
+ if survival is None:
150
+ return result
151
+ if isinstance(survival, Mapping):
152
+ items = [
153
+ (label, registry.id_of(label) if _user_label(label, registry) else -1, value)
154
+ for label, value in survival.items()
155
+ ]
156
+ else:
157
+ values = list(survival)
158
+ if len(values) != registry.n:
159
+ raise ForestPDInputError(
160
+ f"Expected {registry.n} survival probabilities, got {len(values)}"
161
+ )
162
+ items = [(registry.label_of(i), i, value) for i, value in enumerate(values)]
163
+ for label, taxon, value in items:
164
+ if taxon < 0:
165
+ raise ForestPDInputError(f"Survival probability given for unknown taxon: {label!r}")
166
+ if isinstance(value, (bool, np.bool_)) or not isinstance(
167
+ value, (int, float, np.integer, np.floating)
168
+ ):
169
+ raise ForestPDInputError(
170
+ f"Survival probability for taxon {label!r} must be a number, "
171
+ f"got {type(value).__name__}"
172
+ )
173
+ if not (0.0 <= float(value) < 1.0):
174
+ raise ForestPDInputError(
175
+ f"Survival probability for taxon {label!r} must lie in [0, 1), got {value}"
176
+ )
177
+ result[taxon] = float(value)
178
+ return result
179
+
180
+
181
+ def validate_budget(budget: int) -> None:
182
+ """
183
+ Raise :class:`ForestPDInputError` unless ``budget`` is a non-negative integer.
184
+
185
+ Parameters
186
+ ----------
187
+ budget : int
188
+ The total budget ``B``.
189
+
190
+ Raises
191
+ ------
192
+ ForestPDInputError
193
+ If ``budget`` is not an integer (Python or NumPy; ``bool`` is rejected) or is negative.
194
+ """
195
+ if isinstance(budget, (bool, np.bool_)) or not isinstance(budget, (int, np.integer)):
196
+ raise ForestPDInputError(f"budget must be an integer, got {type(budget).__name__}")
197
+ if budget < 0:
198
+ raise ForestPDInputError(f"budget must be non-negative, got {budget}")
199
+
200
+
201
+ def validate_uniform(trees: Sequence[PhyloTree], registry: TaxonRegistry) -> None:
202
+ """
203
+ Check that every tree has the same leaf set, namely all taxa in ``registry``, and no
204
+ internal vertex of degree 2 (trees read from Newick or PhyloZoo never have one).
205
+
206
+ Parameters
207
+ ----------
208
+ trees : Sequence[PhyloTree]
209
+ The input trees.
210
+ registry : TaxonRegistry
211
+ The registry of all taxa in the instance.
212
+
213
+ Raises
214
+ ------
215
+ ForestPDInputError
216
+ If the trees do not all span the same taxon set, or one has an internal vertex of
217
+ degree 2.
218
+ """
219
+ for j, tree in enumerate(trees):
220
+ if tree.n_leaves != registry.n:
221
+ raise ForestPDInputError(
222
+ f"Tree {j} has {tree.n_leaves} taxa but the instance has {registry.n}. "
223
+ "All trees must carry the same taxon set."
224
+ )
225
+ if has_degree_two(tree):
226
+ raise ForestPDInputError(
227
+ f"Tree {j} has an internal vertex of degree 2; suppress it first with "
228
+ "forestpd.core.tree.suppress_degree_two (this does not change any PD)."
229
+ )
230
+
231
+
232
+ def taxon_ids(registry: TaxonRegistry, taxa: Iterable[str]) -> set[int]:
233
+ """
234
+ The ids of a set of taxon labels given by a user.
235
+
236
+ Parameters
237
+ ----------
238
+ registry : TaxonRegistry
239
+ The taxon registry.
240
+ taxa : Iterable[str]
241
+ Taxon labels; a single string is rejected rather than read as its characters.
242
+
243
+ Returns
244
+ -------
245
+ set[int]
246
+ Their ids.
247
+
248
+ Raises
249
+ ------
250
+ ForestPDInputError
251
+ If ``taxa`` is a string, or a label is unknown (the reserved root label included).
252
+ """
253
+ if isinstance(taxa, str):
254
+ raise ForestPDInputError(
255
+ f"Expected a collection of taxon labels, got the string {taxa!r}; "
256
+ f"wrap a single label as [{taxa!r}]."
257
+ )
258
+ ids = set()
259
+ for label in taxa:
260
+ if not _user_label(label, registry):
261
+ raise ForestPDInputError(f"Unknown taxon: {label!r}")
262
+ ids.add(registry.id_of(label))
263
+ return ids
264
+
265
+
266
+ def _user_label(label: object, registry: TaxonRegistry) -> bool:
267
+ """Whether ``label`` names a taxon a user may refer to (not the reserved root leaf)."""
268
+ return label != ROOT_LABEL and label in registry
269
+
270
+
271
+ __all__ = [
272
+ "taxon_ids",
273
+ "weights_to_array",
274
+ "costs_to_array",
275
+ "survival_to_array",
276
+ "validate_budget",
277
+ "validate_uniform",
278
+ ]
@@ -0,0 +1,45 @@
1
+ """
2
+ Stage 3 of the pipeline: the phylogenetic-diversity dynamic program.
3
+
4
+ Modules, in the order the algorithm uses them:
5
+
6
+ =========================== =============================================================
7
+ :mod:`.local` ``LocalInstance`` and the ``f_2`` tables that link clusters
8
+ :mod:`.profile` per-cluster precomputation, done once before the ``2^k`` loop
9
+ :mod:`.survival` survival probabilities: side constants and the per-cluster sums
10
+ :mod:`.convolve` max-plus convolution (compiled with Numba)
11
+ :mod:`.component_dp` ``PD_i^{(S)}``, Lemma "compute-PD_i"
12
+ :mod:`.local_solver` Algorithm 3: one cluster, all signatures
13
+ :mod:`.solver` Algorithm 1: bottom-up pass over the tree of clusters
14
+ :mod:`.witness` reconstruction of an optimal taxon set
15
+ =========================== =============================================================
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .component_dp import component_dp_table, component_table
21
+ from .convolve import HAVE_NUMBA, fold_max_plus, max_plus
22
+ from .local import LocalInstance, NEG_INF, ordinary_f2, pseudo_leaf_f2
23
+ from .local_solver import LocalSolution, signature_weights, solve_local
24
+ from .profile import ClusterProfile, ComponentTree, build_profile
25
+ from .solver import Solution, solve
26
+
27
+ __all__ = [
28
+ "LocalInstance",
29
+ "NEG_INF",
30
+ "ordinary_f2",
31
+ "pseudo_leaf_f2",
32
+ "ClusterProfile",
33
+ "ComponentTree",
34
+ "build_profile",
35
+ "max_plus",
36
+ "fold_max_plus",
37
+ "HAVE_NUMBA",
38
+ "component_table",
39
+ "component_dp_table",
40
+ "LocalSolution",
41
+ "solve_local",
42
+ "signature_weights",
43
+ "solve",
44
+ "Solution",
45
+ ]