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.