Benchmark against GRAPP BOLT-LMM#

This tutorial compares the GRG-backed evolutionary model with GRAPP’s bolt_lmm_inf [DeHaas et al., 2026] on the forward-simulated populations from Explicit forward simulation with SLiM. It asks whether the evolutionary model reproduces the distribution of genic variance across allele frequencies, and how much fitting time it requires on the same GRG-backed data.

The shared simulation uses:

  • 2,000 diploid individuals and a 10N = 20,000 generation burn-in;

  • L = 10^6 bases and V_S = 2N = 4,000, hence W_S = V_S/(2N) = 1;

  • sigma_a^2 = sigma_b^2 = 1 and the simplified rho^2 = 1 model; and

  • residual variance sigma_e^2 = 0.4.

The ten deterministic replicates (seeds 812–821) are shared between the two tutorials. Each has its own phenotype and full GRG. For fitting, the tree sequence is split into two blocks so that GRAPP’s leave-one-chromosome-out calibration has a block to leave out. Both blocks retain all 2,000 individuals and use the Genotype Representation Graph data structure [DeHaas et al., 2025], including coalescent counts for GRAPP’s XTX traversal.

Timings cover fitting only: operator setup, variance-component estimation, and each method’s calibration or trace work. They exclude the shared SLiM run, tskit simplification, and GRG conversion. Both methods use 15 stochastic trials/probes and a 5e-4 CG tolerance. Times will vary by machine; the example prints the values observed locally.

Cumulative genic variance#

For a variant with minor allele frequency x, the genic contribution is 2*x*(1-x)*E[beta^2 | x]. The single panel below follows the layout and MAF bins of Figure A11 in stab1_genetics_template.pdf. It shows the realized SLiM contribution, the configured evolutionary prior, evo-lmm’s fitted prior, and GRAPP BOLT-LMM’s global standardized-GRM allocation.

The GRAPP curve is intentionally described as a global-GRM allocation. BOLT-LMM assigns the fitted sigma_g2 equally across its M eligible standardized markers (sigma_g2/M per marker). In raw-dosage effect notation this is E[beta^2 | x] = sigma_g2/(M*2*x*(1-x)); after multiplying by 2*x*(1-x), each marker contributes sigma_g2/M. Its cumulative curve is therefore a neutral frequency-independent reference. evo-lmm instead uses the frequency-dependent simplified prior sigma_b^2/(1 + 2*tau*x*(1-x)). Each point is the mean across the ten forward replicates, and error bars show the sample standard deviation. The runtime annotation uses the same summary.

Show the benchmark implementation
  1"""Benchmark evo-lmm against GRAPP's GRG-backed BOLT-LMM implementation."""
  2
  3from __future__ import annotations
  4
  5from dataclasses import dataclass
  6from pathlib import Path
  7import sys
  8import tempfile
  9import time
 10from typing import Any
 11
 12import matplotlib.pyplot as plt
 13import numpy as np
 14import pygrgl
 15import tskit
 16
 17from evo_lmm import (
 18    EvolutionaryLmmOps,
 19    SimplifiedPrior,
 20    fit_reml,
 21    sample_allele_frequencies,
 22)
 23from evo_lmm.grapp_backend import wrap_grg
 24
 25
 26# The forward tutorial is the source of truth for the simulation configuration.
 27# Sphinx's plot directive executes this file without defining __file__, so add
 28# the tutorial directory explicitly in that execution mode.
 29if "__file__" in globals():
 30    _TUTORIAL_DIRECTORY = Path(__file__).resolve().parent
 31else:
 32    _TUTORIAL_DIRECTORY = Path("docs/tutorials").resolve()
 33if str(_TUTORIAL_DIRECTORY) not in sys.path:
 34    sys.path.insert(0, str(_TUTORIAL_DIRECTORY))
 35
 36from slim_forward_simplified import (  # noqa: E402
 37    N_GENERATIONS,
 38    N_INDIVIDUALS,
 39    RESIDUAL_VARIANCE,
 40    SEED,
 41    SIGMA_A2,
 42    TRUE_TAU,
 43    V_S,
 44    W_S,
 45    mutation_effects,
 46    run_slim,
 47)
 48
 49
 50MAF_THRESHOLDS = np.array([0.001, 0.01, 0.1, 0.2, 0.3, 0.4, 0.5])
 51N_FORWARD_BLOCKS = 2
 52# Match GRAPP's numerical work budget.  At N=2,000 its automatic Monte Carlo
 53# rule selects 15 trials and its public BOLT driver defaults to this CG
 54# tolerance.  Using 64 probes and 1e-8 here measures a different accuracy
 55# target rather than the overhead of the evolutionary kernel.
 56TRACE_PROBES = 15
 57CG_TOL = 5e-4
 58
 59
 60@dataclass(frozen=True)
 61class BenchmarkData:
 62    """One forward-simulation data set and its two physical GRG blocks."""
 63
 64    tree_sequence: tskit.TreeSequence
 65    full_grg: Any
 66    full_frequencies: np.ndarray
 67    alpha: np.ndarray
 68    phenotype: np.ndarray
 69    blocks: tuple[tuple[str, Any], ...]
 70    block_frequencies: dict[str, np.ndarray]
 71    bolt_blocks: tuple[tuple[str, Any], ...]
 72    bolt_block_frequencies: dict[str, np.ndarray]
 73
 74
 75@dataclass(frozen=True)
 76class BenchmarkReplicateResult:
 77    """One fit pair, runtime pair, and data set used in the benchmark."""
 78
 79    seed: int
 80    data: BenchmarkData
 81    evo_fit: Any
 82    bolt_fit: Any
 83    bolt_stats: list[Any]
 84    evo_seconds: float
 85    bolt_seconds: float
 86
 87
 88@dataclass(frozen=True)
 89class BenchmarkResult:
 90    """Aggregate benchmark result over one or more forward replicates."""
 91
 92    replicates: tuple[BenchmarkReplicateResult, ...]
 93
 94    def __post_init__(self) -> None:
 95        if not self.replicates:
 96            raise ValueError("benchmark requires at least one replicate")
 97
 98    @property
 99    def seed(self) -> int:
100        """Return the first seed for backwards-compatible single-run callers."""
101
102        return self.replicates[0].seed
103
104    @property
105    def data(self) -> BenchmarkData:
106        """Return the first data set for backwards-compatible callers."""
107
108        return self.replicates[0].data
109
110    @property
111    def evo_fit(self) -> Any:
112        return self.replicates[0].evo_fit
113
114    @property
115    def bolt_fit(self) -> Any:
116        return self.replicates[0].bolt_fit
117
118    @property
119    def bolt_stats(self) -> list[Any]:
120        return self.replicates[0].bolt_stats
121
122    @property
123    def evo_seconds(self) -> float:
124        return float(np.mean([result.evo_seconds for result in self.replicates]))
125
126    @property
127    def bolt_seconds(self) -> float:
128        return float(np.mean([result.bolt_seconds for result in self.replicates]))
129
130    @property
131    def n_replicates(self) -> int:
132        return len(self.replicates)
133
134    def runtime_summary(self) -> dict[str, tuple[float, float]]:
135        """Return mean and sample standard deviation of each fit runtime."""
136
137        evo = np.asarray([result.evo_seconds for result in self.replicates])
138        bolt = np.asarray([result.bolt_seconds for result in self.replicates])
139        ddof = 1 if self.n_replicates > 1 else 0
140        return {
141            "evo-lmm": (float(np.mean(evo)), float(np.std(evo, ddof=ddof))),
142            "GRAPP BOLT-LMM": (float(np.mean(bolt)), float(np.std(bolt, ddof=ddof))),
143        }
144
145
146def _split_forward_tree_sequence(
147    tree_sequence: tskit.TreeSequence,
148    output_directory: Path,
149) -> tuple[tuple[str, Any], ...]:
150    """Convert two physical halves of one tree sequence to coalescent GRGs.
151
152    GRAPP's top-level BOLT-LMM driver performs leave-one-chromosome-out
153    calibration. Two blocks are therefore needed even though the simulation
154    has one continuous chromosome. The blocks contain disjoint variants from
155    the same tree sequence and preserve the original individual sample set.
156    """
157
158    midpoint = float(tree_sequence.sequence_length) / N_FORWARD_BLOCKS
159    intervals = ((0.0, midpoint), (midpoint, float(tree_sequence.sequence_length)))
160    blocks: list[tuple[str, Any]] = []
161    for index, (left, right) in enumerate(intervals, start=1):
162        block = tree_sequence.keep_intervals([[left, right]], simplify=True).trim()
163        if block.num_mutations == 0:
164            raise RuntimeError(f"forward block {index} contains no mutations")
165        block_path = output_directory / f"slim_forward_block_{index}.trees"
166        block.dump(str(block_path))
167        # GRAPP's XTX traversal requires coalescent counts in the GRG.
168        grg = pygrgl.grg_from_trees(str(block_path), compute_coals=True)
169        blocks.append((f"block-{index}", grg))
170    return tuple(blocks)
171
172
173def _make_bolt_blocks(
174    blocks: tuple[tuple[str, Any], ...],
175    block_frequencies: dict[str, np.ndarray],
176    output_directory: Path,
177) -> tuple[tuple[tuple[str, Any], ...], dict[str, np.ndarray]]:
178    """Remove fixed columns before GRAPP's standardized-GRM calibration."""
179
180    bolt_blocks: list[tuple[str, Any]] = []
181    bolt_frequencies: dict[str, np.ndarray] = {}
182    for label, grg in blocks:
183        frequencies = block_frequencies[label]
184        selected = np.flatnonzero(frequencies * (1.0 - frequencies) > 0.0)
185        if selected.size == 0:
186            raise RuntimeError(f"forward block {label} has no segregating variants")
187        bolt_path = output_directory / f"{label}.segregating.grg"
188        if not pygrgl.save_subset(
189            grg,
190            str(bolt_path),
191            pygrgl.TraversalDirection.DOWN,
192            selected.tolist(),
193        ):
194            raise RuntimeError(f"could not write segregating GRG for {label}")
195        bolt_grg = pygrgl.load_immutable_grg(str(bolt_path))
196        bolt_blocks.append((label, bolt_grg))
197        bolt_frequencies[label] = sample_allele_frequencies(bolt_grg)
198    return tuple(bolt_blocks), bolt_frequencies
199
200
201def _build_benchmark_data(output_directory: Path, seed: int) -> BenchmarkData:
202    """Generate one benchmark data set into a persistent directory."""
203
204    output_directory = Path(output_directory).resolve()
205    output_directory.mkdir(parents=True, exist_ok=True)
206    tree_path = run_slim(output_directory, seed)
207    recorded = tskit.load(str(tree_path))
208    alpha = mutation_effects(recorded)
209    tree_sequence = recorded.simplify(filter_sites=False)
210    if alpha.size != tree_sequence.num_mutations:
211        raise ValueError(
212            "SLiM effect order changed during simplification: "
213            f"{alpha.size} effects for {tree_sequence.num_mutations} mutations"
214        )
215
216    full_path = output_directory / "slim_forward.simplified.trees"
217    tree_sequence.dump(str(full_path))
218    full_grg = pygrgl.grg_from_trees(str(full_path), compute_coals=True)
219    full_frequencies = sample_allele_frequencies(full_grg)
220    if alpha.size != full_grg.num_mutations:
221        raise ValueError(
222            "SLiM effect order does not match GRG mutation order: "
223            f"{alpha.size} effects for {full_grg.num_mutations} mutations"
224        )
225
226    prior = SimplifiedPrior(sigma_b2=SIGMA_A2, tau=TRUE_TAU)
227    full_ops = EvolutionaryLmmOps(
228        full_grg,
229        frequencies=full_frequencies,
230        model="simplified",
231    )
232    genetic_value = full_ops.apply_model_x(alpha)
233    rng = np.random.default_rng(seed + 1)
234    phenotype = genetic_value + rng.normal(
235        0.0,
236        np.sqrt(RESIDUAL_VARIANCE),
237        size=full_ops.n,
238    )
239
240    blocks = _split_forward_tree_sequence(tree_sequence, output_directory)
241    block_frequencies = {
242        label: sample_allele_frequencies(grg) for label, grg in blocks
243    }
244    bolt_blocks, bolt_block_frequencies = _make_bolt_blocks(
245        blocks,
246        block_frequencies,
247        output_directory,
248    )
249    concatenated_frequencies = np.concatenate(
250        [block_frequencies[label] for label, _ in blocks]
251    )
252    np.testing.assert_allclose(concatenated_frequencies, full_frequencies)
253    np.save(output_directory / "alpha.npy", alpha)
254    np.save(output_directory / "phenotype.npy", phenotype)
255    np.save(output_directory / "full.frequencies.npy", full_frequencies)
256    for label, frequencies in block_frequencies.items():
257        np.save(output_directory / f"{label}.frequencies.npy", frequencies)
258    for label, frequencies in bolt_block_frequencies.items():
259        np.save(output_directory / f"{label}.segregating.frequencies.npy", frequencies)
260    (output_directory / "seed.txt").write_text(f"{seed}\n", encoding="utf-8")
261
262    return BenchmarkData(
263        tree_sequence=tree_sequence,
264        full_grg=full_grg,
265        full_frequencies=full_frequencies,
266        alpha=alpha,
267        phenotype=phenotype,
268        blocks=blocks,
269        block_frequencies=block_frequencies,
270        bolt_blocks=bolt_blocks,
271        bolt_block_frequencies=bolt_block_frequencies,
272    )
273
274
275def prepare_benchmark_data(output_directory: Path, seed: int = SEED) -> BenchmarkData:
276    """Run SLiM once and persist all data required by subsequent fit runs."""
277
278    return _build_benchmark_data(Path(output_directory), seed)
279
280
281def load_benchmark_data(output_directory: Path) -> BenchmarkData:
282    """Load a previously prepared benchmark without rerunning SLiM."""
283
284    directory = Path(output_directory)
285    if not (directory / "slim_forward.simplified.trees").exists():
286        raise FileNotFoundError(
287            f"benchmark data are missing in {directory}; run prepare_bolt_benchmark.py first"
288        )
289    tree_sequence = tskit.load(str(directory / "slim_forward.simplified.trees"))
290    full_grg = pygrgl.grg_from_trees(
291        str(directory / "slim_forward.simplified.trees"), compute_coals=True
292    )
293    labels = ("block-1", "block-2")
294    blocks = tuple(
295        (
296            label,
297            pygrgl.grg_from_trees(
298                str(directory / f"slim_forward_{label.replace('-', '_')}.trees"),
299                compute_coals=True,
300            ),
301        )
302        for label in labels
303    )
304    bolt_blocks = tuple(
305        (
306            label,
307            pygrgl.load_immutable_grg(str(directory / f"{label}.segregating.grg")),
308        )
309        for label in labels
310    )
311    block_frequencies = {
312        label: np.load(directory / f"{label}.frequencies.npy") for label in labels
313    }
314    bolt_block_frequencies = {
315        label: np.load(directory / f"{label}.segregating.frequencies.npy")
316        for label in labels
317    }
318    return BenchmarkData(
319        tree_sequence=tree_sequence,
320        full_grg=full_grg,
321        full_frequencies=np.load(directory / "full.frequencies.npy"),
322        alpha=np.load(directory / "alpha.npy"),
323        phenotype=np.load(directory / "phenotype.npy"),
324        blocks=blocks,
325        block_frequencies=block_frequencies,
326        bolt_blocks=bolt_blocks,
327        bolt_block_frequencies=bolt_block_frequencies,
328    )
329
330
331def benchmark_data_from_forward_result(
332    forward_result: dict,
333    output_directory: Path,
334) -> BenchmarkData:
335    """Build benchmark blocks from a previously simulated forward replicate.
336
337    ``slim_forward_simplified.run_replicates`` returns the simplified tree
338    sequence, GRG, frequencies, effects, and phenotype used by its two-panel
339    figure.  This adapter reuses those objects and only performs the physical
340    block split needed by GRAPP; it never invokes SLiM.
341    """
342
343    output_directory = Path(output_directory).resolve()
344    output_directory.mkdir(parents=True, exist_ok=True)
345    tree_sequence = forward_result["tree_sequence"]
346    full_grg = forward_result["grg"]
347    full_frequencies = np.asarray(forward_result["frequencies"], dtype=np.float64)
348    alpha = np.asarray(forward_result["alpha"], dtype=np.float64)
349    phenotype = np.asarray(forward_result["phenotype"], dtype=np.float64)
350    if alpha.size != full_grg.num_mutations:
351        raise ValueError(
352            "forward effect order does not match GRG mutation order: "
353            f"{alpha.size} effects for {full_grg.num_mutations} mutations"
354        )
355    blocks = _split_forward_tree_sequence(tree_sequence, output_directory)
356    block_frequencies = {
357        label: sample_allele_frequencies(grg) for label, grg in blocks
358    }
359    bolt_blocks, bolt_block_frequencies = _make_bolt_blocks(
360        blocks,
361        block_frequencies,
362        output_directory,
363    )
364    concatenated_frequencies = np.concatenate(
365        [block_frequencies[label] for label, _ in blocks]
366    )
367    np.testing.assert_allclose(concatenated_frequencies, full_frequencies)
368    return BenchmarkData(
369        tree_sequence=tree_sequence,
370        full_grg=full_grg,
371        full_frequencies=full_frequencies,
372        alpha=alpha,
373        phenotype=phenotype,
374        blocks=blocks,
375        block_frequencies=block_frequencies,
376        bolt_blocks=bolt_blocks,
377        bolt_block_frequencies=bolt_block_frequencies,
378    )
379
380
381def simulate_forward_data(seed: int = SEED) -> BenchmarkData:
382    """Run the simulation in a temporary directory for in-process callers."""
383
384    with tempfile.TemporaryDirectory(prefix="evo_lmm_bolt_benchmark_") as directory:
385        return _build_benchmark_data(Path(directory), seed)
386
387
388def _fit_benchmark_data(seed: int, data: BenchmarkData) -> BenchmarkReplicateResult:
389    """Fit both methods for one prepared data set and time only the fits."""
390
391    initial = SimplifiedPrior(sigma_b2=SIGMA_A2, tau=TRUE_TAU)
392
393    start = time.perf_counter()
394    evo_ops = EvolutionaryLmmOps(
395        data.blocks,
396        frequencies=data.block_frequencies,
397        model="simplified",
398    )
399    evo_fit = fit_reml(
400        evo_ops,
401        data.phenotype,
402        initial=initial,
403        trace_probes=TRACE_PROBES,
404        # GRAPP's secant search makes at most seven variance-component
405        # evaluations (two initial points plus five updates). Allow one extra
406        # AI-REML update while keeping optimizer work comparable.
407        max_iter=8,
408        cg_tol=CG_TOL,
409        seed=seed + 2,
410        trace_method="hutchinson",
411    )
412    evo_seconds = time.perf_counter() - start
413
414    # Import GRAPP's public BOLT-LMM driver here so the data-generation helper
415    # remains usable for inspecting the shared data without fitting.
416    from grapp.assoc.bolt_inf_core import CovariateBasis
417    from grapp.assoc.bolt_lmm import bolt_lmm_inf
418
419    grapp_blocks = [(label, wrap_grg(grg)) for label, grg in data.bolt_blocks]
420    covariates = CovariateBasis.intercept_only(N_INDIVIDUALS)
421    start = time.perf_counter()
422    bolt_fit, _calibration, _residuals, bolt_stats = bolt_lmm_inf(
423        grapp_blocks,
424        data.phenotype,
425        covariates,
426        mc_trials=TRACE_PROBES,
427        cg_tol=CG_TOL,
428        seed=seed + 2,
429        threads=1,
430        batched_apply_x=True,
431    )
432    bolt_seconds = time.perf_counter() - start
433
434    return BenchmarkReplicateResult(
435        seed=seed,
436        data=data,
437        evo_fit=evo_fit,
438        bolt_fit=bolt_fit,
439        bolt_stats=bolt_stats,
440        evo_seconds=evo_seconds,
441        bolt_seconds=bolt_seconds,
442    )
443
444
445def run_benchmark(
446    seed: int = SEED,
447    *,
448    data_directory: Path | None = None,
449    forward_results: list[dict] | None = None,
450) -> BenchmarkResult:
451    """Fit evo-lmm and GRAPP over one or more prepared replicates.
452
453    ``forward_results`` is the output of the two-panel SLiM tutorial and is
454    preferred for the documentation benchmark: it reuses those simulations
455    and phenotypes while fitting each method with the benchmark's matched
456    numerical budget.  ``data_directory`` remains a single-replicate fallback
457    for the fit-only command and backwards-compatible callers.
458    """
459
460    if forward_results is not None and data_directory is not None:
461        raise ValueError("pass either forward_results or data_directory, not both")
462
463    if forward_results is None:
464        data = (
465            load_benchmark_data(data_directory)
466            if data_directory is not None
467            else simulate_forward_data(seed)
468        )
469        return BenchmarkResult((_fit_benchmark_data(seed, data),))
470
471    if not forward_results:
472        raise ValueError("forward_results must contain at least one replicate")
473    with tempfile.TemporaryDirectory(prefix="evo_lmm_benchmark_blocks_") as directory:
474        root = Path(directory)
475        fitted = []
476        for index, forward_result in enumerate(forward_results):
477            replicate_seed = int(forward_result.get("seed", seed + index))
478            data = benchmark_data_from_forward_result(
479                forward_result,
480                root / f"seed_{replicate_seed}",
481            )
482            fitted.append(_fit_benchmark_data(replicate_seed, data))
483    return BenchmarkResult(tuple(fitted))
484
485
486def _cumulative_by_maf(
487    maf: np.ndarray,
488    contributions: np.ndarray,
489    *,
490    eligible: np.ndarray | None = None,
491) -> np.ndarray:
492    """Sum per-variant genic contributions below each MAF threshold."""
493
494    maf = np.asarray(maf, dtype=np.float64)
495    contributions = np.asarray(contributions, dtype=np.float64)
496    if maf.shape != contributions.shape:
497        raise ValueError("maf and contributions must have matching shapes")
498    if eligible is not None:
499        eligible = np.asarray(eligible, dtype=bool)
500        if eligible.shape != maf.shape:
501            raise ValueError("eligible must match maf")
502        maf = maf[eligible]
503        contributions = contributions[eligible]
504    order = np.argsort(maf, kind="stable")
505    sorted_maf = maf[order]
506    sorted_contributions = contributions[order]
507    cumulative = np.cumsum(sorted_contributions)
508    positions = np.searchsorted(sorted_maf, MAF_THRESHOLDS, side="right")
509    return np.where(positions > 0, cumulative[np.maximum(positions - 1, 0)], 0.0)
510
511
512def _bolt_curve(result: BenchmarkReplicateResult) -> np.ndarray:
513    """Return GRAPP's global-GRM cumulative variance allocation.
514
515    BOLT-LMM uses a standardized global GRM, so its fitted genetic scale is
516    allocated equally across model SNPs: each eligible marker contributes
517    ``sigma_g2 / M``. This is the neutral reference curve for this comparison,
518    rather than the frequency-dependent evolutionary prior used by evo-lmm.
519    """
520
521    frequencies = np.concatenate(
522        [np.asarray(stats.a1freq, dtype=np.float64) for stats in result.bolt_stats]
523    )
524    se = np.concatenate([np.asarray(stats.se, dtype=np.float64) for stats in result.bolt_stats])
525    eligible = np.isfinite(se)
526    marker_count = int(np.count_nonzero(eligible))
527    if marker_count == 0:
528        raise RuntimeError("GRAPP BOLT-LMM returned no model markers")
529    marker_contribution = np.zeros(frequencies.size, dtype=np.float64)
530    marker_contribution[eligible] = result.bolt_fit.sigma_g2 / marker_count
531    maf = np.minimum(frequencies, 1.0 - frequencies)
532    return _cumulative_by_maf(maf, marker_contribution, eligible=eligible)
533
534
535def _replicate_curves(result: BenchmarkReplicateResult) -> dict[str, np.ndarray]:
536    """Compute the four cumulative genic-variance curves for one replicate."""
537
538    data = result.data
539    maf = np.minimum(data.full_frequencies, 1.0 - data.full_frequencies)
540    q = data.full_frequencies * (1.0 - data.full_frequencies)
541    segregating = q > 0.0
542    genic_factor = 2.0 * q
543    configured_prior = SimplifiedPrior(sigma_b2=SIGMA_A2, tau=TRUE_TAU)
544    return {
545        "SLiM realization": _cumulative_by_maf(
546            maf,
547            np.square(data.alpha) * genic_factor,
548            eligible=segregating,
549        ),
550        "configured evolutionary prior": _cumulative_by_maf(
551            maf,
552            configured_prior.effect_variances(data.full_frequencies) * genic_factor,
553            eligible=segregating,
554        ),
555        "evo-lmm fitted prior": _cumulative_by_maf(
556            maf,
557            result.evo_fit.prior.effect_variances(data.full_frequencies) * genic_factor,
558            eligible=segregating,
559        ),
560        "GRAPP BOLT-LMM": _bolt_curve(result),
561    }
562
563
564def make_summary(result: BenchmarkResult) -> plt.Figure:
565    """Plot mean cumulative genic variance with replicate variation bars."""
566
567    curve_values = {
568        label: np.stack([_replicate_curves(rep)[label] for rep in result.replicates])
569        for label in (
570            "SLiM realization",
571            "configured evolutionary prior",
572            "evo-lmm fitted prior",
573            "GRAPP BOLT-LMM",
574        )
575    }
576    means = {label: np.mean(values, axis=0) for label, values in curve_values.items()}
577    ddof = 1 if result.n_replicates > 1 else 0
578    variations = {
579        label: np.std(values, axis=0, ddof=ddof)
580        for label, values in curve_values.items()
581    }
582
583    figure, axis = plt.subplots(figsize=(6.4, 4.2), constrained_layout=True)
584    x = np.arange(MAF_THRESHOLDS.size)
585    axis.errorbar(
586        x,
587        means["SLiM realization"],
588        yerr=variations["SLiM realization"],
589        color="tab:green",
590        marker="o",
591        linewidth=1.8,
592        capsize=3,
593        label="SLiM realization",
594    )
595    axis.errorbar(
596        x,
597        means["configured evolutionary prior"],
598        yerr=variations["configured evolutionary prior"],
599        color="black",
600        linewidth=1.4,
601        capsize=3,
602        label=r"configured evolutionary prior ($\sigma_a^2=1$, $W_S=1$)",
603    )
604    axis.errorbar(
605        x,
606        means["evo-lmm fitted prior"],
607        yerr=variations["evo-lmm fitted prior"],
608        color="tab:red",
609        linestyle="--",
610        marker="s",
611        linewidth=1.8,
612        capsize=3,
613        label="evo-lmm fitted prior",
614    )
615    axis.errorbar(
616        x,
617        means["GRAPP BOLT-LMM"],
618        yerr=variations["GRAPP BOLT-LMM"],
619        color="tab:blue",
620        linestyle=":",
621        marker="^",
622        linewidth=2.0,
623        capsize=3,
624        label=r"GRAPP BOLT-LMM (global GRM)",
625    )
626    axis.set_xticks(x, [f"{threshold:g}" for threshold in MAF_THRESHOLDS], rotation=35, ha="right")
627    axis.set_xlabel("cumulative MAF bin")
628    axis.set_ylabel("cumulative genic variance")
629    axis.set_title(
630        "Cumulative genic variance across MAF bins\n"
631        rf"$N={N_INDIVIDUALS}$, $V_S={V_S:g}$, $W_S={W_S:g}$, "
632        rf"$\rho^2=1$, {result.n_replicates} replicates"
633    )
634    axis.grid(axis="y", linestyle=":", alpha=0.55)
635    axis.set_ylim(bottom=0.0)
636    axis.legend(loc="upper left", fontsize=8)
637    axis.text(
638        0.98,
639        0.06,
640        "runtime (mean $\\pm$ SD)\n"
641        f"evo-lmm: {result.runtime_summary()['evo-lmm'][0]:.2f} "
642        f"$\\pm$ {result.runtime_summary()['evo-lmm'][1]:.2f} s\n"
643        f"GRAPP BOLT-LMM: {result.runtime_summary()['GRAPP BOLT-LMM'][0]:.2f} "
644        f"$\\pm$ {result.runtime_summary()['GRAPP BOLT-LMM'][1]:.2f} s",
645        transform=axis.transAxes,
646        ha="right",
647        va="bottom",
648        fontsize=8,
649        bbox={"facecolor": "white", "edgecolor": "0.7", "alpha": 0.85},
650    )
651    return figure
652
653
654if __name__ == "__main__":
655    forward_artifacts = Path("docs/_artifacts/forward_replicates")
656    artifact_directory = Path("docs/_artifacts/bolt_seed_812")
657    if forward_artifacts.exists():
658        from slim_forward_simplified import load_simulation_replicates
659
660        benchmark = run_benchmark(
661            forward_results=load_simulation_replicates(forward_artifacts),
662        )
663    else:
664        benchmark = run_benchmark(
665            data_directory=artifact_directory if artifact_directory.exists() else None,
666        )
667    print(
668        f"replicates={benchmark.n_replicates} "
669        f"mutations_first={benchmark.data.full_grg.num_mutations} "
670        f"evo_lmm_seconds={benchmark.evo_seconds:.6f} "
671        f"grapp_bolt_lmm_seconds={benchmark.bolt_seconds:.6f}"
672    )
673    print(
674        f"evo_lmm_sigma_b2={benchmark.evo_fit.prior.sigma_b2:.6g} "
675        f"evo_lmm_tau={benchmark.evo_fit.prior.tau:.6g} "
676        f"grapp_sigma_g2={benchmark.bolt_fit.sigma_g2:.6g}"
677    )
678    make_summary(benchmark)
679    if "agg" not in plt.get_backend().lower():
680        plt.show()

Generate the ten forward-simulation replicates and their GRGs:

uv run python docs/tutorials/prepare_bolt_benchmark.py

Then rerun the ten-replicate fitting comparison without repeating SLiM:

uv run python docs/tutorials/fit_bolt_benchmark.py

The fit-only script reports each replicate together with the mean and sample standard deviation of both runtimes.

The current ten-replicate fit run reports:

Method

Mean fit seconds

Sample SD seconds

evo-lmm (warm Hutchinson, 15 vectors)

27.24

6.93

GRAPP BOLT-LMM

10.67

0.96

Cumulative genic variance benchmark for evo-lmm and GRAPP BOLT-LMM