zombi2.species¶
Level 1: the species tree every other level lives on. One forward engine, shaped by scopes and modifiers rather than by a model zoo.
zombi2.species.simulate_species_tree
¶
simulate_species_tree(birth, death=0.0, *, n_extant=None, total_time=None, mass_extinctions=None, sampling=1.0, fossils=0.0, seed=None, progress=False, max_lineages=100000) -> SpeciesResult
Grow a forward birth-death tree.
birth and death are rate specs (a number, or a scope with verbs chained onto it); the
default scope is per lineage (each lineage speciates/dies at
the base rate, so the tree grows exponentially). Yule = death=0.
Rates that vary from lineage to lineage.
PerLineage(1.0).varying_among('lineages', Drift(LogNormal(0.0, σ))) is inherited
variation — a daughter starts from its parent's rate and is nudged at the split, so fast clades
stay fast (clade drift; the literature's ClaDS).
PerLineage(1.0).varying_among('lineages', LogNormal(0.0, σ)) is
independent variation — every lineage draws its own multiplier with no memory of its parent
(relaxed rates), which is the null to compare drift against: the same amount of rate
heterogeneity, none of it heritable, so the tree-shape signature that heritability leaves
(lopsidedness — fast clades hoarding the tips) is absent. Both draws are mean-corrected, so
widening the law's σ spreads lineages out without moving the average one off the base rate; both
make the lineage that speciates or dies drawn weighted by its own rate; and both must be counted
per lineage. They answer the same question and a rate carrying both is refused.
Stop at exactly n_extant living lineages, or at total_time — give exactly
one. n_extant is conditioned on survival: a birth-death tree can die out, so we
restart (advancing the same generator) until one reaches n_extant. Deterministic given
seed.
Where n_extant puts the present. The run stops the first moment n_extant lineages
are alive together, then draws one more waiting time and places the present where that next
event would have fired, without applying it — so the two newest tips get a real branch length
rather than a zero-length one. Under pure birth this is exactly the general sampling approach
(Hartmann, Wong & Stadler 2010): the tree is the process observed at an instant drawn uniformly
over the time it spends holding n lineages. With extinction it is not. It is a first
hitting rule — the run stops the first time it touches n, so an interval at n lineages reached
by falling back from n+1 is never sampled — and the trees are correspondingly shallower than the
birth-death process conditioned on n tips. The gap grows with turnover and shrinks with n: it is
within noise at death=0, around a tenth of the tree height at n=10 with death/birth=0.4,
and roughly a third to a half at n=10 with death/birth=0.8; by n=50 at moderate turnover it
is back in the noise. If you publish trees grown this way, say which rule made them — a rate
estimator applied to them will otherwise look broken for reasons that are not its fault.
total_time is not conditioned on survival: it can die out, and then it raises rather than
handing back a tree with no present. Looping over seeds and skipping the failures is survival
conditioning by another name, and changes the distribution of everything downstream.
mass_extinctions is a list of (time, fraction_lost) pulses — e.g. [(3.0, 0.75)] culls
75% of the lineages alive at time 3.0 (time runs forward from the origin, t=0). It is a point-in-time
intervention on the process (not a rate) placed on the timeline, so it needs a fixed end:
give total_time (not n_extant), with each time strictly inside (0, total_time).
sampling (ρ, default 1.0) is incomplete extant sampling: each survivor is observed with
probability ρ, the rest relabelled unsampled. It prunes the extant tree to the sampled
survivors (the unsampled ones remain only in the complete tree). n_extant still stops at that
many survivors; sampling then thins what you observe, so result.n_extant can be smaller.
fossils is a recovery rate along the branches: each branch of length L yields
Poisson(fossils × L) fossils, returned as result.fossils = (lineage, time) pairs. A
side output — the fossil's lineage is not removed and does not enter the extant tree.
Source code in zombi2/species/__init__.py
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 | |
zombi2.species.SpeciesResult
dataclass
¶
SpeciesResult(complete_tree: Tree, events: list[Event], seed: int | None, fossils: list[tuple[int, float]] = list(), rates_at_birth: dict[int, tuple[float, float, float, int]] = dict(), _rates: dict = dict())
What simulate_species_tree returns: the complete_tree (with the dead) and the derived
extant_tree (the observed survivors), the events log (the recorded true history), the
seed, and any fossils.
n_extant
property
¶
The number of observed survivors — the extant tips. Under sampling < 1 this is
the sampled subset (the rest are unsampled), so it matches the extant tree's tip count.
extant_tree
cached
property
¶
The survivors' tree — the complete tree pruned to extant lineages with the
unifurcations suppressed (dated, bifurcating). None if nothing survived, which
simulate_species_tree refuses to return: a run with no present raises there instead, so a
result that came from it always has one.
lineage_rates
¶
{lineage: rate} — the rate that lineage itself ran under, in events per unit time.
A rate that varies among lineages gives every lineage a factor of its own, and the tree records only what happened, not the rate it happened under: two runs with the same shape can come from very different rates. This is that number, for the lineage rather than for the run — the base with this lineage's own factors applied — so a branch can be coloured by it.
Taken at the lineage's birth, which is when its factors are drawn. A rate that also depends on time or on standing diversity keeps moving along the branch afterwards; this is its value at the start of it. A rate that varies among lineages in no way gives the same number for every lineage, which is correct rather than useless: it is that rate.
Lineages are named as every output file names them (n12), so the result drops straight
into a plot keyed by tip name.
Source code in zombi2/species/__init__.py
summary
¶
What this run produced, as a plain dict — the payload of species_summary.json.
Counts, not parameters: the log already says what was asked for. The realised rates are here because they are the cheapest check anyone can make on a tree — events divided by the exposure that generated them, which is what a declared per-lineage rate means.
Source code in zombi2/species/__init__.py
write
¶
Write outputs to directory, each file prefixed species_; outputs selects which
(default = all applicable): "complete" → species_complete.nwk, "extant" →
species_extant.nwk (if any survived), "events" → species_events.tsv (the
always-recorded true history, time · kind · parents · children),
"fossils" → species_fossils.tsv (if any recovered),
"fates" → species_fates.tsv (each tip's resolved fate),
"summary" → species_summary.json (the summary() payload).
species_events.tsv names the lineages an event consumed and the lineages it produced, the
same parents / children pair every event file uses: a speciation row is one parent
and its two children (;-packed), an extinction row is the dying lineage as the parent
with no children.
species_fates.tsv is the tip-fate table: one lineage<TAB>fate row per tip, with fate
one of extant / extinct / unsampled. Fate is resolved once, at the end of the run,
on the same stable n<id> that keys every other file, so it never renames anything — it is
a materialised view of information the run already holds. It exists because the .nwk records
only branch lengths, from which a reader cannot tell an extinct tip from a survivor that sits at
the present; this table says so directly, so a downstream level can build the extant set from
fate rather than guessing from tip depth.
Source code in zombi2/species/__init__.py
zombi2.species.Event
dataclass
¶
A recorded event in the true history: a speciation (with its two children) or an extinction.
Trees¶
The tree object itself, and the readers and shape helpers that work on it. These live in
zombi2.tree — from zombi2.tree import read_newick — and are documented here because the
species level is the one that grows a tree; every other level takes one. read_newick reads a ZOMBI2
tree or an external one, so a genome run can start from a published phylogeny.
zombi2.tree.Tree
dataclass
¶
The complete tree: every lineage that ever lived, keyed by id, rooted at root.
labels
¶
{node id: its written name} — n<id>, or e<id> for a lineage that went extinct.
The tree is the only thing that knows a lineage's fate, so this is where a run's names come
from: a writer builds the map once and every id it prints goes through it. Not cached, because
fates are assigned after construction when a tree is read back (read_newick()), and a map
frozen before that would name every tip n.
Source code in zombi2/tree.py
leaves
¶
extant_leaves
¶
The lineages alive at the present. (A tip list, not a tree — the pruned survivors' tree
is SpeciesResult.extant_tree.)
extinct_leaves
¶
unsampled_leaves
¶
Survivors not observed under incomplete sampling — kept in the complete tree (told
apart by their fate) but pruned from the extant tree.
to_newick
¶
Serialise to Newick (matching tree.to_newick() elsewhere in the codebase). Each
branch length is end_time - birth_time and every node — leaves and internals — is named
n<id>, or e<id> for a lineage that went extinct (see node_label()).
The root carries a branch length like any other node: its stem, the time from the origin
to the first split. A forward birth–death run starts from one lineage, so that stem is real
simulated time in which events happen, and writing )n0; would silently discard it — for a
tree whose crown comes late, a large fraction of its history. It is emitted as )n0:<stem>;
and read_newick() reads it back.
precision is the number of significant digits each branch length is written to.
None (the default) writes the shortest string that reads back as exactly the same
float, so a tree written and re-read is the tree you had.
That exactness is not cosmetic: the CLI hands a tree between levels through this file, so a
rounded length is a different tree. At the old fixed 12 digits every branch shifted by about
2e-12 on the round trip, which moved every downstream Gillespie waiting time — and
zombi2 genomes --seed 7 and simulate_genomes_family(sp, seed=7) then produced
different histories from the same tree and the same seed. Both were valid draws, but a seed
that means one run through Python and another through the CLI is not a seed anyone can
publish. Writing lengths in full costs a few bytes a branch and makes the two front doors the
same run.
Digits also matter downstream. Seven — the default before 12 — was not enough: a tip's
depth is a sum of branch lengths, so rounding accumulates down the path, and on a 40-tip
tree of height 4 two tips written at 7 digits came out about 1e-6 apart. That is far above the
tolerance ape::is.ultrametric() allows (~1e-8), so an ultrametric tree — which every
extant tree from a dated run is, to 1e-16 in memory — was rejected by the first thing anyone
does with it in R. Pass precision= to go back to fixed significant digits for a smaller
file; the tree it writes no longer round-trips exactly.
Source code in zombi2/tree.py
zombi2.tree.Node
dataclass
¶
Node(id: int, parent: int | None, birth_time: float, end_time: float = math.inf, children: tuple[int, int] | tuple[()] = (), fate: str = 'alive')
One lineage segment: born at birth_time, ended at end_time by a split, a
death, or reaching the present. A split has two children; a leaf has none.
end_time defaults to inf — has not ended yet, which is the state a lineage is in while
the engine is still growing it. Every node of a finished Tree has a real end, so the toolkit
reads it as the number it is; a stray inf that escaped would surface as an infinite branch
length rather than as a None propagating quietly through the arithmetic.
is_leaf
property
¶
No descendants — a tip. (GeneNode spells this the same way; the two node types diverge
in how they store children, but "is this a tip" reads identically on both.)
zombi2.tree.prune
¶
Prune the complete tree to a kept set (matching prune(tree, keep=...) in the codebase):
drop the pruned subtrees and suppress the unifurcations they leave behind, giving a dated,
bifurcating tree. Branch lengths merge across suppressed nodes; None if nothing is kept.
keep="extant" (default) keeps the survivors — the extant tree. "sampled", the
fossil/serially-sampled tree, is not built: simulate_species_tree reports fossils as
(lineage, time) pairs rather than as taxa, so there are no sampled ancestors to keep.
tips keeps a named set of leaves instead, whatever their fate — which is the same
operation on a different question: "the tree of the survivors" against "the tree of these taxa".
Comparing a gene tree to the species tree needs the second, because a family present in part of
the tree can only be judged against the part it occupies; without it zombi2 tools treedist
could score nothing but a universal single-copy family. The branch lengths merge across the
suppressed nodes exactly as they do for the extant tree, so the pruned tree is a real dated tree
and a length-aware metric means something on it.
Source code in zombi2/tree.py
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | |
zombi2.tree.read_newick
¶
read_newick(newick: str, *, tip_fates: dict[str, str] | None = None, assume_extant: bool = False) -> tuple[Tree, dict[int, str]]
Parse a Newick string into a complete Tree and a name-map {id: user label}.
This is how the CLI loads a species tree back for the downstream levels. Branch lengths are read
as durations: the root sits at time 0 and each node's birth_time is its parent's
end_time, so end_time - birth_time is the parsed length. Two kinds of tree are accepted,
told apart by the labels:
- a ZOMBI complete tree (every node — internal ones too — is
n<id>, ore<id>for a lineage that died, asto_newickwrites it): the ids come from the labels, and the name-map is empty (the labels are the ids). Fate comes fromtip_fateswhen given — the run'sspecies_fates.tsv, keyed by the same label — which is authoritative; without it ane<id>label is the fate, and only a tree carrying noelabels at all falls back to depth (a leaf is"extinct"if it ends before the tree's greatest depth, else"extant"). Neither fallback can recover an"unsampled"tip. -
any external tree (leaves named freely, internal nodes usually unlabelled): fresh ids are minted in traversal order (root 0, parents before children), the original labels are returned as the name-map (
{minted id: user label}), and fates depend on whether the tree is ultrametric (all root-to-tip depths equal, within1e-6 × height): -
ultrametric → the tips are contemporaneous, so every tip is
"extant"(observed); - not ultrametric → the differing tip depths could mean extinct lineages or early
samples, which ZOMBI cannot tell apart, so it refuses to guess: pass
tip_fates— a{tip label: "extant" | "extinct" | "unsampled"}map covering every tip — or aValueErroris raised. (The CLI fillstip_fatesfrom--tip-fates FILE, which reads the same format a species run writes tospecies_fates.tsv.)
A root branch length is read when present — to_newick writes one, so a ZOMBI tree round-trips
with its stem intact. External trees usually have none, and then the root gets zero duration and
the tree starts at its crown, which is all the file says.
The .nwk records only branch lengths, so "unsampled" fate cannot be read from it alone —
pass tip_fates (the run's species_fates.tsv) to recover it; without one a survivor reads
back "extant", which is still fine for evolving genomes/traits along the tree.
Only bifurcating trees are supported (an internal node with other than two children raises).
Source code in zombi2/tree.py
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 | |
zombi2.tree.with_stem
¶
Return a copy whose stem — the branch above the crown (root) — is length (mode="set")
or is extended by length (mode="add"). Every other branch length is unchanged, so
to_newick writes the new stem as )n<root>:<stem>; and nothing below moves.
Source code in zombi2/tree.py
zombi2.tree.make_ultrametric
¶
Return a copy in which every tip sits at the present (exactly ultrametric), by extending the
terminal branches to a common depth. Snaps only when the tip-depth spread is within tol of
the tree height — i.e. rounding; a larger spread raises, because differing tip depths then carry
real signal (extinct lineages or serial samples) that this must not silently flatten.
A fixed precision= undoes this. The snap is exact here — tip depths agree
to about 1e-16 — and to_newick's default (precision=None) writes every length back
exactly, so the round trip keeps it. A fixed precision= does not: a depth is a sum of
branch lengths, so to_newick(precision=7) reintroduces a spread of roughly 1e-6 on an
ordinary tree, more than enough for ape::is.ultrametric() to reject the file this function was
called to produce. zombi2 tools tree --round writes at the default, so the file it produces
is still ultrametric.
Source code in zombi2/tree.py
zombi2.tree.rescale
¶
Return a copy with every branch length scaled — either so the root-to-tip height equals
height, or by a raw factor. Exactly one of the two must be given.
Source code in zombi2/tree.py
zombi2.tree.relative_evolutionary_divergence
¶
Relative Evolutionary Divergence (Parks et al. 2018) of every node — root 0.0, leaves
1.0, keyed by node id. Walking root-outward, a node sits at RED(parent) + a/(a+b)·(1 −
RED(parent)) where a is its branch and b the mean branch-length distance from it to the
leaves of its subtree. RED is invariant to a global rescaling, so a rate-distorted phylogram reads
as an approximate relative timeline; on an ultrametric tree it returns each node's exact relative
age. A zero-length branch passes the parent's value straight down.
Source code in zombi2/tree.py
zombi2.tree.red_scaled
¶
Return a copy whose node depths are their RED — ultrametric on [0, 1], root at 0, every
tip at 1. Branch lengths become RED increments. This is the tree GTDB-style rank normalisation
reads (relative_evolutionary_divergence() gives the raw per-node values).
Source code in zombi2/tree.py
zombi2.tree.gamma_statistic
¶
Pybus & Harvey's γ — where a dated tree's branching times sit relative to what a constant rate would give.
Standard normal under constant-rate pure birth, so a value near 0 is the null. It goes negative when speciation slows toward the present, because the branching times then bunch up early, and positive when it accelerates. The tree must be dated and ultrametric: γ reads the waiting times between splits, so branch lengths in substitutions mean nothing here. Extinct lineages must be pruned first — γ is defined on the reconstructed tree.
Needs at least four tips: the statistic divides by n - 2 and by the tree's total branch
length, neither of which is usable below that.
Source code in zombi2/tree.py
zombi2.tree.distance
¶
Distance between two rooted trees over their shared tips (matched by node id). Raises if the
two leaf sets differ. metric: "rf" (Robinson–Foulds — the number of clades in one tree but
not the other), "rf-normalized" (that count over the total number of non-trivial clades), or
"branch-score" (Kuhner–Felsenstein — √Σ(branch-length difference)² over all clades, terminal
branches included).