Explicit forward simulation with SLiM#

This tutorial uses SLiM to generate ten independent populations under the simplified evolutionary model, then fits evo-lmm to the sampled genotypes and phenotypes. Its purpose is to check the model in the setting from which its frequency-dependent prior is derived: mutation, drift, recombination, and stabilizing selection jointly determine which variants are segregating and their effects [Lee and Terhorst, 2026].

It is tempting to draw variant effects from the final conditional prior, simulate y = G beta + e, and fit the same model back to those data. That is a useful numerical check of the fitting code, but it is not a check of the evolutionary argument. In the model specification, the key step is the effect-dependent site-frequency spectrum p(G_j | alpha_j): stabilizing selection changes the chance that a mutation with a given latent effect is observed at a particular frequency. Conditioning that population process on the sampled genotype is what yields E[beta_j^2 | G_j]. A direct regression simulation assumes this relationship rather than generating it, and omits the finite-population and linkage effects that accompany it. Forward simulation therefore provides the appropriate end-to-end experiment.

The SLiM model follows the forward-simulation design described in [Lee and Terhorst, 2026]. Mutations have normally distributed latent effects, fitness is exp(-phenotype^2 / (2 * V_S)), and W_S = V_S / (2N) is the dimensionless selection-width parameter. This tutorial uses the simplified rho^2 = 1 case, for which the focal and selected effects agree: beta_j = alpha_j.

Prerequisites#

SLiM is an external executable rather than a Python dependency of this project. Check that it is available before running the example:

slim -v

The project environment supplies tskit, pygrgl, and evo_lmm. The complete SLiM source is included as slim_simplified_prior.slim.

The forward model#

The simulation fixes a diploid population of 2,000 individuals, runs for 10N = 20,000 generations across L = 10^6 bases, and records a tree sequence. It uses V_S = 2N = 4,000, hence W_S = 1. The complete SLiM source is included in the following expandable block.

Show the SLiM model
 1// Explicit forward simulation for the simplified evolutionary prior.
 2//
 3// This follows the m2/stabilizing-selection construction in:
 4// https://github.com/hanbin973/param_arch_stab_paper/blob/main/codes/scripts/burnin.slim
 5// and main.slim. Here m2 selection coefficients are the latent alpha effects.
 6// The simplified rho^2 = 1 specialization sets beta = alpha in Python.
 7
 8initialize() {
 9    if (!exists("N")) defineConstant("N", 2000);
10    if (!exists("L")) defineConstant("L", 1000000);
11    if (!exists("MUT_RATE")) defineConstant("MUT_RATE", 1e-7);
12    if (!exists("REC_RATE")) defineConstant("REC_RATE", 1e-8);
13    if (!exists("SIGMA_A")) defineConstant("SIGMA_A", 1.0);
14    // V_S is the fitness-landscape width. W_S = V_S / (2N) is the
15    // dimensionless width used after diffusion-time rescaling.
16    if (!exists("V_S")) defineConstant("V_S", 2.0 * N);
17    if (!exists("W_S")) defineConstant("W_S", V_S / (2.0 * N));
18    if (!exists("BURN_IN")) defineConstant("BURN_IN", 10 * N);
19    if (!exists("OUTPUT_FILE")) defineConstant("OUTPUT_FILE", "slim_forward.trees");
20
21    initializeTreeSeq();
22    initializeMutationType("m2", 0.5, "n", 0.0, SIGMA_A);
23    initializeGenomicElementType("g1", m2, 1.0);
24    initializeGenomicElement(g1, 0, L - 1);
25    initializeMutationRate(MUT_RATE);
26    initializeRecombinationRate(REC_RATE);
27}
28
291 early() {
30    sim.addSubpop("p1", N);
31    // The population mean is centered at the start, as in burnin.slim.
32    defineConstant("INITIAL_MEAN", 0.0);
33}
34
35mutationEffect(m2) {
36    // m2 coefficients are trait effects, not direct mutation fitness effects.
37    return 1.0;
38}
39
40fitnessEffect() {
41    phenotype = individual.sumOfMutationsOfType(m2) - INITIAL_MEAN;
42    return exp(-1.0 * phenotype^2 / (2.0 * V_S));
43}
44
45BURN_IN late() {
46    muts = sim.mutationsOfType(m2);
47    sim.treeSeqOutput(OUTPUT_FILE);
48    catn("Forward simulation complete: " + size(muts) + " m2 mutations");
49    sim.simulationFinished();
50}

SLiM records the mutation effects in tree-sequence metadata. The production scripts separate burn-in and replicate runs; this compact version combines the essential steps for a reproducible example.

SLiM to GRG to evo-lmm#

For each replicate, the driver simplifies the extant samples with tskit, converts the tree sequence with pygrgl.grg_from_trees, computes sample allele frequencies, and uses the raw GRG dosage operator to form genetic values from the metadata-derived effects. The GRG is a compact, lossless representation that supports these graph-native operations [DeHaas et al., 2025]. The driver then adds residual noise and fits the simplified model. Simplification is needed because SLiM’s recording also marks historical nodes as samples.

Show the Python driver
  1"""SLiM -> tree sequence -> GRG -> evo-lmm simplified-prior check."""
  2
  3from __future__ import annotations
  4
  5from concurrent.futures import ThreadPoolExecutor
  6import json
  7from pathlib import Path
  8import subprocess
  9import tempfile
 10
 11import matplotlib.pyplot as plt
 12import numpy as np
 13import pygrgl
 14import tskit
 15
 16from evo_lmm import (
 17    EvolutionaryLmmOps,
 18    SimplifiedPrior,
 19    fit_evolutionary_bolt_lmm,
 20    sample_allele_frequencies,
 21)
 22
 23
 24N_INDIVIDUALS = 2_000
 25N_GENERATIONS = 10 * N_INDIVIDUALS
 26N_REPLICATES = 10
 27SEQUENCE_LENGTH = 1_000_000
 28SIGMA_A2 = 1.0
 29V_S = 2.0 * N_INDIVIDUALS
 30W_S = V_S / (2.0 * N_INDIVIDUALS)
 31TRUE_TAU = SIGMA_A2 / W_S
 32RESIDUAL_VARIANCE = 0.4
 33SEED = 812
 34
 35
 36def _simulation_manifest(seed: int) -> dict[str, int | float]:
 37    """Return the configuration that defines a persisted forward replicate."""
 38
 39    return {
 40        "seed": int(seed),
 41        "individuals": N_INDIVIDUALS,
 42        "generations": N_GENERATIONS,
 43        "sequence_length": SEQUENCE_LENGTH,
 44        "sigma_a2": SIGMA_A2,
 45        "residual_variance": RESIDUAL_VARIANCE,
 46    }
 47
 48
 49def run_slim(output_directory: Path, seed: int = SEED) -> Path:
 50    """Run the checked-in SLiM script and return its tree-sequence file."""
 51
 52    output_directory.mkdir(parents=True, exist_ok=True)
 53    tree_path = output_directory / "slim_forward.trees"
 54    if "__file__" in globals():
 55        script = Path(__file__).with_name("slim_simplified_prior.slim")
 56    else:
 57        # Keep this fallback for interactive execution from a source checkout.
 58        # Hosted documentation uses the pre-generated PNG assets instead.
 59        script = Path("docs/tutorials/slim_simplified_prior.slim")
 60        if not script.exists():
 61            script = Path("slim_simplified_prior.slim")
 62    script = script.resolve()
 63    subprocess.run(
 64        [
 65            "slim",
 66            "-s",
 67            str(seed),
 68            "-d",
 69            f"N={N_INDIVIDUALS}",
 70            "-d",
 71            f"L={SEQUENCE_LENGTH}",
 72            "-d",
 73            f'OUTPUT_FILE="{tree_path}"',
 74            "-d",
 75            f"BURN_IN={N_GENERATIONS}",
 76            str(script),
 77        ],
 78        check=True,
 79        cwd=output_directory,
 80    )
 81    return tree_path
 82
 83
 84def mutation_effects(tree_sequence: tskit.TreeSequence) -> np.ndarray:
 85    """Extract current SLiM m2 effects in tskit's mutation-row order.
 86
 87    SLiM can stack recurrent mutations at one site. In that case, a tskit
 88    mutation row stores the current mutation first and its inherited mutation
 89    history after it; GRGL exposes one mutation column for the current row.
 90    """
 91
 92    effects = []
 93    for mutation in tree_sequence.mutations():
 94        mutation_list = mutation.metadata.get("mutation_list", [])
 95        if not mutation_list:
 96            raise ValueError(
 97                "expected SLiM mutation metadata for every mutation row, "
 98                f"got none for mutation {mutation.id}"
 99            )
100        effects.append(float(mutation_list[0]["selection_coeff"]))
101    return np.asarray(effects, dtype=np.float64)
102
103
104def _load_or_simulate_data(seed: int, output_directory: Path) -> dict:
105    """Load one persisted simulation or create it in ``output_directory``."""
106
107    output_directory = Path(output_directory).resolve()
108    output_directory.mkdir(parents=True, exist_ok=True)
109    simplified_path = output_directory / "slim_forward.simplified.trees"
110    required = (
111        simplified_path,
112        output_directory / "alpha.npy",
113        output_directory / "frequencies.npy",
114        output_directory / "phenotype.npy",
115        output_directory / "seed.txt",
116        output_directory / "simulation.json",
117    )
118    try:
119        recorded_seed = int((output_directory / "seed.txt").read_text(encoding="utf-8"))
120    except (FileNotFoundError, ValueError):
121        recorded_seed = None
122    try:
123        recorded_manifest = json.loads(
124            (output_directory / "simulation.json").read_text(encoding="utf-8")
125        )
126    except (FileNotFoundError, ValueError, json.JSONDecodeError):
127        recorded_manifest = None
128    if (
129        recorded_seed != int(seed)
130        or recorded_manifest != _simulation_manifest(seed)
131        or not all(path.exists() for path in required)
132    ):
133        tree_path = run_slim(output_directory, seed)
134        recorded = tskit.load(str(tree_path))
135        alpha = mutation_effects(recorded)
136        # SLiM marks historical nodes as samples in its full recording. GRGL
137        # expects the current haploid genomes to be the leaf samples, so use
138        # ordinary tskit simplification before conversion. This is not PySLiM
139        # annotation and preserves mutation order with filter_sites=False.
140        tree_sequence = recorded.simplify(filter_sites=False)
141        tree_sequence.dump(str(simplified_path))
142        grg = pygrgl.grg_from_trees(str(simplified_path))
143        frequencies = sample_allele_frequencies(grg)
144        if alpha.size != grg.num_mutations:
145            raise ValueError(
146                "SLiM effect order does not match GRG mutation order: "
147                f"{alpha.size} effects for {grg.num_mutations} mutations"
148            )
149        prior = SimplifiedPrior(sigma_b2=SIGMA_A2, tau=TRUE_TAU)
150        ops = EvolutionaryLmmOps(grg, frequencies=frequencies, model="simplified")
151        # rho^2 = 1: the focal effect beta is the selected-trait effect alpha.
152        genetic_value = ops.apply_model_x(alpha)
153        rng = np.random.default_rng(seed + 1)
154        phenotype = genetic_value + rng.normal(
155            0.0,
156            np.sqrt(RESIDUAL_VARIANCE),
157            size=ops.n,
158        )
159        np.save(output_directory / "alpha.npy", alpha)
160        np.save(output_directory / "frequencies.npy", frequencies)
161        np.save(output_directory / "phenotype.npy", phenotype)
162        (output_directory / "seed.txt").write_text(f"{seed}\n", encoding="utf-8")
163        (output_directory / "simulation.json").write_text(
164            json.dumps(_simulation_manifest(seed), sort_keys=True) + "\n",
165            encoding="utf-8",
166        )
167    else:
168        tree_sequence = tskit.load(str(simplified_path))
169        grg = pygrgl.grg_from_trees(str(simplified_path))
170        alpha = np.load(output_directory / "alpha.npy")
171        frequencies = np.load(output_directory / "frequencies.npy")
172        phenotype = np.load(output_directory / "phenotype.npy")
173        if alpha.size != grg.num_mutations:
174            raise ValueError(
175                "persisted SLiM effect order does not match GRG mutation order: "
176                f"{alpha.size} effects for {grg.num_mutations} mutations"
177            )
178    if frequencies.shape != (grg.num_mutations,):
179        raise ValueError("persisted frequency vector does not match GRG mutations")
180    return {
181        "seed": int(seed),
182        "tree_sequence": tree_sequence,
183        "grg": grg,
184        "frequencies": frequencies,
185        "alpha": alpha,
186        "phenotype": phenotype,
187    }
188
189
190def _fit_forward_data(data: dict) -> dict:
191    """Fit the tutorial model to one already prepared simulation."""
192
193    seed = int(data["seed"])
194    prior = SimplifiedPrior(sigma_b2=SIGMA_A2, tau=TRUE_TAU)
195    fit = fit_evolutionary_bolt_lmm(
196        [("slim", data["grg"])],
197        data["phenotype"],
198        frequencies={"slim": data["frequencies"]},
199        model="simplified",
200        initial=prior,
201        trace_probes=64,
202        max_iter=30,
203        cg_tol=1e-8,
204        seed=seed + 2,
205    )
206    return {**data, "fit": fit}
207
208
209def simulate_and_fit(seed: int = SEED, *, output_directory: Path | None = None):
210    """Run or load one SLiM data set, convert it to a GRG, and fit the prior."""
211
212    if output_directory is not None:
213        return _fit_forward_data(_load_or_simulate_data(seed, output_directory))
214    with tempfile.TemporaryDirectory(prefix="evo_lmm_slim_") as directory:
215        return _fit_forward_data(_load_or_simulate_data(seed, Path(directory)))
216
217
218def prepare_replicates(
219    workers: int = 1,
220    *,
221    artifact_directory: Path,
222) -> list[dict]:
223    """Run or load the ten forward simulations without fitting them."""
224
225    seeds = [SEED + replicate for replicate in range(N_REPLICATES)]
226    artifact_root = Path(artifact_directory).resolve()
227    artifact_root.mkdir(parents=True, exist_ok=True)
228
229    def run(seed: int) -> dict:
230        return _load_or_simulate_data(seed, artifact_root / f"seed_{seed}")
231
232    if workers <= 1:
233        return [run(seed) for seed in seeds]
234    with ThreadPoolExecutor(max_workers=int(workers)) as executor:
235        return list(executor.map(run, seeds))
236
237
238def run_replicates(
239    workers: int = 1,
240    *,
241    artifact_directory: Path | None = None,
242) -> list[dict]:
243    """Run independent SLiM replicates and fit the tutorial prior.
244
245    ``workers=1`` preserves the simple sequential tutorial execution. Local
246    documentation generation may use a small thread pool because each
247    replicate owns its SLiM process and GRG objects.  When ``artifact_directory``
248    is supplied, the simulation outputs are persisted for the benchmark.
249    """
250
251    if artifact_directory is None:
252        with tempfile.TemporaryDirectory(prefix="evo_lmm_slim_replicates_") as directory:
253            data = prepare_replicates(workers, artifact_directory=Path(directory))
254            if workers <= 1:
255                return [_fit_forward_data(item) for item in data]
256            with ThreadPoolExecutor(max_workers=int(workers)) as executor:
257                return list(executor.map(_fit_forward_data, data))
258    data = prepare_replicates(workers, artifact_directory=artifact_directory)
259    if workers <= 1:
260        return [_fit_forward_data(item) for item in data]
261    with ThreadPoolExecutor(max_workers=int(workers)) as executor:
262        return list(executor.map(_fit_forward_data, data))
263
264
265def load_simulation_replicates(artifact_directory: Path) -> list[dict]:
266    """Load persisted forward simulations without rerunning the tutorial fits."""
267
268    root = Path(artifact_directory).resolve()
269    if not root.exists():
270        raise FileNotFoundError(
271            f"forward artifacts are missing in {root}; run the figure generator first"
272        )
273    missing = [
274        root / f"seed_{SEED + replicate}"
275        for replicate in range(N_REPLICATES)
276        if not (root / f"seed_{SEED + replicate}" / "slim_forward.simplified.trees").exists()
277    ]
278    if missing:
279        raise FileNotFoundError(
280            "forward replicate artifacts are incomplete; missing "
281            + ", ".join(str(path) for path in missing)
282        )
283    return prepare_replicates(workers=1, artifact_directory=root)
284
285
286def local_linear_regression(
287    x: np.ndarray,
288    y: np.ndarray,
289    query: np.ndarray,
290    *,
291    span: float = 0.4,
292) -> np.ndarray:
293    """Evaluate a log-frequency local-linear regression of ``y`` on ``x``.
294
295    The bandwidth is adaptive: each query point uses the nearest ``span``
296    fraction of observations with a tricube kernel. This empirical smoother
297    is plotted separately from the evolutionary prior formula.
298    """
299
300    if not 0.0 < span <= 1.0:
301        raise ValueError("span must be in (0, 1]")
302    x = np.asarray(x, dtype=np.float64)
303    y = np.asarray(y, dtype=np.float64)
304    query = np.asarray(query, dtype=np.float64)
305    valid = np.isfinite(x) & np.isfinite(y) & (x > 0.0)
306    if not np.any(valid):
307        raise ValueError("local regression requires positive finite x values")
308    log_x = np.log(x[valid])
309    observations = y[valid]
310    neighbor_count = min(
311        log_x.size,
312        max(8, int(np.ceil(span * log_x.size))),
313    )
314    fitted = np.empty(query.size, dtype=np.float64)
315    for index, point in enumerate(np.log(query)):
316        distances = np.abs(log_x - point)
317        bandwidth = np.partition(distances, neighbor_count - 1)[neighbor_count - 1]
318        bandwidth = max(bandwidth, np.finfo(np.float64).eps)
319        scaled = distances / bandwidth
320        weights = np.where(scaled < 1.0, (1.0 - scaled**3) ** 3, 0.0)
321        design = np.column_stack((np.ones_like(log_x), log_x - point))
322        weighted_design = weights[:, None] * design
323        normal = design.T @ weighted_design
324        rhs = design.T @ (weights * observations)
325        try:
326            fitted[index] = np.linalg.solve(normal, rhs)[0]
327        except np.linalg.LinAlgError:
328            fitted[index] = np.average(observations, weights=weights)
329    return fitted
330
331
332def make_summary(results: list[dict]) -> plt.Figure:
333    """Plot one effect spectrum and replicate-level fitted components."""
334
335    representative = results[0]
336    fit = representative["fit"]
337    frequencies = representative["frequencies"]
338    alpha = representative["alpha"]
339    minor_frequencies = np.minimum(frequencies, 1.0 - frequencies)
340    observed = np.square(alpha)
341    segregating = minor_frequencies > 0.0
342    plot_frequencies = minor_frequencies[segregating]
343    plot_observed = observed[segregating]
344    curve_frequencies = np.geomspace(plot_frequencies.min(), 0.5, 200)
345    parametric = fit.prior.effect_variances(curve_frequencies)
346    local = local_linear_regression(plot_frequencies, plot_observed, curve_frequencies)
347
348    figure, axes = plt.subplots(1, 2, figsize=(10, 3.8), constrained_layout=True)
349    axes[0].scatter(
350        plot_frequencies,
351        plot_observed,
352        s=8,
353        alpha=0.25,
354        label=r"SLiM $\alpha_j^2$",
355    )
356    axes[0].plot(
357        curve_frequencies,
358        parametric,
359        color="tab:red",
360        linewidth=2,
361        label=r"parametric fitted $E[\beta_j^2\mid x_j]$",
362    )
363    axes[0].plot(
364        curve_frequencies,
365        local,
366        color="tab:purple",
367        linestyle="--",
368        linewidth=1.5,
369        label="local linear regression",
370    )
371    axes[0].set_xlabel("sample minor allele frequency")
372    axes[0].set_ylabel(r"effect square / variance")
373    axes[0].set_title("Forward-simulated effect spectrum")
374    axes[0].set_xscale("log")
375    axes[0].legend(loc="best")
376    axes[0].grid(alpha=0.3)
377
378    labels = [r"$\sigma_b^2$", r"$\tau$", r"$\sigma_e^2$"]
379    estimates = np.asarray(
380        [
381            [result["fit"].prior.sigma_b2, result["fit"].prior.tau, result["fit"].sigma_e2]
382            for result in results
383        ],
384        dtype=np.float64,
385    )
386    true_values = [SIGMA_A2, TRUE_TAU, RESIDUAL_VARIANCE]
387    axes[1].boxplot(
388        estimates,
389        tick_labels=labels,
390        showmeans=True,
391        meanprops={"marker": "^", "markerfacecolor": "tab:green", "markeredgecolor": "tab:green"},
392    )
393    positions = np.arange(1, len(labels) + 1, dtype=np.float64)
394    for position, true_value in zip(positions, true_values):
395        axes[1].hlines(
396            true_value,
397            position - 0.3,
398            position + 0.3,
399            colors="tab:red",
400            linestyles=":",
401            linewidth=2,
402            label="generating value" if position == positions[0] else None,
403        )
404    axes[1].set_ylim(bottom=0.0)
405    axes[1].set_title(f"N={N_INDIVIDUALS}, {N_REPLICATES} replicates")
406    axes[1].grid(axis="y", alpha=0.3)
407    axes[1].legend(loc="best")
408    return figure
409
410
411if __name__ == "__main__":
412    results = run_replicates()
413    for replicate, result in enumerate(results):
414        print(
415            f"replicate={replicate} mutations={result['grg'].num_mutations} "
416            f"prior={result['fit'].prior} sigma_e2={result['fit'].sigma_e2} "
417            f"diagnostics={result['fit'].diagnostics}"
418        )
419    make_summary(results)
420    if "agg" not in plt.get_backend().lower():
421        plt.show()

Run the simulation and fitting workflow with:

uv run python docs/tutorials/slim_forward_simplified.py

The fitted sigma_b2 and tau are finite-sample estimates, not exact recovery targets. Each replicate contains one finite genomic region and one phenotype vector, which leave limited information about the frequency-shape parameter even after the 10N-generation burn-in. Inspect FitDiagnostics before interpreting an individual fit.

Summary figure#

The script produces a two-panel summary. The left panel compares realized alpha_j^2 values among segregating variants in the first replicate with two summaries on the minor-allele-frequency scale. The solid red curve is the fitted evolutionary prior E[beta_j^2 | x_j] = sigma_b2 / (1 + 2 * tau * x_j * (1 - x_j)); the dashed purple curve is a local linear summary of the same segregating variants. Folding to minor allele frequency displays the symmetry in x * (1 - x) directly. The right panel shows fitted variance components across all ten replicates; dotted segments mark their generating values.

Forward-simulation effect spectrum and fitted simplified prior summary