Population histories¤
Demography describes population sizes and timing. DemographicTransition
combines that history with a numerical backend, an evolution method, and one
frequency map used throughout the history. Supply an initial distribution to
compute the distribution at the final population size. For a
complete biological comparison, see the bottleneck example.
import equinox as eqx
import jax
import jax.numpy as jnp
import drifft
jax.config.update("jax_enable_x64", True)
history = drifft.Demography(
ne=[100, 60, 120],
generations=[20, 10],
)
model = drifft.DemographicTransition(
history,
backend=drifft.FFT,
frequency_map=drifft.Underdominance(0.01),
)
initial = jnp.zeros(2 * history.initial_ne + 1).at[1].set(1.0)
result = model.advance(initial)
final = result.distribution
Timing and units¤
Sizes run oldest to newest and are positive integer diploid effective
population sizes. The allele-count grid has 2 * ne + 1 entries. Lists or
concrete one-dimensional integer arrays are accepted.
There must be one fewer duration than sizes. Each positive integer duration
counts the generations between population boundaries: hold the source size
for duration - 1 generations, then change to the next size on the final
generation. The example above evaluates:
| Generations | Operation |
|---|---|
| 1–19 | Evolve at Ne = 100 |
| 20 | Transition from Ne = 100 to Ne = 60 |
| 21–29 | Evolve at Ne = 60 |
| 30 | Transition from Ne = 60 to Ne = 120 |
Boundary timing
The size-changing step applies the shared frequency map and binomial reproduction. There is no additional resampling step and no subsequent evolution at the final size. The initial distribution is supplied by the caller; no equilibrium or burn-in is assumed.
Repeated sizes are valid: Demography([100, 100], [50]) evaluates 50 generations
at Ne = 100. Demography([100], []) is an identity history. Use
history.initial_ne, history.final_ne, and history.total_generations to
inspect the schedule.
Expectations and selection gradients¤
With the default Sequential method, model.expect(values) maps final-state
values to expectations conditional on each initial allele count. Its input has
2 * history.final_ne + 1 entries and
its output has 2 * history.initial_ne + 1 entries. It is the adjoint of
the distribution returned by .advance(), not backward population evolution.
Both methods accept signed vectors.
The frequency map applies on the source grid at every generation, including size changes. Update an Equinox selection model without rebuilding geometry:
terminal_frequency = jnp.linspace(0.0, 1.0, 2 * history.final_ne + 1)
def expected_frequency(strength):
updated = eqx.tree_at(lambda m: m.frequency_map.strength, model, strength)
return updated.advance(initial).distribution @ terminal_frequency
gradient = jax.grad(expected_frequency)(jnp.asarray(0.01))
Construct the history and model outside the differentiated function; update only biological parameters inside it. See differentiating biological parameters for supported inputs and gradient checks.
Backend configuration and limits¤
A history needs transition geometry for several population sizes, so pass a backend constructor or factory rather than a single backend instance. To customize its settings, supply a factory such as:
from functools import partial
spectral_model = drifft.DemographicTransition(
history,
backend=partial(drifft.NUFFT, accuracy=1e-11, derivative_accuracy=True),
frequency_map=drifft.Underdominance(0.01),
)
backend prepares constant-size geometry; boundary_backend prepares the
one-generation size-changing transitions. The boundary factory defaults to
backend for every backend.
The method defaults to Sequential(). Factories receive gene-copy counts,
even though the history uses diploid Ne.
For other backend and method combinations, see choosing numerical algorithms. The Schiffels–Durbin example uses Jacobi with RationalKrylov and an explicit FFT boundary backend. For custom orchestration, see direct epoch operations.
Segregating mass and mutation input¤
Singleton input represents new mutations entering the segregating population each generation. It is a source of new mass, distinct from recurrent mutation of alleles already represented in the distribution.
model.advance_segregating(initial, mutation_rate=1e-8) adds singleton mass
equal to the destination gene-copy count times the mutation rate after each
generation. Input and output contain interior counts only (2 * ne - 1
entries), with endpoint mass discarded.
Use full-state advance() when recurrent mutation should allow endpoint re-entry.
For arbitrary source vectors, see direct epoch operations.
Results and failures¤
Forward methods return the final distribution together with convergence information. Check that status before using a result from a call that disables exceptions.
The DemographicResult contains distribution, converged, status, and
per-interval diagnostics. It does not retain intermediate distributions.
Numerical failure raises by default; with throw=False, inspect converged
and status. Domain errors still raise, and a failing member under vmap
may fail the batch when throwing is enabled.
The accuracy guide explains how to validate the final statistic and gradient beyond checking convergence.