zombi2.traits¶
Level 4: a trait evolving on the species tree, alongside the genome chain rather than inside it.
Two functions, because a continuous trait and a discrete one take genuinely different arguments. A
third, simulate_traits, is for several traits that depend on each other — the case with no
order to grow them in.
zombi2.traits.simulate_continuous
¶
simulate_continuous(tree, *, start=0.0, rate=1.0, reverts_to=None, pull=None, correlation=None, at_speciation=None, regimes=None, seed=None, progress=False) -> TraitsResult
Evolve a continuous trait down a tree and return a TraitsResult. One process, its
variants selected by knobs (SPEC §4): Brownian motion (bare rate), Ornstein–Uhlenbeck
(add reverts_to + pull), early burst (a changing_at schedule on rate), and
variable-rates BM (a Drift law on rate).
Correlated traits ride together in one call (the joint rule inside a level): pass
start and rate as dicts keyed by trait name and a correlation={(a, b): ρ} overlay
(each ρ ∈ [−1, 1]). The traits then diffuse jointly — the branch increment is drawn from
MVN(0, Σ·dt) with Σ = D R D (D = diag(σ_i), R the correlation matrix), so at a tip
the correlation between two traits is exactly their ρ. Add reverts_to and pull — one
value shared, or a dict of one per trait — and it is multivariate Ornstein–Uhlenbeck in its
diagonal-drift restriction: each trait reverts to its own optimum at its own strength, the
correlation stays in the diffusion, and the branch covariance is
Σ_ij·(1 − e^{−(α_i+α_j)·dt})/(α_i + α_j). One trait's deviation pulling another — a full
drift matrix — is a different model and is refused by name, not read as a diagonal. A correlated
run takes bare per-trait rates. Its log is widened rather than absent: a value is a per-trait
vector, so trait_events.tsv gets one from/to column pair per trait.
tree is the complete species tree (a Tree, or a
SpeciesResult whose complete_tree is used). The trait evolves on
every lineage, extant and extinct alike, so the ancestral states are exact and complete; the
observed dataset is the extant tips, result.values.
start is the value at t = 0 (the origin, root.birth_time): the root lineage
diffuses over its own branch [0, first split] like any other, so a trait and a genome evolve
over the same branch set, and each node's stored value is the trait at that node's
end_time (node_values[root] is the value at the first split, not start).
rate is the variance-rate σ² (a scope(base) with verbs chained onto it), per lineage:
each lineage diffuses independently at σ², never pooled across the tree. A bare number is
Brownian motion (Normal(0, σ²·dt) over a branch); changing_at({…}) makes σ² change
through time — early burst / ACDC — with the per-branch variance the exact integral
∫ σ²(t) dt;
varying_among('lineages', Drift(LogNormal(0.0, …))) makes σ² drift branch-to-branch — variable-rates BM ("ClaDS
for traits") — each lineage inheriting its parent's σ² times a lognormal kick drawn at the split;
scaled_by(TotalDiversity(cap=…)) makes σ² slow as the clade fills up — diversity-dependent /
ecological-limits trait evolution — σ² scaled by (1 − standing_diversity/cap) as the tree's
lineages-through-time grows (the tree is a fixed input the trait reads);
scaled_by(driver, {…}) makes σ² read another level — the driver grown first on this
same tree and handed over as its result object or its written trait_events.tsv, so a lineage
diffuses faster while the driver is in one state than another. A discrete driver switches
mid-branch, and the per-branch variance is the integral across those pieces, so a branch that
spends half its length in the fast state accrues exactly half the fast variance.
reverts_to (the optimum θ) and pull (the strength α > 0) turn the diffusion into
Ornstein–Uhlenbeck — the value is pulled toward θ while it diffuses, the exact per-branch
transition being Normal(θ + (x−θ)·e^{−α·dt}, σ²/(2α)·(1−e^{−2α·dt})). Give both or
neither. The optimum and the pull compose with the σ² modifiers: a trait that bursts early
and reverts to an optimum is one rate with one modifier and two arguments. A σ² that moves along
the branch leaves the mean untouched (it never read σ²) and makes the variance the exact
pull-weighted integral ∫ e^{−2α(t₁−s)}·σ²(s) ds, stepping where the schedule, the standing
diversity or the driver steps. That weight is the whole difference from Brownian motion's
∫ σ²(s) ds: under OU, variance accrued early has been pulled back toward θ by the time the
branch ends, so the two integrals differ by an order of magnitude on a typical branch.
at_speciation adds an on-speciation jump — Normal(0, at_speciation) on each daughter at
every speciation (the punctuational mode), layered on top of the along-branch anagenesis. Under
correlation= it takes one variance per trait (or one shared) and the jump is drawn under the
same overlay the diffusion uses.
regimes gives multi-optimum OU: pass a discrete TraitsResult (a stochastic map
painted by simulate_discrete() on this same tree) and a per-regime reverts_to={regime: θ},
and the value follows OU toward whichever regime's optimum a branch is in; it takes
at_speciation too, one jump variance shared across regimes, and it takes a bare σ² — a
modified variance-rate with regimes is not implemented yet. Deterministic given seed.
Source code in zombi2/traits/continuous.py
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 | |
zombi2.traits.simulate_discrete
¶
simulate_discrete(tree, *, states, switch=None, start=None, liability=None, threshold=None, correlation=None, at_speciation=None, seed=None, progress=False) -> TraitsResult
Evolve a discrete-state trait down a tree and return a TraitsResult. Two mechanisms:
- Mk (
switch=) — a continuous-time Markov chain over thestates, simulated exactly by Gillespie along every branch, so each node's(state, duration)segments are the realized history (.history) and.eventsreads off the transitions.switchis a symmetric rate (0.1, orPerLineage(0.1)written out), a{"marine->terrestrial": 0.1}dict, or ak×kmatrix of numbers (see_q_matrix()).startis the root state (a label instates;Nonedraws one uniformly). A switch rate may be driven by another level grown first on this same tree — write it as a rate expression,switch=PerLineage(0.4).scaled_by(habitat, {"aquatic": 3.0})or per transition,switch={"a->b": PerLineage(0.2).scaled_by(habitat, {"aquatic": 3.0}), "b->a": 0.2}. The driver switches mid-branch, so the generator is rebuilt at each of its switches and the branch is simulated piece by piece — the exact CTMC with a time-varying generator, not one sample per branch. - Threshold (
liability=+threshold=) — the Wright–Felsenstein model: a discrete state read off an underlying continuous Brownian liability (variance-rateliability), cut intostatesby thethresholdcut point(s) (k−1increasing cuts forkstates).startis the initial liability (a number, default 0.0). Giveliabilityas a dict + acorrelation={(a, b): ρ}overlay to evolve correlated discrete traits jointly — their liabilities diffuse together (Σ = D R D) and each is cut by the shared thresholds. A threshold trait has no Gillespie map, so.historyisNoneand.eventsempty.
tree is the complete species tree (a Tree or
SpeciesResult); the trait evolves on every lineage (convention B: the root
diffuses over its own branch), and .values reads the extant tips. On an Mk trait,
at_speciation (a probability in [0, 1]) adds an on-speciation shift — each daughter hops
to a uniformly-chosen other state with that chance at every speciation. Deterministic given seed.
Source code in zombi2/traits/discrete.py
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 | |
zombi2.traits.simulate_traits
¶
Evolve several traits at once along a fixed tree, each one able to read the others.
One trait is simulate_discrete() / simulate_continuous(), and a trait grown first and then
read is conditioning — two ordinary runs. This is for the case with no order: trait A's switch
rate reads trait B while B's reads A, so neither can be finished before the other starts. That is
the trait level joined to itself (SPEC §3), and being one level with one kind of result it stays
here rather than going to zombi2.joint.simulate.
traits is a list of discrete() specs, each with a name, and a rate reads another by
scaled_by("traits:<name>", …). joint=True says the run is what it is, and is checked both
ways: asking for it when no trait reads another is an error, and reading another without it is an
error too.
at_speciation works here as it does in a run of its own: each trait carrying one hops on its
own at the split, and the pair lands wherever the two hops leave it.
Returns {name: TraitsResult} — one complete result per trait, exactly what the single-trait
runners return, so every reader of one works on these unchanged. Deterministic given seed.
Source code in zombi2/traits/discrete.py
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 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 | |
zombi2.traits.TraitsResult
dataclass
¶
TraitsResult(complete_tree: Tree, node_values: dict[int, object], events: list[Change] = list(), seed: int | None = None, kind: str = 'continuous')
What simulate_continuous / simulate_discrete returns: the complete_tree it ran on,
node_values at every node (the value at each node — extant, extinct, and internal alike; a
float for a continuous trait, a state label for a discrete / threshold one, a per-trait dict for
correlated traits), the events log, the seed, and the kind ("continuous" /
"discrete" / "threshold"). The observed dataset is the extant tips, .values.
events is the timestamped event log — the same shape as the genome level's and the source
of truth for a discrete (Mk) trait, from which history (the per-branch stochastic character
map) is derived. A continuous trait has no along-branch events, so its log holds the initial
row and the on-speciation jumps (just the initial row without at_speciation) while
node_values carries the diffusion;
a threshold trait's crossings are un-timed, so its log is empty and it has no map.
values
property
¶
The observed trait dataset — the value at each extant tip (the comparative-data
vector), keyed by the tip name the tree writes: n5, or e5 for a lineage that died.
Keyed by name because the only thing anyone does with this is join it to the tree, and the
tree names its tips. It used to be keyed by the bare node id (5), which the written
trait_values.tsv and every Newick label do not use — so a comparative dataset built in
Python shared no keys at all with the tree beside it, and nothing said so. The two files
a write() produces always did match; it was the in-memory pair that did not.
values_by_id is the old view, for code that joins on node ids. Internal and extinct nodes
keep their exact ancestral / lineage values in node_values, which stays id-keyed: it is
the run's own record, not a dataset to export.
values_by_id
property
¶
values, keyed by node id rather than tip name — the shape values had before it was
keyed to match the tree. For joining against node_values, complete_tree.nodes or
anything else that works in ids.
history
cached
property
¶
The per-branch stochastic character map — {node: [(state, duration), …]} whose
durations sum to the branch length — derived from the event log (a discrete / Mk trait
only). None for a continuous trait (a diffusion has no map) and for a threshold trait
(its liability crossings are un-timed).
summary
¶
What this run produced, as a plain dict — the payload of trait_summary.json.
A trait's shape depends on its kind, so the summary does too. A discrete trait — and a threshold one, which reads a discrete state off a continuous liability — is described by its switches: how many, and how the tips ended up distributed over the states — which is the thing you look at first, because a run whose tips are all in one state has told you nothing. A continuous one is described by where the values got to, since there are no along-branch events to count; its log holds the on-speciation jumps, and that count is here so an empty one is visibly empty rather than ambiguous.
Source code in zombi2/traits/result.py
write
¶
Write chosen outputs to directory (created if needed); the default is the set
zombi2 traits writes for this kind, so the command and the API leave the same directory
(_DEFAULT_OUTPUTS). "values" →
trait_values.tsv (the node<TAB>kind<TAB>trait table over every node — tips, extinct
lineages and internal nodes; kind is the tip's fate — extant / extinct (/ unsampled
under incomplete sampling) — or ancestor for an internal node, so the extant tips filter out
with kind == "extant"); "events" →
trait_events.tsv, the event log (time · kind · lineage · from · to) — one initial
row at t=0 giving the initial state, then every switch in time order; "summary" →
trait_summary.json, what came out, as JSON (summary); "tree" →
trait_tree.nwk, the complete tree as Newick with every node annotated [&trait=…]
(a trait tree, carrying the exact ancestral values; opens in FigTree / iTOL).
trait_events.tsv is also the driver file: a genome / sequence run drives a rate
with scaled_by("trait_events.tsv", …), replaying it against the shared tree. A
discrete trait's log reconstructs its state on every lineage exactly (that is what the
initial row and the switch times are for); a continuous trait's diffusion cannot be rebuilt
from events, so it carries only the initial row and any on-speciation jumps.
Source code in zombi2/traits/result.py
zombi2.traits.Change
dataclass
¶
A realized trait change — one entry of the event log, the trait twin of the genome level's
GeneEdge. On lineage lineage at time (origin-forward, the species-tree
clock) the state went from from_state to to_state. kind is "on_branch" — a switch
along a branch (an Mk transition) — "on_speciation" — a jump at a speciation node (from
at_speciation; for a continuous trait from_state / to_state are the pre- and post-jump
values) — or "initial", one synthetic entry at t=0 giving the initial state the run
started in (from_state None, time the root's birth_time). That row is what lets the
log stand on its own: the tree plus the initial state plus the switches determines the trait on
every lineage at every instant, so no separate driver file is needed.
zombi2.traits.DiscreteTrait
dataclass
¶
DiscreteTrait(states: tuple, switch: object, start: object = None, at_speciation: object = None, name: 'str | None' = None)
A discrete (Mk) trait process — its parameters bundled but not yet run (SPEC §4).
simulate_discrete(tree, ...) is the runner that grows this on a fixed
tree; a joint model instead takes this spec and grows the trait with the tree it drives
(joint.simulate(species.birth_death(...), traits.discrete(...))), so neither can be simulated
first. Same
parameters as simulate_discrete() (the Mk half): states, switch (the rate spec),
start (the root state, None = uniform), at_speciation (the on-speciation shift
probability).
zombi2.traits.ContinuousTrait
dataclass
¶
ContinuousTrait(start: float = 0.0, rate: object = 1.0, reverts_to: float | None = None, pull: float | None = None, at_speciation: object = None, name: str | None = None)
A continuous (diffusing) trait process — its parameters bundled but not yet run (SPEC §4).
simulate_continuous is the runner that grows this on a tree that already exists. This spec is
for the case where the tree does not: hand it to
joint.simulate(species.birth_death(...), traits.continuous(...)) and the trait grows with
the tree whose speciation it drives — QuaSSE. Same arguments, same meanings, no tree.
A diffusing driver moves at every instant, so the birth rate it drives is never constant and a
Gillespie step has nothing to hold still. The run therefore slices: the driver is held fixed
across a step of step and released at each boundary, which the driven rate declares —
scaled_by("trait", Curve(f), step=0.05). That is an approximation, and the only one in a
joint run: everything else races exactly. Halve step, rerun the same seed, and see whether
the answer moves.
The fields are simulate_continuous's: start (the value at t=0), rate (the variance-rate
σ², per lineage, a bare number or a changing_at skyline), reverts_to + pull
(Ornstein–Uhlenbeck; give both or neither), at_speciation (the variance of a jump at each
split) and name (what a rate calls it, "traits:<name>"; a run holding one trait also
answers to "trait").