--- title: "Combining assortative mating with realistic local LD" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Combining assortative mating with realistic local LD} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4) ## This vignette intentionally uses tiny marker counts to keep its examples ## fast and to illustrate finite-locus behavior; the warning is explained in ## the text below instead of repeated in every chunk's output. options(rBahadur.warn_small_m = FALSE) ``` ## The problem Two very different processes create correlation between genetic markers. Recombination creates **local** linkage disequilibrium. Markers that sit close together on a chromosome are rarely separated by a crossover, so they travel together and their correlation decays with genetic distance. The resulting covariance matrix is essentially banded. Assortative mating creates **global** linkage disequilibrium. When mates resemble each other phenotypically, every causal variant becomes correlated with every other causal variant that affects the same trait, no matter which chromosome it sits on. The resulting covariance matrix is dense. `am_simulate()` handles the second process exactly, but treats markers as unlinked, so it produces no local LD at all. Asking a single Bahadur order-2 distribution to produce both at once does not work: strong local correlations push the target matrix outside the feasible region described in `?am_covariance_structure`, and the sampler refuses. `am_mosaic()` sidesteps this by combining the two rather than approximating them jointly. ## How it works 1. Causal variants are drawn with `rb_dplr()`, exactly as in `am_simulate()`. This supplies the global assortative mating structure, and it is well behaved because causal variants are far apart and therefore only weakly correlated by linkage. 2. The genome is partitioned into blocks, each containing exactly one causal variant. Block boundaries are drawn from the genetic map, so breakpoints concentrate where recombination actually occurs. 3. Each block is filled by copying a contiguous stretch from a real reference haplotype, chosen from among those carrying the allele already drawn at that block's causal variant. Because the copied stretches are real human haplotypes, local LD comes for free and is correct by construction. Because each donor is required to match at the causal variant, the assortative mating structure is left untouched. ## A worked example The package bundles a small real reference panel so this runs offline: a 1 Mb window of chromosome 22 from phase 3 of the 1000 Genomes Project, restricted to common biallelic SNVs, with genetic map positions attached. ```{r} library(rBahadur) panel <- kg_reference() str(panel[c("pos", "cM", "chrom", "build")], max.level = 1) dim(panel$haplotypes) ``` The panel carries real LD, which is the whole point of using it: ```{r} H <- matrix(as.integer(panel$haplotypes), nrow = nrow(panel$haplotypes)) ld_decay <- function(G, pos, breaks = c(0, 5, 10, 25, 50, 100, 250, 1000)) { set.seed(1) ii <- sort(sample(ncol(G), 300)) R <- suppressWarnings(stats::cor(G[, ii])) d <- abs(outer(pos[ii], pos[ii], "-")) / 1000 ut <- upper.tri(R) tapply(R[ut]^2, cut(d[ut], breaks), mean, na.rm = TRUE) } round(ld_decay(H, panel$pos), 4) ``` Now simulate under equilibrium assortative mating. We deliberately use few causal variants here, because with only 1 Mb of sequence a large number of causal variants would chop the region into blocks shorter than the LD itself. Calls below 50 causal variants normally warn that equilibrium quantities are large-locus targets; the warning is suppressed in this vignette because that finite-locus discrepancy is the subject of the next section. ```{r} set.seed(2026) sim <- am_mosaic(h2_0 = 0.5, r = 0.5, n = 1500, panel = panel, m = 10) dim(sim$X) length(sim$causal_idx) ``` ### The assortative mating structure is preserved exactly The mosaic never alters a causal genotype. The genetic values recovered from the output matrix are identical, not merely close, to the ones the sampler produced: ```{r} g_from_X <- as.vector(sim$X[, sim$causal_idx, drop = FALSE] %*% sim$beta_raw) all.equal(g_from_X, as.vector(sim$g)) ``` That holds for any number of causal variants. Agreement with the *equilibrium variance* is a separate and weaker claim, because `vg_eq()` is derived in the limit of infinitely many causal variants. With only ten of them the simulation falls well short of it: ```{r} c(empirical = var(as.vector(sim$g)), infinitesimal_limit = vg_eq(0.5, 0.5, 0.5)) ``` With enough causal variants the gap closes: ```{r} set.seed(11) big <- am_mosaic(h2_0 = 0.5, r = 0.5, n = 1500, panel = panel, m = 200) c(empirical = var(as.vector(big$g)), infinitesimal_limit = vg_eq(0.5, 0.5, 0.5)) c(empirical = var(as.vector(big$g)) / var(as.vector(big$y)), infinitesimal_limit = h2_eq(0.5, 0.5)) ``` Across replicates the shortfall runs to roughly 18% at ten causal variants and 10% at twenty five, and falls within a few percent, which is the replicate to replicate noise, from about fifty upward. This is a property of `vg_eq()` rather than of the mosaic, and `am_simulate()` behaves the same way. ### Local LD is preserved too ```{r} round(ld_decay(sim$X, panel$pos), 4) ``` Compare that with `am_simulate()`, which treats every marker as unlinked and therefore produces essentially no local structure: ```{r} set.seed(2026) flat <- am_simulate(h2_0 = 0.5, r = 0.5, m = 500, n = 1500) round(ld_decay(flat$X, panel$pos[seq_len(500)]), 4) ``` ## Choosing the number of causal variants There is a real tradeoff here and it is worth being explicit about. Blocks are delimited by causal variants, so the number of causal variants sets the block length, and LD cannot survive across a block boundary except through the causal variants themselves. ```{r} reach <- sapply(c(5, 20, 80), function(m) { set.seed(3) s <- am_mosaic(0.5, 0.3, n = 400, panel = panel, m = m) d <- ld_decay(s$X, panel$pos) d[["(10,25]"]] }) data.frame(m = c(5, 20, 80), mean_block_kb = round(diff(range(panel$pos)) / 1000 / c(5, 20, 80)), r2_at_10_25kb = round(reach, 4)) ``` So there are two pressures pulling in opposite directions. Local LD wants **few** causal variants, so that blocks stay long. The equilibrium variance formulas want **many**, because they are infinitesimal-limit results. In this 1 Mb window those pressures genuinely conflict, and neither ten nor two hundred causal variants satisfies both. At genome scale the conflict disappears, which is the situation the method is built for. A thousand causal variants spread across three billion base pairs gives blocks averaging several megabases, far longer than the tens of kilobases over which human LD decays, while a thousand is more than enough for the equilibrium formulas. The tension here is an artifact of demonstrating on 1 Mb. The practical rule: choose the number of causal variants so the average block comfortably exceeds the LD decay length of your region, then check that the number you land on is at least a few hundred. If it is not, your region is too small to want this method rather than `am_simulate()`. ## Working at realistic scale For real use you want a whole chromosome rather than 1 Mb. Two helpers build a panel from real data. Neither is run here because both need network access and a substantial download. ```{r, eval = FALSE} ## build a panel directly from files you already have panel <- vcf_to_panel( vcf = "ALL.chr22.shapeit2_integrated_v1a.GRCh38.20181129.phased.vcf.gz", map = "plink.chr22.GRCh38.map", min_maf = 0.01 ) ## or fetch a region of 1000 Genomes and its genetic map in one call panel <- download_1kg_panel(chrom = "22", start = 20e6, end = 30e6) ``` A phased VCF is strongly recommended. Unphased calls are accepted, but the function warns because their written allele order must be treated as phase; that arbitrary ordering can create artificial haplotypes and local LD. At that size the genotype matrix stops fitting comfortably in memory, so `am_mosaic()` takes the same `path`, `format`, and `batch_size` arguments as `am_simulate()` and streams straight to disk: ```{r, eval = FALSE} out <- am_mosaic(h2_0 = 0.5, r = 0.5, n = 50000, panel = panel, m = 2000, path = "chr22_am", format = "bed") ## readable by plink, GCTA, or any other standard tool ## and by read_genotypes() back in R ## the .bim preserves chromosome, position, map, ID, and allele metadata ``` ## Limitations The simulated haplotypes are mosaics of panel haplotypes, so the method cannot create variation the panel does not contain. A small panel will show inflated identity by descent between simulated individuals, and rare variants present in only a handful of panel haplotypes will be over-represented in some simulated samples. Use the largest reference panel you reasonably can. Correlation across a block boundary is broken apart from what the causal variants carry, so the method reproduces LD *within* blocks faithfully rather than reproducing the full correlation structure of the region. That is the price of keeping the assortative mating structure exact.