From 71ed925f22a9e116aee740598ce6cb5cf07ad5f5 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 13:45:24 -0700 Subject: [PATCH 01/22] fix: separate donor-local nuisance inference --- rust/src/analysis.rs | 1077 +++++++++++++++++++++++------------------- rust/src/lib.rs | 454 ++++++++++++------ 2 files changed, 896 insertions(+), 635 deletions(-) diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index 73f6d60..b1a36d3 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -24,8 +24,6 @@ pub struct VariantCounts { pub ref_count: u32, pub alt_count: u32, pub region: String, - /// Donor/sample identifier; required only by the per_donor model. - pub sample: Option, /// Genotype phase relative to the reference allele: 0 = ref|alt, 1 = alt|ref. /// `None` when genotypes are absent or unphased. Required by phased analysis. pub gt: Option, @@ -60,6 +58,16 @@ pub struct ImbalanceResult { pub fdr_pval: f64, // FDR-corrected p-value } +/// Results and fitted nuisance parameters from one independent analysis table. +#[derive(Debug, Clone)] +pub struct AnalysisOutput { + pub results: Vec, + pub rho: Option, + pub linear_params: Option<(f64, f64)>, + pub n_observations: usize, + pub effective_phased: bool, +} + /// Configuration for analysis #[derive(Debug, Clone)] pub struct AnalysisConfig { @@ -71,9 +79,71 @@ pub struct AnalysisConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnalysisMethod { - Single, // Single global dispersion parameter - Linear, // Linear dispersion model: rho = expit(d1 + N*d2) - PerDonor, // Per-donor dispersion: rho fit separately per sample + Single, // Single global dispersion parameter + Linear, // Linear dispersion model: rho = expit(d1 + N*d2) +} + +/// Nuisance parameters fitted under the balanced beta-binomial null. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DispersionParameters { + Single { rho: f64 }, + Linear { d1: f64, d2: f64 }, +} + +impl DispersionParameters { + fn validate_for(self, method: AnalysisMethod) -> Result { + match (method, self) { + (AnalysisMethod::Single, Self::Single { rho }) => { + if !rho.is_finite() || !(0.0..=1.0).contains(&rho) { + anyhow::bail!("fixed scalar rho must be finite and in [0, 1]"); + } + } + (AnalysisMethod::Linear, Self::Linear { d1, d2 }) => { + if !d1.is_finite() || !d2.is_finite() { + anyhow::bail!("fixed linear dispersion parameters must be finite"); + } + } + (AnalysisMethod::Single, Self::Linear { .. }) => { + anyhow::bail!("method 'single' requires a fixed scalar rho"); + } + (AnalysisMethod::Linear, Self::Single { .. }) => { + anyhow::bail!("method 'linear' requires fixed linear_d1 and linear_d2"); + } + } + Ok(self) + } + + #[inline] + fn rho_at_depth(self, n: u32) -> f64 { + match self { + Self::Single { rho } => clamp_rho(rho), + Self::Linear { d1, d2 } => { + let exp_in = (d1 + n as f64 * d2).clamp(-10.0, 10.0); + clamp_rho(expit(exp_in)) + } + } + } + + pub fn rho(self) -> Option { + match self { + Self::Single { rho } => Some(rho), + Self::Linear { .. } => None, + } + } + + pub fn linear_params(self) -> Option<(f64, f64)> { + match self { + Self::Single { .. } => None, + Self::Linear { d1, d2 } => Some((d1, d2)), + } + } +} + +/// Result of nuisance-only fitting on one independent table. +#[derive(Debug, Clone, Copy)] +pub struct DispersionFit { + pub parameters: DispersionParameters, + pub n_observations: usize, } impl Default for AnalysisConfig { @@ -246,9 +316,10 @@ fn optimize_dispersion(ref_counts: &[u32], n_array: &[u32]) -> Result { } }; - // Use golden section search (simple but effective) - let result = golden_section_search(objective, 0.001, 0.999, 1e-6)?; - Ok(result) + // Match scipy.optimize.minimize_scalar(method="bounded") used by AHO. + // In sparse donors the MLE can be far below 0.001, so imposing a floor + // changes the null likelihood and every downstream p-value. + scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500) } /// Optimize linear dispersion parameters using Nelder-Mead @@ -404,9 +475,9 @@ fn optimize_prob_phased( /// /// Python equivalent: `opt_unphased_dp()` in as_analysis.py /// -/// This marginalizes over unknown phase using dynamic programming. At each -/// position after the first, we consider both phase possibilities and weight -/// them equally (0.5 each), accumulating likelihood across the region. +/// This marginalizes over unknown phase independently at every position. Both +/// orientations receive equal prior weight, including the first SNP, so the +/// feature likelihood does not depend on input row order. /// /// # Arguments /// * `prob` - Probability parameter (0 to 1) @@ -427,6 +498,10 @@ pub fn optimize_prob_unphased_dp( phase_ref: &[u32], phase_n: &[u32], ) -> Result { + if disp.is_empty() { + anyhow::bail!("at least one dispersion value is required"); + } + // Split per-variant dispersion for first vs subsequent positions (linear model) let (first_disp, phase_disp): (f64, &[f64]) = if disp.len() > 1 { (disp[0], &disp[1..]) @@ -435,53 +510,38 @@ pub fn optimize_prob_unphased_dp( (disp[0], disp) }; - // Get likelihood of first position (negative log-likelihood) - let first_ll = opt_prob(prob, first_disp, first_ref, first_n)?; + // Accumulate every position under both orientation hypotheses in log-space. + // For each SNP: + // log(0.5 * p1 + 0.5 * p2) = ln(0.5) + logaddexp(ln(p1), ln(p2)) + let orientation_log_likelihood = |ref_count: u32, n: u32, rho: f64| -> Result { + let rho = clamp_rho(rho); + let alpha1 = prob * (1.0 - rho) / rho; + let beta1 = (1.0 - prob) * (1.0 - rho) / rho; + let bb1 = BetaBinomial::new(n, alpha1, beta1) + .context("Failed to create beta-binomial for orientation 1")?; + let lp1 = bb1.ln_f(&(ref_count as u64)); - // If no subsequent positions, just return first_ll - if phase_ref.is_empty() { - return Ok(first_ll); - } + let bb2 = BetaBinomial::new(n, beta1, alpha1) + .context("Failed to create beta-binomial for orientation 2")?; + let lp2 = bb2.ln_f(&(ref_count as u64)); + Ok((0.5_f64).ln() + logaddexp(lp1, lp2)) + }; - // Accumulate likelihoods for subsequent positions under both phase hypotheses - // in LOG-space (mathematically identical to the old linear product, but without - // underflow). For each position: - // log(0.5 * p1 + 0.5 * p2) = ln(0.5) + logaddexp(ln(p1), ln(p2)) - // where lp1 = ln-pmf with prob and lp2 = ln-pmf with (1 - prob). - let mut log_prev: f64 = 0.0; + let mut log_likelihood = orientation_log_likelihood(first_ref, first_n, first_disp)?; for (i, (&ref_count, &n)) in phase_ref.iter().zip(phase_n.iter()).enumerate() { - // Get dispersion for this position let rho = if phase_disp.len() > 1 { - clamp_rho(phase_disp[i.min(phase_disp.len() - 1)]) + phase_disp[i.min(phase_disp.len() - 1)] } else { - clamp_rho(phase_disp[0]) + phase_disp[0] }; - - // Phase 1: use prob - let alpha1 = prob * (1.0 - rho) / rho; - let beta1 = (1.0 - prob) * (1.0 - rho) / rho; - let bb1 = BetaBinomial::new(n, alpha1, beta1) - .context("Failed to create beta-binomial for phase1")?; - let lp1 = bb1.ln_f(&(ref_count as u64)); // log-pmf - - // Phase 2: use (1 - prob) - let alpha2 = (1.0 - prob) * (1.0 - rho) / rho; - let beta2 = prob * (1.0 - rho) / rho; - let bb2 = BetaBinomial::new(n, alpha2, beta2) - .context("Failed to create beta-binomial for phase2")?; - let lp2 = bb2.ln_f(&(ref_count as u64)); // log-pmf - - // DP update in log-space: marginalize over both phases with equal weight. - log_prev += (0.5_f64).ln() + logaddexp(lp1, lp2); + log_likelihood += orientation_log_likelihood(ref_count, n, rho)?; } - // Return first_ll + (-log_prev) - if !log_prev.is_finite() { + if !log_likelihood.is_finite() { return Ok(f64::INFINITY); } - - Ok(first_ll + (-log_prev)) + Ok(-log_likelihood) } /// Numerically stable two-argument log-sum-exp: ln(exp(a) + exp(b)). @@ -499,6 +559,134 @@ fn logaddexp(a: f64, b: f64) -> f64 { } } +/// Exact two-sided beta-binomial p-value under the balanced null. +/// +/// Outcomes at least as far from `n / 2` as the observed reference count are +/// included. Summation and normalization are performed in log-space so fitted +/// rho values close to the binomial boundary remain usable. +pub fn exact_beta_binomial_pvalue(k: u32, n: u32, rho: f64) -> Result { + if k > n { + anyhow::bail!("reference count cannot exceed total count"); + } + + let rho = clamp_rho(rho); + let alpha = 0.5 * (1.0 - rho) / rho; + let distribution = BetaBinomial::new(n, alpha, alpha) + .context("Failed to create beta-binomial for exact SNV test")?; + let observed_deviation = (2_i64 * k as i64 - n as i64).abs(); + let mut log_total = f64::NEG_INFINITY; + let mut log_tail = f64::NEG_INFINITY; + + for outcome in 0..=n { + let log_pmf = distribution.ln_f(&(outcome as u64)); + log_total = logaddexp(log_total, log_pmf); + let deviation = (2_i64 * outcome as i64 - n as i64).abs(); + if deviation >= observed_deviation { + log_tail = logaddexp(log_tail, log_pmf); + } + } + + if !log_total.is_finite() || !log_tail.is_finite() { + anyhow::bail!("exact beta-binomial p-value was non-finite"); + } + Ok((log_tail - log_total).exp().clamp(0.0, 1.0)) +} + +/// Bounded Brent minimizer matching SciPy's default scalar-bounded routine. +fn scipy_bounded_minimize(f: F, x1: f64, x2: f64, xatol: f64, maxfun: usize) -> Result +where + F: Fn(f64) -> f64, +{ + if !x1.is_finite() || !x2.is_finite() || x1 > x2 { + anyhow::bail!("invalid bounded optimization interval"); + } + let sqrt_eps = (2.2e-16_f64).sqrt(); + let golden_mean = 0.5 * (3.0 - 5.0_f64.sqrt()); + let (mut a, mut b) = (x1, x2); + let mut fulc = a + golden_mean * (b - a); + let (mut nfc, mut xf) = (fulc, fulc); + let (mut rat, mut e) = (0.0_f64, 0.0_f64); + let mut fx = f(xf); + let (mut ffulc, mut fnfc) = (fx, fx); + let mut fu = f64::INFINITY; + let mut calls = 1_usize; + let mut xm = 0.5 * (a + b); + let mut tol1 = sqrt_eps * xf.abs() + xatol / 3.0; + let mut tol2 = 2.0 * tol1; + + while (xf - xm).abs() > tol2 - 0.5 * (b - a) { + let mut golden = true; + if e.abs() > tol1 { + let r = (xf - nfc) * (fx - ffulc); + let q0 = (xf - fulc) * (fx - fnfc); + let mut p = (xf - fulc) * q0 - (xf - nfc) * r; + let mut q = 2.0 * (q0 - r); + if q > 0.0 { + p = -p; + } + q = q.abs(); + let previous_e = e; + e = rat; + if p.abs() < (0.5 * q * previous_e).abs() && p > q * (a - xf) && p < q * (b - xf) { + rat = p / q; + let candidate = xf + rat; + if candidate - a < tol2 || b - candidate < tol2 { + rat = tol1 * if xm - xf >= 0.0 { 1.0 } else { -1.0 }; + } + golden = false; + } + } + if golden { + e = if xf >= xm { a - xf } else { b - xf }; + rat = golden_mean * e; + } + + let direction = if rat >= 0.0 { 1.0 } else { -1.0 }; + let x = xf + direction * rat.abs().max(tol1); + fu = f(x); + calls += 1; + if fu <= fx { + if x >= xf { + a = xf; + } else { + b = xf; + } + fulc = nfc; + ffulc = fnfc; + nfc = xf; + fnfc = fx; + xf = x; + fx = fu; + } else { + if x < xf { + a = x; + } else { + b = x; + } + if fu <= fnfc || nfc == xf { + fulc = nfc; + ffulc = fnfc; + nfc = x; + fnfc = fu; + } else if fu <= ffulc || fulc == xf || fulc == nfc { + fulc = x; + ffulc = fu; + } + } + + xm = 0.5 * (a + b); + tol1 = sqrt_eps * xf.abs() + xatol / 3.0; + tol2 = 2.0 * tol1; + if calls >= maxfun { + anyhow::bail!("bounded scalar optimization exceeded {maxfun} evaluations"); + } + } + if !xf.is_finite() || !fx.is_finite() || fu.is_nan() { + anyhow::bail!("bounded scalar optimization produced a non-finite result"); + } + Ok(xf) +} + /// Golden section search for 1D optimization /// /// Simple but robust method for bounded scalar optimization. @@ -690,160 +878,117 @@ pub fn fdr_correction(pvals: &[f64]) -> Vec { /// Single dispersion model analysis /// /// Python equivalent: `single_model()` in as_analysis.py -pub fn single_model(variants: Vec, phased: bool) -> Result> { +fn single_model_with_dispersion( + variants: Vec, + phased: bool, +) -> Result<(Vec, f64)> { if variants.is_empty() { - return Ok(vec![]); + return Ok((vec![], f64::NAN)); } - // Extract ref_counts and N for all variants - let ref_counts: Vec = variants.iter().map(|v| v.ref_count).collect(); - let n_array: Vec = variants.iter().map(|v| v.ref_count + v.alt_count).collect(); - - // Step 1: Optimize global dispersion parameter eprintln!("Optimizing dispersion parameter..."); - let disp = optimize_dispersion(&ref_counts, &n_array)?; + let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Single)?; + let DispersionParameters::Single { rho: disp } = parameters else { + unreachable!("single fit returned linear parameters") + }; eprintln!(" Dispersion: {:.6}", disp); + let results = test_fixed_dispersion(variants, phased, parameters, false, 0)?; + Ok((results, disp)) +} - // Step 2: Group by region - let mut region_map: HashMap> = HashMap::new(); - for (i, variant) in variants.iter().enumerate() { - region_map - .entry(variant.region.clone()) - .or_default() - .push(i); - } - - eprintln!( - "Optimizing imbalance likelihood for {} regions...", - region_map.len() - ); - - // Step 3: Calculate null and alternative likelihoods per region (parallel) - // Clamp disp before calculating null_param (Issue #228) - let disp = clamp_rho(disp); - let null_param = 0.5 * (1.0 - disp) / disp; - - let results: Result> = region_map - .par_iter() - .map(|(region, indices)| -> Result { - // Extract counts for this region - let region_ref: Vec = indices.iter().map(|&i| ref_counts[i]).collect(); - let region_n: Vec = indices.iter().map(|&i| n_array[i]).collect(); - - // Null model: prob = 0.5 (no imbalance) - let null_ll = betabinom_logpmf_sum(®ion_ref, ®ion_n, null_param, null_param)?; +pub fn single_model(variants: Vec, phased: bool) -> Result> { + Ok(single_model_with_dispersion(variants, phased)?.0) +} - // Alternative model: optimize prob (phase-aware routing) - let all_gt = indices.iter().all(|&i| variants[i].gt.is_some()); - let (alt_ll, mu) = if phased && indices.len() > 1 && all_gt { - // Phased multi-SNP region: use known genotype phase. - let region_gt: Vec = indices - .iter() - .map(|&i| variants[i].gt.expect("gt present (checked by all_gt)")) - .collect(); - // single_model uses one global scalar dispersion; pass it as a - // 1-element slice so the broadcast fallback keeps results identical. - optimize_prob_phased(®ion_ref, ®ion_n, ®ion_gt, &[disp])? - } else if indices.len() > 1 { - // Unphased multi-SNP region: marginalize over phase via DP, - // minimizing opt_unphased_dp over prob in (0, 1) - // (first = index 0, phase = remaining), matching Python's - // parse_opt() unphased branch (minimize_scalar(opt_unphased_dp,...)). - let disp_slice = [disp]; - let first_ref = region_ref[0]; - let first_n = region_n[0]; - let phase_ref = ®ion_ref[1..]; - let phase_n = ®ion_n[1..]; - let objective = |prob: f64| -> f64 { - optimize_prob_unphased_dp( - prob, - &disp_slice, - first_ref, - first_n, - phase_ref, - phase_n, - ) - .unwrap_or(f64::INFINITY) - }; - let mu = golden_section_search(objective, 0.0, 1.0, 1e-6)?; - let alt_ll = -objective(mu); - (alt_ll, mu) - } else { - optimize_prob(®ion_ref, ®ion_n, disp)? - }; +/// Linear dispersion model analysis +/// +/// Python equivalent: `linear_model()` in as_analysis.py +/// Uses ρ = expit(d1 + N × d2) where d1, d2 are globally optimized parameters. +pub fn linear_model_with_dispersion( + variants: Vec, + phased: bool, +) -> Result<(Vec, f64, f64)> { + if variants.is_empty() { + return Ok((vec![], f64::NAN, f64::NAN)); + } - // Likelihood ratio test - let lrt = -2.0 * (null_ll - alt_ll); + eprintln!("Optimizing linear dispersion parameters..."); + let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Linear)?; + let DispersionParameters::Linear { d1, d2 } = parameters else { + unreachable!("linear fit returned scalar parameters") + }; + eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2); + let results = test_fixed_dispersion(variants, phased, parameters, false, 0)?; + Ok((results, d1, d2)) +} - // P-value from chi-squared distribution (df=1) - let chi2 = ChiSquared::new(1.0).context("Failed to create chi-squared distribution")?; - let pval = 1.0 - chi2.cdf(lrt); +pub fn linear_model(variants: Vec, phased: bool) -> Result> { + Ok(linear_model_with_dispersion(variants, phased)?.0) +} - // Sum counts for this region - let total_ref: u32 = region_ref.iter().sum(); - let total_alt: u32 = indices.iter().map(|&i| variants[i].alt_count).sum(); - let total_n = total_ref + total_alt; +fn fit_dispersion_parameters( + variants: &[VariantCounts], + method: AnalysisMethod, +) -> Result { + if variants.is_empty() { + anyhow::bail!("cannot fit dispersion without observations"); + } - Ok(ImbalanceResult { - region: region.clone(), - ref_count: total_ref, - alt_count: total_alt, - n: total_n, - snp_count: indices.len(), - null_ll, - alt_ll, - mu, - lrt, - pval, - fdr_pval: 0.0, // Will be filled later - }) - }) + let ref_counts: Vec = variants.iter().map(|variant| variant.ref_count).collect(); + let n_array: Vec = variants + .iter() + .map(|variant| variant.ref_count + variant.alt_count) .collect(); - - let mut results = results?; - - // Sort by region name for deterministic output across runs - results.sort_by(|a, b| a.region.cmp(&b.region)); - - // Step 4: FDR correction - let pvals: Vec = results.iter().map(|r| r.pval).collect(); - let fdr_pvals = fdr_correction(&pvals); - - for (result, fdr_pval) in results.iter_mut().zip(fdr_pvals.iter()) { - result.fdr_pval = *fdr_pval; + match method { + AnalysisMethod::Single => Ok(DispersionParameters::Single { + rho: optimize_dispersion(&ref_counts, &n_array)?, + }), + AnalysisMethod::Linear => { + let (d1, d2) = optimize_dispersion_linear(&ref_counts, &n_array)?; + Ok(DispersionParameters::Linear { d1, d2 }) + } } +} - Ok(results) +fn canonicalize_region_indices( + region_map: &mut HashMap>, + variants: &[VariantCounts], +) { + for indices in region_map.values_mut() { + indices.sort_unstable_by(|left, right| { + let left = &variants[*left]; + let right = &variants[*right]; + left.chrom + .cmp(&right.chrom) + .then_with(|| left.pos.cmp(&right.pos)) + .then_with(|| left.ref_count.cmp(&right.ref_count)) + .then_with(|| left.alt_count.cmp(&right.alt_count)) + .then_with(|| left.gt.cmp(&right.gt)) + }); + } } -/// Linear dispersion model analysis -/// -/// Python equivalent: `linear_model()` in as_analysis.py -/// Uses ρ = expit(d1 + N × d2) where d1, d2 are globally optimized parameters. -pub fn linear_model(variants: Vec, phased: bool) -> Result> { +fn test_fixed_dispersion( + variants: Vec, + phased: bool, + parameters: DispersionParameters, + exact_snv_pvalues: bool, + pseudocount: u32, +) -> Result> { if variants.is_empty() { return Ok(vec![]); } - // Extract ref_counts and N for all variants - let ref_counts: Vec = variants.iter().map(|v| v.ref_count).collect(); - let n_array: Vec = variants.iter().map(|v| v.ref_count + v.alt_count).collect(); - - // Step 1: Optimize linear dispersion parameters (d1, d2) - eprintln!("Optimizing linear dispersion parameters..."); - let (d1, d2) = optimize_dispersion_linear(&ref_counts, &n_array)?; - eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2); - - // Step 2: Compute per-variant rho array + let ref_counts: Vec = variants.iter().map(|variant| variant.ref_count).collect(); + let n_array: Vec = variants + .iter() + .map(|variant| variant.ref_count + variant.alt_count) + .collect(); let rho_array: Vec = n_array .iter() - .map(|&n| { - let exp_in = (d1 + n as f64 * d2).clamp(-10.0, 10.0); - clamp_rho(expit(exp_in)) - }) + .map(|&n| parameters.rho_at_depth(n)) .collect(); - // Step 3: Group by region let mut region_map: HashMap> = HashMap::new(); for (i, variant) in variants.iter().enumerate() { region_map @@ -851,22 +996,20 @@ pub fn linear_model(variants: Vec, phased: bool) -> Result> = region_map .par_iter() .map(|(region, indices)| -> Result { - // Extract counts and rho for this region let region_ref: Vec = indices.iter().map(|&i| ref_counts[i]).collect(); let region_n: Vec = indices.iter().map(|&i| n_array[i]).collect(); let region_rho: Vec = indices.iter().map(|&i| rho_array[i]).collect(); - // Null model: prob = 0.5, using per-variant rho let mut null_ll = 0.0; for ((&ref_count, &n), &rho) in region_ref .iter() @@ -880,26 +1023,14 @@ pub fn linear_model(variants: Vec, phased: bool) -> Result 1 && all_gt { - // Phased multi-SNP region: use known genotype phase. let region_gt: Vec = indices .iter() .map(|&i| variants[i].gt.expect("gt present (checked by all_gt)")) .collect(); optimize_prob_phased(®ion_ref, ®ion_n, ®ion_gt, ®ion_rho)? } else if indices.len() > 1 { - // Unphased multi-SNP region: marginalize over phase via DP, - // minimizing opt_unphased_dp over prob in (0, 1) - // (first = index 0, phase = remaining), matching Python's - // parse_opt() unphased branch (minimize_scalar(opt_unphased_dp,...)). - // region_rho is in the region's index order ([0] = first, - // [1..] = phase), matching opt_unphased_dp's internal split. let first_ref = region_ref[0]; let first_n = region_n[0]; let phase_ref = ®ion_ref[1..]; @@ -915,214 +1046,32 @@ pub fn linear_model(variants: Vec, phased: bool) -> Result = results.iter().map(|r| r.pval).collect(); - let fdr_pvals = fdr_correction(&pvals); - - for (result, fdr_pval) in results.iter_mut().zip(fdr_pvals.iter()) { - result.fdr_pval = *fdr_pval; - } - - Ok(results) -} - -/// Fit per-donor dispersion (rho) via bounded MLE, one value per `sample`. -/// -/// Python equivalent: `compute_per_donor_rho()` — each donor's rho is the -/// symmetric beta-binomial MLE over all that donor's observations, bounded to -/// (1e-6, 1-1e-6). No extra floor (matches locked donor_rho.tsv: min ~7e-6). -fn compute_donor_rho(variants: &[VariantCounts]) -> Result> { - // Group observation indices by donor - let mut donor_map: HashMap> = HashMap::new(); - for (i, v) in variants.iter().enumerate() { - let donor = v.sample.clone().ok_or_else(|| { - anyhow::anyhow!("per_donor model requires a 'sample' column in the counts TSV") - })?; - donor_map.entry(donor).or_default().push(i); - } - - // Fit rho per donor in parallel (golden-section on symmetric beta-binomial) - donor_map - .par_iter() - .map(|(donor, idx)| -> Result<(String, f64)> { - let dref: Vec = idx.iter().map(|&i| variants[i].ref_count).collect(); - let dn: Vec = idx - .iter() - .map(|&i| variants[i].ref_count + variants[i].alt_count) - .collect(); - let objective = |rho: f64| -> f64 { - let rho = clamp_rho(rho); - let a = 0.5 * (1.0 - rho) / rho; - match betabinom_logpmf_sum(&dref, &dn, a, a) { - Ok(ll) => -ll, - Err(_) => f64::INFINITY, - } - }; - // Bounds (1e-6, 1-1e-6) match scipy minimize_scalar(method="bounded") - let rho = golden_section_search(objective, 1e-6, 1.0 - 1e-6, 1e-6)?; - Ok((donor.clone(), rho)) - }) - .collect() -} - -/// Per-donor dispersion model analysis. -/// -/// Python equivalent: WASP2 `per_donor` extension — fit rho per `sample`, then -/// run the standard per-region beta-binomial LRT using each observation's -/// donor-specific rho (per-observation rho in BOTH null and alternative). -pub fn per_donor_model(variants: Vec, phased: bool) -> Result> { - if variants.is_empty() { - return Ok(vec![]); - } - - // Step 1: fit per-donor dispersion - eprintln!("Fitting per-donor dispersion..."); - let donor_rho = compute_donor_rho(&variants)?; - eprintln!(" Fit rho for {} donors", donor_rho.len()); - // Emit fitted rho (for validation against locked donor_rho.tsv) - let mut donors_sorted: Vec<(&String, &f64)> = donor_rho.iter().collect(); - donors_sorted.sort_by(|a, b| a.0.cmp(b.0)); - for (donor, rho) in donors_sorted { - eprintln!("DONOR_RHO\t{}\t{:.8}", donor, rho); - } - - // Step 2: per-observation rho = each obs's donor rho - let ref_counts: Vec = variants.iter().map(|v| v.ref_count).collect(); - let n_array: Vec = variants.iter().map(|v| v.ref_count + v.alt_count).collect(); - let rho_array: Vec = variants - .iter() - .map(|v| { - let donor = v - .sample - .as_ref() - .expect("sample present (checked in compute_donor_rho)"); - clamp_rho( - *donor_rho - .get(donor) - .expect("donor rho fit for every sample"), - ) - }) - .collect(); - - // Step 3: group by region - let mut region_map: HashMap> = HashMap::new(); - for (i, variant) in variants.iter().enumerate() { - region_map - .entry(variant.region.clone()) - .or_default() - .push(i); - } - eprintln!( - "Optimizing imbalance likelihood for {} regions...", - region_map.len() - ); - - // Step 4: per-region null/alt LL + LRT, using per-observation rho - let results: Result> = region_map - .par_iter() - .map(|(region, indices)| -> Result { - let region_ref: Vec = indices.iter().map(|&i| ref_counts[i]).collect(); - let region_n: Vec = indices.iter().map(|&i| n_array[i]).collect(); - let region_rho: Vec = indices.iter().map(|&i| rho_array[i]).collect(); - - use rv::traits::HasDensity; - - // Null model: prob = 0.5, per-observation rho - let mut null_ll = 0.0; - for ((&ref_count, &n), &rho) in region_ref - .iter() - .zip(region_n.iter()) - .zip(region_rho.iter()) - { - let alpha = 0.5 * (1.0 - rho) / rho; - let bb = rv::dist::BetaBinomial::new(n, alpha, alpha) - .context("Failed to create beta-binomial for null")?; - null_ll += bb.ln_f(&(ref_count as u64)); - } - - // Alternative model: optimize one pi, per-observation rho. - // Phase-aware routing (mirrors single_model): for a phased multi-SNP - // region with known GT, use the haplotype-phased likelihood, feeding - // the per-observation (per-donor) rho slice into optimize_prob_phased's - // &[f64] disp argument. Otherwise (unphased, or single-SNP, or missing - // GT) use the unphased shared-pi product UNCHANGED — so phased=false is - // byte-identical to the pre-phasing per_donor model. - let all_gt = indices.iter().all(|&i| variants[i].gt.is_some()); - let (alt_ll, mu) = if phased && indices.len() > 1 && all_gt { - let region_gt: Vec = indices - .iter() - .map(|&i| variants[i].gt.expect("gt present (checked by all_gt)")) - .collect(); - // Per-observation donor rho flows straight into the &[f64] slice. - optimize_prob_phased(®ion_ref, ®ion_n, ®ion_gt, ®ion_rho)? + let pval = if exact_snv_pvalues && indices.len() == 1 { + let variant = &variants[indices[0]]; + let raw_ref = variant.ref_count.checked_sub(pseudocount).ok_or_else(|| { + anyhow::anyhow!("reference count is smaller than the pseudocount") + })?; + let raw_alt = variant.alt_count.checked_sub(pseudocount).ok_or_else(|| { + anyhow::anyhow!("alternate count is smaller than the pseudocount") + })?; + let raw_n = raw_ref + raw_alt; + exact_beta_binomial_pvalue(raw_ref, raw_n, parameters.rho_at_depth(raw_n))? } else { - // Unphased: optimize one pi over the per-observation beta-binomial - // product (matches Python _alt_ll: a=pi(1-rho)/rho, b=(1-pi)(1-rho)/rho) - let alt_obj = |pi: f64| -> f64 { - let pi = pi.clamp(1e-6, 1.0 - 1e-6); - let mut nll = 0.0; - for ((&ref_count, &n), &rho) in region_ref - .iter() - .zip(region_n.iter()) - .zip(region_rho.iter()) - { - let a = pi * (1.0 - rho) / rho; - let b = (1.0 - pi) * (1.0 - rho) / rho; - match rv::dist::BetaBinomial::new(n, a, b) { - Ok(bb) => nll -= bb.ln_f(&(ref_count as u64)), - Err(_) => return f64::INFINITY, - } - } - nll - }; - let mu = golden_section_search(alt_obj, 1e-6, 1.0 - 1e-6, 1e-6)?; - let alt_ll = -alt_obj(mu); - (alt_ll, mu) + let chi2 = + ChiSquared::new(1.0).context("Failed to create chi-squared distribution")?; + 1.0 - chi2.cdf(lrt) }; - // Likelihood ratio test (clamped at 0 like Python) - let lrt = (-2.0 * (null_ll - alt_ll)).max(0.0); - let chi2 = ChiSquared::new(1.0).context("Failed to create chi-squared distribution")?; - let pval = 1.0 - chi2.cdf(lrt); - let total_ref: u32 = region_ref.iter().sum(); let total_alt: u32 = indices.iter().map(|&i| variants[i].alt_count).sum(); let total_n = total_ref + total_alt; @@ -1144,28 +1093,24 @@ pub fn per_donor_model(variants: Vec, phased: bool) -> Result = results.iter().map(|r| r.pval).collect(); let fdr_pvals = fdr_correction(&pvals); for (result, fdr_pval) in results.iter_mut().zip(fdr_pvals.iter()) { result.fdr_pval = *fdr_pval; } - Ok(results) } /// Main entry point for allelic imbalance analysis /// /// Python equivalent: `get_imbalance()` in as_analysis.py -pub fn analyze_imbalance( +struct PreparedAnalysis { variants: Vec, - config: &AnalysisConfig, -) -> Result> { - // Apply filters and pseudocounts + effective_phased: bool, +} + +fn prepare_analysis(variants: Vec, config: &AnalysisConfig) -> PreparedAnalysis { let filtered: Vec = variants .into_iter() .map(|mut v| { @@ -1183,49 +1128,141 @@ pub fn analyze_imbalance( let phased = effective_phased(&filtered, config.phased); - // Deduplicate variants for Python parity (single and linear models only). + // Deduplicate variants for Python parity. // Python's get_imbalance() does: df[keep_cols].drop_duplicates() - // Per-donor model skips dedup as it's a Rust-only feature. - let deduped = if config.method != AnalysisMethod::PerDonor { - let before = filtered.len(); - let after = deduplicate_variants(filtered, phased); - if after.len() < before { - eprintln!( - "Deduplicated {} → {} variants ({} duplicates removed)", - before, - after.len(), - before - after.len() - ); - } - after - } else { - filtered - }; - - // Run analysis based on method - let mut results = match config.method { - AnalysisMethod::Single => single_model(deduped, phased)?, - AnalysisMethod::Linear => linear_model(deduped, phased)?, - AnalysisMethod::PerDonor => per_donor_model(deduped, phased)?, - }; + let before = filtered.len(); + let deduped = deduplicate_variants(filtered, phased); + if deduped.len() < before { + eprintln!( + "Deduplicated {} → {} variants ({} duplicates removed)", + before, + deduped.len(), + before - deduped.len() + ); + } + PreparedAnalysis { + variants: deduped, + effective_phased: phased, + } +} - // Remove pseudocounts from results - for result in results.iter_mut() { - if result.ref_count < config.pseudocount - || result.alt_count < config.pseudocount - || result.n < 2 * config.pseudocount +fn remove_result_pseudocounts(results: &mut [ImbalanceResult], pseudocount: u32) { + for result in results { + let region_pseudocount = pseudocount.saturating_mul(result.snp_count as u32); + if result.ref_count < region_pseudocount + || result.alt_count < region_pseudocount + || result.n < 2 * region_pseudocount { eprintln!( "[WARN] Counts smaller than pseudocount for region {}: ref={}, alt={}, n={}, pc={}", - result.region, result.ref_count, result.alt_count, result.n, config.pseudocount + result.region, result.ref_count, result.alt_count, result.n, region_pseudocount ); } - result.ref_count = result.ref_count.saturating_sub(config.pseudocount); - result.alt_count = result.alt_count.saturating_sub(config.pseudocount); - result.n = result.n.saturating_sub(2 * config.pseudocount); + result.ref_count = result.ref_count.saturating_sub(region_pseudocount); + result.alt_count = result.alt_count.saturating_sub(region_pseudocount); + result.n = result.n.saturating_sub(2 * region_pseudocount); } +} - Ok(results) +fn analyze_prepared( + prepared: PreparedAnalysis, + config: &AnalysisConfig, + parameters: DispersionParameters, + exact_snv_pvalues: bool, +) -> Result { + let n_observations = prepared.variants.len(); + let mut results = test_fixed_dispersion( + prepared.variants, + prepared.effective_phased, + parameters, + exact_snv_pvalues, + config.pseudocount, + )?; + remove_result_pseudocounts(&mut results, config.pseudocount); + + Ok(AnalysisOutput { + results, + rho: parameters.rho(), + linear_params: parameters.linear_params(), + n_observations, + effective_phased: prepared.effective_phased, + }) +} + +/// Fit only the balanced-null nuisance parameters for one independent table. +pub fn fit_imbalance_dispersion( + variants: Vec, + config: &AnalysisConfig, +) -> Result { + let prepared = prepare_analysis(variants, config); + if prepared.variants.is_empty() { + anyhow::bail!("no variants passed the count threshold for dispersion fitting"); + } + + match config.method { + AnalysisMethod::Single => eprintln!("Optimizing dispersion parameter..."), + AnalysisMethod::Linear => eprintln!("Optimizing linear dispersion parameters..."), + } + let parameters = fit_dispersion_parameters(&prepared.variants, config.method)?; + match parameters { + DispersionParameters::Single { rho } => eprintln!(" Dispersion: {:.6}", rho), + DispersionParameters::Linear { d1, d2 } => { + eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2) + } + } + Ok(DispersionFit { + parameters, + n_observations: prepared.variants.len(), + }) +} + +/// Test effects with nuisance parameters supplied by a separate fitting run. +pub fn analyze_imbalance_with_fixed_dispersion( + variants: Vec, + config: &AnalysisConfig, + parameters: DispersionParameters, + exact_snv_pvalues: bool, +) -> Result { + let parameters = parameters.validate_for(config.method)?; + let prepared = prepare_analysis(variants, config); + analyze_prepared(prepared, config, parameters, exact_snv_pvalues) +} + +pub fn analyze_imbalance_detailed( + variants: Vec, + config: &AnalysisConfig, +) -> Result { + let prepared = prepare_analysis(variants, config); + let n_observations = prepared.variants.len(); + if n_observations == 0 { + return Ok(AnalysisOutput { + results: vec![], + rho: None, + linear_params: None, + n_observations, + effective_phased: prepared.effective_phased, + }); + } + + match config.method { + AnalysisMethod::Single => eprintln!("Optimizing dispersion parameter..."), + AnalysisMethod::Linear => eprintln!("Optimizing linear dispersion parameters..."), + } + let parameters = fit_dispersion_parameters(&prepared.variants, config.method)?; + match parameters { + DispersionParameters::Single { rho } => eprintln!(" Dispersion: {:.6}", rho), + DispersionParameters::Linear { d1, d2 } => { + eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2) + } + } + analyze_prepared(prepared, config, parameters, false) +} + +pub fn analyze_imbalance( + variants: Vec, + config: &AnalysisConfig, +) -> Result> { + Ok(analyze_imbalance_detailed(variants, config)?.results) } #[cfg(test)] @@ -1304,6 +1341,14 @@ mod tests { assert!((min - 0.7).abs() < 1e-5); } + #[test] + fn test_scipy_bounded_minimize_preserves_near_zero_solution() { + let minimum = scipy_bounded_minimize(|rho| rho, 0.0, 1.0, 1e-5, 500).unwrap(); + let scipy_boundary = 5.9608609865491405e-6; + assert!((minimum - scipy_boundary).abs() < 1e-15); + assert!(minimum < 0.001); + } + #[test] fn test_optimize_prob_unphased_dp_single_position() { // Test with only first position (no subsequent positions) @@ -1360,6 +1405,19 @@ mod tests { assert!(result_imbalanced.is_finite()); } + #[test] + fn test_exact_beta_binomial_pvalue_is_symmetric_and_handles_low_rho() { + let left = exact_beta_binomial_pvalue(2, 10, 0.08).unwrap(); + let right = exact_beta_binomial_pvalue(8, 10, 0.08).unwrap(); + assert!((left - right).abs() < 1e-12); + assert!((exact_beta_binomial_pvalue(5, 10, 0.08).unwrap() - 1.0).abs() < 1e-12); + + // At the clamp boundary this converges to the ordinary two-sided + // binomial probability for observing 0 or 10 successes. + let near_binomial = exact_beta_binomial_pvalue(0, 10, 0.0).unwrap(); + assert!((near_binomial - 2.0 / 1024.0).abs() < 1e-7); + } + fn vc_peak(pos: u32, rc: u32, ac: u32, gt: Option) -> VariantCounts { VariantCounts { chrom: "chr1".to_string(), @@ -1367,7 +1425,6 @@ mod tests { ref_count: rc, alt_count: ac, region: "peak1".to_string(), - sample: Some("d1".to_string()), gt, } } @@ -1431,69 +1488,109 @@ mod tests { assert_result_close(&phased[0], &unphased[0]); } - // Helper: a per_donor VariantCounts for one donor "d1". - fn vc_d1(pos: u32, rc: u32, ac: u32, gt: u8) -> VariantCounts { - VariantCounts { - chrom: "chr1".to_string(), - pos, - ref_count: rc, - alt_count: ac, - region: "peak1".to_string(), - sample: Some("d1".to_string()), - gt: Some(gt), - } + #[test] + fn test_multi_snp_output_removes_every_pseudocount() { + let variants = vec![vc_peak(100, 10, 5, None), vc_peak(200, 7, 8, None)]; + let config = AnalysisConfig { + min_count: 0, + pseudocount: 1, + method: AnalysisMethod::Single, + phased: false, + }; + let output = analyze_imbalance_detailed(variants, &config).unwrap(); + assert_eq!(output.results.len(), 1); + let result = &output.results[0]; + assert_eq!(result.snp_count, 2); + assert_eq!(result.ref_count, 17); + assert_eq!(result.alt_count, 13); + assert_eq!(result.n, 30); } #[test] - fn test_per_donor_phased_wiring() { - // Phased per_donor on a single-donor multi-SNP region must equal a DIRECT - // optimize_prob_phased call with that donor's fitted rho — proving - // region_rho / region_gt are wired into the phased likelihood correctly. + fn test_unphased_feature_is_permutation_invariant_with_linear_nuisance() { let variants = vec![ - vc_d1(100, 18, 4, 0), - vc_d1(200, 5, 15, 1), - vc_d1(300, 12, 8, 0), + vc_peak(300, 17, 3, None), + vc_peak(100, 4, 16, None), + vc_peak(200, 13, 12, None), ]; + let mut reversed = variants.clone(); + reversed.reverse(); + let config = AnalysisConfig { + min_count: 0, + pseudocount: 0, + method: AnalysisMethod::Linear, + phased: false, + }; + let nuisance = DispersionParameters::Linear { + d1: -3.0, + d2: 0.025, + }; - let rho_map = compute_donor_rho(&variants).unwrap(); - let rho = clamp_rho(rho_map["d1"]); - let region_ref = vec![18u32, 5, 12]; - let region_n = vec![22u32, 20, 20]; - let region_gt = vec![0u8, 1, 0]; - let region_rho = vec![rho, rho, rho]; - let (exp_alt, exp_mu) = - optimize_prob_phased(®ion_ref, ®ion_n, ®ion_gt, ®ion_rho).unwrap(); - - let res = per_donor_model(variants, true).unwrap(); - assert_eq!(res.len(), 1); - assert!( - (res[0].alt_ll - exp_alt).abs() < 1e-9, - "phased per_donor alt_ll {} != direct optimize_prob_phased {}", - res[0].alt_ll, - exp_alt - ); - assert!( - (res[0].mu - exp_mu).abs() < 1e-9, - "mu {} != {}", - res[0].mu, - exp_mu - ); - assert!(res[0].lrt >= 0.0, "LRT must be clamped >= 0"); + let forward = + analyze_imbalance_with_fixed_dispersion(variants, &config, nuisance, false).unwrap(); + let backward = + analyze_imbalance_with_fixed_dispersion(reversed, &config, nuisance, false).unwrap(); + assert_eq!(forward.results.len(), 1); + assert_eq!(backward.results.len(), 1); + assert_result_close(&forward.results[0], &backward.results[0]); + assert_eq!(forward.results[0].fdr_pval, backward.results[0].fdr_pval); } #[test] - fn test_per_donor_unphased_ignores_gt() { - // phased=false uses the shared-pi product, which does NOT use GT — - // flipping GT must leave the unphased result unchanged. - let a = vec![vc_d1(100, 18, 4, 0), vc_d1(200, 5, 15, 1)]; - let b = vec![vc_d1(100, 18, 4, 1), vc_d1(200, 5, 15, 0)]; // GT flipped - let ra = per_donor_model(a, false).unwrap(); - let rb = per_donor_model(b, false).unwrap(); - assert!( - (ra[0].lrt - rb[0].lrt).abs() < 1e-12, - "unphased per_donor must ignore GT: {} vs {}", - ra[0].lrt, - rb[0].lrt - ); + fn test_fixed_nuisance_exact_snv_uses_raw_counts() { + let variants = vec![vc_peak(100, 8, 2, None)]; + let no_pseudocount = AnalysisConfig { + min_count: 0, + pseudocount: 0, + method: AnalysisMethod::Single, + phased: false, + }; + let with_pseudocount = AnalysisConfig { + pseudocount: 3, + ..no_pseudocount.clone() + }; + let nuisance = DispersionParameters::Single { rho: 0.04 }; + + let raw = analyze_imbalance_with_fixed_dispersion( + variants.clone(), + &no_pseudocount, + nuisance, + true, + ) + .unwrap(); + let padded = + analyze_imbalance_with_fixed_dispersion(variants, &with_pseudocount, nuisance, true) + .unwrap(); + assert_eq!(raw.results[0].ref_count, 8); + assert_eq!(padded.results[0].ref_count, 8); + assert_eq!(raw.results[0].alt_count, 2); + assert_eq!(padded.results[0].alt_count, 2); + assert!((raw.results[0].pval - padded.results[0].pval).abs() < 1e-12); + assert_eq!(raw.rho, Some(0.04)); + assert_eq!(padded.rho, Some(0.04)); + } + + #[test] + fn test_fixed_linear_nuisance_is_reported_without_refitting() { + let config = AnalysisConfig { + min_count: 0, + pseudocount: 0, + method: AnalysisMethod::Linear, + phased: false, + }; + let nuisance = DispersionParameters::Linear { + d1: -4.25, + d2: 0.031, + }; + let output = analyze_imbalance_with_fixed_dispersion( + vec![vc_peak(100, 7, 5, None)], + &config, + nuisance, + false, + ) + .unwrap(); + assert_eq!(output.linear_params, Some((-4.25, 0.031))); + assert_eq!(output.rho, None); + assert_eq!(output.n_observations, 1); } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a1f0e34..d39d2b4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,8 +1,4 @@ -#![allow( - clippy::too_many_arguments, - clippy::type_complexity, - non_local_definitions -)] +#![allow(non_local_definitions)] use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -268,169 +264,195 @@ fn remap_all_chromosomes( /// for r in results: /// print(f"{r['region']}: pval={r['pval']:.4f}") /// ``` -#[pyfunction] -#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, method="single", phased=false, region_col=None))] -fn analyze_imbalance( - py: Python, +fn parse_analysis_method(method: &str) -> PyResult { + match method { + "single" => Ok(analysis::AnalysisMethod::Single), + "linear" => Ok(analysis::AnalysisMethod::Linear), + "per_donor" => Err(PyRuntimeError::new_err( + "method='per_donor' has been removed because it pooled effect likelihoods across donors; split the count table by donor, fit each donor with fit_imbalance_dispersion(), and pass those fixed nuisance parameters to analyze_imbalance_run()", + )), + _ => Err(PyRuntimeError::new_err(format!( + "Unknown method '{method}'; expected 'single' or 'linear'" + ))), + } +} + +fn fixed_dispersion_parameters( + method: analysis::AnalysisMethod, + rho: Option, + linear_d1: Option, + linear_d2: Option, +) -> PyResult> { + match method { + analysis::AnalysisMethod::Single => { + if linear_d1.is_some() || linear_d2.is_some() { + return Err(PyRuntimeError::new_err( + "method='single' accepts rho, not linear_d1/linear_d2", + )); + } + Ok(rho.map(|rho| analysis::DispersionParameters::Single { rho })) + } + analysis::AnalysisMethod::Linear => { + if rho.is_some() { + return Err(PyRuntimeError::new_err( + "method='linear' accepts linear_d1/linear_d2, not rho", + )); + } + match (linear_d1, linear_d2) { + (None, None) => Ok(None), + (Some(d1), Some(d2)) => Ok(Some(analysis::DispersionParameters::Linear { d1, d2 })), + _ => Err(PyRuntimeError::new_err( + "method='linear' requires both linear_d1 and linear_d2", + )), + } + } + } +} + +fn read_analysis_variants( tsv_path: &str, - min_count: u32, - pseudocount: u32, - method: &str, - phased: bool, - region_col: Option, -) -> PyResult> { - use pyo3::types::{PyDict, PyList}; + region_col: Option<&str>, +) -> PyResult> { + use flate2::read::MultiGzDecoder; + use std::collections::HashSet; use std::fs::File; use std::io::{BufRead, BufReader}; - // Parse method - let analysis_method = match method { - "single" => analysis::AnalysisMethod::Single, - "linear" => analysis::AnalysisMethod::Linear, - "per_donor" => analysis::AnalysisMethod::PerDonor, - _ => { + let file = File::open(tsv_path) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to open TSV: {e}")))?; + let mut reader: Box = if tsv_path.ends_with(".gz") { + Box::new(BufReader::new(MultiGzDecoder::new(file))) + } else { + Box::new(BufReader::new(file)) + }; + + let mut header_line = String::new(); + if reader + .read_line(&mut header_line) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to read TSV header: {e}")))? + == 0 + { + return Err(PyRuntimeError::new_err("Count TSV is empty")); + } + let headers: Vec<&str> = header_line + .trim_end_matches(['\r', '\n']) + .split('\t') + .collect(); + let unique: HashSet<&str> = headers.iter().copied().collect(); + if unique.len() != headers.len() { + return Err(PyRuntimeError::new_err( + "Count TSV contains duplicate column names", + )); + } + for name in ["chrom", "pos", "ref", "alt", "ref_count", "alt_count"] { + if !unique.contains(name) { return Err(PyRuntimeError::new_err(format!( - "Unknown method: {}", - method - ))) + "Required column '{name}' is missing from count TSV" + ))); } + } + let index = |name: &str| { + headers + .iter() + .position(|header| *header == name) + .expect("required header checked") }; - - let config = analysis::AnalysisConfig { - min_count, - pseudocount, - method: analysis_method, - phased, + let chrom_idx = index("chrom"); + let pos_idx = index("pos"); + let ref_idx = index("ref"); + let alt_idx = index("alt"); + let ref_count_idx = index("ref_count"); + let alt_count_idx = index("alt_count"); + let gt_idx = headers.iter().position(|header| *header == "GT"); + let region_idx = match region_col { + Some(name) => Some( + headers + .iter() + .position(|header| *header == name) + .ok_or_else(|| { + PyRuntimeError::new_err(format!( + "region_col '{name}' not found in header columns: {headers:?}" + )) + })?, + ), + None => None, }; - // Read TSV file - let file = File::open(tsv_path) - .map_err(|e| PyRuntimeError::new_err(format!("Failed to open TSV: {}", e)))?; - let reader = BufReader::new(file); - let mut variants = Vec::new(); - - // Resolve columns from the header. Count tables can carry pos0, GT, feature, - // parent, sample, or donor metadata, so fixed offsets are not reliable. - let mut chrom_idx: usize = 0; - let mut pos_idx: usize = 1; - let mut ref_count_idx: usize = 4; - let mut alt_count_idx: usize = 5; - let mut gt_idx: Option = None; - // Region column index (use input region names instead of chrom_pos format) - let mut region_idx: Option = None; - // Optional donor/sample column (used by the per_donor model) - let mut sample_idx: Option = None; - let mut header_seen = false; - - for (line_idx, line) in reader.lines().enumerate() { - let line = - line.map_err(|e| PyRuntimeError::new_err(format!("Failed to read line: {}", e)))?; - - if !header_seen { - header_seen = true; - let headers: Vec<&str> = line.split('\t').collect(); - let required_column = |name: &str| { - headers - .iter() - .position(|header| *header == name) - .ok_or_else(|| { - PyRuntimeError::new_err(format!( - "Required column '{}' not found in header columns: {:?}", - name, headers - )) - }) - }; - chrom_idx = required_column("chrom")?; - pos_idx = required_column("pos")?; - ref_count_idx = required_column("ref_count")?; - alt_count_idx = required_column("alt_count")?; - gt_idx = headers.iter().position(|header| *header == "GT"); - // Detect an optional `sample` column by name (per_donor input) - sample_idx = headers.iter().position(|h| *h == "sample"); - // Resolve grouping column from the region_col argument (mirror Python get_imbalance): - // Some(name) => group by that column (error if the column is absent) - // None => per-variant grouping (chrom_pos), ignoring any "region" column - region_idx = match ®ion_col { - Some(name) => match headers.iter().position(|h| *h == name.as_str()) { - Some(ix) => Some(ix), - None => { - return Err(PyRuntimeError::new_err(format!( - "region_col '{}' not found in header columns: {:?}", - name, headers - ))) - } - }, - None => None, - }; - continue; - } - - if line.trim().is_empty() { - continue; + for (offset, line) in reader.lines().enumerate() { + let line_number = offset + 2; + let line = line.map_err(|e| { + PyRuntimeError::new_err(format!("Failed to read line {line_number}: {e}")) + })?; + if line.is_empty() { + return Err(PyRuntimeError::new_err(format!( + "Blank row at line {line_number}" + ))); } let fields: Vec<&str> = line.split('\t').collect(); - let field = |index: usize, name: &str| { - fields.get(index).copied().ok_or_else(|| { - PyRuntimeError::new_err(format!( - "Missing '{}' value on TSV line {}", - name, - line_idx + 1 - )) - }) - }; - - let chrom = field(chrom_idx, "chrom")?.to_string(); - let pos = field(pos_idx, "pos")? - .parse::() - .map_err(|e| PyRuntimeError::new_err(format!("Invalid pos: {}", e)))?; - let ref_count = field(ref_count_idx, "ref_count")? - .parse::() - .map_err(|e| PyRuntimeError::new_err(format!("Invalid ref_count: {}", e)))?; - let alt_count = field(alt_count_idx, "alt_count")? - .parse::() - .map_err(|e| PyRuntimeError::new_err(format!("Invalid alt_count: {}", e)))?; - - // region_col=Some => use that column verbatim; region_col=None => per-variant chrom_pos. - // The chrom_pos label matches Python's df["chrom"] + "_" + df["pos"] for SNV-solo parity. - let region = match region_idx { - Some(ri) => field(ri, "region")?.to_string(), - None => format!("{}_{}", chrom, pos), - }; - - let sample = sample_idx.and_then(|si| fields.get(si).map(|s| s.to_string())); - - // Parse the phased genotype (GT) column when present. - // Only fully-phased hets map to a value; everything else (unphased, - // homozygous, missing) is None. Emits ONLY Some(0)/Some(1)/None so - // downstream `1 - g` on u8 never underflows. - // - // AHO parity: only numeric VCF-style phased heterozygotes are trusted. - // Allele-string GT values such as "A|G" are not interpreted as - // cross-SNV haplotype phase by the archived Python implementation. - let gt: Option = gt_idx.and_then(|gi| { - let gt_str = fields.get(gi)?; - parse_numeric_phased_gt(gt_str) - }); + if fields.len() != headers.len() { + return Err(PyRuntimeError::new_err(format!( + "Line {line_number} has {} fields; expected {}", + fields.len(), + headers.len() + ))); + } + let chrom = fields[chrom_idx].to_string(); + let ref_allele = fields[ref_idx]; + let alt_allele = fields[alt_idx]; + if chrom.is_empty() || ref_allele.is_empty() || alt_allele.is_empty() { + return Err(PyRuntimeError::new_err(format!( + "Empty chromosome, REF, or ALT value at line {line_number}" + ))); + } + if ref_allele == alt_allele || alt_allele.contains(',') { + return Err(PyRuntimeError::new_err(format!( + "Line {line_number} is not a biallelic REF/ALT observation" + ))); + } + let pos = fields[pos_idx].parse::().map_err(|e| { + PyRuntimeError::new_err(format!("Invalid pos at line {line_number}: {e}")) + })?; + if pos == 0 { + return Err(PyRuntimeError::new_err(format!( + "Position must be one-based at line {line_number}" + ))); + } + let ref_count = fields[ref_count_idx].parse::().map_err(|e| { + PyRuntimeError::new_err(format!("Invalid ref_count at line {line_number}: {e}")) + })?; + let alt_count = fields[alt_count_idx].parse::().map_err(|e| { + PyRuntimeError::new_err(format!("Invalid alt_count at line {line_number}: {e}")) + })?; + let region = region_idx + .map(|idx| fields[idx].to_string()) + .unwrap_or_else(|| format!("{chrom}_{pos}")); + if region.is_empty() { + return Err(PyRuntimeError::new_err(format!( + "Empty region at line {line_number}" + ))); + } + let gt = gt_idx.and_then(|idx| parse_numeric_phased_gt(fields[idx])); variants.push(analysis::VariantCounts { chrom, pos, ref_count, alt_count, region, - sample, gt, }); } + Ok(variants) +} - // Run analysis - let results = analysis::analyze_imbalance(variants, &config) - .map_err(|e| PyRuntimeError::new_err(format!("Analysis failed: {}", e)))?; +fn results_to_py<'py>( + py: Python<'py>, + results: Vec, +) -> PyResult> { + use pyo3::types::{PyDict, PyList}; - // Convert to Python list of dicts let py_list = PyList::empty(py); - for result in results { let py_dict = PyDict::new(py); py_dict.set_item("region", result.region)?; @@ -446,8 +468,146 @@ fn analyze_imbalance( py_dict.set_item("fdr_pval", result.fdr_pval)?; py_list.append(py_dict)?; } + Ok(py_list) +} + +#[pyfunction] +#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, method="single", phased=false, region_col=None))] +fn analyze_imbalance( + py: Python, + tsv_path: &str, + min_count: u32, + pseudocount: u32, + method: &str, + phased: bool, + region_col: Option, +) -> PyResult> { + let config = analysis::AnalysisConfig { + min_count, + pseudocount, + method: parse_analysis_method(method)?, + phased, + }; + let variants = read_analysis_variants(tsv_path, region_col.as_deref())?; + let output = analysis::analyze_imbalance_detailed(variants, &config) + .map_err(|e| PyRuntimeError::new_err(format!("Analysis failed: {e}")))?; + Ok(results_to_py(py, output.results)?.unbind().into_any()) +} + +/// Fit balanced-null dispersion without testing allelic effects. +#[pyfunction] +#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, method="single", phased=false, region_col=None))] +fn fit_imbalance_dispersion( + py: Python, + tsv_path: &str, + min_count: u32, + pseudocount: u32, + method: &str, + phased: bool, + region_col: Option, +) -> PyResult> { + use pyo3::types::PyDict; + + let analysis_method = parse_analysis_method(method)?; + let config = analysis::AnalysisConfig { + min_count, + pseudocount, + method: analysis_method, + phased, + }; + let variants = read_analysis_variants(tsv_path, region_col.as_deref())?; + let fit = analysis::fit_imbalance_dispersion(variants, &config) + .map_err(|e| PyRuntimeError::new_err(format!("Dispersion fitting failed: {e}")))?; + + let result = PyDict::new(py); + result.set_item("method", method)?; + result.set_item("rho", fit.parameters.rho())?; + result.set_item( + "linear_d1", + fit.parameters.linear_params().map(|params| params.0), + )?; + result.set_item( + "linear_d2", + fit.parameters.linear_params().map(|params| params.1), + )?; + result.set_item("n_observations", fit.n_observations)?; + Ok(result.unbind().into_any()) +} + +/// Analyze one independent count table and return dispersion metadata. +/// +/// Supplying `rho` (single) or `linear_d1`/`linear_d2` (linear) runs a +/// fixed-nuisance test. Omitting them retains the legacy fit-and-test behavior. +#[pyfunction] +#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, phased=false, region_col=None, method="single", rho=None, linear_d1=None, linear_d2=None, exact_snv=None))] +#[allow(clippy::too_many_arguments)] +fn analyze_imbalance_run( + py: Python, + tsv_path: &str, + min_count: u32, + pseudocount: u32, + phased: bool, + region_col: Option, + method: &str, + rho: Option, + linear_d1: Option, + linear_d2: Option, + exact_snv: Option, +) -> PyResult> { + use pyo3::types::PyDict; + + let analysis_method = parse_analysis_method(method)?; + let fixed_parameters = fixed_dispersion_parameters(analysis_method, rho, linear_d1, linear_d2)?; + let fixed_nuisance = fixed_parameters.is_some(); + let exact_snv = exact_snv.unwrap_or(fixed_nuisance && region_col.is_none()); + if exact_snv && !fixed_nuisance { + return Err(PyRuntimeError::new_err( + "exact_snv=True requires nuisance parameters from fit_imbalance_dispersion()", + )); + } + let config = analysis::AnalysisConfig { + min_count, + pseudocount, + method: analysis_method, + phased, + }; + let variants = read_analysis_variants(tsv_path, region_col.as_deref())?; + let output = match fixed_parameters { + Some(parameters) => analysis::analyze_imbalance_with_fixed_dispersion( + variants, &config, parameters, exact_snv, + ), + None => analysis::analyze_imbalance_detailed(variants, &config), + } + .map_err(|e| PyRuntimeError::new_err(format!("Analysis failed: {e}")))?; + if output.results.is_empty() { + return Err(PyRuntimeError::new_err( + "No variants passed the count threshold; no results were produced", + )); + } - Ok(py_list.unbind().into_any()) + let result = PyDict::new(py); + result.set_item("results", results_to_py(py, output.results)?)?; + result.set_item("method", method)?; + result.set_item("rho", output.rho)?; + result.set_item("linear_d1", output.linear_params.map(|params| params.0))?; + result.set_item("linear_d2", output.linear_params.map(|params| params.1))?; + result.set_item("n_observations", output.n_observations)?; + result.set_item("requested_phased", phased)?; + result.set_item("effective_phased", output.effective_phased)?; + result.set_item( + "nuisance_source", + if fixed_nuisance { "fixed" } else { "fitted" }, + )?; + result.set_item("exact_snv", exact_snv)?; + result.set_item( + "pvalue_method", + if exact_snv { + "beta_binomial_exact_two_sided" + } else { + "chi_square_lrt" + }, + )?; + Ok(result.unbind().into_any()) } // ============================================================================ @@ -875,7 +1035,7 @@ fn parse_intersect_bed_multi( // Convert sample_alleles to list of tuples let alleles_list = PyList::empty(py); for (h1, h2) in &span.sample_alleles { - let tuple = pyo3::types::PyTuple::new(py, [h1.as_str(), h2.as_str()])?; + let tuple = pyo3::types::PyTuple::new(py, &[h1.as_str(), h2.as_str()])?; alleles_list.append(&tuple)?; } span_dict.set_item("sample_alleles", alleles_list)?; @@ -972,6 +1132,8 @@ fn sum_as_string(a: usize, b: usize) -> PyResult { /// - analyze_imbalance: Fast beta-binomial analysis for AI detection (IMPLEMENTED) #[pymodule] fn wasp2_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + // Legacy test function m.add_function(wrap_pyfunction!(sum_as_string, m)?)?; @@ -1015,6 +1177,8 @@ fn wasp2_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { // Analysis module (beta-binomial allelic imbalance detection) m.add_function(wrap_pyfunction!(analyze_imbalance, m)?)?; + m.add_function(wrap_pyfunction!(fit_imbalance_dispersion, m)?)?; + m.add_function(wrap_pyfunction!(analyze_imbalance_run, m)?)?; Ok(()) } From 844180adc3d7b308d83f33e58c4aa2f270846469 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 13:45:45 -0700 Subject: [PATCH 02/22] feat: add donor-local imbalance orchestration --- README.md | 37 + docs/source/api/analysis.rst | 8 + docs/source/user_guide/analysis.rst | 38 +- src/analysis/__main__.py | 107 ++- src/analysis/run_analysis.py | 58 +- src/analysis/run_analysis_per_donor.py | 1063 ++++++++++++++++++++++++ src/wasp2_rust/__init__.pyi | 54 ++ 7 files changed, 1350 insertions(+), 15 deletions(-) create mode 100644 src/analysis/run_analysis_per_donor.py diff --git a/README.md b/README.md index eac466b..117fa8f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,43 @@ to retain peak identifiers for feature-level analysis. wasp2-analyze find-imbalance counts.tsv -o ai_results.tsv ``` +### Donor-local cohort analysis + +Analyze the locked count directory (or its `count_manifest.json` or +`counts.tsv.gz`) with an explicit unit. SNV analysis rejects `--phased` and tests +each variant independently within each donor: + +```bash +wasp2-analyze find-imbalance cohort_counts \ + --scope per-donor --unit snv --min-donor-observations 50 \ + -o atac_snv_results.tsv +``` + +`--dispersion-scope per-donor` is the default. `--dispersion-scope global` +fits one nuisance model over unique eligible donor-SNV rows and reuses that exact +fit for every included donor; testing and BH correction remain donor-local. Both +`single` and `linear` dispersion models are supported. + +Feature analysis preserves one SNV in every overlapping feature. `peak` remains +an alias for `feature`: + +```bash +wasp2-count count-cohort donors.tsv variants.vcf.gz feature_counts \ + --unit feature --regions peaks.bed + +wasp2-analyze find-imbalance feature_counts \ + --scope per-donor --unit feature --model linear --phased \ + -o atac_feature_results.tsv +``` + +The donor-local route defaults to pseudocount 0. It writes +`atac_feature_results.tsv`, `atac_feature_results.dispersion.tsv`, +`atac_feature_results.qc.tsv`, and `atac_feature_results.provenance.json` and +refuses to overwrite any of them. For standalone legacy count tables only, +`sample` is accepted and normalized to `donor_id`; providing both columns is an +error. Use `--expected-manifest-sha256` to require an externally trusted locked +bundle manifest. + ## Single-Cell Example ```bash diff --git a/docs/source/api/analysis.rst b/docs/source/api/analysis.rst index a4e09d3..c7b3f81 100644 --- a/docs/source/api/analysis.rst +++ b/docs/source/api/analysis.rst @@ -44,6 +44,14 @@ run_analysis :undoc-members: :show-inheritance: +run_analysis_per_donor +~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: analysis.run_analysis_per_donor + :members: + :undoc-members: + :show-inheritance: + run_analysis_sc ~~~~~~~~~~~~~~~ diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index d9edc97..d642144 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -33,6 +33,41 @@ Useful options: * ``--region_col``: explicit region column name if auto-detection is not desired * ``--groupby``: group on an alternate annotation column, such as a parent gene column +Donor-Local Bulk Analysis +------------------------- + +Locked cohort count bundles use ``donor_id`` as the donor identity. Analyze the +bundle directory, its ``count_manifest.json``, or its locked ``counts.tsv.gz``: + +.. code-block:: bash + + wasp2-analyze find-imbalance cohort_counts \ + --scope per-donor \ + --unit snv \ + --model single \ + --dispersion-scope per-donor \ + --min-donor-observations 50 \ + --output donor_snv.tsv + +The supported units are ``snv`` and ``feature``; ``peak`` is an alias for +``feature``. SNV analysis is unphased. Feature analysis may use ``--phased`` and +uses the locked ``region`` column, or an explicit ``--region-col`` for a +standalone count table. Overlapping feature memberships are retained for feature +tests but contribute only one donor-SNV row to dispersion fitting. + +``--dispersion-scope per-donor`` fits ``single`` or ``linear`` nuisance +parameters independently for each included donor. ``--dispersion-scope global`` +fits unique eligible donor-SNV rows once and reuses the returned fit ID and +parameters unchanged in each donor run. In both modes, effect inference and +Benjamini-Hochberg correction are independent within each donor. + +The donor-local route defaults to pseudocount 0 and creates result, +``.dispersion.tsv``, ``.qc.tsv``, and ``.provenance.json`` artifacts without +overwriting existing files. ``--expected-manifest-sha256`` can pin the locked +bundle manifest to an external digest. Standalone legacy tables may use +``sample`` instead of ``donor_id``; a locked bundle may not, and a table with +both columns is rejected. + Single-Cell Analysis -------------------- @@ -81,7 +116,8 @@ Notes Defaults and contracts ---------------------- -- ``pseudocount`` defaults to **1** (added to both ``ref_count`` and +- ``pseudocount`` defaults to **1** on the legacy route and **0** with + ``--scope per-donor``. On the legacy route it is added to both ``ref_count`` and ``alt_count`` — Laplace-style shrinkage toward :math:`\mu = 0.5`, conservative under :math:`H_0`). - ``min_count`` defaults to **10**; regions with :math:`N < 10` are dropped. diff --git a/src/analysis/__main__.py b/src/analysis/__main__.py index 31c69a7..13ff437 100644 --- a/src/analysis/__main__.py +++ b/src/analysis/__main__.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Annotated import typer @@ -5,8 +6,26 @@ from wasp2.cli import create_version_callback, verbosity_callback from .run_analysis import run_ai_analysis -from .run_analysis_sc import run_ai_analysis_sc -from .run_compare_ai import run_ai_comparison + + +class AnalysisScopeChoice(str, Enum): + per_donor = "per-donor" + + +class AnalysisUnitChoice(str, Enum): + snv = "snv" + feature = "feature" + peak = "peak" + + +class AnalysisModelChoice(str, Enum): + single = "single" + linear = "linear" + + +class DispersionScopeChoice(str, Enum): + per_donor = "per-donor" + global_ = "global" def _get_analysis_deps() -> dict[str, str]: @@ -55,7 +74,10 @@ def main( @app.command() def find_imbalance( - counts: Annotated[str, typer.Argument(help="Count File")], + counts: Annotated[ + str, + typer.Argument(help="Count table, locked count bundle, or count_manifest.json."), + ], min: Annotated[ int | None, typer.Option( @@ -71,7 +93,10 @@ def find_imbalance( "--ps", "--pseudo", "--pseudocount", - help="Pseudocount added when measuring allelic imbalance. (Default: 1)", + help=( + "Pseudocount added when measuring allelic imbalance. Defaults to 0 for " + "--scope per-donor and 1 for the legacy route." + ), ), ] = None, out_file: Annotated[ @@ -97,22 +122,22 @@ def find_imbalance( ), ] = False, model: Annotated[ - str | None, + AnalysisModelChoice | None, typer.Option( "-m", "--model", - help=( - "Model used for measuring optimization parameter when finding imbalance. " - "HIGHLY RECOMMENDED TO LEAVE AS DEFAULT FOR SINGLE DISPERSION MODEL. " - "Choice of 'single' or 'linear'. (Default: 'single')" - ), + help=("Dispersion model used for imbalance inference. Defaults to 'single'."), ), ] = None, region_col: Annotated[ str | None, typer.Option( + "--region-col", "--region_col", - help="Name of region column for current data. 'region' for ATAC-seq. Attribute name for RNA-seq. (Default: Auto-parses if none provided)", + help=( + "Feature identifier column. Locked feature bundles use 'region'; standalone " + "feature tables require this option." + ), ), ] = None, groupby: Annotated[ @@ -142,17 +167,61 @@ def find_imbalance( ), ), ] = False, + scope: Annotated[ + AnalysisScopeChoice | None, + typer.Option( + "--scope", + help=( + "Analyze each donor independently. Requires --unit and canonical donor_id; " + "standalone legacy tables may use sample instead." + ), + ), + ] = None, + unit: Annotated[ + AnalysisUnitChoice | None, + typer.Option( + "--unit", + help="Statistical unit: snv or feature. peak is an alias for feature.", + ), + ] = None, + dispersion_scope: Annotated[ + DispersionScopeChoice, + typer.Option( + "--dispersion-scope", + help="Fit dispersion globally across eligible donor-SNV rows or per donor.", + ), + ] = DispersionScopeChoice.per_donor, + min_donor_observations: Annotated[ + int, + typer.Option( + "--min-donor-observations", + min=1, + help="Exclude donors with fewer eligible unique SNV observations.", + ), + ] = 50, + expected_manifest_sha256: Annotated[ + str | None, + typer.Option( + "--expected-manifest-sha256", + help="Require a locked bundle manifest with this SHA-256 digest.", + ), + ] = None, ) -> None: run_ai_analysis( count_file=counts, min_count=min, - model=model, + model=model.value if model is not None else None, pseudocount=pseudocount, phased=phased, out_file=out_file, region_col=region_col, groupby=groupby, per_variant=per_variant, + scope=scope.value if scope is not None else None, + unit=unit.value if unit is not None else None, + dispersion_scope=dispersion_scope.value, + min_donor_observations=min_donor_observations, + expected_manifest_sha256=expected_manifest_sha256, ) @@ -234,6 +303,13 @@ def find_imbalance_sc( ), ] = None, ) -> None: + try: + from .run_analysis_sc import run_ai_analysis_sc + except (ImportError, ModuleNotFoundError) as error: + raise typer.BadParameter( + "Single-cell analysis requires: pip install 'wasp2[single-cell]'" + ) from error + run_ai_analysis_sc( count_file=counts, bc_map=bc_map, @@ -325,6 +401,13 @@ def compare_imbalance( ), ] = None, ) -> None: + try: + from .run_compare_ai import run_ai_comparison + except (ImportError, ModuleNotFoundError) as error: + raise typer.BadParameter( + "Single-cell comparison requires: pip install 'wasp2[single-cell]'" + ) from error + run_ai_comparison( count_file=counts, bc_map=bc_map, diff --git a/src/analysis/run_analysis.py b/src/analysis/run_analysis.py index 94a33e5..acde790 100644 --- a/src/analysis/run_analysis.py +++ b/src/analysis/run_analysis.py @@ -13,6 +13,8 @@ import pandas as pd +from .run_analysis_per_donor import run_per_donor_analysis + # Rust analysis (required; no Python fallback) try: from wasp2_rust import analyze_imbalance as rust_analyze_imbalance @@ -143,7 +145,12 @@ def run_ai_analysis( region_col: str | None = None, groupby: str | None = None, per_variant: bool = False, -) -> None: + scope: str | None = None, + unit: str | None = None, + dispersion_scope: str = "per-donor", + min_donor_observations: int = 50, + expected_manifest_sha256: str | None = None, +) -> dict[str, Path] | None: """Run allelic imbalance analysis pipeline. Parameters @@ -153,7 +160,8 @@ def run_ai_analysis( min_count : int | None, optional Minimum total count threshold, by default 10. pseudocount : int | None, optional - Pseudocount to add, by default 1. + Pseudocount to add. Defaults to 0 for donor-local analysis and 1 for + the legacy route. phased : bool | None, optional Use phased genotype information, by default False. model : str | None, optional @@ -167,12 +175,57 @@ def run_ai_analysis( per_variant : bool, optional Test each SNV independently (per-variant) instead of grouping by a region column. Forces per-variant even when a region column is present. Default False. + scope : str | None, optional + Set to ``"per-donor"`` to use donor-local orchestration. + unit : str | None, optional + Donor-local analysis unit: ``"snv"`` or ``"feature"``; ``"peak"`` is an alias. + dispersion_scope : str, optional + Fit dispersion globally or independently per donor, by default ``"per-donor"``. + min_donor_observations : int, optional + Minimum eligible unique donor-SNV rows required for inference, by default 50. + expected_manifest_sha256 : str | None, optional + External trust anchor for a locked count bundle manifest. Raises ------ RuntimeError If Rust analysis extension is not available. """ + if model not in {None, "single", "linear"}: + raise ValueError("model must be 'single' or 'linear'") + if scope is not None: + if scope != "per-donor": + raise ValueError("scope must be 'per-donor'") + if unit not in {"snv", "feature", "peak"}: + raise ValueError( + "Per-donor analysis requires --unit snv or --unit feature (--unit peak is an alias)" + ) + if groupby is not None or per_variant: + raise ValueError("Per-donor analysis cannot use --groupby or --per-variant") + if dispersion_scope not in {"global", "per-donor"}: + raise ValueError("dispersion_scope must be 'global' or 'per-donor'") + output = out_file if out_file is not None else str(Path.cwd() / "ai_results.tsv") + return run_per_donor_analysis( + count_file, + output, + unit=unit, + model="single" if model is None else model, + dispersion_scope=dispersion_scope, + region_col=region_col, + phased=bool(phased), + min_count=10 if min_count is None else min_count, + pseudocount=0 if pseudocount is None else pseudocount, + min_donor_observations=min_donor_observations, + expected_manifest_sha256=expected_manifest_sha256, + ) + + if unit is not None: + raise ValueError("--unit requires --scope per-donor in the bulk analysis command") + if dispersion_scope != "per-donor": + raise ValueError("--dispersion-scope requires --scope per-donor") + if expected_manifest_sha256 is not None: + raise ValueError("--expected-manifest-sha256 requires --scope per-donor") + # Fail closed: --groupby is not supported by the Rust backend. It only re-keys the grouping # column (region_col = groupby), so the identical result is obtained with --region_col . # Erroring prevents silently returning feature-level results when parent-level was requested. @@ -218,3 +271,4 @@ def run_ai_analysis( # Write results ai_df.to_csv(ai_files.out_file, sep="\t", header=True, index=False) + return None diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py new file mode 100644 index 0000000..ef253fb --- /dev/null +++ b/src/analysis/run_analysis_per_donor.py @@ -0,0 +1,1063 @@ +"""Donor-local allelic imbalance orchestration.""" + +from __future__ import annotations + +import csv +import gzip +import hashlib +import inspect +import json +import math +import os +import shutil +import tempfile +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Literal, cast + +import pandas as pd + +try: + from wasp2_rust import analyze_imbalance_run as rust_analyze_imbalance_run + from wasp2_rust import fit_imbalance_dispersion as rust_fit_imbalance_dispersion +except ImportError: + rust_analyze_imbalance_run = None + rust_fit_imbalance_dispersion = None + +AnalysisUnit = Literal["snv", "feature"] +AnalysisUnitInput = Literal["snv", "feature", "peak"] +DispersionScope = Literal["global", "per-donor"] +Model = Literal["single", "linear"] + +UTC = timezone.utc +SCHEMA_VERSION = "wasp2.donor-local-analysis.v1" +COMMON_COLUMNS = { + "donor_id", + "chrom", + "pos", + "ref", + "alt", + "ref_count", + "alt_count", +} +SNV_COLUMNS = ["donor_id", "chrom", "pos", "ref", "alt"] +RUST_RESULT_COLUMNS = [ + "region", + "ref_count", + "alt_count", + "N", + "snp_count", + "null_ll", + "alt_ll", + "mu", + "lrt", + "pval", + "fdr_pval", +] +STATISTIC_COLUMNS = [ + "ref_count", + "alt_count", + "N", + "snp_count", + "null_ll", + "alt_ll", + "mu", + "lrt", + "pval", + "fdr_pval", +] + + +@dataclass(frozen=True) +class CountInput: + """A verified count table and its optional locked-bundle manifest.""" + + counts: Path + counts_record: dict[str, str | int] + counts_signature: tuple[int, int, int, int] + manifest: Path | None + manifest_record: dict[str, str | int] | None + manifest_signature: tuple[int, int, int, int] | None + manifest_data: dict[str, Any] | None + + @property + def bundled(self) -> bool: + return self.manifest is not None + + +@dataclass(frozen=True) +class DispersionFit: + """Validated nuisance-parameter fit returned by Rust.""" + + payload: Mapping[str, Any] + fit_id: str + method: Model + rho: float | None + linear_d1: float | None + linear_d2: float | None + n_observations: int + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _signature(path: Path) -> tuple[int, int, int, int]: + stat = path.stat() + return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns + + +def _file_record(path: Path) -> tuple[dict[str, str | int], tuple[int, int, int, int]]: + resolved = path.resolve(strict=True) + before = _signature(resolved) + digest = _sha256(resolved) + if _signature(resolved) != before: + raise RuntimeError(f"Input changed while it was being hashed: {resolved}") + stat = resolved.stat() + return ( + { + "path": str(resolved), + "sha256": digest, + "size_bytes": stat.st_size, + "mtime_utc": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(), + }, + before, + ) + + +def _package_version() -> str: + try: + return version("wasp2") + except PackageNotFoundError: + return "unknown" + + +def _normalize_unit(unit: AnalysisUnitInput) -> AnalysisUnit: + normalized = "feature" if unit == "peak" else unit + if normalized not in {"snv", "feature"}: + raise ValueError("unit must be 'snv' or 'feature' ('peak' is an alias)") + return cast(AnalysisUnit, normalized) + + +def _validate_sha256(value: str, label: str) -> str: + normalized = value.lower() + if len(normalized) != 64 or any( + character not in "0123456789abcdef" for character in normalized + ): + raise ValueError(f"{label} must be a 64-character hexadecimal SHA-256 digest") + return normalized + + +def _locate_manifest(source: Path) -> Path | None: + if source.is_dir(): + manifest = source / "count_manifest.json" + if not manifest.is_file(): + raise ValueError(f"Count bundle directory has no count_manifest.json: {source}") + return manifest + if source.name == "count_manifest.json": + return source + adjacent = source.parent / "count_manifest.json" + return adjacent if adjacent.is_file() else None + + +def _resolve_count_input( + count_file: str | Path, + *, + unit: AnalysisUnit, + expected_manifest_sha256: str | None, +) -> CountInput: + source = Path(count_file).expanduser() + if not source.exists(): + raise FileNotFoundError(f"Count input does not exist: {source}") + source = source.resolve(strict=True) + manifest_path = _locate_manifest(source) + + if manifest_path is None: + if source.is_dir(): + raise ValueError(f"Count input is not a regular file: {source}") + if expected_manifest_sha256 is not None: + raise ValueError( + "--expected-manifest-sha256 requires a locked count bundle; " + "standalone count tables have no manifest trust anchor" + ) + counts_record, counts_signature = _file_record(source) + return CountInput( + counts=source, + counts_record=counts_record, + counts_signature=counts_signature, + manifest=None, + manifest_record=None, + manifest_signature=None, + manifest_data=None, + ) + + from counting.run_counting_cohort import verify_count_bundle + + expected = ( + _validate_sha256(expected_manifest_sha256, "expected manifest SHA-256") + if expected_manifest_sha256 is not None + else None + ) + manifest_data = verify_count_bundle( + manifest_path, + expected_manifest_sha256=expected, + ) + bundle_unit = manifest_data.get("analysis_unit") + if bundle_unit != unit: + raise ValueError( + f"Requested analysis unit {unit!r} does not match locked count bundle unit " + f"{bundle_unit!r}" + ) + + counts_path = (manifest_path.parent / "counts.tsv.gz").resolve(strict=True) + if source.is_file() and source != manifest_path.resolve(strict=True) and source != counts_path: + raise ValueError( + f"Input is inside a count bundle but is not its locked counts table: {source}" + ) + counts_record, counts_signature = _file_record(counts_path) + manifest_record, manifest_signature = _file_record(manifest_path) + return CountInput( + counts=counts_path, + counts_record=counts_record, + counts_signature=counts_signature, + manifest=manifest_path.resolve(strict=True), + manifest_record=manifest_record, + manifest_signature=manifest_signature, + manifest_data=manifest_data, + ) + + +def _read_header(path: Path) -> list[str]: + opener = gzip.open if path.name.endswith(".gz") else open + with opener(path, "rt", newline="") as handle: + header = next(csv.reader(handle, delimiter="\t"), None) + if not header: + raise ValueError("Count table is empty or has no header") + if any(not column for column in header): + raise ValueError("Count table contains an empty column name") + duplicate_columns = sorted({column for column in header if header.count(column) > 1}) + if duplicate_columns: + raise ValueError(f"Count table contains duplicate columns: {duplicate_columns}") + return header + + +def _coerce_nonnegative_integer(frame: pd.DataFrame, column: str, maximum: int) -> None: + values = frame[column].astype(str) + valid = values.str.fullmatch(r"[0-9]+") + if not valid.all(): + raise ValueError(f"Column {column!r} contains invalid integer values") + integers = values.map(int) + if integers.gt(maximum).any(): + raise ValueError(f"Column {column!r} contains values larger than {maximum}") + frame[column] = integers.astype("uint64") + + +def _validate_text_column(frame: pd.DataFrame, column: str) -> None: + values = frame[column].astype(str) + invalid = values.eq("") | values.str.contains(r"[\t\r\n]", regex=True) + if invalid.any(): + raise ValueError(f"Column {column!r} contains missing, empty, or multiline values") + frame[column] = values + + +def _normalize_genotypes(frame: pd.DataFrame) -> dict[str, int]: + if "GT" not in frame.columns: + raise ValueError("Phased feature analysis requires a GT column") + gt = frame["GT"].astype(str) + ref = frame["ref"] + alt = frame["alt"] + numeric_forward = gt.eq("0|1") + numeric_reverse = gt.eq("1|0") + allele_forward = gt.eq(ref + "|" + alt) + allele_reverse = gt.eq(alt + "|" + ref) + valid = numeric_forward | numeric_reverse | allele_forward | allele_reverse + if not valid.all(): + bad = frame.loc[~valid, ["donor_id", "chrom", "pos", "ref", "alt", "GT"]].iloc[0] + raise ValueError( + f"Phased GT must be exact 0|1, 1|0, REF|ALT, or ALT|REF; found {bad.to_dict()}" + ) + forward = numeric_forward | allele_forward + frame["GT"] = forward.map({True: "0|1", False: "1|0"}) + return { + "forward": int(forward.sum()), + "reverse": int((~forward).sum()), + "converted_from_alleles": int((allele_forward | allele_reverse).sum()), + } + + +def _first_conflicting_group( + frame: pd.DataFrame, + identity: Sequence[str], + compared: Sequence[str], +) -> tuple[Any, ...] | None: + conflicts = ( + frame.groupby(list(identity), sort=False, dropna=False)[list(compared)] + .nunique(dropna=False) + .gt(1) + .any(axis=1) + ) + if not conflicts.any(): + return None + key = conflicts.index[conflicts][0] + return key if isinstance(key, tuple) else (key,) + + +def _load_counts( + count_input: CountInput, + *, + unit: AnalysisUnit, + region_col: str | None, + phased: bool, +) -> tuple[pd.DataFrame, str | None, dict[str, Any]]: + header = _read_header(count_input.counts) + has_donor_id = "donor_id" in header + has_sample = "sample" in header + if has_donor_id and has_sample: + raise ValueError("Count table cannot contain both donor_id and legacy sample columns") + if count_input.bundled and not has_donor_id: + raise ValueError("Locked count bundles require the canonical donor_id column") + if not has_donor_id and not has_sample: + raise ValueError("Count table requires a donor_id column") + + selected_region = region_col + if unit == "snv" and region_col is not None: + raise ValueError( + "SNV analysis tests variants independently and does not accept --region-col" + ) + if unit == "feature": + if count_input.bundled and region_col not in {None, "region"}: + raise ValueError("Locked feature bundles require the canonical region column") + selected_region = "region" if region_col is None and count_input.bundled else region_col + if not selected_region: + raise ValueError( + "Standalone feature analysis requires --region-col; locked feature bundles use region" + ) + if selected_region in COMMON_COLUMNS or selected_region == "sample": + raise ValueError( + f"Feature region column conflicts with a reserved column: {selected_region}" + ) + + required = set(COMMON_COLUMNS) + if not has_donor_id: + required.remove("donor_id") + required.add("sample") + if selected_region is not None: + required.add(selected_region) + missing = sorted(required.difference(header)) + if missing: + raise ValueError(f"Count table is missing required columns: {missing}") + + frame = pd.read_csv( + count_input.counts, + sep="\t", + dtype=str, + keep_default_na=False, + na_filter=False, + low_memory=False, + ) + if frame.empty: + raise ValueError("Count table contains no observations") + if has_sample: + frame = frame.rename(columns={"sample": "donor_id"}) + + text_columns = ["donor_id", "chrom", "ref", "alt"] + if selected_region is not None: + text_columns.append(selected_region) + for column in text_columns: + _validate_text_column(frame, column) + for column in ["pos", "ref_count", "alt_count"]: + _coerce_nonnegative_integer(frame, column, 2**32 - 1) + if frame["pos"].eq(0).any(): + raise ValueError("SNV positions must be one-based") + invalid_alleles = ( + frame["ref"].eq(frame["alt"]) + | frame["ref"].str.len().ne(1) + | frame["alt"].str.len().ne(1) + | frame["alt"].str.contains(",", regex=False) + ) + if invalid_alleles.any(): + raise ValueError("Donor-local analysis requires biallelic SNV observations") + + site_identity = ["donor_id", "chrom", "pos"] + allele_conflict = _first_conflicting_group(frame, site_identity, ["ref", "alt"]) + if allele_conflict is not None: + donor_id, chrom, pos = allele_conflict + raise ValueError(f"Conflicting alleles for donor/SNV {donor_id} {chrom}:{pos}") + + gt_qc = _normalize_genotypes(frame) if phased else None + repeated_values = ["ref_count", "alt_count"] + if phased: + repeated_values.append("GT") + count_conflict = _first_conflicting_group(frame, SNV_COLUMNS, repeated_values) + if count_conflict is not None: + donor_id, chrom, pos, ref, alt = count_conflict + raise ValueError( + f"Conflicting repeated rows for donor/SNV {donor_id} {chrom}:{pos}:{ref}>{alt}" + ) + + raw_rows = len(frame) + identity = [*SNV_COLUMNS, selected_region] if selected_region is not None else SNV_COLUMNS + frame = frame.drop_duplicates(identity, keep="first").copy() + frame["pos"] = frame["pos"].astype("uint32") + frame["ref_count"] = frame["ref_count"].astype("uint32") + frame["alt_count"] = frame["alt_count"].astype("uint32") + frame["_total_count"] = frame["ref_count"].astype("uint64") + frame["alt_count"].astype( + "uint64" + ) + return ( + frame, + selected_region, + { + "raw_rows": raw_rows, + "validated_rows": len(frame), + "exact_duplicate_rows_removed": raw_rows - len(frame), + "donors": int(frame["donor_id"].nunique()), + "unique_donor_snv_rows": int(frame.drop_duplicates(SNV_COLUMNS).shape[0]), + "phase": gt_qc, + "legacy_sample_normalized": has_sample, + }, + ) + + +def _artifact_paths(result_path: Path) -> dict[str, Path]: + if result_path.suffix != ".tsv": + raise ValueError("Donor-local output must use a .tsv filename") + stem = result_path.stem + return { + "results": result_path, + "dispersion": result_path.with_name(f"{stem}.dispersion.tsv"), + "qc": result_path.with_name(f"{stem}.qc.tsv"), + "provenance": result_path.with_name(f"{stem}.provenance.json"), + } + + +def _write_tsv(frame: pd.DataFrame, path: Path, columns: Sequence[str]) -> None: + frame.loc[:, list(columns)].to_csv(path, sep="\t", header=True, index=False) + + +def _finite_float(value: Any, label: str) -> float: + if isinstance(value, bool): + raise RuntimeError(f"Rust returned an invalid {label}") + try: + result = float(value) + except (TypeError, ValueError) as error: + raise RuntimeError(f"Rust returned an invalid {label}") from error + if not math.isfinite(result): + raise RuntimeError(f"Rust returned a non-finite {label}") + return result + + +def _validate_fit(raw_fit: Mapping[str, Any], *, model: Model, expected_rows: int) -> DispersionFit: + fit_id = raw_fit.get("fit_id") + if not isinstance(fit_id, str) or not fit_id: + raise RuntimeError("Rust dispersion fit did not return a non-empty fit_id") + returned_method = raw_fit.get("method", raw_fit.get("model")) + if returned_method != model: + raise RuntimeError( + f"Rust dispersion fit method {returned_method!r} does not match requested {model!r}" + ) + try: + n_observations = int(raw_fit["n_observations"]) + except (KeyError, TypeError, ValueError) as error: + raise RuntimeError("Rust dispersion fit did not return n_observations") from error + if n_observations != expected_rows: + raise RuntimeError( + f"Rust dispersion fit used {n_observations} observations; expected {expected_rows}" + ) + + if model == "single": + rho = _finite_float(raw_fit.get("rho"), "rho") + linear_d1 = None + linear_d2 = None + if raw_fit.get("linear_d1") is not None or raw_fit.get("linear_d2") is not None: + raise RuntimeError("Rust single-dispersion fit returned linear parameters") + else: + rho = None + linear_d1 = _finite_float(raw_fit.get("linear_d1"), "linear_d1") + linear_d2 = _finite_float(raw_fit.get("linear_d2"), "linear_d2") + if raw_fit.get("rho") is not None: + raise RuntimeError("Rust linear-dispersion fit returned scalar rho") + return DispersionFit( + payload=dict(raw_fit), + fit_id=fit_id, + method=model, + rho=rho, + linear_d1=linear_d1, + linear_d2=linear_d2, + n_observations=n_observations, + ) + + +def _generated_fit_id( + path: Path, + fit: Mapping[str, Any], + *, + model: Model, + min_count: int, + pseudocount: int, +) -> str: + def float_identity(value: Any) -> str | None: + return None if value is None else _finite_float(value, "dispersion parameter").hex() + + identity = { + "input_sha256": _sha256(path), + "method": model, + "min_count": min_count, + "pseudocount": pseudocount, + "rho": float_identity(fit.get("rho")), + "linear_d1": float_identity(fit.get("linear_d1")), + "linear_d2": float_identity(fit.get("linear_d2")), + "n_observations": fit.get("n_observations"), + } + encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _fit_dispersion( + frame: pd.DataFrame, + path: Path, + *, + model: Model, + min_count: int, + pseudocount: int, +) -> DispersionFit: + fitter = rust_fit_imbalance_dispersion + if fitter is None: + raise RuntimeError("Rust dispersion-fitting API is not available") + fit_columns = [*SNV_COLUMNS, "ref_count", "alt_count"] + _write_tsv(frame, path, fit_columns) + kwargs: dict[str, Any] = { + "min_count": min_count, + "pseudocount": pseudocount, + "method": model, + } + try: + parameters = inspect.signature(fitter).parameters + except (TypeError, ValueError): + parameters = {} + if "phased" in parameters: + kwargs["phased"] = False + if "region_col" in parameters: + # Rust deduplicates on region. Including donor_id preserves repeated + # cohort SNVs as distinct donor-SNV observations in a global fit. + kwargs["region_col"] = "donor_id" + raw_fit = fitter(str(path), **kwargs) + if not isinstance(raw_fit, Mapping): + raise RuntimeError("Rust dispersion-fitting API returned an invalid payload") + payload = dict(raw_fit) + payload.setdefault( + "fit_id", + _generated_fit_id( + path, + payload, + model=model, + min_count=min_count, + pseudocount=pseudocount, + ), + ) + return _validate_fit(payload, model=model, expected_rows=len(frame)) + + +def _call_analyzer_with_fit( + path: Path, + *, + fit: DispersionFit, + model: Model, + phased: bool, + region_col: str | None, + min_count: int, + pseudocount: int, +) -> Mapping[str, Any]: + analyzer = rust_analyze_imbalance_run + if analyzer is None: + raise RuntimeError("Rust donor-analysis API is not available") + kwargs: dict[str, Any] = { + "min_count": min_count, + "pseudocount": pseudocount, + "phased": phased, + "region_col": region_col, + "method": model, + } + try: + parameters = inspect.signature(analyzer).parameters + except (TypeError, ValueError): + parameters = {} + accepts_keywords = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() + ) + explicit_fixed_parameters = {"rho", "linear_d1", "linear_d2"}.issubset(parameters) + if "dispersion_fit" in parameters or (accepts_keywords and not explicit_fixed_parameters): + kwargs["dispersion_fit"] = fit.payload + run = analyzer(str(path), **kwargs) + elif explicit_fixed_parameters: + kwargs.update( + { + "rho": fit.rho, + "linear_d1": fit.linear_d1, + "linear_d2": fit.linear_d2, + } + ) + if "exact_snv" in parameters: + kwargs["exact_snv"] = region_col is None + raw_run = analyzer(str(path), **kwargs) + if not isinstance(raw_run, Mapping): + raise RuntimeError("Rust donor-analysis API returned an invalid payload") + returned_parameters = ( + raw_run.get("rho"), + raw_run.get("linear_d1"), + raw_run.get("linear_d2"), + ) + if returned_parameters != (fit.rho, fit.linear_d1, fit.linear_d2): + raise RuntimeError("Rust donor-analysis API changed the fixed dispersion parameters") + if raw_run.get("nuisance_source") not in {None, "fixed"}: + raise RuntimeError( + "Rust donor-analysis API refitted the supplied dispersion parameters" + ) + run = {**raw_run, "dispersion_fit": fit.payload} + else: + raise RuntimeError("Rust donor-analysis API cannot accept a reusable dispersion fit") + if not isinstance(run, Mapping): + raise RuntimeError("Rust donor-analysis API returned an invalid payload") + return run + + +def _run_fit_values(run: Mapping[str, Any]) -> tuple[Any, Any, Any, Any]: + nested = run.get("dispersion_fit") + source = nested if isinstance(nested, Mapping) else run + fit_id = source.get("fit_id", source.get("dispersion_fit_id")) + return fit_id, source.get("rho"), source.get("linear_d1"), source.get("linear_d2") + + +def _require_exact_fit_echo(run: Mapping[str, Any], fit: DispersionFit, donor_id: str) -> None: + observed = _run_fit_values(run) + expected = (fit.fit_id, fit.rho, fit.linear_d1, fit.linear_d2) + if observed != expected: + raise RuntimeError( + f"Rust donor run for {donor_id} did not reuse the requested dispersion fit exactly: " + f"expected {expected!r}, observed {observed!r}" + ) + + +def _validate_rust_results(run: Mapping[str, Any], donor_id: str) -> pd.DataFrame: + raw_results = run.get("results") + if not isinstance(raw_results, list) or not raw_results: + raise RuntimeError(f"Rust returned no results for included donor {donor_id}") + results = pd.DataFrame(raw_results) + missing = sorted(set(RUST_RESULT_COLUMNS).difference(results.columns)) + if missing: + raise RuntimeError(f"Rust results for {donor_id} are missing columns: {missing}") + if results["region"].astype(str).duplicated().any(): + raise RuntimeError(f"Rust returned duplicate result identities for donor {donor_id}") + return results.loc[:, RUST_RESULT_COLUMNS].copy() + + +def _prepare_analysis_table( + donor_frame: pd.DataFrame, + path: Path, + *, + unit: AnalysisUnit, + region_col: str | None, + phased: bool, +) -> tuple[str | None, pd.DataFrame]: + columns = ["chrom", "pos", "ref", "alt"] + rust_region_col = None + if phased: + columns.append("GT") + if unit == "feature": + if region_col is None: + raise RuntimeError("Feature analysis has no normalized region column") + columns.append(region_col) + rust_region_col = region_col + columns.extend(["ref_count", "alt_count"]) + _write_tsv(donor_frame, path, columns) + return rust_region_col, donor_frame + + +def _format_donor_results( + run: Mapping[str, Any], + donor_frame: pd.DataFrame, + *, + donor_id: str, + unit: AnalysisUnit, + region_col: str | None, +) -> pd.DataFrame: + results = _validate_rust_results(run, donor_id) + try: + n_observations = int(run["n_observations"]) + except (KeyError, TypeError, ValueError) as error: + raise RuntimeError(f"Rust donor run for {donor_id} omitted n_observations") from error + if n_observations != len(donor_frame): + raise RuntimeError( + f"Rust donor run for {donor_id} used {n_observations} rows; expected {len(donor_frame)}" + ) + + if unit == "snv": + annotations = donor_frame.loc[ + :, ["chrom", "pos", "ref", "alt", "ref_count", "alt_count"] + ].rename( + columns={ + "ref_count": "expected_ref_count", + "alt_count": "expected_alt_count", + } + ) + annotations["region"] = annotations["chrom"] + "_" + annotations["pos"].astype(str) + annotations["snv_id"] = ( + annotations["chrom"] + + ":" + + annotations["pos"].astype(str) + + ":" + + annotations["ref"] + + ":" + + annotations["alt"] + ) + results = annotations.merge(results, on="region", how="inner", validate="one_to_one") + if len(results) != len(annotations): + raise RuntimeError(f"Rust omitted eligible SNV results for donor {donor_id}") + mismatch = ( + results["ref_count"].ne(results["expected_ref_count"]) + | results["alt_count"].ne(results["expected_alt_count"]) + | results["N"].ne(results["expected_ref_count"] + results["expected_alt_count"]) + | results["snp_count"].ne(1) + ) + if mismatch.any(): + raise RuntimeError(f"Rust SNV counts differ from input for donor {donor_id}") + leading_columns = ["donor_id", "snv_id", "chrom", "pos", "ref", "alt"] + results = results.drop(columns=["region", "expected_ref_count", "expected_alt_count"]) + else: + if region_col is None: + raise RuntimeError("Feature analysis has no normalized region column") + expected = ( + donor_frame.groupby(region_col, sort=False) + .agg( + expected_ref_count=("ref_count", "sum"), + expected_alt_count=("alt_count", "sum"), + expected_snp_count=("pos", "size"), + ) + .reset_index() + .rename(columns={region_col: "region"}) + ) + results = expected.merge(results, on="region", how="inner", validate="one_to_one") + if len(results) != len(expected): + raise RuntimeError(f"Rust omitted eligible feature results for donor {donor_id}") + mismatch = ( + results["ref_count"].ne(results["expected_ref_count"]) + | results["alt_count"].ne(results["expected_alt_count"]) + | results["snp_count"].ne(results["expected_snp_count"]) + ) + if mismatch.any(): + raise RuntimeError(f"Rust feature aggregation differs from input for donor {donor_id}") + results = results.drop( + columns=["expected_ref_count", "expected_alt_count", "expected_snp_count"] + ).rename(columns={"region": "feature_id"}) + leading_columns = ["donor_id", "feature_id"] + + results.insert(0, "donor_id", donor_id) + results["significant_q05"] = results["fdr_pval"] < 0.05 + results["significant_q10"] = results["fdr_pval"] < 0.10 + return results.loc[ + :, [*leading_columns, *STATISTIC_COLUMNS, "significant_q05", "significant_q10"] + ] + + +def _fsync_file(path: Path) -> None: + with path.open("rb") as handle: + os.fsync(handle.fileno()) + + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _same_inode(left: Path, right: Path) -> bool: + try: + left_stat = left.stat() + right_stat = right.stat() + except FileNotFoundError: + return False + return (left_stat.st_dev, left_stat.st_ino) == (right_stat.st_dev, right_stat.st_ino) + + +def _publish_no_replace(staged: Mapping[str, Path], destinations: Mapping[str, Path]) -> None: + """Publish sidecars first and the result commit marker last without clobbering.""" + + published: list[tuple[Path, Path]] = [] + try: + for name in ["dispersion", "qc", "provenance", "results"]: + source = staged[name] + destination = destinations[name] + os.link(source, destination, follow_symlinks=False) + published.append((source, destination)) + _fsync_directory(destinations["results"].parent) + except BaseException: + for source, destination in reversed(published): + if _same_inode(source, destination): + destination.unlink() + _fsync_directory(destinations["results"].parent) + raise + + +def _require_unchanged(count_input: CountInput) -> None: + if _signature(count_input.counts) != count_input.counts_signature: + raise RuntimeError("Count input changed while donor-local analysis was running") + if count_input.counts_record["sha256"] != _sha256(count_input.counts): + raise RuntimeError("Count input content changed while donor-local analysis was running") + if count_input.manifest is not None: + if count_input.manifest_signature is None or count_input.manifest_record is None: + raise RuntimeError("Internal error: bundle manifest lock is incomplete") + if _signature(count_input.manifest) != count_input.manifest_signature: + raise RuntimeError("Count manifest changed while donor-local analysis was running") + if count_input.manifest_record["sha256"] != _sha256(count_input.manifest): + raise RuntimeError( + "Count manifest content changed while donor-local analysis was running" + ) + + +def run_per_donor_analysis( + count_file: str | Path, + out_file: str | Path, + *, + unit: AnalysisUnitInput, + model: Model = "single", + dispersion_scope: DispersionScope = "per-donor", + region_col: str | None = None, + phased: bool = False, + min_count: int = 10, + pseudocount: int = 0, + min_donor_observations: int = 50, + expected_manifest_sha256: str | None = None, +) -> dict[str, Path]: + """Run independent donor tests with an explicit reusable dispersion fit.""" + + normalized_unit = _normalize_unit(unit) + if model not in {"single", "linear"}: + raise ValueError("model must be 'single' or 'linear'") + if dispersion_scope not in {"global", "per-donor"}: + raise ValueError("dispersion_scope must be 'global' or 'per-donor'") + if min_count < 0 or pseudocount < 0: + raise ValueError("min_count and pseudocount must be nonnegative") + if min_donor_observations < 1: + raise ValueError("min_donor_observations must be positive") + if normalized_unit == "snv" and phased: + raise ValueError("Per-donor SNV analysis is unphased; --phased is not valid") + + count_input = _resolve_count_input( + count_file, + unit=normalized_unit, + expected_manifest_sha256=expected_manifest_sha256, + ) + frame, normalized_region_col, input_qc = _load_counts( + count_input, + unit=normalized_unit, + region_col=region_col, + phased=phased, + ) + + result_path = Path(os.path.abspath(Path(out_file).expanduser())) + paths = _artifact_paths(result_path) + if not result_path.parent.is_dir(): + raise ValueError(f"Output parent directory does not exist: {result_path.parent}") + existing = [str(path) for path in paths.values() if os.path.lexists(path)] + if existing: + raise FileExistsError(f"Refusing to overwrite donor-local analysis artifacts: {existing}") + if rust_fit_imbalance_dispersion is None or rust_analyze_imbalance_run is None: + raise RuntimeError( + "Rust donor-local analysis APIs are not available. Build the extension with " + "`maturin develop --release` in the WASP2 environment." + ) + + eligible_rows = frame.loc[frame["_total_count"] >= min_count].copy() + fit_rows = ( + eligible_rows.drop_duplicates(SNV_COLUMNS, keep="first") + .sort_values(SNV_COLUMNS, kind="mergesort") + .copy() + ) + donor_qc = ( + frame.groupby("donor_id", sort=True) + .agg( + input_rows=("donor_id", "size"), + unique_snv_rows=("pos", "size"), + ) + .reset_index() + ) + unique_snv_counts = ( + frame.drop_duplicates(SNV_COLUMNS) + .groupby("donor_id", sort=True) + .size() + .rename("unique_snv_rows") + ) + eligible_counts = ( + fit_rows.groupby("donor_id", sort=True).size().rename("eligible_snv_observations") + ) + eligible_test_rows = ( + eligible_rows.groupby("donor_id", sort=True).size().rename("eligible_test_rows") + ) + donor_qc = donor_qc.drop(columns="unique_snv_rows").join(unique_snv_counts, on="donor_id") + donor_qc = donor_qc.join(eligible_counts, on="donor_id").join(eligible_test_rows, on="donor_id") + donor_qc[["eligible_snv_observations", "eligible_test_rows"]] = ( + donor_qc[["eligible_snv_observations", "eligible_test_rows"]].fillna(0).astype(int) + ) + donor_qc["analyzed"] = donor_qc["eligible_snv_observations"] >= min_donor_observations + donor_qc["status"] = donor_qc["analyzed"].map( + {True: "included", False: "excluded_min_observations"} + ) + included_donors = donor_qc.loc[donor_qc["analyzed"], "donor_id"].tolist() + if not included_donors: + raise RuntimeError("No donors met the minimum eligible-SNV observation requirement") + + eligible_rows = eligible_rows.loc[eligible_rows["donor_id"].isin(included_donors)].copy() + fit_rows = fit_rows.loc[fit_rows["donor_id"].isin(included_donors)].copy() + staging = Path(tempfile.mkdtemp(prefix=f".{result_path.stem}.staging-", dir=result_path.parent)) + work = staging / "_work" + work.mkdir() + try: + global_fit = None + if dispersion_scope == "global": + global_fit = _fit_dispersion( + fit_rows, + work / "global_fit.tsv", + model=model, + min_count=min_count, + pseudocount=pseudocount, + ) + + result_frames: list[pd.DataFrame] = [] + dispersion_rows: list[dict[str, Any]] = [] + for donor_id in included_donors: + donor_id = str(donor_id) + donor_rows = eligible_rows.loc[eligible_rows["donor_id"] == donor_id].copy() + donor_fit_rows = fit_rows.loc[fit_rows["donor_id"] == donor_id].copy() + fit = global_fit or _fit_dispersion( + donor_fit_rows, + work / "donor_fit.tsv", + model=model, + min_count=min_count, + pseudocount=pseudocount, + ) + rust_region_col, donor_rows = _prepare_analysis_table( + donor_rows, + work / "donor_counts.tsv", + unit=normalized_unit, + region_col=normalized_region_col, + phased=phased, + ) + run = _call_analyzer_with_fit( + work / "donor_counts.tsv", + fit=fit, + model=model, + phased=phased, + region_col=rust_region_col, + min_count=min_count, + pseudocount=pseudocount, + ) + _require_exact_fit_echo(run, fit, donor_id) + if bool(run.get("requested_phased")) != phased: + raise RuntimeError(f"Rust donor run for {donor_id} changed requested_phased") + if phased and not bool(run.get("effective_phased")): + raise RuntimeError(f"Rust silently fell back to unphased analysis for {donor_id}") + donor_results = _format_donor_results( + run, + donor_rows, + donor_id=donor_id, + unit=normalized_unit, + region_col=normalized_region_col, + ) + result_frames.append(donor_results) + dispersion_rows.append( + { + "donor_id": donor_id, + "dispersion_scope": dispersion_scope, + "fit_id": fit.fit_id, + "model": model, + "rho": fit.rho, + "linear_d1": fit.linear_d1, + "linear_d2": fit.linear_d2, + "fit_observations": fit.n_observations, + "donor_eligible_snv_observations": len(donor_fit_rows), + "result_rows": len(donor_results), + } + ) + + _require_unchanged(count_input) + sort_columns = ( + ["donor_id", "snv_id"] + if normalized_unit == "snv" + else [ + "donor_id", + "feature_id", + ] + ) + results = pd.concat(result_frames, ignore_index=True).sort_values( + sort_columns, kind="mergesort" + ) + dispersion = pd.DataFrame(dispersion_rows).sort_values("donor_id", kind="mergesort") + qc = donor_qc.sort_values("donor_id", kind="mergesort") + + staged_paths = {name: staging / path.name for name, path in paths.items()} + results.to_csv(staged_paths["results"], sep="\t", header=True, index=False) + dispersion.to_csv(staged_paths["dispersion"], sep="\t", header=True, index=False) + qc.to_csv(staged_paths["qc"], sep="\t", header=True, index=False) + for name in ["results", "dispersion", "qc"]: + _fsync_file(staged_paths[name]) + + output_records: dict[str, dict[str, str | int]] = {} + for name in ["results", "dispersion", "qc"]: + record, _ = _file_record(staged_paths[name]) + output_records[name] = {**record, "path": paths[name].name} + provenance = { + "schema_version": SCHEMA_VERSION, + "created_at_utc": datetime.now(UTC).isoformat(), + "software": {"package": "wasp2", "version": _package_version()}, + "analysis": { + "scope": "per-donor", + "unit": normalized_unit, + "model": model, + "dispersion_scope": dispersion_scope, + "region_col": normalized_region_col, + "phased": phased, + "multiple_testing": "Benjamini-Hochberg independently within each donor", + }, + "parameters": { + "min_count": min_count, + "pseudocount": pseudocount, + "min_donor_observations": min_donor_observations, + }, + "inputs": { + "counts": count_input.counts_record, + "count_manifest": count_input.manifest_record, + "count_bundle_schema": ( + count_input.manifest_data.get("schema_version") + if count_input.manifest_data is not None + else None + ), + "qc": input_qc, + }, + "donors": { + "total": len(qc), + "included": int(qc["analyzed"].sum()), + "excluded": int((~qc["analyzed"]).sum()), + }, + "outputs": output_records, + } + staged_paths["provenance"].write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n" + ) + _fsync_file(staged_paths["provenance"]) + _fsync_directory(staging) + _require_unchanged(count_input) + _publish_no_replace(staged_paths, paths) + shutil.rmtree(staging, ignore_errors=True) + return paths + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise diff --git a/src/wasp2_rust/__init__.pyi b/src/wasp2_rust/__init__.pyi index b6b932a..dad5083 100644 --- a/src/wasp2_rust/__init__.pyi +++ b/src/wasp2_rust/__init__.pyi @@ -4,6 +4,8 @@ This module provides high-performance implementations of bottleneck functions for allele-specific analysis in WASP2. """ +__version__: str + from typing import TypedDict class UnifiedStats(TypedDict): @@ -43,6 +45,30 @@ class ImbalanceResult(TypedDict): pval: float fdr_pval: float +class DispersionFit(TypedDict): + """Dispersion parameters fitted from independent observations.""" + + method: str + rho: float | None + linear_d1: float | None + linear_d2: float | None + n_observations: int + +class ImbalanceRun(TypedDict): + """One independent analysis run with fitted or fixed nuisance parameters.""" + + results: list[ImbalanceResult] + method: str + rho: float | None + linear_d1: float | None + linear_d2: float | None + n_observations: int + requested_phased: bool + effective_phased: bool + nuisance_source: str + exact_snv: bool + pvalue_method: str + class VariantSpan(TypedDict): """Variant span information from intersection parsing.""" @@ -561,6 +587,8 @@ def analyze_imbalance( min_count: int = 10, pseudocount: int = 1, method: str = "single", + phased: bool = False, + region_col: str | None = None, ) -> list[ImbalanceResult]: """Analyze allelic imbalance using beta-binomial model. @@ -581,3 +609,29 @@ def analyze_imbalance( List of imbalance results per region. """ ... + +def analyze_imbalance_run( + tsv_path: str, + min_count: int = 10, + pseudocount: int = 1, + phased: bool = False, + region_col: str | None = None, + method: str = "single", + rho: float | None = None, + linear_d1: float | None = None, + linear_d2: float | None = None, + exact_snv: bool | None = None, +) -> ImbalanceRun: + """Analyze one table with optional fixed nuisance parameters.""" + ... + +def fit_imbalance_dispersion( + tsv_path: str, + min_count: int = 10, + pseudocount: int = 1, + method: str = "single", + phased: bool = False, + region_col: str | None = None, +) -> DispersionFit: + """Fit reusable nuisance parameters from independent SNV observations.""" + ... From fd82811869c33a80dff3eb6db730123f6318cfed Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 13:49:26 -0700 Subject: [PATCH 03/22] test: expand donor-local inference coverage --- tests/analysis/test_donor_local_contract.py | 684 ++++++++++++++++++ tests/analysis/test_donor_local_statistics.py | 356 +++++++++ tests/analysis/test_per_donor_peak.py | 140 ++++ tests/analysis/test_per_donor_snv.py | 140 ++++ tests/analysis/test_region_col_modes.py | 60 +- tests/test_extension_version.py | 7 + 6 files changed, 1354 insertions(+), 33 deletions(-) create mode 100644 tests/analysis/test_donor_local_contract.py create mode 100644 tests/analysis/test_donor_local_statistics.py create mode 100644 tests/analysis/test_per_donor_peak.py create mode 100644 tests/analysis/test_per_donor_snv.py create mode 100644 tests/test_extension_version.py diff --git a/tests/analysis/test_donor_local_contract.py b/tests/analysis/test_donor_local_contract.py new file mode 100644 index 0000000..72885c5 --- /dev/null +++ b/tests/analysis/test_donor_local_contract.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pytest +from scipy.stats import false_discovery_control +from typer.testing import CliRunner + +from analysis import __main__ as analysis_cli +from analysis import run_analysis +from analysis import run_analysis_per_donor as donor_analysis + + +def _count_frame(*, overlap: bool = False, exact_duplicate: bool = False) -> pd.DataFrame: + rows: list[dict[str, Any]] = [] + donor_counts = { + "donor_a": [(18, 2), (10, 10), (14, 6), (7, 13)], + "donor_b": [(16, 4), (9, 11), (13, 7), (8, 12)], + } + for donor_id, counts in donor_counts.items(): + for index, (ref_count, alt_count) in enumerate(counts, start=1): + rows.append( + { + "donor_id": donor_id, + "chrom": "chr1", + "pos": index * 100, + "ref": "A", + "alt": "G", + "GT": "0|1" if index % 2 else "1|0", + "region": f"feature_{(index + 1) // 2}", + "ref_count": ref_count, + "alt_count": alt_count, + } + ) + if overlap: + for donor_id in donor_counts: + repeated = next(row.copy() for row in rows if row["donor_id"] == donor_id) + repeated["region"] = "feature_overlap" + rows.append(repeated) + if exact_duplicate: + rows.append(rows[0].copy()) + return pd.DataFrame(rows) + + +def _write_counts( + path: Path, + *, + donor_column: str = "donor_id", + overlap: bool = False, + exact_duplicate: bool = False, +) -> pd.DataFrame: + frame = _count_frame(overlap=overlap, exact_duplicate=exact_duplicate) + if donor_column != "donor_id": + frame = frame.rename(columns={"donor_id": donor_column}) + frame.to_csv(path, sep="\t", index=False) + return frame + + +def _default_pvalues(frame: pd.DataFrame, groups: list[tuple[str, pd.DataFrame]]) -> list[float]: + del frame + return [min(0.95, 0.01 * (index + 1)) for index in range(len(groups))] + + +def _install_backend( + monkeypatch: pytest.MonkeyPatch, + *, + pvalues: Callable[[pd.DataFrame, list[tuple[str, pd.DataFrame]]], list[float]] = ( + _default_pvalues + ), + scalar_rho: float = float.fromhex("0x1.23456789abcdep-5"), + linear_d1: float = float.fromhex("-0x1.3456789abcdep+1"), + linear_d2: float = float.fromhex("-0x1.23456789abcdep-6"), +) -> dict[str, list[dict[str, Any]]]: + calls: dict[str, list[dict[str, Any]]] = {"fit": [], "analyze": []} + + def fake_fit(path: str, *, min_count: int, pseudocount: int, method: str): + frame = pd.read_csv(path, sep="\t") + donors = tuple(frame["donor_id"].drop_duplicates()) + fit_id = f"{method}:{','.join(donors)}" + payload = { + "fit_id": fit_id, + "method": method, + "rho": scalar_rho if method == "single" else None, + "linear_d1": linear_d1 if method == "linear" else None, + "linear_d2": linear_d2 if method == "linear" else None, + "n_observations": len(frame), + } + calls["fit"].append( + { + "frame": frame.copy(), + "min_count": min_count, + "pseudocount": pseudocount, + "method": method, + "payload": payload, + } + ) + return payload + + def fake_analyze( + path: str, + *, + min_count: int, + pseudocount: int, + phased: bool, + region_col: str | None, + method: str, + dispersion_fit: dict[str, Any], + ): + frame = pd.read_csv(path, sep="\t") + if region_col is None: + groups = [ + (f"{row.chrom}_{row.pos}", frame.iloc[[index]]) + for index, row in enumerate(frame.itertuples(index=False)) + ] + else: + groups = [ + (str(region), group.copy()) + for region, group in frame.groupby(region_col, sort=False) + ] + raw_pvalues = pvalues(frame, groups) + adjusted = false_discovery_control(raw_pvalues, method="bh") + results = [] + for (region, group), pval, fdr_pval in zip(groups, raw_pvalues, adjusted): + ref_count = int(group["ref_count"].sum()) + alt_count = int(group["alt_count"].sum()) + results.append( + { + "region": region, + "ref_count": ref_count, + "alt_count": alt_count, + "N": ref_count + alt_count, + "snp_count": len(group), + "null_ll": -3.0, + "alt_ll": -2.0, + "mu": 0.6, + "lrt": 2.0, + "pval": float(pval), + "fdr_pval": float(fdr_pval), + } + ) + calls["analyze"].append( + { + "frame": frame.copy(), + "min_count": min_count, + "pseudocount": pseudocount, + "phased": phased, + "region_col": region_col, + "method": method, + "dispersion_fit": dispersion_fit, + } + ) + return { + "results": results, + "n_observations": len(frame), + "requested_phased": phased, + "effective_phased": phased, + "dispersion_fit": dispersion_fit, + } + + monkeypatch.setattr(donor_analysis, "rust_fit_imbalance_dispersion", fake_fit) + monkeypatch.setattr(donor_analysis, "rust_analyze_imbalance_run", fake_analyze) + return calls + + +@pytest.mark.unit +@pytest.mark.parametrize("unit,region_col", [("snv", None), ("feature", "region")]) +@pytest.mark.parametrize("dispersion_scope", ["global", "per-donor"]) +@pytest.mark.parametrize("model", ["single", "linear"]) +def test_model_scope_unit_matrix( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + unit: str, + region_col: str | None, + dispersion_scope: str, + model: str, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts) + calls = _install_backend(monkeypatch) + + paths = donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit=unit, + model=model, + dispersion_scope=dispersion_scope, + region_col=region_col, + phased=False, + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + assert len(calls["fit"]) == (1 if dispersion_scope == "global" else 2) + assert len(calls["analyze"]) == 2 + assert {call["method"] for call in calls["fit"]} == {model} + assert {call["method"] for call in calls["analyze"]} == {model} + assert {call["region_col"] for call in calls["analyze"]} == {region_col} + assert all("donor_id" not in call["frame"] for call in calls["analyze"]) + assert all(call["pseudocount"] == 0 for call in calls["fit"] + calls["analyze"]) + + fit_ids = [call["dispersion_fit"]["fit_id"] for call in calls["analyze"]] + assert (len(set(fit_ids)) == 1) is (dispersion_scope == "global") + results = pd.read_csv(paths["results"], sep="\t") + identity = "snv_id" if unit == "snv" else "feature_id" + assert results.groupby("donor_id")[identity].count().to_dict() == { + "donor_a": 4 if unit == "snv" else 2, + "donor_b": 4 if unit == "snv" else 2, + } + dispersion = pd.read_csv(paths["dispersion"], sep="\t") + assert dispersion["model"].eq(model).all() + assert dispersion["dispersion_scope"].eq(dispersion_scope).all() + + +@pytest.mark.unit +@pytest.mark.parametrize("model", ["single", "linear"]) +def test_global_fit_payload_is_reused_exactly( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + model: str, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts) + calls = _install_backend(monkeypatch) + + donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + model=model, + dispersion_scope="global", + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + fitted = calls["fit"][0]["payload"] + reused = [call["dispersion_fit"] for call in calls["analyze"]] + assert reused[0] is reused[1] + assert reused[0] == fitted + assert reused[0]["fit_id"] == fitted["fit_id"] + parameter_names = ["rho"] if model == "single" else ["linear_d1", "linear_d2"] + for parameter in parameter_names: + assert float(reused[0][parameter]).hex() == float(fitted[parameter]).hex() + assert float(reused[1][parameter]).hex() == float(fitted[parameter]).hex() + + +@pytest.mark.unit +def test_bh_correction_and_likelihood_calls_are_donor_local( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts) + expected = { + "donor_a": np.array([0.001, 0.04, 0.2, 0.8]), + "donor_b": np.array([0.03, 0.031, 0.032, 0.9]), + } + + def donor_pvalues(frame: pd.DataFrame, groups: list[tuple[str, pd.DataFrame]]) -> list[float]: + donor_id = "donor_a" if int(frame.iloc[0]["ref_count"]) == 18 else "donor_b" + assert len(groups) == len(expected[donor_id]) + return expected[donor_id].tolist() + + calls = _install_backend(monkeypatch, pvalues=donor_pvalues) + paths = donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + model="single", + dispersion_scope="per-donor", + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + assert len(calls["analyze"]) == 2 + assert all(len(call["frame"]) == 4 for call in calls["analyze"]) + results = pd.read_csv(paths["results"], sep="\t") + observed_qvalues = [] + for donor_id, raw_pvalues in expected.items(): + donor_results = results.loc[results["donor_id"] == donor_id].sort_values("snv_id") + np.testing.assert_allclose(donor_results["pval"], raw_pvalues, rtol=0, atol=0) + expected_qvalues = false_discovery_control(raw_pvalues, method="bh") + np.testing.assert_allclose(donor_results["fdr_pval"], expected_qvalues, rtol=0, atol=1e-15) + observed_qvalues.extend(donor_results["fdr_pval"].tolist()) + pooled_qvalues = false_discovery_control(np.concatenate(list(expected.values())), method="bh") + assert not np.allclose(observed_qvalues, pooled_qvalues) + + +@pytest.mark.unit +@pytest.mark.parametrize("donor_column", ["donor_id", "sample"]) +def test_canonical_and_legacy_donor_columns_normalize_to_donor_id( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + donor_column: str, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts, donor_column=donor_column) + _install_backend(monkeypatch) + + paths = donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + results = pd.read_csv(paths["results"], sep="\t") + assert "donor_id" in results + assert "sample" not in results + assert sorted(results["donor_id"].unique()) == ["donor_a", "donor_b"] + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["inputs"]["qc"]["legacy_sample_normalized"] is (donor_column == "sample") + + +@pytest.mark.unit +def test_both_donor_columns_fail_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + counts = tmp_path / "counts.tsv" + frame = _write_counts(counts) + frame["sample"] = frame["donor_id"] + frame.to_csv(counts, sep="\t", index=False) + _install_backend(monkeypatch) + + with pytest.raises(ValueError, match="both donor_id and legacy sample"): + donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + min_donor_observations=1, + ) + + +@pytest.mark.unit +def test_overlapping_feature_membership_is_preserved_but_fit_once_per_snv( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts, overlap=True, exact_duplicate=True) + calls = _install_backend(monkeypatch) + + paths = donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="feature", + model="single", + dispersion_scope="global", + region_col="region", + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + assert len(calls["fit"]) == 1 + assert len(calls["fit"][0]["frame"]) == 8 + assert [len(call["frame"]) for call in calls["analyze"]] == [5, 5] + results = pd.read_csv(paths["results"], sep="\t") + assert results.groupby("donor_id")["feature_id"].apply(set).to_dict() == { + "donor_a": {"feature_1", "feature_2", "feature_overlap"}, + "donor_b": {"feature_1", "feature_2", "feature_overlap"}, + } + provenance = json.loads(paths["provenance"].read_text()) + input_qc = provenance["inputs"]["qc"] + assert input_qc["raw_rows"] == 11 + assert input_qc["validated_rows"] == 10 + assert input_qc["exact_duplicate_rows_removed"] == 1 + assert input_qc["unique_donor_snv_rows"] == 8 + + +@pytest.mark.unit +@pytest.mark.parametrize("conflict_column,replacement", [("alt", "T"), ("ref_count", 17)]) +def test_conflicting_repeated_feature_rows_fail_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + conflict_column: str, + replacement: Any, +) -> None: + counts = tmp_path / "counts.tsv" + frame = _count_frame(overlap=True) + overlap_index = frame.index[ + (frame["donor_id"] == "donor_a") & (frame["region"] == "feature_overlap") + ][0] + frame.loc[overlap_index, conflict_column] = replacement + frame.to_csv(counts, sep="\t", index=False) + calls = _install_backend(monkeypatch) + + with pytest.raises(ValueError, match="Conflicting"): + donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + min_count=0, + min_donor_observations=1, + ) + assert calls == {"fit": [], "analyze": []} + + +@pytest.mark.unit +def test_phased_snv_rejected_before_input_io(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _install_backend(monkeypatch) + with pytest.raises(ValueError, match="SNV analysis is unphased"): + donor_analysis.run_per_donor_analysis( + "does-not-exist.tsv", + "results.tsv", + unit="snv", + phased=True, + ) + assert calls == {"fit": [], "analyze": []} + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_model", ["per_donor", "per-donor", "single-global", "mystery"]) +def test_unknown_and_legacy_pooled_models_fail_before_input_io( + monkeypatch: pytest.MonkeyPatch, + bad_model: str, +) -> None: + calls = _install_backend(monkeypatch) + with pytest.raises(ValueError, match="single.*linear"): + donor_analysis.run_per_donor_analysis( + "does-not-exist.tsv", + "results.tsv", + unit="snv", + model=bad_model, + ) + assert calls == {"fit": [], "analyze": []} + + +@pytest.mark.unit +def test_per_donor_route_defaults_to_raw_counts_without_pseudocount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, Any] = {} + + def fake_run(count_file: str | Path, out_file: str | Path, **kwargs: Any): + observed.update({"count_file": count_file, "out_file": out_file, **kwargs}) + return {"results": Path(out_file)} + + monkeypatch.setattr(run_analysis, "run_per_donor_analysis", fake_run) + run_analysis.run_ai_analysis( + "counts.tsv", + out_file="results.tsv", + scope="per-donor", + unit="feature", + region_col="region", + model="linear", + dispersion_scope="global", + ) + + assert observed["unit"] == "feature" + assert observed["model"] == "linear" + assert observed["dispersion_scope"] == "global" + assert observed["pseudocount"] == 0 + + +@pytest.mark.unit +@pytest.mark.parametrize("unit,region_col", [("snv", None), ("feature", "region")]) +@pytest.mark.parametrize("dispersion_scope", ["global", "per-donor"]) +@pytest.mark.parametrize("model", ["single", "linear"]) +def test_run_ai_analysis_forwards_model_scope_unit_matrix( + monkeypatch: pytest.MonkeyPatch, + unit: str, + region_col: str | None, + dispersion_scope: str, + model: str, +) -> None: + observed: dict[str, Any] = {} + + def fake_run(count_file: str | Path, out_file: str | Path, **kwargs: Any): + observed.update({"count_file": count_file, "out_file": out_file, **kwargs}) + return {"results": Path(out_file)} + + monkeypatch.setattr(run_analysis, "run_per_donor_analysis", fake_run) + run_analysis.run_ai_analysis( + "counts.tsv", + out_file="results.tsv", + scope="per-donor", + unit=unit, + region_col=region_col, + model=model, + dispersion_scope=dispersion_scope, + ) + + assert observed["unit"] == unit + assert observed["region_col"] == region_col + assert observed["model"] == model + assert observed["dispersion_scope"] == dispersion_scope + assert observed["pseudocount"] == 0 + + +@pytest.mark.unit +def test_console_interface_forwards_donor_local_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, Any] = {} + + def fake_run(**kwargs: Any) -> None: + observed.update(kwargs) + + monkeypatch.setattr(analysis_cli, "run_ai_analysis", fake_run) + manifest_digest = "a" * 64 + result = CliRunner().invoke( + analysis_cli.app, + [ + "find-imbalance", + "count-bundle", + "--scope", + "per-donor", + "--unit", + "feature", + "--model", + "linear", + "--dispersion-scope", + "global", + "--region-col", + "region", + "--pseudocount", + "0", + "--min-donor-observations", + "12", + "--expected-manifest-sha256", + manifest_digest, + "-o", + "results.tsv", + ], + ) + + assert result.exit_code == 0, result.output + assert observed["count_file"] == "count-bundle" + assert observed["out_file"] == "results.tsv" + assert observed["scope"] == "per-donor" + assert observed["unit"] == "feature" + assert observed["model"] == "linear" + assert observed["dispersion_scope"] == "global" + assert observed["pseudocount"] == 0 + assert observed["min_donor_observations"] == 12 + assert observed["expected_manifest_sha256"] == manifest_digest + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_model", ["per_donor", "per-donor", "single-global", "mystery"]) +def test_legacy_global_route_rejects_unknown_models( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + bad_model: str, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts) + monkeypatch.setattr(run_analysis, "rust_analyze_imbalance", lambda *args, **kwargs: []) + + with pytest.raises(ValueError, match="single.*linear"): + run_analysis.run_ai_analysis( + counts, + out_file=tmp_path / "results.tsv", + model=bad_model, + per_variant=True, + ) + + +@pytest.mark.unit +def test_retired_cohort_shared_surface_is_absent() -> None: + assert importlib.util.find_spec("analysis.run_analysis_cohort_shared") is None + assert not hasattr(run_analysis, "run_cohort_shared_snv_analysis") + with pytest.raises(ValueError, match="scope must be 'per-donor'"): + run_analysis.run_ai_analysis( + "does-not-exist.tsv", + scope="cohort-shared", + unit="snv", + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("unit,region_col", [("snv", None), ("feature", "region")]) +def test_sidecar_and_provenance_schema( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + unit: str, + region_col: str | None, +) -> None: + counts = tmp_path / "counts.tsv" + _write_counts(counts) + _install_backend(monkeypatch) + paths = donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit=unit, + model="linear", + dispersion_scope="global", + region_col=region_col, + min_count=0, + pseudocount=0, + min_donor_observations=1, + ) + + assert set(paths) == {"results", "dispersion", "qc", "provenance"} + assert all(path.is_file() for path in paths.values()) + result_prefix = ( + ["donor_id", "snv_id", "chrom", "pos", "ref", "alt"] + if unit == "snv" + else ["donor_id", "feature_id"] + ) + assert pd.read_csv(paths["results"], sep="\t").columns.tolist() == [ + *result_prefix, + "ref_count", + "alt_count", + "N", + "snp_count", + "null_ll", + "alt_ll", + "mu", + "lrt", + "pval", + "fdr_pval", + "significant_q05", + "significant_q10", + ] + assert pd.read_csv(paths["dispersion"], sep="\t").columns.tolist() == [ + "donor_id", + "dispersion_scope", + "fit_id", + "model", + "rho", + "linear_d1", + "linear_d2", + "fit_observations", + "donor_eligible_snv_observations", + "result_rows", + ] + assert pd.read_csv(paths["qc"], sep="\t").columns.tolist() == [ + "donor_id", + "input_rows", + "unique_snv_rows", + "eligible_snv_observations", + "eligible_test_rows", + "analyzed", + "status", + ] + + provenance = json.loads(paths["provenance"].read_text()) + assert set(provenance) == { + "schema_version", + "created_at_utc", + "software", + "analysis", + "parameters", + "inputs", + "donors", + "outputs", + } + assert provenance["schema_version"] == donor_analysis.SCHEMA_VERSION + datetime.fromisoformat(provenance["created_at_utc"]) + assert provenance["analysis"] == { + "scope": "per-donor", + "unit": unit, + "model": "linear", + "dispersion_scope": "global", + "region_col": region_col, + "phased": False, + "multiple_testing": "Benjamini-Hochberg independently within each donor", + } + assert provenance["parameters"] == { + "min_count": 0, + "pseudocount": 0, + "min_donor_observations": 1, + } + assert provenance["donors"] == {"total": 2, "included": 2, "excluded": 0} + assert set(provenance["outputs"]) == {"results", "dispersion", "qc"} + for name, record in provenance["outputs"].items(): + assert set(record) == {"path", "sha256", "size_bytes", "mtime_utc"} + assert record["path"] == paths[name].name + assert record["sha256"] == hashlib.sha256(paths[name].read_bytes()).hexdigest() + assert record["size_bytes"] == paths[name].stat().st_size + datetime.fromisoformat(record["mtime_utc"]) diff --git a/tests/analysis/test_donor_local_statistics.py b/tests/analysis/test_donor_local_statistics.py new file mode 100644 index 0000000..42d99b2 --- /dev/null +++ b/tests/analysis/test_donor_local_statistics.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pytest +from scipy.optimize import minimize_scalar +from scipy.special import expit, logsumexp +from scipy.stats import betabinom, chi2, false_discovery_control + +from analysis import run_analysis_per_donor as donor_analysis + +RHO_EPSILON = 1e-10 + + +def _statistical_counts() -> pd.DataFrame: + rows: list[dict[str, Any]] = [] + counts = { + "donor_a": [(18, 2), (22, 8), (32, 8), (14, 6), (24, 26), (11, 9)], + "donor_b": [(16, 4), (18, 12), (29, 11), (8, 12), (27, 23), (9, 11)], + } + for donor_id, donor_counts in counts.items(): + for index, (ref_count, alt_count) in enumerate(donor_counts, start=1): + rows.append( + { + "donor_id": donor_id, + "chrom": "chr1", + "pos": index * 100, + "ref": "A", + "alt": "G", + "GT": "0|1" if index % 2 else "1|0", + "region": f"feature_{(index + 1) // 2}", + "ref_count": ref_count, + "alt_count": alt_count, + } + ) + return pd.DataFrame(rows) + + +@pytest.fixture +def rust_backend(monkeypatch: pytest.MonkeyPatch): + wasp2_rust = pytest.importorskip("wasp2_rust") + required = ["fit_imbalance_dispersion", "analyze_imbalance_run", "analyze_imbalance"] + if not all(hasattr(wasp2_rust, name) for name in required): + pytest.skip("worktree Rust extension needs rebuilding") + monkeypatch.setattr( + donor_analysis, + "rust_fit_imbalance_dispersion", + wasp2_rust.fit_imbalance_dispersion, + ) + monkeypatch.setattr( + donor_analysis, + "rust_analyze_imbalance_run", + wasp2_rust.analyze_imbalance_run, + ) + return wasp2_rust + + +def _rho_at_depth(dispersion: pd.Series, total: int) -> float: + if dispersion["model"] == "single": + rho = float(dispersion["rho"]) + else: + linear = float(dispersion["linear_d1"]) + total * float(dispersion["linear_d2"]) + rho = float(expit(np.clip(linear, -10.0, 10.0))) + return float(np.clip(rho, RHO_EPSILON, 1.0 - RHO_EPSILON)) + + +def _logpmf(probability: float, rho: float, ref_count: int, total: int) -> float: + if probability <= 0.0 or probability >= 1.0: + return -np.inf + alpha = probability * (1.0 - rho) / rho + beta = (1.0 - probability) * (1.0 - rho) / rho + return float(betabinom.logpmf(ref_count, total, alpha, beta)) + + +def _exact_two_sided_pvalue(ref_count: int, total: int, rho: float) -> float: + outcomes = np.arange(total + 1) + alpha = 0.5 * (1.0 - rho) / rho + log_probabilities = betabinom.logpmf(outcomes, total, alpha, alpha) + observed_deviation = abs(2 * ref_count - total) + tail = np.abs(2 * outcomes - total) >= observed_deviation + return float(np.exp(logsumexp(log_probabilities[tail]) - logsumexp(log_probabilities))) + + +def _single_snv_oracle(row: pd.Series, dispersion: pd.Series) -> dict[str, float]: + ref_count = int(row["ref_count"]) + alt_count = int(row["alt_count"]) + total = ref_count + alt_count + rho = _rho_at_depth(dispersion, total) + null_ll = _logpmf(0.5, rho, ref_count, total) + fit = minimize_scalar( + lambda probability: -_logpmf(probability, rho, ref_count, total), + bounds=(0.0, 1.0), + method="bounded", + options={"xatol": 1e-10}, + ) + assert fit.success + alt_ll = -float(fit.fun) + lrt = max(0.0, 2.0 * (alt_ll - null_ll)) + return { + "null_ll": null_ll, + "alt_ll": alt_ll, + "mu": float(fit.x), + "lrt": lrt, + "pval": _exact_two_sided_pvalue(ref_count, total, rho), + } + + +def _unphased_feature_oracle(frame: pd.DataFrame, dispersion: pd.Series) -> dict[str, float]: + observations = [] + for row in frame.sort_values(["chrom", "pos", "ref_count", "alt_count"]).itertuples(): + total = int(row.ref_count + row.alt_count) + observations.append((int(row.ref_count), total, _rho_at_depth(dispersion, total))) + null_ll = sum(_logpmf(0.5, rho, ref_count, total) for ref_count, total, rho in observations) + + def negative_log_likelihood(probability: float) -> float: + log_likelihood = 0.0 + for ref_count, total, rho in observations: + orientations = [ + _logpmf(probability, rho, ref_count, total), + _logpmf(1.0 - probability, rho, ref_count, total), + ] + log_likelihood += float(logsumexp(orientations) - np.log(2.0)) + return -log_likelihood + + fit = minimize_scalar( + negative_log_likelihood, + bounds=(0.5, 1.0), + method="bounded", + options={"xatol": 1e-10}, + ) + assert fit.success + alt_ll = -float(fit.fun) + lrt = max(0.0, 2.0 * (alt_ll - null_ll)) + return { + "null_ll": null_ll, + "alt_ll": alt_ll, + "mu": float(fit.x), + "lrt": lrt, + "pval": float(chi2.sf(lrt, 1)), + } + + +def _oracle_results( + counts: pd.DataFrame, + dispersion: pd.DataFrame, + *, + unit: str, +) -> pd.DataFrame: + rows = [] + for donor_id, donor_counts in counts.groupby("donor_id", sort=True): + donor_dispersion = dispersion.loc[dispersion["donor_id"] == donor_id].iloc[0] + if unit == "snv": + for row in donor_counts.sort_values(["chrom", "pos"]).to_dict("records"): + series = pd.Series(row) + rows.append( + { + "donor_id": donor_id, + "identity": (f"{row['chrom']}:{row['pos']}:{row['ref']}:{row['alt']}"), + **_single_snv_oracle(series, donor_dispersion), + } + ) + else: + for feature_id, feature in donor_counts.groupby("region", sort=True): + rows.append( + { + "donor_id": donor_id, + "identity": feature_id, + **_unphased_feature_oracle(feature, donor_dispersion), + } + ) + oracle = pd.DataFrame(rows) + oracle["fdr_pval"] = oracle.groupby("donor_id", sort=False)["pval"].transform( + lambda values: false_discovery_control(values.to_numpy(), method="bh") + ) + return oracle.sort_values(["donor_id", "identity"]).reset_index(drop=True) + + +@pytest.mark.rust +@pytest.mark.parametrize("unit,region_col", [("snv", None), ("feature", "region")]) +@pytest.mark.parametrize("dispersion_scope", ["global", "per-donor"]) +@pytest.mark.parametrize("model", ["single", "linear"]) +def test_model_scope_unit_matrix_matches_independent_scipy_oracle( + tmp_path: Path, + rust_backend: Any, + unit: str, + region_col: str | None, + dispersion_scope: str, + model: str, +) -> None: + del rust_backend + counts = _statistical_counts() + count_path = tmp_path / "counts.tsv" + counts.to_csv(count_path, sep="\t", index=False) + paths = donor_analysis.run_per_donor_analysis( + count_path, + tmp_path / f"{unit}-{model}-{dispersion_scope}.tsv", + unit=unit, + model=model, + dispersion_scope=dispersion_scope, + region_col=region_col, + phased=False, + min_count=0, + pseudocount=0, + min_donor_observations=4, + ) + + observed = pd.read_csv(paths["results"], sep="\t") + identity_column = "snv_id" if unit == "snv" else "feature_id" + observed = observed.rename(columns={identity_column: "identity"}).sort_values( + ["donor_id", "identity"] + ) + observed = observed.reset_index(drop=True) + dispersion = pd.read_csv(paths["dispersion"], sep="\t") + oracle = _oracle_results(counts, dispersion, unit=unit) + + assert observed[["donor_id", "identity"]].equals(oracle[["donor_id", "identity"]]) + for column in ["null_ll", "alt_ll", "mu", "lrt", "pval", "fdr_pval"]: + np.testing.assert_allclose( + observed[column], + oracle[column], + rtol=3e-5, + atol=3e-5, + err_msg=f"{model}/{dispersion_scope}/{unit}: {column}", + ) + assert observed["ref_count"].sum() == counts["ref_count"].sum() + assert observed["alt_count"].sum() == counts["alt_count"].sum() + if dispersion_scope == "global": + assert dispersion["fit_id"].nunique() == 1 + parameters = ["rho"] if model == "single" else ["linear_d1", "linear_d2"] + assert all(dispersion[column].nunique() == 1 for column in parameters) + else: + assert dispersion["fit_id"].nunique() == 2 + + +def _run_legacy( + rust_backend: Any, + path: Path, + *, + method: str, + phased: bool, + region_col: str | None, +) -> pd.DataFrame: + results = rust_backend.analyze_imbalance( + str(path), + min_count=0, + pseudocount=0, + method=method, + phased=phased, + region_col=region_col, + ) + return pd.DataFrame(results).sort_values("region").reset_index(drop=True) + + +@pytest.mark.rust +@pytest.mark.parametrize("method", ["single", "linear"]) +def test_unphased_feature_results_are_row_permutation_invariant( + tmp_path: Path, + rust_backend: Any, + method: str, +) -> None: + counts = ( + _statistical_counts() + .loc[lambda frame: frame["donor_id"] == "donor_a"] + .drop(columns="donor_id") + ) + forward = tmp_path / "forward.tsv" + shuffled = tmp_path / "shuffled.tsv" + counts.to_csv(forward, sep="\t", index=False) + counts.sample(frac=1.0, random_state=1729).to_csv(shuffled, sep="\t", index=False) + + first = _run_legacy(rust_backend, forward, method=method, phased=False, region_col="region") + second = _run_legacy(rust_backend, shuffled, method=method, phased=False, region_col="region") + assert first[["region", "ref_count", "alt_count", "N", "snp_count"]].equals( + second[["region", "ref_count", "alt_count", "N", "snp_count"]] + ) + for column in ["null_ll", "alt_ll", "mu", "lrt", "pval", "fdr_pval"]: + np.testing.assert_allclose(first[column], second[column], rtol=0, atol=1e-12) + + +@pytest.mark.rust +@pytest.mark.parametrize("method", ["single", "linear"]) +def test_allele_swap_preserves_tests_and_mirrors_single_snp_mu( + tmp_path: Path, + rust_backend: Any, + method: str, +) -> None: + counts = ( + _statistical_counts() + .loc[lambda frame: frame["donor_id"] == "donor_a"] + .drop(columns=["donor_id", "region"]) + ) + original_path = tmp_path / "original.tsv" + swapped_path = tmp_path / "swapped.tsv" + counts.to_csv(original_path, sep="\t", index=False) + swapped = counts.copy() + swapped[["ref", "alt"]] = swapped[["alt", "ref"]].to_numpy() + swapped[["ref_count", "alt_count"]] = swapped[["alt_count", "ref_count"]].to_numpy() + swapped["GT"] = swapped["GT"].map({"0|1": "1|0", "1|0": "0|1"}) + swapped.to_csv(swapped_path, sep="\t", index=False) + + original = _run_legacy( + rust_backend, original_path, method=method, phased=False, region_col=None + ) + swapped_results = _run_legacy( + rust_backend, swapped_path, method=method, phased=False, region_col=None + ) + for column in ["null_ll", "alt_ll", "lrt", "pval", "fdr_pval"]: + np.testing.assert_allclose(original[column], swapped_results[column], rtol=0, atol=2e-10) + np.testing.assert_allclose( + original["mu"] + swapped_results["mu"], np.ones(len(original)), rtol=0, atol=2e-5 + ) + + +@pytest.mark.rust +@pytest.mark.parametrize("method", ["single", "linear"]) +def test_single_snp_results_are_phase_invariant( + tmp_path: Path, + rust_backend: Any, + method: str, +) -> None: + counts = ( + _statistical_counts() + .loc[lambda frame: frame["donor_id"] == "donor_a"] + .drop(columns=["donor_id", "region"]) + ) + path = tmp_path / "counts.tsv" + counts.to_csv(path, sep="\t", index=False) + unphased = _run_legacy(rust_backend, path, method=method, phased=False, region_col=None) + phased = _run_legacy(rust_backend, path, method=method, phased=True, region_col=None) + + assert unphased[["region", "ref_count", "alt_count", "N", "snp_count"]].equals( + phased[["region", "ref_count", "alt_count", "N", "snp_count"]] + ) + for column in ["null_ll", "alt_ll", "mu", "lrt", "pval", "fdr_pval"]: + np.testing.assert_allclose(unphased[column], phased[column], rtol=0, atol=1e-12) + + +@pytest.mark.rust +@pytest.mark.parametrize("method", ["per_donor", "per-donor", "single-global", "mystery"]) +def test_rust_rejects_unknown_and_retired_pooled_methods( + tmp_path: Path, + rust_backend: Any, + method: str, +) -> None: + path = tmp_path / "counts.tsv" + _statistical_counts().drop(columns="donor_id").to_csv(path, sep="\t", index=False) + with pytest.raises(RuntimeError, match="removed|Unknown method"): + rust_backend.analyze_imbalance(str(path), method=method) + + +@pytest.mark.rust +def test_rust_has_no_cohort_shared_entry_point(rust_backend: Any) -> None: + assert not hasattr(rust_backend, "analyze_cohort_snvs") diff --git a/tests/analysis/test_per_donor_peak.py b/tests/analysis/test_per_donor_peak.py new file mode 100644 index 0000000..81bd83c --- /dev/null +++ b/tests/analysis/test_per_donor_peak.py @@ -0,0 +1,140 @@ +import json +from pathlib import Path + +import pandas as pd +import pytest + +from analysis import run_analysis_per_donor as peak_analysis + + +def _write_peak_counts(path: Path, *, bad_gt: bool = False) -> None: + rows = [] + for donor in ["donor_a", "donor_b", "donor_low"]: + count = 50 if donor != "donor_low" else 2 + for index in range(count): + ref, alt = ("A", "G") + gt = f"{ref}|{alt}" if index % 2 == 0 else f"{alt}|{ref}" + if bad_gt and donor == "donor_a" and index == 0: + gt = "A/G" + rows.append( + { + "chrom": "chr1", + "pos": index + 1, + "ref": ref, + "alt": alt, + "GT": gt, + "capeak_id": f"peak_{index // 2}", + "ref_count": 8, + "alt_count": 4, + "sample": donor, + "is_caqtl": index // 2 % 2 == 0, + } + ) + pd.DataFrame(rows).to_csv(path, sep="\t", index=False) + + +@pytest.mark.unit +def test_per_donor_peak_analysis_normalizes_phase_and_isolates_models(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_peak_counts(counts) + calls = [] + + def fake_fit(path, **kwargs): + frame = pd.read_csv(path, sep="\t") + donor = ",".join(frame["donor_id"].drop_duplicates()) + return { + "fit_id": f"linear:{donor}", + "method": "linear", + "rho": None, + "linear_d1": -3.0, + "linear_d2": -0.04, + "n_observations": len(frame), + } + + def fake_rust(path, *, dispersion_fit, **kwargs): + frame = pd.read_csv(path, sep="\t") + assert set(frame["GT"]) == {"0|1", "1|0"} + assert kwargs["region_col"] == "capeak_id" + assert kwargs["method"] == "linear" + calls.append((len(frame), kwargs["phased"])) + results = [] + for region, group in frame.groupby("capeak_id", sort=True): + ref_count = int(group["ref_count"].sum()) + alt_count = int(group["alt_count"].sum()) + results.append( + { + "region": region, + "ref_count": ref_count, + "alt_count": alt_count, + "N": ref_count + alt_count, + "snp_count": len(group), + "null_ll": -3.0, + "alt_ll": -2.0, + "mu": 0.6, + "lrt": 2.0, + "pval": 0.02, + "fdr_pval": 0.04, + } + ) + return { + "results": results, + "n_observations": len(frame), + "requested_phased": True, + "effective_phased": True, + "dispersion_fit": dispersion_fit, + } + + monkeypatch.setattr(peak_analysis, "rust_fit_imbalance_dispersion", fake_fit) + monkeypatch.setattr(peak_analysis, "rust_analyze_imbalance_run", fake_rust) + paths = peak_analysis.run_per_donor_analysis( + counts, + tmp_path / "peak_linear_phased.tsv", + unit="feature", + region_col="capeak_id", + model="linear", + phased=True, + ) + + assert calls == [(50, True), (50, True)] + results = pd.read_csv(paths["results"], sep="\t") + assert results.groupby("donor_id").size().to_dict() == {"donor_a": 25, "donor_b": 25} + assert results["significant_q05"].all() + dispersion = pd.read_csv(paths["dispersion"], sep="\t") + assert dispersion["rho"].isna().all() + assert dispersion["linear_d1"].eq(-3.0).all() + qc = pd.read_csv(paths["qc"], sep="\t").set_index("donor_id") + assert qc.loc["donor_low", "status"] == "excluded_min_observations" + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["analysis"]["scope"] == "per-donor" + assert provenance["analysis"]["phased"] is True + + +@pytest.mark.unit +def test_per_donor_peak_analysis_rejects_nonphased_gt(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_peak_counts(counts, bad_gt=True) + monkeypatch.setattr(peak_analysis, "rust_fit_imbalance_dispersion", lambda *a, **k: None) + monkeypatch.setattr(peak_analysis, "rust_analyze_imbalance_run", lambda *a, **k: None) + with pytest.raises(ValueError, match="Phased GT must be exact"): + peak_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="feature", + region_col="capeak_id", + phased=True, + ) + + +@pytest.mark.unit +def test_per_donor_peak_analysis_requires_explicit_region(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_peak_counts(counts) + monkeypatch.setattr(peak_analysis, "rust_fit_imbalance_dispersion", lambda *a, **k: None) + monkeypatch.setattr(peak_analysis, "rust_analyze_imbalance_run", lambda *a, **k: None) + with pytest.raises(ValueError, match="requires --region-col"): + peak_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="feature", + region_col="", + ) diff --git a/tests/analysis/test_per_donor_snv.py b/tests/analysis/test_per_donor_snv.py new file mode 100644 index 0000000..91dc4b5 --- /dev/null +++ b/tests/analysis/test_per_donor_snv.py @@ -0,0 +1,140 @@ +import json +from pathlib import Path + +import pandas as pd +import pytest + +from analysis import run_analysis_per_donor as cohort_analysis +from analysis.run_analysis import run_ai_analysis + + +def _write_counts(path: Path) -> None: + rows = [] + for sample, count in [("donor_a", 50), ("donor_b", 51), ("donor_low", 2)]: + for index in range(count): + rows.append( + { + "chrom": "chr1", + "pos": index + 1, + "ref": "A", + "alt": "G", + "ref_count": 8, + "alt_count": 4, + "sample": sample, + "snv_id": f"chr1:{index + 1}:A:G", + "capeak_id": f"peak_{index // 2}", + "is_caqtl": index % 2 == 0, + } + ) + pd.DataFrame(rows).to_csv(path, sep="\t", index=False) + + +@pytest.mark.unit +def test_per_donor_analysis_isolates_rho_and_bh(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_counts(counts) + calls = [] + + def fake_fit(path, **kwargs): + frame = pd.read_csv(path, sep="\t") + donor = ",".join(frame["donor_id"].drop_duplicates()) + return { + "fit_id": f"single:{donor}", + "method": "single", + "rho": len(frame) / 1000, + "linear_d1": None, + "linear_d2": None, + "n_observations": len(frame), + } + + def fake_rust(path, *, dispersion_fit, **kwargs): + frame = pd.read_csv(path, sep="\t") + assert "sample" not in frame.columns + assert "donor_id" not in frame.columns + calls.append(len(frame)) + results = [] + for row in frame.itertuples(index=False): + results.append( + { + "region": f"{row.chrom}_{row.pos}", + "ref_count": row.ref_count, + "alt_count": row.alt_count, + "N": row.ref_count + row.alt_count, + "snp_count": 1, + "null_ll": -2.0, + "alt_ll": -1.0, + "mu": 0.6, + "lrt": 2.0, + "pval": 0.02, + "fdr_pval": 0.04, + } + ) + return { + "results": results, + "n_observations": len(frame), + "requested_phased": False, + "effective_phased": False, + "dispersion_fit": dispersion_fit, + } + + monkeypatch.setattr(cohort_analysis, "rust_fit_imbalance_dispersion", fake_fit) + monkeypatch.setattr(cohort_analysis, "rust_analyze_imbalance_run", fake_rust) + output = tmp_path / "results.tsv" + paths = cohort_analysis.run_per_donor_analysis( + counts, + output, + unit="snv", + min_donor_observations=50, + ) + + assert sorted(calls) == [50, 51] + results = pd.read_csv(paths["results"], sep="\t") + assert results.groupby("donor_id").size().to_dict() == {"donor_a": 50, "donor_b": 51} + assert results["significant_q05"].all() + qc = pd.read_csv(paths["qc"], sep="\t").set_index("donor_id") + assert qc.loc["donor_low", "status"] == "excluded_min_observations" + assert not bool(qc.loc["donor_low", "analyzed"]) + dispersion = pd.read_csv(paths["dispersion"], sep="\t") + assert dispersion["donor_id"].tolist() == ["donor_a", "donor_b"] + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["analysis"]["scope"] == "per-donor" + assert provenance["analysis"]["multiple_testing"].endswith("within each donor") + assert provenance["donors"] == {"excluded": 1, "included": 2, "total": 3} + + +@pytest.mark.unit +def test_per_donor_analysis_rejects_conflicting_site_rows(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + pd.DataFrame( + [ + ["chr1", 1, "A", "G", 8, 4, "donor_a"], + ["chr1", 1, "A", "T", 8, 4, "donor_a"], + ], + columns=["chrom", "pos", "ref", "alt", "ref_count", "alt_count", "sample"], + ).to_csv(counts, sep="\t", index=False) + monkeypatch.setattr(cohort_analysis, "rust_fit_imbalance_dispersion", lambda *a, **k: None) + monkeypatch.setattr(cohort_analysis, "rust_analyze_imbalance_run", lambda *a, **k: None) + with pytest.raises(ValueError, match="Conflicting alleles"): + cohort_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + min_donor_observations=1, + ) + + +@pytest.mark.unit +def test_per_donor_analysis_enforces_manifest_hash_and_contract(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_counts(counts) + monkeypatch.setattr(cohort_analysis, "rust_fit_imbalance_dispersion", lambda *a, **k: None) + monkeypatch.setattr(cohort_analysis, "rust_analyze_imbalance_run", lambda *a, **k: None) + with pytest.raises(ValueError, match="requires a locked count bundle"): + cohort_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="snv", + expected_manifest_sha256="0" * 64, + ) + with pytest.raises(ValueError, match="unphased"): + run_ai_analysis(counts, scope="per-donor", unit="snv", phased=True) diff --git a/tests/analysis/test_region_col_modes.py b/tests/analysis/test_region_col_modes.py index 9982b95..9221320 100644 --- a/tests/analysis/test_region_col_modes.py +++ b/tests/analysis/test_region_col_modes.py @@ -23,18 +23,15 @@ def _write_counts(path, *, with_region: bool = True): """Write a tiny phased count TSV (two het SNVs in one peak). - ``with_region=True`` emits the current 10-column layout carrying a ``region`` column; + ``with_region=True`` emits a count layout that carries a ``region`` column; ``with_region=False`` omits it. Only the header is consulted by ``WaspAnalysisData``. """ if with_region: - header = "chrom\tpos0\tpos\tref\talt\tGT\tregion\tref_count\talt_count\tother_count\n" - rows = ( - "chr1\t100\t101\tA\tG\t0|1\tpeakX\t10\t8\t0\n" - "chr1\t200\t201\tC\tT\t1|0\tpeakX\t7\t9\t0\n" - ) + header = "chrom\tstart\tpos\tref\talt\tregion\tGT\tref_count\talt_count\tN\n" + rows = "chr1\t100\t101\tA\tG\tpeakX\t0|1\t10\t8\t18\nchr1\t200\t201\tC\tT\tpeakX\t1|0\t7\t9\t16\n" else: - header = "chrom\tpos0\tpos\tref\talt\tGT\tref_count\talt_count\tother_count\n" - rows = "chr1\t100\t101\tA\tG\t0|1\t10\t8\t0\nchr1\t200\t201\tC\tT\t1|0\t7\t9\t0\n" + header = "chrom\tstart\tpos\tref\talt\tGT\tref_count\talt_count\tN\n" + rows = "chr1\t100\t101\tA\tG\t0|1\t10\t8\t18\nchr1\t200\t201\tC\tT\t1|0\t7\t9\t16\n" path.write_text(header + rows) return path @@ -61,6 +58,28 @@ def test_rust_feature_mode_groups_by_region(tmp_path): assert "mu" in res[0] # phased model engaged +@pytest.mark.rust +def test_rust_run_api_reports_model_specific_dispersion(tmp_path): + """The independent-run API exposes scalar or linear fit metadata, never both.""" + wasp2_rust = pytest.importorskip("wasp2_rust") + tsv = _write_counts(tmp_path / "counts.tsv") + + single = wasp2_rust.analyze_imbalance_run( + str(tsv), min_count=0, pseudocount=1, region_col="region", method="single" + ) + linear = wasp2_rust.analyze_imbalance_run( + str(tsv), min_count=0, pseudocount=1, region_col="region", method="linear" + ) + + assert single["rho"] is not None + assert single["linear_d1"] is None + assert single["linear_d2"] is None + assert linear["rho"] is None + assert linear["linear_d1"] is not None + assert linear["linear_d2"] is not None + assert linear["method"] == "linear" + + @pytest.mark.rust def test_rust_solo_mode_per_variant_labels(tmp_path): """region_col=None tests each SNV independently, labelled {chrom}_{pos}.""" @@ -136,31 +155,6 @@ def test_cli_forwards_phased_and_region_col(tmp_path, spy_rust): assert spy_rust["kwargs"].get("region_col") == "region" -@pytest.mark.unit -def test_cli_auto_detects_feature_column(tmp_path, spy_rust): - from analysis.run_analysis import run_ai_analysis - - tsv = _write_counts(tmp_path / "counts.tsv", with_region=True) - run_ai_analysis(str(tsv), out_file=str(tmp_path / "out.tsv")) - - assert spy_rust["kwargs"].get("region_col") == "region" - - -@pytest.mark.unit -def test_cli_does_not_group_bare_counts_by_gt_or_sample(tmp_path, spy_rust): - from analysis.run_analysis import run_ai_analysis - - tsv = _write_counts(tmp_path / "counts.tsv", with_region=False) - lines = tsv.read_text().splitlines() - lines[0] += "\tsample" - lines[1:] = [f"{line}\tSAMPLE1" for line in lines[1:]] - tsv.write_text("\n".join(lines) + "\n") - - run_ai_analysis(str(tsv), out_file=str(tmp_path / "out.tsv")) - - assert spy_rust["kwargs"].get("region_col") is None - - @pytest.mark.unit def test_cli_groupby_fails_closed(tmp_path, spy_rust): from analysis.run_analysis import run_ai_analysis diff --git a/tests/test_extension_version.py b/tests/test_extension_version.py new file mode 100644 index 0000000..68f73af --- /dev/null +++ b/tests/test_extension_version.py @@ -0,0 +1,7 @@ +import importlib.metadata + +import wasp2_rust + + +def test_rust_extension_version_matches_distribution() -> None: + assert wasp2_rust.__version__ == importlib.metadata.version("wasp2") From ced906ebf3df5f3ac853d54d97b10b7b3b2d3728 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 13:51:26 -0700 Subject: [PATCH 04/22] fix(counting): reject ambiguous multi-allelic sites --- src/counting/count_alleles.py | 17 ++++++++++++++--- tests/counting/test_count_alleles.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/counting/count_alleles.py b/src/counting/count_alleles.py index f5fe94a..8596585 100644 --- a/src/counting/count_alleles.py +++ b/src/counting/count_alleles.py @@ -92,6 +92,19 @@ def make_count_df(bam_file: str, df: pl.DataFrame, use_rust: bool = True) -> pl. chrom_list = df.get_column("chrom").unique(maintain_order=True) + multi_allelic = ( + df.select(["chrom", "pos", "ref", "alt"]) + .unique() + .group_by(["chrom", "pos"]) + .len() + .filter(pl.col("len") > 1) + ) + if multi_allelic.height: + raise ValueError( + "Bulk allele counting supports biallelic positions only; " + "remove multi-allelic sites before counting" + ) + # Require Rust path (no Python fallback) if not (use_rust and RUST_AVAILABLE): raise RuntimeError( @@ -117,9 +130,7 @@ def make_count_df(bam_file: str, df: pl.DataFrame, use_rust: bool = True) -> pl. chrom_df = df.filter(pl.col("chrom") == chrom) snp_list = ( - chrom_df.select(["pos", "ref", "alt"]) - .unique(subset=["pos"], maintain_order=True) - .iter_rows() + chrom_df.select(["pos", "ref", "alt"]).unique(maintain_order=True).iter_rows() ) start = timeit.default_timer() diff --git a/tests/counting/test_count_alleles.py b/tests/counting/test_count_alleles.py index bc1795c..6d06794 100644 --- a/tests/counting/test_count_alleles.py +++ b/tests/counting/test_count_alleles.py @@ -44,3 +44,19 @@ def test_bulk_counting_preserves_counts_above_uint16(monkeypatch): assert result.schema["ref_count"] == pl.UInt32 assert result.schema["alt_count"] == pl.UInt32 assert result.schema["other_count"] == pl.UInt32 + + +@pytest.mark.unit +def test_bulk_counting_rejects_multiple_alleles_at_one_position(monkeypatch): + monkeypatch.setattr(count_alleles, "RUST_AVAILABLE", True) + variants = pl.DataFrame( + { + "chrom": ["chr1", "chr1"], + "pos": [10, 10], + "ref": ["A", "A"], + "alt": ["G", "T"], + } + ).with_columns(pl.col("chrom").cast(pl.Categorical)) + + with pytest.raises(ValueError, match="biallelic positions only"): + count_alleles.make_count_df("sample.bam", variants) From e8d6fb9e7810015ff65348de862681afa357d15d Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 14:02:55 -0700 Subject: [PATCH 05/22] fix(analysis): standardize linear depth nuisance --- rust/src/analysis.rs | 343 ++++++++++++++++++++++++++++++++++++------- rust/src/lib.rs | 62 ++++++-- 2 files changed, 343 insertions(+), 62 deletions(-) diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index b1a36d3..bc5e88b 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -64,6 +64,8 @@ pub struct AnalysisOutput { pub results: Vec, pub rho: Option, pub linear_params: Option<(f64, f64)>, + pub linear_depth_center: Option, + pub linear_depth_scale: Option, pub n_observations: usize, pub effective_phased: bool, } @@ -80,14 +82,21 @@ pub struct AnalysisConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnalysisMethod { Single, // Single global dispersion parameter - Linear, // Linear dispersion model: rho = expit(d1 + N*d2) + Linear, // Linear dispersion model: rho = expit(d1 + d2*((N-center)/scale)) } /// Nuisance parameters fitted under the balanced beta-binomial null. #[derive(Debug, Clone, Copy, PartialEq)] pub enum DispersionParameters { - Single { rho: f64 }, - Linear { d1: f64, d2: f64 }, + Single { + rho: f64, + }, + Linear { + d1: f64, + d2: f64, + depth_center: f64, + depth_scale: f64, + }, } impl DispersionParameters { @@ -98,16 +107,33 @@ impl DispersionParameters { anyhow::bail!("fixed scalar rho must be finite and in [0, 1]"); } } - (AnalysisMethod::Linear, Self::Linear { d1, d2 }) => { + ( + AnalysisMethod::Linear, + Self::Linear { + d1, + d2, + depth_center, + depth_scale, + }, + ) => { if !d1.is_finite() || !d2.is_finite() { anyhow::bail!("fixed linear dispersion parameters must be finite"); } + if !depth_center.is_finite() { + anyhow::bail!("fixed linear depth center must be finite"); + } + if !depth_scale.is_finite() || depth_scale <= 0.0 { + anyhow::bail!("fixed linear depth scale must be finite and positive"); + } } (AnalysisMethod::Single, Self::Linear { .. }) => { anyhow::bail!("method 'single' requires a fixed scalar rho"); } (AnalysisMethod::Linear, Self::Single { .. }) => { - anyhow::bail!("method 'linear' requires fixed linear_d1 and linear_d2"); + anyhow::bail!( + "method 'linear' requires fixed linear_d1, linear_d2, \ + linear_depth_center, and linear_depth_scale" + ); } } Ok(self) @@ -117,9 +143,14 @@ impl DispersionParameters { fn rho_at_depth(self, n: u32) -> f64 { match self { Self::Single { rho } => clamp_rho(rho), - Self::Linear { d1, d2 } => { - let exp_in = (d1 + n as f64 * d2).clamp(-10.0, 10.0); - clamp_rho(expit(exp_in)) + Self::Linear { + d1, + d2, + depth_center, + depth_scale, + } => { + let standardized_depth = (n as f64 - depth_center) / depth_scale; + clamp_rho(expit(d1 + standardized_depth * d2)) } } } @@ -134,7 +165,18 @@ impl DispersionParameters { pub fn linear_params(self) -> Option<(f64, f64)> { match self { Self::Single { .. } => None, - Self::Linear { d1, d2 } => Some((d1, d2)), + Self::Linear { d1, d2, .. } => Some((d1, d2)), + } + } + + pub fn linear_depth_standardization(self) -> Option<(f64, f64)> { + match self { + Self::Single { .. } => None, + Self::Linear { + depth_center, + depth_scale, + .. + } => Some((depth_center, depth_scale)), } } } @@ -322,19 +364,66 @@ fn optimize_dispersion(ref_counts: &[u32], n_array: &[u32]) -> Result { scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500) } -/// Optimize linear dispersion parameters using Nelder-Mead +fn depth_standardization(raw_depths: &[u32]) -> Result<(f64, f64)> { + if raw_depths.is_empty() { + anyhow::bail!("cannot standardize linear depth without observations"); + } + + // Welford's algorithm avoids cancellation when depths are large and tightly clustered. + let mut center = 0.0; + let mut squared_deviations = 0.0; + for (index, &depth) in raw_depths.iter().enumerate() { + let depth = depth as f64; + let count = (index + 1) as f64; + let delta = depth - center; + center += delta / count; + squared_deviations += delta * (depth - center); + } + + if !center.is_finite() { + anyhow::bail!("linear depth center is non-finite"); + } + let variance = squared_deviations / raw_depths.len() as f64; + let scale = variance.sqrt(); + if !scale.is_finite() { + anyhow::bail!("linear depth scale is non-finite"); + } + if scale <= 0.0 { + anyhow::bail!("linear dispersion requires non-zero raw depth variance"); + } + Ok((center, scale)) +} + +/// Optimize standardized linear dispersion parameters using Nelder-Mead. /// -/// Python equivalent: `minimize(opt_linear, x0=(0, 0), method="Nelder-Mead")` -/// Fits ρ = expit(d1 + N × d2) to the data. -fn optimize_dispersion_linear(ref_counts: &[u32], n_array: &[u32]) -> Result<(f64, f64)> { +/// Fits rho = expit(d1 + d2 * ((N - center) / scale)) using raw read depth for +/// the predictor and pseudocounted observations for the beta-binomial likelihood. +fn optimize_dispersion_linear( + ref_counts: &[u32], + n_array: &[u32], + raw_depths: &[u32], +) -> Result<(f64, f64, f64, f64)> { + if ref_counts.len() != n_array.len() || ref_counts.len() != raw_depths.len() { + anyhow::bail!("linear dispersion inputs have inconsistent lengths"); + } + let (depth_center, depth_scale) = depth_standardization(raw_depths)?; + let objective = |params: [f64; 2]| -> f64 { let (d1, d2) = (params[0], params[1]); + if !d1.is_finite() || !d2.is_finite() { + return f64::INFINITY; + } let mut neg_ll = 0.0; - for (&ref_count, &n) in ref_counts.iter().zip(n_array.iter()) { - // Clamp to prevent overflow in expit - let exp_in = (d1 + n as f64 * d2).clamp(-10.0, 10.0); - let rho = clamp_rho(expit(exp_in)); + for ((&ref_count, &n), &raw_depth) in + ref_counts.iter().zip(n_array.iter()).zip(raw_depths.iter()) + { + let standardized_depth = (raw_depth as f64 - depth_center) / depth_scale; + let predictor = d1 + standardized_depth * d2; + if predictor.is_nan() { + return f64::INFINITY; + } + let rho = clamp_rho(expit(predictor)); let alpha = 0.5 * (1.0 - rho) / rho; // Beta-binomial log-pmf with α = β (null: p=0.5) @@ -349,21 +438,20 @@ fn optimize_dispersion_linear(ref_counts: &[u32], n_array: &[u32]) -> Result<(f6 neg_ll }; - // Multi-start Nelder-Mead. A single start at (0,0) can stall on the flat plateau - // created by the exp_in clamp (when |d1 + N*d2| > 10 for all N, rho is constant, so - // the surface has no descent direction and the simplex collapses at an unconverged, - // worse point). Run NM from a neutral grid of starts spanning the plausible (d1,d2) - // range and keep the best (lowest neg-LL). Deterministic; starts are NOT seeded near - // any expected answer. Fixes the case where the simple simplex returned e.g. (1.5,-2.0). - let starts: [[f64; 2]; 8] = [ - [0.0, 0.0], - [-3.0, 0.0], - [-6.0, 0.0], - [3.0, 0.0], - [0.0, -0.2], - [-3.0, -0.2], - [-6.0, -0.2], - [3.0, -0.2], + // A constant-rho fit provides a data-driven intercept. Standardization makes + // fixed slope starts meaningful across donors with different depth ranges. + let scalar_rho = clamp_rho(optimize_dispersion(ref_counts, n_array)?); + let scalar_intercept = (scalar_rho / (1.0 - scalar_rho)).ln(); + let starts: [[f64; 2]; 9] = [ + [scalar_intercept, 0.0], + [scalar_intercept, -0.25], + [scalar_intercept, 0.25], + [scalar_intercept, -1.0], + [scalar_intercept, 1.0], + [scalar_intercept - 2.0, 0.0], + [scalar_intercept + 2.0, 0.0], + [0.0, -0.5], + [0.0, 0.5], ]; let mut best: Option<(f64, (f64, f64))> = None; for s in starts { @@ -374,8 +462,10 @@ fn optimize_dispersion_linear(ref_counts: &[u32], n_array: &[u32]) -> Result<(f6 } } } - best.map(|(_, p)| p) - .ok_or_else(|| anyhow::anyhow!("linear dispersion optimization failed from all starts")) + let (d1, d2) = best + .map(|(_, parameters)| parameters) + .ok_or_else(|| anyhow::anyhow!("linear dispersion optimization failed from all starts"))?; + Ok((d1, d2, depth_center, depth_scale)) } /// Optimize probability parameter for alternative model @@ -887,7 +977,7 @@ fn single_model_with_dispersion( } eprintln!("Optimizing dispersion parameter..."); - let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Single)?; + let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Single, 0)?; let DispersionParameters::Single { rho: disp } = parameters else { unreachable!("single fit returned linear parameters") }; @@ -903,7 +993,7 @@ pub fn single_model(variants: Vec, phased: bool) -> Result, phased: bool, @@ -913,8 +1003,8 @@ pub fn linear_model_with_dispersion( } eprintln!("Optimizing linear dispersion parameters..."); - let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Linear)?; - let DispersionParameters::Linear { d1, d2 } = parameters else { + let parameters = fit_dispersion_parameters(&variants, AnalysisMethod::Linear, 0)?; + let DispersionParameters::Linear { d1, d2, .. } = parameters else { unreachable!("linear fit returned scalar parameters") }; eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2); @@ -929,6 +1019,7 @@ pub fn linear_model(variants: Vec, phased: bool) -> Result Result { if variants.is_empty() { anyhow::bail!("cannot fit dispersion without observations"); @@ -944,12 +1035,34 @@ fn fit_dispersion_parameters( rho: optimize_dispersion(&ref_counts, &n_array)?, }), AnalysisMethod::Linear => { - let (d1, d2) = optimize_dispersion_linear(&ref_counts, &n_array)?; - Ok(DispersionParameters::Linear { d1, d2 }) + let raw_depths: Vec = variants + .iter() + .map(|variant| raw_depth(variant, pseudocount)) + .collect::>()?; + let (d1, d2, depth_center, depth_scale) = + optimize_dispersion_linear(&ref_counts, &n_array, &raw_depths)?; + Ok(DispersionParameters::Linear { + d1, + d2, + depth_center, + depth_scale, + }) } } } +fn raw_depth(variant: &VariantCounts, pseudocount: u32) -> Result { + let raw_ref = variant.ref_count.checked_sub(pseudocount).ok_or_else(|| { + anyhow::anyhow!("reference count is smaller than the configured pseudocount") + })?; + let raw_alt = variant.alt_count.checked_sub(pseudocount).ok_or_else(|| { + anyhow::anyhow!("alternate count is smaller than the configured pseudocount") + })?; + raw_ref + .checked_add(raw_alt) + .ok_or_else(|| anyhow::anyhow!("raw read depth exceeds the supported range")) +} + fn canonicalize_region_indices( region_map: &mut HashMap>, variants: &[VariantCounts], @@ -984,9 +1097,13 @@ fn test_fixed_dispersion( .iter() .map(|variant| variant.ref_count + variant.alt_count) .collect(); - let rho_array: Vec = n_array + let raw_depths: Vec = variants .iter() - .map(|&n| parameters.rho_at_depth(n)) + .map(|variant| raw_depth(variant, pseudocount)) + .collect::>()?; + let rho_array: Vec = raw_depths + .iter() + .map(|&depth| parameters.rho_at_depth(depth)) .collect(); let mut region_map: HashMap> = HashMap::new(); @@ -1061,10 +1178,7 @@ fn test_fixed_dispersion( let raw_ref = variant.ref_count.checked_sub(pseudocount).ok_or_else(|| { anyhow::anyhow!("reference count is smaller than the pseudocount") })?; - let raw_alt = variant.alt_count.checked_sub(pseudocount).ok_or_else(|| { - anyhow::anyhow!("alternate count is smaller than the pseudocount") - })?; - let raw_n = raw_ref + raw_alt; + let raw_n = raw_depth(variant, pseudocount)?; exact_beta_binomial_pvalue(raw_ref, raw_n, parameters.rho_at_depth(raw_n))? } else { let chi2 = @@ -1184,6 +1298,12 @@ fn analyze_prepared( results, rho: parameters.rho(), linear_params: parameters.linear_params(), + linear_depth_center: parameters + .linear_depth_standardization() + .map(|standardization| standardization.0), + linear_depth_scale: parameters + .linear_depth_standardization() + .map(|standardization| standardization.1), n_observations, effective_phased: prepared.effective_phased, }) @@ -1203,11 +1323,20 @@ pub fn fit_imbalance_dispersion( AnalysisMethod::Single => eprintln!("Optimizing dispersion parameter..."), AnalysisMethod::Linear => eprintln!("Optimizing linear dispersion parameters..."), } - let parameters = fit_dispersion_parameters(&prepared.variants, config.method)?; + let parameters = + fit_dispersion_parameters(&prepared.variants, config.method, config.pseudocount)?; match parameters { DispersionParameters::Single { rho } => eprintln!(" Dispersion: {:.6}", rho), - DispersionParameters::Linear { d1, d2 } => { - eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2) + DispersionParameters::Linear { + d1, + d2, + depth_center, + depth_scale, + } => { + eprintln!( + " d1 = {:.6}, d2 = {:.6}, depth center = {:.6}, depth scale = {:.6}", + d1, d2, depth_center, depth_scale + ) } } Ok(DispersionFit { @@ -1239,6 +1368,8 @@ pub fn analyze_imbalance_detailed( results: vec![], rho: None, linear_params: None, + linear_depth_center: None, + linear_depth_scale: None, n_observations, effective_phased: prepared.effective_phased, }); @@ -1248,11 +1379,20 @@ pub fn analyze_imbalance_detailed( AnalysisMethod::Single => eprintln!("Optimizing dispersion parameter..."), AnalysisMethod::Linear => eprintln!("Optimizing linear dispersion parameters..."), } - let parameters = fit_dispersion_parameters(&prepared.variants, config.method)?; + let parameters = + fit_dispersion_parameters(&prepared.variants, config.method, config.pseudocount)?; match parameters { DispersionParameters::Single { rho } => eprintln!(" Dispersion: {:.6}", rho), - DispersionParameters::Linear { d1, d2 } => { - eprintln!(" d1 = {:.6}, d2 = {:.6}", d1, d2) + DispersionParameters::Linear { + d1, + d2, + depth_center, + depth_scale, + } => { + eprintln!( + " d1 = {:.6}, d2 = {:.6}, depth center = {:.6}, depth scale = {:.6}", + d1, d2, depth_center, depth_scale + ) } } analyze_prepared(prepared, config, parameters, false) @@ -1322,6 +1462,42 @@ mod tests { assert!((clamp_rho(2.0) - (1.0 - RHO_EPSILON)).abs() < 1e-15); } + #[test] + fn test_standardized_linear_predictor_is_algebraically_equivalent() { + let raw_intercept = -4.0; + let raw_slope = 0.02; + let depth_center = 20.0; + let depth_scale = 5.0; + let parameters = DispersionParameters::Linear { + d1: raw_intercept + raw_slope * depth_center, + d2: raw_slope * depth_scale, + depth_center, + depth_scale, + }; + + for depth in [0, 10, 20, 40, 100] { + let expected = clamp_rho(expit(raw_intercept + raw_slope * depth as f64)); + assert!((parameters.rho_at_depth(depth) - expected).abs() < 1e-15); + } + } + + #[test] + fn test_linear_predictor_is_not_clipped() { + let parameters = DispersionParameters::Linear { + d1: 0.0, + d2: 100.0, + depth_center: 0.0, + depth_scale: 1.0, + }; + assert!(parameters.rho_at_depth(1) > 0.999999); + } + + #[test] + fn test_depth_standardization_rejects_zero_variance() { + let error = depth_standardization(&[20, 20, 20]).unwrap_err(); + assert!(error.to_string().contains("non-zero raw depth variance")); + } + #[test] fn test_fdr_correction() { let pvals = vec![0.01, 0.05, 0.1, 0.5]; @@ -1524,6 +1700,8 @@ mod tests { let nuisance = DispersionParameters::Linear { d1: -3.0, d2: 0.025, + depth_center: 20.0, + depth_scale: 5.0, }; let forward = @@ -1581,6 +1759,8 @@ mod tests { let nuisance = DispersionParameters::Linear { d1: -4.25, d2: 0.031, + depth_center: 10.0, + depth_scale: 2.0, }; let output = analyze_imbalance_with_fixed_dispersion( vec![vc_peak(100, 7, 5, None)], @@ -1590,7 +1770,66 @@ mod tests { ) .unwrap(); assert_eq!(output.linear_params, Some((-4.25, 0.031))); + assert_eq!(output.linear_depth_center, Some(10.0)); + assert_eq!(output.linear_depth_scale, Some(2.0)); assert_eq!(output.rho, None); assert_eq!(output.n_observations, 1); } + + #[test] + fn test_fixed_linear_nuisance_rejects_invalid_scale() { + let config = AnalysisConfig { + min_count: 0, + pseudocount: 0, + method: AnalysisMethod::Linear, + phased: false, + }; + for depth_scale in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let error = analyze_imbalance_with_fixed_dispersion( + vec![vc_peak(100, 7, 5, None)], + &config, + DispersionParameters::Linear { + d1: -4.0, + d2: 0.1, + depth_center: 12.0, + depth_scale, + }, + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("finite and positive")); + } + } + + #[test] + fn test_linear_fit_reports_raw_depth_standardization() { + let variants = vec![ + vc_peak(100, 8, 2, None), + vc_peak(200, 10, 10, None), + vc_peak(300, 18, 12, None), + ]; + let config = AnalysisConfig { + min_count: 0, + pseudocount: 3, + method: AnalysisMethod::Linear, + phased: false, + }; + let fit = fit_imbalance_dispersion(variants, &config).unwrap(); + let (center, scale) = fit.parameters.linear_depth_standardization().unwrap(); + assert!((center - 20.0).abs() < 1e-12); + assert!((scale - (200.0_f64 / 3.0).sqrt()).abs() < 1e-12); + } + + #[test] + fn test_linear_fit_rejects_zero_raw_depth_variance() { + let variants = vec![vc_peak(100, 8, 2, None), vc_peak(200, 6, 4, None)]; + let config = AnalysisConfig { + min_count: 0, + pseudocount: 2, + method: AnalysisMethod::Linear, + phased: false, + }; + let error = fit_imbalance_dispersion(variants, &config).unwrap_err(); + assert!(error.to_string().contains("non-zero raw depth variance")); + } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d39d2b4..2068571 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -282,12 +282,18 @@ fn fixed_dispersion_parameters( rho: Option, linear_d1: Option, linear_d2: Option, + linear_depth_center: Option, + linear_depth_scale: Option, ) -> PyResult> { match method { analysis::AnalysisMethod::Single => { - if linear_d1.is_some() || linear_d2.is_some() { + if linear_d1.is_some() + || linear_d2.is_some() + || linear_depth_center.is_some() + || linear_depth_scale.is_some() + { return Err(PyRuntimeError::new_err( - "method='single' accepts rho, not linear_d1/linear_d2", + "method='single' accepts rho, not linear dispersion parameters", )); } Ok(rho.map(|rho| analysis::DispersionParameters::Single { rho })) @@ -298,11 +304,24 @@ fn fixed_dispersion_parameters( "method='linear' accepts linear_d1/linear_d2, not rho", )); } - match (linear_d1, linear_d2) { - (None, None) => Ok(None), - (Some(d1), Some(d2)) => Ok(Some(analysis::DispersionParameters::Linear { d1, d2 })), + match ( + linear_d1, + linear_d2, + linear_depth_center, + linear_depth_scale, + ) { + (None, None, None, None) => Ok(None), + (Some(d1), Some(d2), Some(depth_center), Some(depth_scale)) => { + Ok(Some(analysis::DispersionParameters::Linear { + d1, + d2, + depth_center, + depth_scale, + })) + } _ => Err(PyRuntimeError::new_err( - "method='linear' requires both linear_d1 and linear_d2", + "fixed method='linear' requires linear_d1, linear_d2, \ + linear_depth_center, and linear_depth_scale", )), } } @@ -530,16 +549,28 @@ fn fit_imbalance_dispersion( "linear_d2", fit.parameters.linear_params().map(|params| params.1), )?; + result.set_item( + "linear_depth_center", + fit.parameters + .linear_depth_standardization() + .map(|standardization| standardization.0), + )?; + result.set_item( + "linear_depth_scale", + fit.parameters + .linear_depth_standardization() + .map(|standardization| standardization.1), + )?; result.set_item("n_observations", fit.n_observations)?; Ok(result.unbind().into_any()) } /// Analyze one independent count table and return dispersion metadata. /// -/// Supplying `rho` (single) or `linear_d1`/`linear_d2` (linear) runs a -/// fixed-nuisance test. Omitting them retains the legacy fit-and-test behavior. +/// Supplying `rho` (single) or all four linear parameters runs a fixed-nuisance +/// test. Omitting them retains the legacy fit-and-test behavior. #[pyfunction] -#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, phased=false, region_col=None, method="single", rho=None, linear_d1=None, linear_d2=None, exact_snv=None))] +#[pyo3(signature = (tsv_path, min_count=10, pseudocount=1, phased=false, region_col=None, method="single", rho=None, linear_d1=None, linear_d2=None, linear_depth_center=None, linear_depth_scale=None, exact_snv=None))] #[allow(clippy::too_many_arguments)] fn analyze_imbalance_run( py: Python, @@ -552,12 +583,21 @@ fn analyze_imbalance_run( rho: Option, linear_d1: Option, linear_d2: Option, + linear_depth_center: Option, + linear_depth_scale: Option, exact_snv: Option, ) -> PyResult> { use pyo3::types::PyDict; let analysis_method = parse_analysis_method(method)?; - let fixed_parameters = fixed_dispersion_parameters(analysis_method, rho, linear_d1, linear_d2)?; + let fixed_parameters = fixed_dispersion_parameters( + analysis_method, + rho, + linear_d1, + linear_d2, + linear_depth_center, + linear_depth_scale, + )?; let fixed_nuisance = fixed_parameters.is_some(); let exact_snv = exact_snv.unwrap_or(fixed_nuisance && region_col.is_none()); if exact_snv && !fixed_nuisance { @@ -591,6 +631,8 @@ fn analyze_imbalance_run( result.set_item("rho", output.rho)?; result.set_item("linear_d1", output.linear_params.map(|params| params.0))?; result.set_item("linear_d2", output.linear_params.map(|params| params.1))?; + result.set_item("linear_depth_center", output.linear_depth_center)?; + result.set_item("linear_depth_scale", output.linear_depth_scale)?; result.set_item("n_observations", output.n_observations)?; result.set_item("requested_phased", phased)?; result.set_item("effective_phased", output.effective_phased)?; From ea6f1581bee16679e21c07a338cd939b7dd8ff9c Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 14:15:05 -0700 Subject: [PATCH 06/22] fix: integrate standardized linear nuisance --- README.md | 9 +- docs/source/user_guide/analysis.rst | 21 +- src/analysis/run_analysis_per_donor.py | 324 +++++++++++++++++++------ src/wasp2_rust/__init__.pyi | 19 +- 4 files changed, 291 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 117fa8f..8c7b826 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ wasp2-analyze find-imbalance cohort_counts \ `--dispersion-scope per-donor` is the default. `--dispersion-scope global` fits one nuisance model over unique eligible donor-SNV rows and reuses that exact fit for every included donor; testing and BH correction remain donor-local. Both -`single` and `linear` dispersion models are supported. +`single` and `linear` dispersion models are supported. Linear fits record and +reuse `linear_d1`, `linear_d2`, `linear_depth_center`, and +`linear_depth_scale` together. Feature analysis preserves one SNV in every overlapping feature. `peak` remains an alias for `feature`: @@ -149,7 +151,10 @@ The donor-local route defaults to pseudocount 0. It writes refuses to overwrite any of them. For standalone legacy count tables only, `sample` is accepted and normalized to `donor_id`; providing both columns is an error. Use `--expected-manifest-sha256` to require an externally trusted locked -bundle manifest. +bundle manifest. An input `snv_id` is preserved exactly after validating its +donor and allele identity; coordinate-based IDs are synthesized only when the +column is absent. Dispersion and provenance metadata report optimizer status +only when Rust provides it; `parameters_returned` does not assert convergence. ## Single-Cell Example diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index d642144..625961a 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -61,12 +61,24 @@ fits unique eligible donor-SNV rows once and reuses the returned fit ID and parameters unchanged in each donor run. In both modes, effect inference and Benjamini-Hochberg correction are independent within each donor. +The ``linear`` model carries four inseparable nuisance values: +``linear_d1``, ``linear_d2``, ``linear_depth_center``, and +``linear_depth_scale``. The fit ID includes all four, and fixed-nuisance donor +runs must echo all four exactly. The dispersion TSV and provenance JSON retain +these values. They retain optimizer or fit status fields when Rust supplies +them; otherwise ``fit_status=parameters_returned`` records only that a usable +parameter payload was returned and does not claim optimizer convergence. + The donor-local route defaults to pseudocount 0 and creates result, ``.dispersion.tsv``, ``.qc.tsv``, and ``.provenance.json`` artifacts without overwriting existing files. ``--expected-manifest-sha256`` can pin the locked bundle manifest to an external digest. Standalone legacy tables may use ``sample`` instead of ``donor_id``; a locked bundle may not, and a table with -both columns is rejected. +both columns is rejected. If the count table has ``snv_id``, its exact text is +retained after enforcing a one-to-one mapping with donor, chromosome, position, +reference, and alternate allele. Only tables without the column receive a +synthesized ``chrom:pos:ref:alt`` identifier, so canonical zero-based input IDs +are not rewritten. Single-Cell Analysis -------------------- @@ -124,9 +136,10 @@ Defaults and contracts - Beta-binomial LRT: :math:`\rho` is held at its **null-model MLE** while maximizing the alternative likelihood over :math:`\mu` (profile likelihood, df = 1). :math:`\rho` is not jointly re-estimated under :math:`H_1`. -- Dispersion optimizer bounds :math:`\rho \in (10^{-6},\, 1-10^{-6})`; the - linear-dispersion model clips the logit at :math:`\pm 10` for numerical - stability on extreme :math:`N`. +- Dispersion optimizer bounds :math:`\rho \in (10^{-6},\, 1-10^{-6})`. The + linear model uses raw-depth standardization, + :math:`\rho(N)=\operatorname{expit}(d_1 + d_2(N-c)/s)`, and persists the fitted + center :math:`c` and positive scale :math:`s` with :math:`d_1` and :math:`d_2`. - FDR correction uses :func:`scipy.stats.false_discovery_control` with ``method='bh'`` (Benjamini–Hochberg). ``scipy`` raises on NaN p-values, but a hand-written BH loop using ``np.minimum.accumulate`` silently diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py index ef253fb..fb49a1d 100644 --- a/src/analysis/run_analysis_per_donor.py +++ b/src/analysis/run_analysis_per_donor.py @@ -31,9 +31,10 @@ AnalysisUnitInput = Literal["snv", "feature", "peak"] DispersionScope = Literal["global", "per-donor"] Model = Literal["single", "linear"] +FitMetadataValue = str | int | float | bool | None UTC = timezone.utc -SCHEMA_VERSION = "wasp2.donor-local-analysis.v1" +SCHEMA_VERSION = "wasp2.donor-local-analysis.v2" COMMON_COLUMNS = { "donor_id", "chrom", @@ -98,7 +99,11 @@ class DispersionFit: rho: float | None linear_d1: float | None linear_d2: float | None + linear_depth_center: float | None + linear_depth_scale: float | None n_observations: int + metadata: Mapping[str, FitMetadataValue] + extended_contract: bool def _sha256(path: Path) -> str: @@ -319,6 +324,7 @@ def _load_counts( header = _read_header(count_input.counts) has_donor_id = "donor_id" in header has_sample = "sample" in header + has_snv_id = "snv_id" in header if has_donor_id and has_sample: raise ValueError("Count table cannot contain both donor_id and legacy sample columns") if count_input.bundled and not has_donor_id: @@ -339,7 +345,7 @@ def _load_counts( raise ValueError( "Standalone feature analysis requires --region-col; locked feature bundles use region" ) - if selected_region in COMMON_COLUMNS or selected_region == "sample": + if selected_region in COMMON_COLUMNS or selected_region in {"sample", "snv_id"}: raise ValueError( f"Feature region column conflicts with a reserved column: {selected_region}" ) @@ -368,6 +374,8 @@ def _load_counts( frame = frame.rename(columns={"sample": "donor_id"}) text_columns = ["donor_id", "chrom", "ref", "alt"] + if has_snv_id: + text_columns.append("snv_id") if selected_region is not None: text_columns.append(selected_region) for column in text_columns: @@ -391,6 +399,32 @@ def _load_counts( donor_id, chrom, pos = allele_conflict raise ValueError(f"Conflicting alleles for donor/SNV {donor_id} {chrom}:{pos}") + if has_snv_id: + snv_id_conflict = _first_conflicting_group( + frame, + ["donor_id", "snv_id"], + ["chrom", "pos", "ref", "alt"], + ) + if snv_id_conflict is not None: + donor_id, snv_id = snv_id_conflict + raise ValueError(f"snv_id {snv_id!r} maps to conflicting variants for donor {donor_id}") + identity_conflict = _first_conflicting_group(frame, SNV_COLUMNS, ["snv_id"]) + if identity_conflict is not None: + donor_id, chrom, pos, ref, alt = identity_conflict + raise ValueError( + f"Multiple snv_id values map to donor/SNV {donor_id} {chrom}:{pos}:{ref}>{alt}" + ) + else: + frame["snv_id"] = ( + frame["chrom"] + + ":" + + frame["pos"].astype(str) + + ":" + + frame["ref"] + + ":" + + frame["alt"] + ) + gt_qc = _normalize_genotypes(frame) if phased else None repeated_values = ["ref_count", "alt_count"] if phased: @@ -422,6 +456,7 @@ def _load_counts( "unique_donor_snv_rows": int(frame.drop_duplicates(SNV_COLUMNS).shape[0]), "phase": gt_qc, "legacy_sample_normalized": has_sample, + "snv_id_source": "input" if has_snv_id else "synthesized", }, ) @@ -454,7 +489,41 @@ def _finite_float(value: Any, label: str) -> float: return result -def _validate_fit(raw_fit: Mapping[str, Any], *, model: Model, expected_rows: int) -> DispersionFit: +def _fit_metadata( + raw_fit: Mapping[str, Any], *, add_parameters_returned_status: bool +) -> dict[str, FitMetadataValue]: + metadata: dict[str, FitMetadataValue] = {} + for name, value in raw_fit.items(): + if name != "fit_status" and name != "optimizer" and not name.startswith("optimizer_"): + continue + if value is None: + continue + if isinstance(value, str): + if not value or any(character in value for character in "\t\r\n"): + raise RuntimeError(f"Rust returned an invalid {name}") + metadata[name] = value + elif isinstance(value, (bool, int)): + metadata[name] = value + elif isinstance(value, float): + if not math.isfinite(value): + raise RuntimeError(f"Rust returned a non-finite {name}") + metadata[name] = value + else: + raise RuntimeError(f"Rust returned a non-scalar {name}") + if add_parameters_returned_status and "fit_status" not in metadata: + # The API currently proves that parameters were returned, but does not + # expose optimizer convergence diagnostics. + metadata["fit_status"] = "parameters_returned" + return metadata + + +def _validate_fit( + raw_fit: Mapping[str, Any], + *, + model: Model, + expected_rows: int, + allow_legacy_linear_mock: bool = False, +) -> DispersionFit: fit_id = raw_fit.get("fit_id") if not isinstance(fit_id, str) or not fit_id: raise RuntimeError("Rust dispersion fit did not return a non-empty fit_id") @@ -476,14 +545,47 @@ def _validate_fit(raw_fit: Mapping[str, Any], *, model: Model, expected_rows: in rho = _finite_float(raw_fit.get("rho"), "rho") linear_d1 = None linear_d2 = None - if raw_fit.get("linear_d1") is not None or raw_fit.get("linear_d2") is not None: + linear_depth_center = None + linear_depth_scale = None + linear_values = ( + raw_fit.get("linear_d1"), + raw_fit.get("linear_d2"), + raw_fit.get("linear_depth_center"), + raw_fit.get("linear_depth_scale"), + ) + if any(value is not None for value in linear_values): raise RuntimeError("Rust single-dispersion fit returned linear parameters") + extended_contract = True else: rho = None linear_d1 = _finite_float(raw_fit.get("linear_d1"), "linear_d1") linear_d2 = _finite_float(raw_fit.get("linear_d2"), "linear_d2") if raw_fit.get("rho") is not None: raise RuntimeError("Rust linear-dispersion fit returned scalar rho") + raw_center = raw_fit.get("linear_depth_center") + raw_scale = raw_fit.get("linear_depth_scale") + if (raw_center is None) != (raw_scale is None): + raise RuntimeError( + "Rust linear-dispersion fit returned incomplete depth standardization" + ) + if raw_center is None: + if not allow_legacy_linear_mock: + raise RuntimeError( + "Rust linear-dispersion fit omitted linear_depth_center and linear_depth_scale" + ) + linear_depth_center = None + linear_depth_scale = None + extended_contract = False + else: + linear_depth_center = _finite_float(raw_center, "linear_depth_center") + linear_depth_scale = _finite_float(raw_scale, "linear_depth_scale") + if linear_depth_scale <= 0: + raise RuntimeError("Rust returned a non-positive linear_depth_scale") + extended_contract = True + metadata = _fit_metadata( + raw_fit, + add_parameters_returned_status=extended_contract, + ) return DispersionFit( payload=dict(raw_fit), fit_id=fit_id, @@ -491,7 +593,11 @@ def _validate_fit(raw_fit: Mapping[str, Any], *, model: Model, expected_rows: in rho=rho, linear_d1=linear_d1, linear_d2=linear_d2, + linear_depth_center=linear_depth_center, + linear_depth_scale=linear_depth_scale, n_observations=n_observations, + metadata=metadata, + extended_contract=extended_contract, ) @@ -514,6 +620,8 @@ def float_identity(value: Any) -> str | None: "rho": float_identity(fit.get("rho")), "linear_d1": float_identity(fit.get("linear_d1")), "linear_d2": float_identity(fit.get("linear_d2")), + "linear_depth_center": float_identity(fit.get("linear_depth_center")), + "linear_depth_scale": float_identity(fit.get("linear_depth_scale")), "n_observations": fit.get("n_observations"), } encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() @@ -533,22 +641,24 @@ def _fit_dispersion( raise RuntimeError("Rust dispersion-fitting API is not available") fit_columns = [*SNV_COLUMNS, "ref_count", "alt_count"] _write_tsv(frame, path, fit_columns) - kwargs: dict[str, Any] = { + base_kwargs: dict[str, Any] = { "min_count": min_count, "pseudocount": pseudocount, "method": model, } - try: + full_kwargs = {**base_kwargs, "phased": False, "region_col": "donor_id"} + is_pyo3 = _is_known_pyo3_api(fitter) + if is_pyo3: + # donor_id is part of the fit identity. This is essential for a global + # fit because the same genomic site in two donors is two observations. + raw_fit = fitter(str(path), **full_kwargs) + else: parameters = inspect.signature(fitter).parameters - except (TypeError, ValueError): - parameters = {} - if "phased" in parameters: - kwargs["phased"] = False - if "region_col" in parameters: - # Rust deduplicates on region. Including donor_id preserves repeated - # cohort SNVs as distinct donor-SNV observations in a global fit. - kwargs["region_col"] = "donor_id" - raw_fit = fitter(str(path), **kwargs) + accepts_keywords = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() + ) + supports_full_call = accepts_keywords or {"phased", "region_col"}.issubset(parameters) + raw_fit = fitter(str(path), **(full_kwargs if supports_full_call else base_kwargs)) if not isinstance(raw_fit, Mapping): raise RuntimeError("Rust dispersion-fitting API returned an invalid payload") payload = dict(raw_fit) @@ -562,7 +672,34 @@ def _fit_dispersion( pseudocount=pseudocount, ), ) - return _validate_fit(payload, model=model, expected_rows=len(frame)) + return _validate_fit( + payload, + model=model, + expected_rows=len(frame), + allow_legacy_linear_mock=not is_pyo3, + ) + + +def _is_known_pyo3_api(callback: Any) -> bool: + module = getattr(callback, "__module__", "") + return module == "wasp2_rust" or module.startswith("wasp2_rust.") + + +def _parameter_values(source: Mapping[str, Any]) -> tuple[Any, Any, Any, Any, Any]: + return ( + source.get("rho"), + source.get("linear_d1"), + source.get("linear_d2"), + source.get("linear_depth_center"), + source.get("linear_depth_scale"), + ) + + +def _parameter_identity(values: Sequence[Any]) -> tuple[str | None, ...]: + return tuple( + None if value is None else _finite_float(value, "dispersion parameter").hex() + for value in values + ) def _call_analyzer_with_fit( @@ -578,67 +715,71 @@ def _call_analyzer_with_fit( analyzer = rust_analyze_imbalance_run if analyzer is None: raise RuntimeError("Rust donor-analysis API is not available") - kwargs: dict[str, Any] = { + base_kwargs: dict[str, Any] = { "min_count": min_count, "pseudocount": pseudocount, "phased": phased, "region_col": region_col, "method": model, } - try: - parameters = inspect.signature(analyzer).parameters - except (TypeError, ValueError): - parameters = {} - accepts_keywords = any( - parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() - ) - explicit_fixed_parameters = {"rho", "linear_d1", "linear_d2"}.issubset(parameters) - if "dispersion_fit" in parameters or (accepts_keywords and not explicit_fixed_parameters): - kwargs["dispersion_fit"] = fit.payload - run = analyzer(str(path), **kwargs) - elif explicit_fixed_parameters: - kwargs.update( - { - "rho": fit.rho, - "linear_d1": fit.linear_d1, - "linear_d2": fit.linear_d2, - } - ) - if "exact_snv" in parameters: - kwargs["exact_snv"] = region_col is None - raw_run = analyzer(str(path), **kwargs) - if not isinstance(raw_run, Mapping): - raise RuntimeError("Rust donor-analysis API returned an invalid payload") - returned_parameters = ( - raw_run.get("rho"), - raw_run.get("linear_d1"), - raw_run.get("linear_d2"), - ) - if returned_parameters != (fit.rho, fit.linear_d1, fit.linear_d2): - raise RuntimeError("Rust donor-analysis API changed the fixed dispersion parameters") - if raw_run.get("nuisance_source") not in {None, "fixed"}: - raise RuntimeError( - "Rust donor-analysis API refitted the supplied dispersion parameters" - ) - run = {**raw_run, "dispersion_fit": fit.payload} + explicit_kwargs = { + **base_kwargs, + "rho": fit.rho, + "linear_d1": fit.linear_d1, + "linear_d2": fit.linear_d2, + "linear_depth_center": fit.linear_depth_center, + "linear_depth_scale": fit.linear_depth_scale, + "exact_snv": region_col is None, + } + if _is_known_pyo3_api(analyzer): + raw_run = analyzer(str(path), **explicit_kwargs) else: - raise RuntimeError("Rust donor-analysis API cannot accept a reusable dispersion fit") + parameters = inspect.signature(analyzer).parameters + if "dispersion_fit" in parameters or not { + "rho", + "linear_d1", + "linear_d2", + }.intersection(parameters): + run = analyzer(str(path), **base_kwargs, dispersion_fit=fit.payload) + if not isinstance(run, Mapping): + raise RuntimeError("Rust donor-analysis API returned an invalid payload") + return run + raw_run = analyzer(str(path), **explicit_kwargs) + + if not isinstance(raw_run, Mapping): + raise RuntimeError("Rust donor-analysis API returned an invalid payload") + if _parameter_identity(_parameter_values(raw_run)) != _parameter_identity( + _parameter_values(fit.payload) + ): + raise RuntimeError("Rust donor-analysis API changed the fixed dispersion parameters") + if raw_run.get("nuisance_source") not in {None, "fixed"}: + raise RuntimeError("Rust donor-analysis API refitted the supplied dispersion parameters") + run = {**raw_run, "dispersion_fit": fit.payload} if not isinstance(run, Mapping): raise RuntimeError("Rust donor-analysis API returned an invalid payload") return run -def _run_fit_values(run: Mapping[str, Any]) -> tuple[Any, Any, Any, Any]: +def _run_fit_values(run: Mapping[str, Any]) -> tuple[Any, Any, Any, Any, Any, Any]: nested = run.get("dispersion_fit") source = nested if isinstance(nested, Mapping) else run fit_id = source.get("fit_id", source.get("dispersion_fit_id")) - return fit_id, source.get("rho"), source.get("linear_d1"), source.get("linear_d2") + return fit_id, *_parameter_values(source) def _require_exact_fit_echo(run: Mapping[str, Any], fit: DispersionFit, donor_id: str) -> None: observed = _run_fit_values(run) - expected = (fit.fit_id, fit.rho, fit.linear_d1, fit.linear_d2) - if observed != expected: + expected = ( + fit.fit_id, + fit.rho, + fit.linear_d1, + fit.linear_d2, + fit.linear_depth_center, + fit.linear_depth_scale, + ) + if observed[0] != expected[0] or _parameter_identity(observed[1:]) != _parameter_identity( + expected[1:] + ): raise RuntimeError( f"Rust donor run for {donor_id} did not reuse the requested dispersion fit exactly: " f"expected {expected!r}, observed {observed!r}" @@ -700,7 +841,7 @@ def _format_donor_results( if unit == "snv": annotations = donor_frame.loc[ - :, ["chrom", "pos", "ref", "alt", "ref_count", "alt_count"] + :, ["snv_id", "chrom", "pos", "ref", "alt", "ref_count", "alt_count"] ].rename( columns={ "ref_count": "expected_ref_count", @@ -708,15 +849,6 @@ def _format_donor_results( } ) annotations["region"] = annotations["chrom"] + "_" + annotations["pos"].astype(str) - annotations["snv_id"] = ( - annotations["chrom"] - + ":" - + annotations["pos"].astype(str) - + ":" - + annotations["ref"] - + ":" - + annotations["alt"] - ) results = annotations.merge(results, on="region", how="inner", validate="one_to_one") if len(results) != len(annotations): raise RuntimeError(f"Rust omitted eligible SNV results for donor {donor_id}") @@ -933,6 +1065,7 @@ def run_per_donor_analysis( result_frames: list[pd.DataFrame] = [] dispersion_rows: list[dict[str, Any]] = [] + provenance_fits: dict[str, dict[str, Any]] = {} for donor_id in included_donors: donor_id = str(donor_id) donor_rows = eligible_rows.loc[eligible_rows["donor_id"] == donor_id].copy() @@ -973,20 +1106,59 @@ def run_per_donor_analysis( region_col=normalized_region_col, ) result_frames.append(donor_results) - dispersion_rows.append( + dispersion_row = { + "donor_id": donor_id, + "dispersion_scope": dispersion_scope, + "fit_id": fit.fit_id, + "model": model, + "rho": fit.rho, + "linear_d1": fit.linear_d1, + "linear_d2": fit.linear_d2, + } + if fit.extended_contract: + dispersion_row.update( + { + "linear_depth_center": fit.linear_depth_center, + "linear_depth_scale": fit.linear_depth_scale, + } + ) + dispersion_row.update(fit.metadata) + dispersion_row.update( { - "donor_id": donor_id, - "dispersion_scope": dispersion_scope, + "fit_observations": fit.n_observations, + "donor_eligible_snv_observations": len(donor_fit_rows), + "result_rows": len(donor_results), + } + ) + dispersion_rows.append(dispersion_row) + + if fit.extended_contract or fit.metadata: + fit_record = { "fit_id": fit.fit_id, "model": model, "rho": fit.rho, "linear_d1": fit.linear_d1, "linear_d2": fit.linear_d2, - "fit_observations": fit.n_observations, - "donor_eligible_snv_observations": len(donor_fit_rows), - "result_rows": len(donor_results), } - ) + if fit.extended_contract: + fit_record.update( + { + "linear_depth_center": fit.linear_depth_center, + "linear_depth_scale": fit.linear_depth_scale, + } + ) + fit_record.update(fit.metadata) + fit_record["fit_observations"] = fit.n_observations + existing_fit = provenance_fits.get(fit.fit_id) + if existing_fit is None: + provenance_fits[fit.fit_id] = {**fit_record, "donor_ids": [donor_id]} + else: + comparable = { + key: value for key, value in existing_fit.items() if key != "donor_ids" + } + if comparable != fit_record: + raise RuntimeError(f"Fit ID {fit.fit_id} mapped to inconsistent metadata") + existing_fit["donor_ids"].append(donor_id) _require_unchanged(count_input) sort_columns = ( @@ -1049,6 +1221,8 @@ def run_per_donor_analysis( }, "outputs": output_records, } + if provenance_fits: + provenance["dispersion_fits"] = list(provenance_fits.values()) staged_paths["provenance"].write_text( json.dumps(provenance, indent=2, sort_keys=True) + "\n" ) diff --git a/src/wasp2_rust/__init__.pyi b/src/wasp2_rust/__init__.pyi index dad5083..6a489e4 100644 --- a/src/wasp2_rust/__init__.pyi +++ b/src/wasp2_rust/__init__.pyi @@ -45,13 +45,26 @@ class ImbalanceResult(TypedDict): pval: float fdr_pval: float -class DispersionFit(TypedDict): +class _DispersionFitDiagnostics(TypedDict, total=False): + """Optional optimizer diagnostics supplied by the Rust backend.""" + + fit_status: str + optimizer: str + optimizer_status: str | int + optimizer_converged: bool + optimizer_iterations: int + optimizer_evaluations: int + optimizer_objective: float + +class DispersionFit(_DispersionFitDiagnostics): """Dispersion parameters fitted from independent observations.""" method: str rho: float | None linear_d1: float | None linear_d2: float | None + linear_depth_center: float | None + linear_depth_scale: float | None n_observations: int class ImbalanceRun(TypedDict): @@ -62,6 +75,8 @@ class ImbalanceRun(TypedDict): rho: float | None linear_d1: float | None linear_d2: float | None + linear_depth_center: float | None + linear_depth_scale: float | None n_observations: int requested_phased: bool effective_phased: bool @@ -620,6 +635,8 @@ def analyze_imbalance_run( rho: float | None = None, linear_d1: float | None = None, linear_d2: float | None = None, + linear_depth_center: float | None = None, + linear_depth_scale: float | None = None, exact_snv: bool | None = None, ) -> ImbalanceRun: """Analyze one table with optional fixed nuisance parameters.""" From ca25fd8fad498757cbd27a959fec9b923fc1b417 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 14:27:26 -0700 Subject: [PATCH 07/22] test: update linear dispersion oracle --- tests/analysis/test_donor_local_statistics.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/analysis/test_donor_local_statistics.py b/tests/analysis/test_donor_local_statistics.py index 42d99b2..5f0dec6 100644 --- a/tests/analysis/test_donor_local_statistics.py +++ b/tests/analysis/test_donor_local_statistics.py @@ -62,8 +62,15 @@ def _rho_at_depth(dispersion: pd.Series, total: int) -> float: if dispersion["model"] == "single": rho = float(dispersion["rho"]) else: - linear = float(dispersion["linear_d1"]) + total * float(dispersion["linear_d2"]) - rho = float(expit(np.clip(linear, -10.0, 10.0))) + center = float(dispersion["linear_depth_center"]) + scale = float(dispersion["linear_depth_scale"]) + assert np.isfinite(center) + assert np.isfinite(scale) and scale > 0 + standardized_depth = (total - center) / scale + linear = float(dispersion["linear_d1"]) + standardized_depth * float( + dispersion["linear_d2"] + ) + rho = float(expit(linear)) return float(np.clip(rho, RHO_EPSILON, 1.0 - RHO_EPSILON)) @@ -229,7 +236,16 @@ def test_model_scope_unit_matrix_matches_independent_scipy_oracle( assert observed["alt_count"].sum() == counts["alt_count"].sum() if dispersion_scope == "global": assert dispersion["fit_id"].nunique() == 1 - parameters = ["rho"] if model == "single" else ["linear_d1", "linear_d2"] + parameters = ( + ["rho"] + if model == "single" + else [ + "linear_d1", + "linear_d2", + "linear_depth_center", + "linear_depth_scale", + ] + ) assert all(dispersion[column].nunique() == 1 for column in parameters) else: assert dispersion["fit_id"].nunique() == 2 From 6dbdb0ac5fa4dd72ae3d47e442e7fe3e770c33bf Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy Date: Tue, 21 Jul 2026 15:45:26 -0700 Subject: [PATCH 08/22] chore(release): finalize donor-local 1.5.0 candidate --- CHANGELOG.md | 11 ++++++++++- CITATION.cff | 2 +- rust/src/analysis.rs | 5 +++++ rust/src/lib.rs | 8 ++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c950273..4518add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,16 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] -## [1.5.0] - 2026-07-14 +## [1.5.0] - 2026-07-21 ### Added - `wasp2-count count-cohort` for one-final-BAM-per-donor bulk counting with explicit `donor_id`/`vcf_sample` mapping, atomic locked outputs, and SHA-256 provenance manifests. - Linear-dispersion and donor-specific-dispersion beta-binomial implementations in the Rust analysis API, including phased feature-level inference. +- Donor-local bulk imbalance orchestration through `wasp2-analyze find-imbalance` with explicit + SNV or feature units, global or per-donor nuisance fitting, donor-local inference and BH + correction, manifest pinning, and non-overwriting result, dispersion, QC, and provenance files. - `--per-variant` / `--snv-solo` for independent SNV tests and explicit `--region_col` support for feature- or peak-level aggregation. - A Rust single-cell ATAC allele counter with chromosome-parallel execution and Python fallback. @@ -22,6 +25,8 @@ All notable changes to WASP2 will be documented in this file. with the archived Python implementation; allele-string genotypes do not imply cross-SNV phase. - `--phased` and `--region_col` are forwarded to the Rust backend; unsupported `--groupby` requests fail closed and direct users to `--region_col `. +- SNV analysis is unphased by contract, while feature analysis supports phased and unphased + likelihoods without pooling effect inference across donors. - Single-cell AnnData output now follows the scverse `obs=cells`, `var=variants` orientation. - Packaging derives the Python version from installed metadata and synchronizes Cargo, pixi, containers, Galaxy, Nextflow, documentation, and citation metadata from one release command. @@ -34,6 +39,10 @@ All notable changes to WASP2 will be documented in this file. - Unphased multi-SNV likelihoods now use log-space dynamic programming to prevent underflow. - Linear inference uses per-SNV dispersion throughout the alternative optimization, phased donor-specific inference uses each observation's donor dispersion, and LRTs are non-negative. +- Linear nuisance fits now standardize raw depth and persist the fitted center and scale with both + coefficients so fixed global fits are reused exactly in every donor-local run. +- Ambiguous multi-allelic count rows are rejected instead of being silently coerced into + biallelic SNV identities. - Bulk counts use `UInt32`, and single-cell pseudobulk/observation sums widen safely instead of overflowing at 65,535; single-cell counting also works without an optional feature file. - Count-analysis TSV parsing is header-driven, so `pos0`, `GT`, sample, and donor metadata cannot diff --git a/CITATION.cff b/CITATION.cff index 2dd28b9..6d02346 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -23,7 +23,7 @@ authors: affiliation: "Salk Institute for Biological Studies" version: "1.5.0" -date-released: "2026-07-14" +date-released: "2026-07-21" license: MIT repository-code: "https://github.com/mcvickerlab/WASP2" diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index bc5e88b..0031fb6 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -968,6 +968,7 @@ pub fn fdr_correction(pvals: &[f64]) -> Vec { /// Single dispersion model analysis /// /// Python equivalent: `single_model()` in as_analysis.py +#[allow(dead_code)] fn single_model_with_dispersion( variants: Vec, phased: bool, @@ -986,6 +987,7 @@ fn single_model_with_dispersion( Ok((results, disp)) } +#[allow(dead_code)] pub fn single_model(variants: Vec, phased: bool) -> Result> { Ok(single_model_with_dispersion(variants, phased)?.0) } @@ -994,6 +996,7 @@ pub fn single_model(variants: Vec, phased: bool) -> Result, phased: bool, @@ -1012,6 +1015,7 @@ pub fn linear_model_with_dispersion( Ok((results, d1, d2)) } +#[allow(dead_code)] pub fn linear_model(variants: Vec, phased: bool) -> Result> { Ok(linear_model_with_dispersion(variants, phased)?.0) } @@ -1398,6 +1402,7 @@ pub fn analyze_imbalance_detailed( analyze_prepared(prepared, config, parameters, false) } +#[allow(dead_code)] pub fn analyze_imbalance( variants: Vec, config: &AnalysisConfig, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 2068571..1d6859a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,4 +1,8 @@ -#![allow(non_local_definitions)] +#![allow( + clippy::too_many_arguments, + clippy::type_complexity, + non_local_definitions +)] use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -1077,7 +1081,7 @@ fn parse_intersect_bed_multi( // Convert sample_alleles to list of tuples let alleles_list = PyList::empty(py); for (h1, h2) in &span.sample_alleles { - let tuple = pyo3::types::PyTuple::new(py, &[h1.as_str(), h2.as_str()])?; + let tuple = pyo3::types::PyTuple::new(py, [h1.as_str(), h2.as_str()])?; alleles_list.append(&tuple)?; } span_dict.set_item("sample_alleles", alleles_list)?; From b5be30933029d667677a55136865157feb1ba411 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:13 -0700 Subject: [PATCH 09/22] fix: preserve Python SNP-feature dispersion observations --- CHANGELOG.md | 3 + docs/source/user_guide/analysis.rst | 11 ++- src/analysis/run_analysis_per_donor.py | 34 ++++++--- tests/analysis/test_donor_archive_parity.py | 82 +++++++++++++++++++++ tests/analysis/test_donor_local_contract.py | 22 +++++- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5c5b7..017a9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Donor dispersion fits retain overlapping SNP-feature observations as archived + Python does, with separate donor-feature identities in global fits. Unique-SNP + QC and donor eligibility counts retain their existing meanings. - Bulk SNP counting preserves archived Python input-order priority across paired mates, with read names consumed before checking aligned bases at each SNP. - Restore archived chi-square LRT p-values and first-SNP-anchored unphased diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index bbbe4cd..2502510 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -53,14 +53,21 @@ The supported units are ``snv`` and ``feature``; ``peak`` is an alias for ``feature``. SNV analysis is unphased. Feature analysis may use ``--phased`` and uses the locked ``region`` column, or an explicit ``--region-col`` for a standalone count table. Overlapping feature memberships are retained for feature -tests but contribute only one donor-SNV row to dispersion fitting. +tests and dispersion fitting, matching archived Python's retained SNP-feature +observations. Exact duplicate memberships are removed. ``--dispersion-scope per-donor`` fits ``single`` or ``linear`` nuisance parameters independently for each included donor. ``--dispersion-scope global`` -fits unique eligible donor-SNV rows once and reuses the returned fit ID and +fits the combined eligible donor-SNP-feature observations and reuses the returned fit ID and parameters unchanged in each donor run. In both modes, effect inference and Benjamini-Hochberg correction are independent within each donor. +QC ``eligible_snv_observations`` and dispersion-sidecar +``donor_eligible_snv_observations`` count unique eligible SNPs per donor, so +multiple memberships do not inflate the donor eligibility threshold. +``fit_observations`` counts actual retained fitting rows: all included donors +for a global fit, or the individual donor for a per-donor fit. + The ``linear`` model carries four inseparable nuisance values: ``linear_d1``, ``linear_d2``, ``linear_depth_center``, and ``linear_depth_scale``. The fit ID includes all four, and fixed-nuisance donor diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py index c3d00ee..081f5dc 100644 --- a/src/analysis/run_analysis_per_donor.py +++ b/src/analysis/run_analysis_per_donor.py @@ -635,22 +635,31 @@ def _fit_dispersion( model: Model, min_count: int, pseudocount: int, + region_col: str | None = None, ) -> DispersionFit: fitter = rust_fit_imbalance_dispersion if fitter is None: raise RuntimeError("Rust dispersion-fitting API is not available") fit_columns = [*SNV_COLUMNS, "ref_count", "alt_count"] - _write_tsv(frame, path, fit_columns) + fit_frame = frame.loc[:, fit_columns].copy() + # Rust deduplicates within its region key. Preserve Python's SNP-feature + # observations and keep identical sites in separate donors distinct. + memberships = frame[region_col] if region_col is not None else [None] * len(frame) + fit_frame["_fit_identity"] = [ + json.dumps([donor, region], ensure_ascii=True, separators=(",", ":")) + for donor, region in zip(frame["donor_id"], memberships, strict=True) + ] + _write_tsv(fit_frame, path, [*fit_columns, "_fit_identity"]) base_kwargs: dict[str, Any] = { "min_count": min_count, "pseudocount": pseudocount, "method": model, } - full_kwargs = {**base_kwargs, "phased": False, "region_col": "donor_id"} + full_kwargs = {**base_kwargs, "phased": False, "region_col": "_fit_identity"} is_pyo3 = _is_known_pyo3_api(fitter) if is_pyo3: - # donor_id is part of the fit identity. This is essential for a global - # fit because the same genomic site in two donors is two observations. + # The donor-feature key retains the same observation multiplicity in + # global and per-donor fits; only the fitting scope changes. raw_fit = fitter(str(path), **full_kwargs) else: parameters = inspect.signature(fitter).parameters @@ -1008,11 +1017,7 @@ def run_per_donor_analysis( ) eligible_rows = frame.loc[frame["_total_count"] >= min_count].copy() - fit_rows = ( - eligible_rows.drop_duplicates(SNV_COLUMNS, keep="first") - .sort_values(SNV_COLUMNS, kind="mergesort") - .copy() - ) + fit_rows = eligible_rows.copy() donor_qc = ( frame.groupby("donor_id", sort=True) .agg( @@ -1028,7 +1033,10 @@ def run_per_donor_analysis( .rename("unique_snv_rows") ) eligible_counts = ( - fit_rows.groupby("donor_id", sort=True).size().rename("eligible_snv_observations") + eligible_rows.drop_duplicates(SNV_COLUMNS) + .groupby("donor_id", sort=True) + .size() + .rename("eligible_snv_observations") ) eligible_test_rows = ( eligible_rows.groupby("donor_id", sort=True).size().rename("eligible_test_rows") @@ -1060,6 +1068,7 @@ def run_per_donor_analysis( model=model, min_count=min_count, pseudocount=pseudocount, + region_col=normalized_region_col, ) result_frames: list[pd.DataFrame] = [] @@ -1075,6 +1084,7 @@ def run_per_donor_analysis( model=model, min_count=min_count, pseudocount=pseudocount, + region_col=normalized_region_col, ) rust_region_col, donor_rows = _prepare_analysis_table( donor_rows, @@ -1125,7 +1135,9 @@ def run_per_donor_analysis( dispersion_row.update( { "fit_observations": fit.n_observations, - "donor_eligible_snv_observations": len(donor_fit_rows), + "donor_eligible_snv_observations": len( + donor_fit_rows.drop_duplicates(SNV_COLUMNS) + ), "result_rows": len(donor_results), } ) diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index 9d528b7..87f3c5b 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -71,3 +71,85 @@ def test_donor_default_pseudocount_matches_python(tmp_path): actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) comparison = compare_frames(pd.DataFrame(REFERENCE["bulk"]["atac/False/1"]), actual) assert comparison["status"] == "PASS", comparison + + +@pytest.mark.rust +@pytest.mark.parametrize("dispersion_scope", ["global", "per-donor"]) +@pytest.mark.parametrize("phased", [False, True]) +@pytest.mark.parametrize("pc", [0, 1]) +def test_overlapping_donor_features_match_python(tmp_path, dispersion_scope, phased, pc): + """Preserve Python observation weights with heterogeneous donors and overlaps.""" + import contextlib + import io + + import numpy as np + from scipy.optimize import OptimizeResult, minimize_scalar + from scipy.stats import betabinom + from scripts.compare_archive_analysis import load_archive + + first = bulk_counts().drop(columns="gene") + second = first.copy() + second["ref_count"] = [11, 12, 21, 20, 17, 22, 8, 11, 19, 21, 9, 12] + second["alt_count"] = [9, 8, 19, 20, 23, 18, 12, 9, 21, 19, 11, 8] + # An identical observation in both donors must survive the global fit. + second.loc[2, ["ref_count", "alt_count"]] = first.loc[2, ["ref_count", "alt_count"]] + donors = {} + for donor, base in [("donorA", first), ("donorB", second)]: + extra = base.iloc[:2].copy() + extra["region"] = "overlap" + # Reverse order to exercise the original first-SNP anchor as well. + donors[donor] = pd.concat([base, extra], ignore_index=True).iloc[::-1].copy() + retained = pd.concat([base.assign(donor_id=donor) for donor, base in donors.items()]) + counts = pd.concat([retained, retained.iloc[:1]], ignore_index=True) + count_file = tmp_path / "counts.tsv" + counts.to_csv(count_file, sep="\t", index=False) + paths = run_per_donor_analysis( + count_file, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + dispersion_scope=dispersion_scope, + phased=phased, + pseudocount=pc, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + fits = pd.read_csv(paths["dispersion"], sep="\t").set_index("donor_id") + qc = pd.read_csv(paths["qc"], sep="\t").set_index("donor_id") + archive = load_archive()["as_analysis"] + original_optimizer = archive.minimize_scalar + global_rho = None + if dispersion_scope == "global": + ref = retained["ref_count"].to_numpy() + pc + depth = retained["ref_count"].to_numpy() + retained["alt_count"].to_numpy() + 2 * pc + + def balanced_null(rho): + shape = 0.5 * (1 - rho) / rho + return -np.sum(betabinom.logpmf(ref, depth, shape, shape)) + + global_rho = minimize_scalar(balanced_null, method="bounded", bounds=(0, 1)).x + + def shared_null_optimizer(fun, *args, **kwargs): + # Only replace the balanced-null dispersion fit. Effect optimizers + # still execute the archived Python functions separately per donor. + if len(kwargs.get("args", ())) == 2: + return OptimizeResult(x=global_rho) + return original_optimizer(fun, *args, **kwargs) + + archive.minimize_scalar = shared_null_optimizer + for donor, base in donors.items(): + with contextlib.redirect_stdout(io.StringIO()): + expected = archive.get_imbalance( + base.copy(), + region_col="region", + phased=phased, + pseudocount=pc, + ) + comparison = compare_frames(expected, actual.loc[actual["donor_id"] == donor]) + assert comparison["status"] == "PASS", comparison + assert fits.loc[donor, "fit_observations"] == (28 if dispersion_scope == "global" else 14) + assert fits.loc[donor, "donor_eligible_snv_observations"] == 12 + assert qc.loc[donor, "eligible_snv_observations"] == 12 + assert qc.loc[donor, "eligible_test_rows"] == 14 + if global_rho is not None: + np.testing.assert_allclose(fits.loc[donor, "rho"], global_rho, rtol=5e-5, atol=5e-5) diff --git a/tests/analysis/test_donor_local_contract.py b/tests/analysis/test_donor_local_contract.py index 95e7ef8..77e181f 100644 --- a/tests/analysis/test_donor_local_contract.py +++ b/tests/analysis/test_donor_local_contract.py @@ -342,7 +342,7 @@ def test_both_donor_columns_fail_closed(tmp_path: Path, monkeypatch: pytest.Monk @pytest.mark.unit -def test_overlapping_feature_membership_is_preserved_but_fit_once_per_snv( +def test_overlapping_feature_membership_is_preserved_in_dispersion_fit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -363,7 +363,7 @@ def test_overlapping_feature_membership_is_preserved_but_fit_once_per_snv( ) assert len(calls["fit"]) == 1 - assert len(calls["fit"][0]["frame"]) == 8 + assert len(calls["fit"][0]["frame"]) == 10 assert [len(call["frame"]) for call in calls["analyze"]] == [5, 5] results = pd.read_csv(paths["results"], sep="\t") assert results.groupby("donor_id")["feature_id"].apply(set).to_dict() == { @@ -683,3 +683,21 @@ def test_sidecar_and_provenance_schema( assert record["sha256"] == hashlib.sha256(paths[name].read_bytes()).hexdigest() assert record["size_bytes"] == paths[name].stat().st_size datetime.fromisoformat(record["mtime_utc"]) + + +@pytest.mark.unit +def test_overlapping_features_do_not_inflate_donor_eligibility(tmp_path, monkeypatch): + counts = tmp_path / "counts.tsv" + _write_counts(counts, overlap=True) + calls = _install_backend(monkeypatch) + # Each donor has five SNP-feature rows but only four unique SNPs. + with pytest.raises(RuntimeError, match="No donors met"): + donor_analysis.run_per_donor_analysis( + counts, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + min_count=0, + min_donor_observations=5, + ) + assert calls["fit"] == [] From cad72befce52fa49f7fe7ace41dc6d82138da9a1 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:17:10 -0700 Subject: [PATCH 10/22] fix: preserve Python donor phasing fallback and provenance --- CHANGELOG.md | 3 + docs/source/user_guide/analysis.rst | 8 ++ src/analysis/run_analysis_per_donor.py | 65 +++++++++------ tests/analysis/test_donor_archive_parity.py | 89 +++++++++++++++++++++ tests/analysis/test_donor_local_contract.py | 15 +++- tests/analysis/test_per_donor_peak.py | 17 ++-- tests/analysis/test_per_donor_snv.py | 15 +++- 7 files changed, 172 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 017a9af..1626d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Donor feature analysis follows archived Python phasing fallback for missing, + identical, or nonnumeric GT after depth filtering, without converting allele + strings. Provenance records requested and effective phase per included donor. - Donor dispersion fits retain overlapping SNP-feature observations as archived Python does, with separate donor-feature identities in global fits. Unique-SNP QC and donor eligibility counts retain their existing meanings. diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index 2502510..0865e76 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -62,6 +62,14 @@ fits the combined eligible donor-SNP-feature observations and reuses the returne parameters unchanged in each donor run. In both modes, effect inference and Benjamini-Hochberg correction are independent within each donor. +A feature-level ``--phased`` request follows archived Python's fallback checks +within each donor after depth filtering. Missing GT, a single distinct GT, or +no numeric ``0|1``/``1|0`` genotype selects unphased inference. Allele-string GT +such as ``A|G`` is retained rather than converted to numeric phase. Provenance +``donors.phasing`` records each included donor's ``requested_phased``, +``effective_phased``, and ``fallback_reason``; ``analysis.phased`` remains the +original request. A shared dispersion fit does not share phasing decisions. + QC ``eligible_snv_observations`` and dispersion-sidecar ``donor_eligible_snv_observations`` count unique eligible SNPs per donor, so multiple memberships do not inflate the donor eligibility threshold. diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py index 081f5dc..0f8f3e5 100644 --- a/src/analysis/run_analysis_per_donor.py +++ b/src/analysis/run_analysis_per_donor.py @@ -272,31 +272,32 @@ def _validate_text_column(frame: pd.DataFrame, column: str) -> None: frame[column] = values -def _normalize_genotypes(frame: pd.DataFrame) -> dict[str, int]: - if "GT" not in frame.columns: - raise ValueError("Phased feature analysis requires a GT column") - gt = frame["GT"].astype(str) - ref = frame["ref"] - alt = frame["alt"] - numeric_forward = gt.eq("0|1") - numeric_reverse = gt.eq("1|0") - allele_forward = gt.eq(ref + "|" + alt) - allele_reverse = gt.eq(alt + "|" + ref) - valid = numeric_forward | numeric_reverse | allele_forward | allele_reverse - if not valid.all(): - bad = frame.loc[~valid, ["donor_id", "chrom", "pos", "ref", "alt", "GT"]].iloc[0] - raise ValueError( - f"Phased GT must be exact 0|1, 1|0, REF|ALT, or ALT|REF; found {bad.to_dict()}" - ) - forward = numeric_forward | allele_forward - frame["GT"] = forward.map({True: "0|1", False: "1|0"}) +def _genotype_qc(frame: pd.DataFrame) -> dict[str, int]: + gt = frame["GT"] if "GT" in frame.columns else pd.Series(dtype=str) return { - "forward": int(forward.sum()), - "reverse": int((~forward).sum()), - "converted_from_alleles": int((allele_forward | allele_reverse).sum()), + "forward": int(gt.eq("0|1").sum()), + "reverse": int(gt.eq("1|0").sum()), + "converted_from_alleles": 0, } +def _phase_fallback_reason(frame: pd.DataFrame, requested: bool) -> str | None: + """Apply archived Python's checks after donor-local depth filtering.""" + if not requested: + return "not_requested" + if "GT" not in frame.columns: + return "missing_gt" + gt = frame["GT"] + if gt.nunique(dropna=False) <= 1: + return "identical_gt" + if not gt.isin(["0|1", "1|0"]).any(): + return "no_numeric_phased_gt" + # The archive parses the first numeric allele only after these checks. + # Malformed mixed genotypes must not be silently treated as informative. + gt.str.split("|", n=1).str[0].astype("uint8") + return None + + def _first_conflicting_group( frame: pd.DataFrame, identity: Sequence[str], @@ -425,9 +426,9 @@ def _load_counts( + frame["alt"] ) - gt_qc = _normalize_genotypes(frame) if phased else None + gt_qc = _genotype_qc(frame) if phased else None repeated_values = ["ref_count", "alt_count"] - if phased: + if phased and "GT" in frame.columns: repeated_values.append("GT") count_conflict = _first_conflicting_group(frame, SNV_COLUMNS, repeated_values) if count_conflict is not None: @@ -817,7 +818,7 @@ def _prepare_analysis_table( ) -> tuple[str | None, pd.DataFrame]: columns = ["chrom", "pos", "ref", "alt"] rust_region_col = None - if phased: + if phased and "GT" in donor_frame.columns: columns.append("GT") if unit == "feature": if region_col is None: @@ -1074,6 +1075,7 @@ def run_per_donor_analysis( result_frames: list[pd.DataFrame] = [] dispersion_rows: list[dict[str, Any]] = [] provenance_fits: dict[str, dict[str, Any]] = {} + donor_phasing: list[dict[str, Any]] = [] for donor_id in included_donors: donor_id = str(donor_id) donor_rows = eligible_rows.loc[eligible_rows["donor_id"] == donor_id].copy() @@ -1086,6 +1088,7 @@ def run_per_donor_analysis( pseudocount=pseudocount, region_col=normalized_region_col, ) + fallback_reason = _phase_fallback_reason(donor_rows, phased) rust_region_col, donor_rows = _prepare_analysis_table( donor_rows, work / "donor_counts.tsv", @@ -1105,8 +1108,17 @@ def run_per_donor_analysis( _require_exact_fit_echo(run, fit, donor_id) if bool(run.get("requested_phased")) != phased: raise RuntimeError(f"Rust donor run for {donor_id} changed requested_phased") - if phased and not bool(run.get("effective_phased")): - raise RuntimeError(f"Rust silently fell back to unphased analysis for {donor_id}") + effective_phased = bool(run.get("effective_phased")) + if effective_phased != (phased and fallback_reason is None): + raise RuntimeError(f"Rust phasing decision disagrees with Python for {donor_id}") + donor_phasing.append( + { + "donor_id": donor_id, + "requested_phased": phased, + "effective_phased": effective_phased, + "fallback_reason": fallback_reason, + } + ) donor_results = _format_donor_results( run, donor_rows, @@ -1230,6 +1242,7 @@ def run_per_donor_analysis( "total": len(qc), "included": int(qc["analyzed"].sum()), "excluded": int((~qc["analyzed"]).sum()), + "phasing": donor_phasing, }, "outputs": output_records, } diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index 87f3c5b..3ae2782 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -153,3 +153,92 @@ def shared_null_optimizer(fun, *args, **kwargs): assert qc.loc[donor, "eligible_test_rows"] == 14 if global_rho is not None: np.testing.assert_allclose(fits.loc[donor, "rho"], global_rho, rtol=5e-5, atol=5e-5) + + +@pytest.mark.rust +@pytest.mark.parametrize("scope", ["global", "per-donor"]) +@pytest.mark.parametrize("pc", [0, 1]) +@pytest.mark.parametrize( + "case,reason", + [ + ("missing", "missing_gt"), + ("same_forward", "identical_gt"), + ("same_reverse", "identical_gt"), + ("alleles", "no_numeric_phased_gt"), + ("unphased", "no_numeric_phased_gt"), + ("filtered", "identical_gt"), + ("informative", None), + ], +) +def test_donor_phasing_matches_archive_fallback(tmp_path, scope, pc, case, reason): + import contextlib + import io + + from scripts.compare_archive_analysis import load_archive + + base = bulk_counts().drop(columns="gene") + if case == "missing": + base = base.drop(columns="GT") + elif case in {"same_forward", "same_reverse"}: + base["GT"] = "0|1" if case == "same_forward" else "1|0" + elif case == "alleles": + base["GT"] = base["GT"].replace({"0|1": "A|G", "1|0": "G|A"}) + elif case == "unphased": + base["GT"] = base["GT"].replace({"0|1": "0/1", "1|0": "1/0"}) + elif case == "filtered": + base.loc[base["GT"] == "1|0", ["ref_count", "alt_count"]] = 0 + file = tmp_path / "counts.tsv" + base.assign(donor_id="donorA").to_csv(file, sep="\t", index=False) + with contextlib.redirect_stdout(io.StringIO()): + expected = load_archive()["as_analysis"].get_imbalance( + base.copy(), + region_col="region", + phased=True, + pseudocount=pc, + ) + paths = run_per_donor_analysis( + file, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + phased=True, + pseudocount=pc, + dispersion_scope=scope, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + comparison = compare_frames(expected, actual) + assert comparison["status"] == "PASS", comparison + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["analysis"]["phased"] is True + assert provenance["donors"]["phasing"] == [ + { + "donor_id": "donorA", + "requested_phased": True, + "effective_phased": reason is None, + "fallback_reason": reason, + } + ] + + +@pytest.mark.rust +def test_donor_phasing_decisions_are_independent(tmp_path): + base = bulk_counts().drop(columns="gene") + first = base.assign(donor_id="informative") + second = base.assign(donor_id="fallback", GT="0|1") + file = tmp_path / "counts.tsv" + pd.concat([first, second]).to_csv(file, sep="\t", index=False) + paths = run_per_donor_analysis( + file, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + phased=True, + dispersion_scope="global", + min_donor_observations=4, + ) + provenance = json.loads(paths["provenance"].read_text()) + decisions = {row["donor_id"]: row for row in provenance["donors"]["phasing"]} + assert decisions["informative"]["effective_phased"] is True + assert decisions["fallback"]["effective_phased"] is False + assert decisions["fallback"]["fallback_reason"] == "identical_gt" diff --git a/tests/analysis/test_donor_local_contract.py b/tests/analysis/test_donor_local_contract.py index 77e181f..42e5e35 100644 --- a/tests/analysis/test_donor_local_contract.py +++ b/tests/analysis/test_donor_local_contract.py @@ -675,7 +675,20 @@ def test_sidecar_and_provenance_schema( "pseudocount": 0, "min_donor_observations": 1, } - assert provenance["donors"] == {"total": 2, "included": 2, "excluded": 0} + assert provenance["donors"] == { + "total": 2, + "included": 2, + "excluded": 0, + "phasing": [ + { + "donor_id": donor, + "requested_phased": False, + "effective_phased": False, + "fallback_reason": "not_requested", + } + for donor in ["donor_a", "donor_b"] + ], + } assert set(provenance["outputs"]) == {"results", "dispersion", "qc"} for name, record in provenance["outputs"].items(): assert set(record) == {"path", "sha256", "size_bytes", "mtime_utc"} diff --git a/tests/analysis/test_per_donor_peak.py b/tests/analysis/test_per_donor_peak.py index 81bd83c..ae5ba90 100644 --- a/tests/analysis/test_per_donor_peak.py +++ b/tests/analysis/test_per_donor_peak.py @@ -13,7 +13,7 @@ def _write_peak_counts(path: Path, *, bad_gt: bool = False) -> None: count = 50 if donor != "donor_low" else 2 for index in range(count): ref, alt = ("A", "G") - gt = f"{ref}|{alt}" if index % 2 == 0 else f"{alt}|{ref}" + gt = "0|1" if index % 2 == 0 else "1|0" if bad_gt and donor == "donor_a" and index == 0: gt = "A/G" rows.append( @@ -34,7 +34,7 @@ def _write_peak_counts(path: Path, *, bad_gt: bool = False) -> None: @pytest.mark.unit -def test_per_donor_peak_analysis_normalizes_phase_and_isolates_models(tmp_path, monkeypatch): +def test_per_donor_peak_analysis_preserves_numeric_phase_and_isolates_models(tmp_path, monkeypatch): counts = tmp_path / "counts.tsv" _write_peak_counts(counts) calls = [] @@ -113,16 +113,9 @@ def fake_rust(path, *, dispersion_fit, **kwargs): def test_per_donor_peak_analysis_rejects_nonphased_gt(tmp_path, monkeypatch): counts = tmp_path / "counts.tsv" _write_peak_counts(counts, bad_gt=True) - monkeypatch.setattr(peak_analysis, "rust_fit_imbalance_dispersion", lambda *a, **k: None) - monkeypatch.setattr(peak_analysis, "rust_analyze_imbalance_run", lambda *a, **k: None) - with pytest.raises(ValueError, match="Phased GT must be exact"): - peak_analysis.run_per_donor_analysis( - counts, - tmp_path / "results.tsv", - unit="feature", - region_col="capeak_id", - phased=True, - ) + frame = pd.read_csv(counts, sep="\t") + with pytest.raises(ValueError, match="invalid literal"): + peak_analysis._phase_fallback_reason(frame.loc[frame["sample"] == "donor_a"], True) @pytest.mark.unit diff --git a/tests/analysis/test_per_donor_snv.py b/tests/analysis/test_per_donor_snv.py index 91dc4b5..cd35b5b 100644 --- a/tests/analysis/test_per_donor_snv.py +++ b/tests/analysis/test_per_donor_snv.py @@ -99,7 +99,20 @@ def fake_rust(path, *, dispersion_fit, **kwargs): provenance = json.loads(paths["provenance"].read_text()) assert provenance["analysis"]["scope"] == "per-donor" assert provenance["analysis"]["multiple_testing"].endswith("within each donor") - assert provenance["donors"] == {"excluded": 1, "included": 2, "total": 3} + assert provenance["donors"] == { + "excluded": 1, + "included": 2, + "total": 3, + "phasing": [ + { + "donor_id": donor, + "requested_phased": False, + "effective_phased": False, + "fallback_reason": "not_requested", + } + for donor in ["donor_a", "donor_b"] + ], + } @pytest.mark.unit From 99242118067b084e33209c243fae32f261da0df6 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:23:57 -0700 Subject: [PATCH 11/22] fix: preserve Python duplicate SNP observations in donor analysis --- CHANGELOG.md | 3 + docs/source/user_guide/analysis.rst | 9 ++- src/analysis/run_analysis_per_donor.py | 61 ++++++++------ tests/analysis/test_donor_archive_parity.py | 90 +++++++++++++++++++++ tests/analysis/test_donor_local_contract.py | 2 +- 5 files changed, 139 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1626d56..4d618a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Retain distinct repeated SNP count observations in donor analysis and apply + Python phase-aware deduplication after depth filtering. SNV outputs aggregate + retained observations per position; fitting preserves their phase identities. - Donor feature analysis follows archived Python phasing fallback for missing, identical, or nonnumeric GT after depth filtering, without converting allele strings. Provenance records requested and effective phase per included donor. diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index 0865e76..7a818dc 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -54,7 +54,11 @@ The supported units are ``snv`` and ``feature``; ``peak`` is an alias for uses the locked ``region`` column, or an explicit ``--region-col`` for a standalone count table. Overlapping feature memberships are retained for feature tests and dispersion fitting, matching archived Python's retained SNP-feature -observations. Exact duplicate memberships are removed. +observations. Deduplication follows Python's chromosome, position, counts, and +feature key, including GT only when phasing is effective. Different count +observations at the same SNP are retained. SNV output aggregates those retained +observations into one result per position, as archived Python does. Allele and +SNV-ID consistency checks remain enforced. ``--dispersion-scope per-donor`` fits ``single`` or ``linear`` nuisance parameters independently for each included donor. ``--dispersion-scope global`` @@ -67,7 +71,8 @@ within each donor after depth filtering. Missing GT, a single distinct GT, or no numeric ``0|1``/``1|0`` genotype selects unphased inference. Allele-string GT such as ``A|G`` is retained rather than converted to numeric phase. Provenance ``donors.phasing`` records each included donor's ``requested_phased``, -``effective_phased``, and ``fallback_reason``; ``analysis.phased`` remains the +``effective_phased``, and ``fallback_reason`` before phase-aware deduplication; +``analysis.phased`` remains the original request. A shared dispersion fit does not share phasing decisions. QC ``eligible_snv_observations`` and dispersion-sidecar diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py index 0f8f3e5..96c42f6 100644 --- a/src/analysis/run_analysis_per_donor.py +++ b/src/analysis/run_analysis_per_donor.py @@ -427,18 +427,12 @@ def _load_counts( ) gt_qc = _genotype_qc(frame) if phased else None - repeated_values = ["ref_count", "alt_count"] - if phased and "GT" in frame.columns: - repeated_values.append("GT") - count_conflict = _first_conflicting_group(frame, SNV_COLUMNS, repeated_values) - if count_conflict is not None: - donor_id, chrom, pos, ref, alt = count_conflict - raise ValueError( - f"Conflicting repeated rows for donor/SNV {donor_id} {chrom}:{pos}:{ref}>{alt}" - ) - raw_rows = len(frame) - identity = [*SNV_COLUMNS, selected_region] if selected_region is not None else SNV_COLUMNS + identity = [*SNV_COLUMNS, "ref_count", "alt_count"] + if selected_region is not None: + identity.append(selected_region) + if phased and "GT" in frame.columns: + identity.append("GT") frame = frame.drop_duplicates(identity, keep="first").copy() frame["pos"] = frame["pos"].astype("uint32") frame["ref_count"] = frame["ref_count"].astype("uint32") @@ -637,6 +631,7 @@ def _fit_dispersion( min_count: int, pseudocount: int, region_col: str | None = None, + phased: bool = False, ) -> DispersionFit: fitter = rust_fit_imbalance_dispersion if fitter is None: @@ -646,9 +641,14 @@ def _fit_dispersion( # Rust deduplicates within its region key. Preserve Python's SNP-feature # observations and keep identical sites in separate donors distinct. memberships = frame[region_col] if region_col is not None else [None] * len(frame) + phases = pd.Series(None, index=frame.index, dtype=object) + if phased: + for _, donor_frame in frame.groupby("donor_id", sort=False): + if _phase_fallback_reason(donor_frame, True) is None: + phases.loc[donor_frame.index] = donor_frame["GT"] fit_frame["_fit_identity"] = [ - json.dumps([donor, region], ensure_ascii=True, separators=(",", ":")) - for donor, region in zip(frame["donor_id"], memberships, strict=True) + json.dumps([donor, region, gt], ensure_ascii=True, separators=(",", ":")) + for donor, region, gt in zip(frame["donor_id"], memberships, phases, strict=True) ] _write_tsv(fit_frame, path, [*fit_columns, "_fit_identity"]) base_kwargs: dict[str, Any] = { @@ -849,13 +849,11 @@ def _format_donor_results( ) if unit == "snv": - annotations = donor_frame.loc[ - :, ["snv_id", "chrom", "pos", "ref", "alt", "ref_count", "alt_count"] - ].rename( - columns={ - "ref_count": "expected_ref_count", - "alt_count": "expected_alt_count", - } + annotation_columns = ["snv_id", "chrom", "pos", "ref", "alt"] + annotations = donor_frame.groupby(annotation_columns, sort=False, as_index=False).agg( + expected_ref_count=("ref_count", "sum"), + expected_alt_count=("alt_count", "sum"), + expected_snp_count=("pos", "size"), ) annotations["region"] = annotations["chrom"] + "_" + annotations["pos"].astype(str) results = annotations.merge(results, on="region", how="inner", validate="one_to_one") @@ -865,12 +863,14 @@ def _format_donor_results( results["ref_count"].ne(results["expected_ref_count"]) | results["alt_count"].ne(results["expected_alt_count"]) | results["N"].ne(results["expected_ref_count"] + results["expected_alt_count"]) - | results["snp_count"].ne(1) + | results["snp_count"].ne(results["expected_snp_count"]) ) if mismatch.any(): raise RuntimeError(f"Rust SNV counts differ from input for donor {donor_id}") leading_columns = ["donor_id", "snv_id", "chrom", "pos", "ref", "alt"] - results = results.drop(columns=["region", "expected_ref_count", "expected_alt_count"]) + results = results.drop( + columns=["region", "expected_ref_count", "expected_alt_count", "expected_snp_count"] + ) else: if region_col is None: raise RuntimeError("Feature analysis has no normalized region column") @@ -1018,6 +1018,19 @@ def run_per_donor_analysis( ) eligible_rows = frame.loc[frame["_total_count"] >= min_count].copy() + retained_groups = [] + phase_decisions: dict[str, str | None] = {} + for donor_id, donor_rows in eligible_rows.groupby("donor_id", sort=False): + fallback_reason = _phase_fallback_reason(donor_rows, phased) + phase_decisions[str(donor_id)] = fallback_reason + identity = [*SNV_COLUMNS, "ref_count", "alt_count"] + if normalized_region_col is not None: + identity.append(normalized_region_col) + if phased and fallback_reason is None: + identity.append("GT") + retained_groups.append(donor_rows.drop_duplicates(identity, keep="first")) + if retained_groups: + eligible_rows = pd.concat(retained_groups).sort_index(kind="stable") fit_rows = eligible_rows.copy() donor_qc = ( frame.groupby("donor_id", sort=True) @@ -1070,6 +1083,7 @@ def run_per_donor_analysis( min_count=min_count, pseudocount=pseudocount, region_col=normalized_region_col, + phased=phased, ) result_frames: list[pd.DataFrame] = [] @@ -1087,8 +1101,9 @@ def run_per_donor_analysis( min_count=min_count, pseudocount=pseudocount, region_col=normalized_region_col, + phased=phased, ) - fallback_reason = _phase_fallback_reason(donor_rows, phased) + fallback_reason = phase_decisions[donor_id] rust_region_col, donor_rows = _prepare_analysis_table( donor_rows, work / "donor_counts.tsv", diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index 3ae2782..2d6e97c 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -242,3 +242,93 @@ def test_donor_phasing_decisions_are_independent(tmp_path): assert decisions["informative"]["effective_phased"] is True assert decisions["fallback"]["effective_phased"] is False assert decisions["fallback"]["fallback_reason"] == "identical_gt" + + +@pytest.mark.rust +@pytest.mark.parametrize("unit,phased", [("snv", False), ("feature", False), ("feature", True)]) +@pytest.mark.parametrize("pc", [0, 1]) +@pytest.mark.parametrize("scope", ["global", "per-donor"]) +def test_distinct_repeated_snp_observations_match_archive(tmp_path, unit, phased, pc, scope): + import contextlib + import io + + from scripts.compare_archive_analysis import load_archive + + base = bulk_counts().drop(columns="gene") + changed = base.iloc[:1].copy() + changed["ref_count"] += 3 + opposite = base.iloc[:1].copy() + opposite["GT"] = "1|0" + parts = [base, changed, opposite, changed.copy()] + if unit == "feature": + overlapping = changed.copy() + overlapping["region"] = "overlap" + parts.append(overlapping) + base = pd.concat(parts, ignore_index=True) + if unit == "snv": + base = base.drop(columns="region") + counts = pd.concat([base.assign(donor_id=donor) for donor in ["A", "B"]]) + file = tmp_path / "counts.tsv" + counts.to_csv(file, sep="\t", index=False) + with contextlib.redirect_stdout(io.StringIO()): + expected = load_archive()["as_analysis"].get_imbalance( + base.copy(), + region_col="region" if unit == "feature" else None, + phased=phased, + pseudocount=pc, + ) + if unit == "snv": + expected = expected.rename(columns={"variant": "region"}) + paths = run_per_donor_analysis( + file, + tmp_path / "results.tsv", + unit=unit, + region_col="region" if unit == "feature" else None, + phased=phased, + pseudocount=pc, + dispersion_scope=scope, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t") + for _, observed in actual.groupby("donor_id"): + observed = observed.copy() + if unit == "feature": + observed = observed.rename(columns={"feature_id": "region"}) + else: + observed["region"] = observed["chrom"] + "_" + observed["pos"].astype(str) + comparison = compare_frames(expected, observed) + assert comparison["status"] == "PASS", comparison + + +@pytest.mark.rust +def test_fallback_duplicate_gt_is_ignored_after_phase_decision(tmp_path): + import contextlib + import io + + from scripts.compare_archive_analysis import load_archive + + base = bulk_counts().drop(columns="gene").assign(GT="A|G") + duplicate = base.iloc[:1].copy() + duplicate["GT"] = "G|A" + base = pd.concat([base, duplicate], ignore_index=True) + file = tmp_path / "counts.tsv" + base.assign(donor_id="A").to_csv(file, sep="\t", index=False) + with contextlib.redirect_stdout(io.StringIO()): + expected = load_archive()["as_analysis"].get_imbalance( + base.copy(), + region_col="region", + phased=True, + ) + paths = run_per_donor_analysis( + file, + tmp_path / "results.tsv", + unit="feature", + region_col="region", + phased=True, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + assert compare_frames(expected, actual)["status"] == "PASS" + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["donors"]["phasing"][0]["fallback_reason"] == "no_numeric_phased_gt" + assert pd.read_csv(paths["dispersion"], sep="\t")["fit_observations"].item() == 12 diff --git a/tests/analysis/test_donor_local_contract.py b/tests/analysis/test_donor_local_contract.py index 42e5e35..3077ef9 100644 --- a/tests/analysis/test_donor_local_contract.py +++ b/tests/analysis/test_donor_local_contract.py @@ -379,7 +379,7 @@ def test_overlapping_feature_membership_is_preserved_in_dispersion_fit( @pytest.mark.unit -@pytest.mark.parametrize("conflict_column,replacement", [("alt", "T"), ("ref_count", 17)]) +@pytest.mark.parametrize("conflict_column,replacement", [("alt", "T")]) def test_conflicting_repeated_feature_rows_fail_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 2d80abcfd369ff82871bf05e1261f8853ac425e9 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:58:38 -0700 Subject: [PATCH 12/22] fix: match Python linear dispersion fitting and prediction --- CHANGELOG.md | 4 + docs/source/user_guide/analysis.rst | 12 +- rust/src/analysis.rs | 253 ++++++++---------- tests/analysis/test_donor_archive_parity.py | 111 ++++++++ tests/analysis/test_donor_local_statistics.py | 2 +- 5 files changed, 243 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d618a9..8435254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Linear dispersion follows archived Python single-start Nelder-Mead fitting + on pseudocounted depth, then converts coefficients for reusable raw-depth + standardization. Equal depths use a unit scale; testing no longer applies + the fitting-only predictor clip. - Retain distinct repeated SNP count observations in donor analysis and apply Python phase-aware deduplication after depth filtering. SNV outputs aggregate retained observations per position; fitting preserves their phase identities. diff --git a/docs/source/user_guide/analysis.rst b/docs/source/user_guide/analysis.rst index 7a818dc..1b11b7e 100644 --- a/docs/source/user_guide/analysis.rst +++ b/docs/source/user_guide/analysis.rst @@ -83,7 +83,17 @@ for a global fit, or the individual donor for a per-donor fit. The ``linear`` model carries four inseparable nuisance values: ``linear_d1``, ``linear_d2``, ``linear_depth_center``, and -``linear_depth_scale``. The fit ID includes all four, and fixed-nuisance donor +``linear_depth_scale``. + +Linear fitting follows archived Python's balanced-null objective on +pseudocounted depth, with Nelder-Mead starting at ``(0, 0)`` and SciPy's default +simplex, tolerances, and evaluation limits. The fitted coefficients are then +converted to the standardized raw-depth representation above. Equal-depth +inputs use a unit scale, preserving their fitted predictions. The ``[-10, 10]`` +predictor guard applies during fitting only; testing uses the unclipped sigmoid +prediction with the existing numerical rho boundary guard. + +The fit ID includes all four, and fixed-nuisance donor runs must echo all four exactly. The dispersion TSV and provenance JSON retain these values. They retain optimizer or fit status fields when Rust supplies them; otherwise ``fit_status=parameters_returned`` records only that a usable diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index 42bc98b..ac1be03 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -150,7 +150,7 @@ impl DispersionParameters { depth_scale, } => { let standardized_depth = (n as f64 - depth_center) / depth_scale; - clamp_rho(expit((d1 + standardized_depth * d2).clamp(-10.0, 10.0))) + clamp_rho(expit(d1 + standardized_depth * d2)) } } } @@ -388,16 +388,13 @@ fn depth_standardization(raw_depths: &[u32]) -> Result<(f64, f64)> { if !scale.is_finite() { anyhow::bail!("linear depth scale is non-finite"); } - if scale <= 0.0 { - anyhow::bail!("linear dispersion requires non-zero raw depth variance"); - } - Ok((center, scale)) + // A unit scale represents constant depths without changing predictions. + // The archive fits these inputs rather than rejecting them. + Ok((center, if scale > 0.0 { scale } else { 1.0 })) } -/// Optimize standardized linear dispersion parameters using Nelder-Mead. -/// -/// Fits rho = expit(d1 + d2 * ((N - center) / scale)) using raw read depth for -/// the predictor and pseudocounted observations for the beta-binomial likelihood. +/// Fit the archived raw, pseudocounted-depth predictor from (0, 0), then +/// express it using reusable standardized raw-depth parameters. fn optimize_dispersion_linear( ref_counts: &[u32], n_array: &[u32], @@ -415,11 +412,8 @@ fn optimize_dispersion_linear( } let mut neg_ll = 0.0; - for ((&ref_count, &n), &raw_depth) in - ref_counts.iter().zip(n_array.iter()).zip(raw_depths.iter()) - { - let standardized_depth = (raw_depth as f64 - depth_center) / depth_scale; - let predictor = d1 + standardized_depth * d2; + for (&ref_count, &n) in ref_counts.iter().zip(n_array.iter()) { + let predictor = d1 + f64::from(n) * d2; if predictor.is_nan() { return f64::INFINITY; } @@ -438,33 +432,19 @@ fn optimize_dispersion_linear( neg_ll }; - // A constant-rho fit provides a data-driven intercept. Standardization makes - // fixed slope starts meaningful across donors with different depth ranges. - let scalar_rho = clamp_rho(optimize_dispersion(ref_counts, n_array)?); - let scalar_intercept = (scalar_rho / (1.0 - scalar_rho)).ln(); - let starts: [[f64; 2]; 9] = [ - [scalar_intercept, 0.0], - [scalar_intercept, -0.25], - [scalar_intercept, 0.25], - [scalar_intercept, -1.0], - [scalar_intercept, 1.0], - [scalar_intercept - 2.0, 0.0], - [scalar_intercept + 2.0, 0.0], - [0.0, -0.5], - [0.0, 0.5], - ]; - let mut best: Option<(f64, (f64, f64))> = None; - for s in starts { - if let Ok((d1, d2)) = nelder_mead_2d(objective, s, 1e-6, 1000) { - let val = objective([d1, d2]); - if val.is_finite() && best.is_none_or(|(bv, _)| val < bv) { - best = Some((val, (d1, d2))); - } - } + // Match scipy.optimize.minimize(method="Nelder-Mead", x0=(0, 0)): + // default simplex, xatol=fatol=1e-4, maxiter=maxfev=400 for two parameters. + let (intercept, slope) = nelder_mead_2d(objective, [0.0, 0.0], 1e-4, 400)?; + let offset = f64::from(n_array[0]) - f64::from(raw_depths[0]); + if n_array + .iter() + .zip(raw_depths) + .any(|(&n, &raw)| (f64::from(n) - f64::from(raw)) != offset) + { + anyhow::bail!("linear fit requires a consistent pseudocount offset"); } - let (d1, d2) = best - .map(|(_, parameters)| parameters) - .ok_or_else(|| anyhow::anyhow!("linear dispersion optimization failed from all starts"))?; + let d1 = intercept + slope * (depth_center + offset); + let d2 = slope * depth_scale; Ok((d1, d2, depth_center, depth_scale)) } @@ -795,103 +775,98 @@ fn nelder_mead_2d(f: F, x0: [f64; 2], tol: f64, max_iter: usize) -> Result<(f where F: Fn([f64; 2]) -> f64, { - // Standard Nelder-Mead coefficients - let alpha = 1.0; // reflection - let gamma = 2.0; // expansion - let rho = 0.5; // contraction - let sigma = 0.5; // shrinkage - - // Initialize simplex: equilateral triangle around x0 - let step = 1.0; + let step = |x: f64| if x == 0.0 { 0.00025 } else { 1.05 * x }; let mut simplex = [ (x0, f(x0)), - ([x0[0] + step, x0[1]], f([x0[0] + step, x0[1]])), - ([x0[0], x0[1] + step], f([x0[0], x0[1] + step])), + ([step(x0[0]), x0[1]], f([step(x0[0]), x0[1]])), + ([x0[0], step(x0[1])], f([x0[0], step(x0[1])])), ]; - - for _iter in 0..max_iter { - // Sort by function value (ascending) - simplex.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); - - let (x_best, f_best) = simplex[0]; - let (x_worst, f_worst) = simplex[2]; - let (x_second, f_second) = simplex[1]; - - // Check convergence: simplex size - let size = ((x_worst[0] - x_best[0]).powi(2) + (x_worst[1] - x_best[1]).powi(2)).sqrt(); - if size < tol { - return Ok((x_best[0], x_best[1])); + let mut evaluations = 3; + let mut evaluate = |point| { + if evaluations >= max_iter { + None + } else { + evaluations += 1; + Some(f(point)) } - - // Centroid of all points except worst - let centroid = [ - (x_best[0] + x_second[0]) / 2.0, - (x_best[1] + x_second[1]) / 2.0, - ]; - - // Reflection - let x_r = [ - centroid[0] + alpha * (centroid[0] - x_worst[0]), - centroid[1] + alpha * (centroid[1] - x_worst[1]), - ]; - let f_r = f(x_r); - - if f_r < f_second && f_r >= f_best { - // Accept reflection - simplex[2] = (x_r, f_r); - continue; + }; + 'iterations: for _ in 1..max_iter { + simplex.sort_by(|a, b| a.1.total_cmp(&b.1)); + let (best, best_value) = simplex[0]; + let (second, second_value) = simplex[1]; + let (worst, worst_value) = simplex[2]; + let coordinate_spread = simplex[1..] + .iter() + .flat_map(|(x, _)| [(x[0] - best[0]).abs(), (x[1] - best[1]).abs()]) + .fold(0.0, f64::max); + let objective_spread = simplex[1..] + .iter() + .map(|(_, value)| (value - best_value).abs()) + .fold(0.0, f64::max); + if coordinate_spread <= tol && objective_spread <= tol { + break; } - - if f_r < f_best { - // Try expansion - let x_e = [ - centroid[0] + gamma * (x_r[0] - centroid[0]), - centroid[1] + gamma * (x_r[1] - centroid[1]), + let center = [0.5 * (best[0] + second[0]), 0.5 * (best[1] + second[1])]; + let reflected = [2.0 * center[0] - worst[0], 2.0 * center[1] - worst[1]]; + let Some(reflected_value) = evaluate(reflected) else { + break; + }; + if reflected_value < best_value { + let expanded = [ + 3.0 * center[0] - 2.0 * worst[0], + 3.0 * center[1] - 2.0 * worst[1], ]; - let f_e = f(x_e); - - if f_e < f_r { - simplex[2] = (x_e, f_e); + let Some(expanded_value) = evaluate(expanded) else { + break; + }; + simplex[2] = if expanded_value < reflected_value { + (expanded, expanded_value) } else { - simplex[2] = (x_r, f_r); - } - continue; - } - - // f_r >= f_second: try contraction - let x_c = if f_r < f_worst { - // Outside contraction - [ - centroid[0] + rho * (x_r[0] - centroid[0]), - centroid[1] + rho * (x_r[1] - centroid[1]), - ] + (reflected, reflected_value) + }; + } else if reflected_value < second_value { + simplex[2] = (reflected, reflected_value); } else { - // Inside contraction - [ - centroid[0] + rho * (x_worst[0] - centroid[0]), - centroid[1] + rho * (x_worst[1] - centroid[1]), - ] - }; - let f_c = f(x_c); - - if f_c < f_worst { - simplex[2] = (x_c, f_c); - continue; - } - - // Shrink toward best point - for point in simplex.iter_mut().skip(1) { - let (x_i, _) = *point; - let x_new = [ - x_best[0] + sigma * (x_i[0] - x_best[0]), - x_best[1] + sigma * (x_i[1] - x_best[1]), - ]; - *point = (x_new, f(x_new)); + let outside = reflected_value < worst_value; + let contracted = if outside { + [ + 1.5 * center[0] - 0.5 * worst[0], + 1.5 * center[1] - 0.5 * worst[1], + ] + } else { + [ + 0.5 * center[0] + 0.5 * worst[0], + 0.5 * center[1] + 0.5 * worst[1], + ] + }; + let Some(contracted_value) = evaluate(contracted) else { + break; + }; + let accept = if outside { + contracted_value <= reflected_value + } else { + contracted_value < worst_value + }; + if accept { + simplex[2] = (contracted, contracted_value); + } else { + for point in simplex.iter_mut().skip(1) { + point.0 = [ + best[0] + 0.5 * (point.0[0] - best[0]), + best[1] + 0.5 * (point.0[1] - best[1]), + ]; + let Some(value) = evaluate(point.0) else { + break 'iterations; + }; + point.1 = value; + } + } } } - - // Return best point after max iterations - simplex.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + simplex.sort_by(|a, b| a.1.total_cmp(&b.1)); + if !simplex[0].1.is_finite() { + anyhow::bail!("linear dispersion optimization produced no finite objective"); + } Ok((simplex[0].0[0], simplex[0].0[1])) } @@ -961,7 +936,8 @@ pub fn single_model(variants: Vec, phased: bool) -> Result, @@ -1426,20 +1402,19 @@ mod tests { } #[test] - fn test_linear_predictor_keeps_archived_fit_guard() { + fn test_linear_prediction_is_not_clipped_at_fit_guard() { let parameters = DispersionParameters::Linear { d1: 0.0, - d2: 100.0, + d2: 12.0, depth_center: 0.0, depth_scale: 1.0, }; - assert!((parameters.rho_at_depth(1) - expit(10.0)).abs() < 1e-12); + assert!((parameters.rho_at_depth(1) - expit(12.0)).abs() < 1e-15); } #[test] - fn test_depth_standardization_rejects_zero_variance() { - let error = depth_standardization(&[20, 20, 20]).unwrap_err(); - assert!(error.to_string().contains("non-zero raw depth variance")); + fn test_depth_standardization_accepts_constant_depth() { + assert_eq!(depth_standardization(&[20, 20, 20]).unwrap(), (20.0, 1.0)); } #[test] @@ -1716,7 +1691,7 @@ mod tests { } #[test] - fn test_linear_fit_rejects_zero_raw_depth_variance() { + fn test_linear_fit_accepts_constant_raw_depth() { let variants = vec![vc_peak(100, 8, 2, None), vc_peak(200, 6, 4, None)]; let config = AnalysisConfig { min_count: 0, @@ -1724,7 +1699,11 @@ mod tests { method: AnalysisMethod::Linear, phased: false, }; - let error = fit_imbalance_dispersion(variants, &config).unwrap_err(); - assert!(error.to_string().contains("non-zero raw depth variance")); + let fit = fit_imbalance_dispersion(variants, &config).unwrap(); + assert_eq!( + fit.parameters.linear_depth_standardization(), + Some((10.0, 1.0)) + ); + assert!(fit.parameters.rho_at_depth(10).is_finite()); } } diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index 2d6e97c..0653cc4 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -332,3 +332,114 @@ def test_fallback_duplicate_gt_is_ignored_after_phase_decision(tmp_path): provenance = json.loads(paths["provenance"].read_text()) assert provenance["donors"]["phasing"][0]["fallback_reason"] == "no_numeric_phased_gt" assert pd.read_csv(paths["dispersion"], sep="\t")["fit_observations"].item() == 12 + + +@pytest.mark.rust +@pytest.mark.parametrize("depth_case", ["variable", "constant", "overlapping"]) +@pytest.mark.parametrize("scope", ["global", "per-donor"]) +@pytest.mark.parametrize("pc", [0, 1]) +@pytest.mark.parametrize("phased", [False, True]) +def test_linear_donor_matches_archive_fitting(tmp_path, depth_case, scope, pc, phased): + """Explicit array repairs; phased cases also repair the archive's ignored flag.""" + import contextlib + import io + + import numpy as np + from scipy.optimize import OptimizeResult + from scipy.special import expit + from scripts.compare_archive_analysis import adapt_linear_reference, load_archive + + base = bulk_counts().drop(columns="gene") + if depth_case == "constant": + base["alt_count"] = 40 - base["ref_count"] + elif depth_case == "overlapping": + extra = base.iloc[:2].copy() + extra["region"] = "overlap" + base = pd.concat([base, extra], ignore_index=True) + second = base.copy() + depths = second["ref_count"] + second["alt_count"] + second["ref_count"] = np.minimum(second["ref_count"] + 2, depths - 1) + second["alt_count"] = depths - second["ref_count"] + donors = {"A": base, "B": second} + counts = pd.concat( + [frame.assign(donor_id=donor) for donor, frame in donors.items()], ignore_index=True + ) + file = tmp_path / "counts.tsv" + counts.to_csv(file, sep="\t", index=False) + archive_modules = load_archive() + adapt_linear_reference(archive_modules) + archive = archive_modules["as_analysis"] + original_minimize = archive.minimize + if phased: + original_parse_opt = archive.parse_opt + + def forward_ignored_phase(frame, disp=None, phased=False): + # Explicitly repair only linear_model's missing phased argument. + return original_parse_opt(frame, disp=disp, phased=True) + + archive.parse_opt = forward_ignored_phase + pooled_parameters = None + if scope == "global": + pooled_parameters = original_minimize( + archive.opt_linear, + x0=(0, 0), + method="Nelder-Mead", + args=( + counts["ref_count"].to_numpy() + pc, + counts["ref_count"].to_numpy() + counts["alt_count"].to_numpy() + 2 * pc, + ), + ).x + reference_parameters = [] + + def reference_fit(*args, **kwargs): + result = ( + OptimizeResult(x=pooled_parameters) + if pooled_parameters is not None + else original_minimize(*args, **kwargs) + ) + reference_parameters.append(result.x.copy()) + return result + + archive.minimize = reference_fit + expected_frames = {} + coefficients = {} + with contextlib.redirect_stdout(io.StringIO()): + for donor, donor_counts in donors.items(): + expected_frames[donor] = archive.get_imbalance( + donor_counts.copy(), + method="linear", + region_col="region", + phased=phased, + pseudocount=pc, + ) + coefficients[donor] = reference_parameters[-1] + paths = run_per_donor_analysis( + file, + tmp_path / "results.tsv", + unit="feature", + model="linear", + region_col="region", + pseudocount=pc, + phased=phased, + dispersion_scope=scope, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + fits = pd.read_csv(paths["dispersion"], sep="\t") + for _, fit in fits.iterrows(): + donor = fit["donor_id"] + donor_counts = donors[donor] + raw_depth = (donor_counts["ref_count"] + donor_counts["alt_count"]).to_numpy() + intercept, slope = coefficients[donor] + expected_rho = expit(intercept + slope * (raw_depth + 2 * pc)) + actual_rho = expit( + fit["linear_d1"] + + fit["linear_d2"] + * ((raw_depth - fit["linear_depth_center"]) / fit["linear_depth_scale"]) + ) + np.testing.assert_allclose(actual_rho, expected_rho, rtol=5e-5, atol=5e-5) + if depth_case == "constant": + assert fit["linear_depth_scale"] == 1 + for donor, observed in actual.groupby("donor_id"): + comparison = compare_frames(expected_frames[donor], observed) + assert comparison["status"] == "PASS", comparison diff --git a/tests/analysis/test_donor_local_statistics.py b/tests/analysis/test_donor_local_statistics.py index 7d1c9b3..ec490cb 100644 --- a/tests/analysis/test_donor_local_statistics.py +++ b/tests/analysis/test_donor_local_statistics.py @@ -70,7 +70,7 @@ def _rho_at_depth(dispersion: pd.Series, total: int) -> float: linear = float(dispersion["linear_d1"]) + standardized_depth * float( dispersion["linear_d2"] ) - rho = float(expit(np.clip(linear, -10.0, 10.0))) + rho = float(expit(linear)) return float(np.clip(rho, RHO_EPSILON, 1.0 - RHO_EPSILON)) From 8e3ef6a7db043d992a24693d88a714c526d31d56 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:00:02 -0700 Subject: [PATCH 13/22] fix: match Python bounded optimization of allelic proportions --- CHANGELOG.md | 4 + README.md | 6 + rust/src/analysis.rs | 56 +- .../real_atac_optimizer_reference.json | 2860 +++++++++++++++++ tests/analysis/test_real_atac_optimizer.py | 53 + 5 files changed, 2929 insertions(+), 50 deletions(-) create mode 100644 tests/analysis/fixtures/real_atac_optimizer_reference.json create mode 100644 tests/analysis/test_real_atac_optimizer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8435254..6c3522e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Allelic-proportion optimization uses archived Python bounded Brent defaults + for single-SNP, phased, and unphased bulk tests, including empty-allele inputs + with pseudocount 0. Balanced first-SNP unphased objectives can still select + mirrored proportions across numerical libraries; no tie-breaking rule is added. - Linear dispersion follows archived Python single-start Nelder-Mead fitting on pseudocounted depth, then converts coefficients for reusable raw-depth standardization. Equal depths use a unit scale; testing no longer applies diff --git a/README.md b/README.md index 237df23..cdeaa0d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ fit for every included donor; testing and BH correction remain donor-local. Both reuse `linear_d1`, `linear_d2`, `linear_depth_center`, and `linear_depth_scale` together. +Allelic-proportion fitting follows archived Python bounded Brent defaults for +SNP, phased, and unphased bulk tests. An unphased region whose first retained SNP +has equal reference and alternate counts has a symmetric likelihood: `mu` and +`1 - mu` are equally likely. Numerical libraries may select opposite optima; +the reported direction is not identifiable in that case. + Feature analysis preserves one SNV in every overlapping feature. `peak` remains an alias for `feature`: diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index ac1be03..fbb3e16 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -458,7 +458,7 @@ fn optimize_prob(ref_counts: &[u32], n_array: &[u32], disp: f64) -> Result<(f64, opt_prob(prob, disp, ref_counts[0], n_array[0]).unwrap_or(f64::INFINITY) }; - let mu = golden_section_search(objective, 0.0, 1.0, 1e-6)?; + let mu = scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500)?; let alt_ll = -objective(mu); return Ok((alt_ll, mu)); } @@ -475,7 +475,7 @@ fn optimize_prob(ref_counts: &[u32], n_array: &[u32], disp: f64) -> Result<(f64, sum }; - let mu = golden_section_search(objective, 0.0, 1.0, 1e-6)?; + let mu = scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500)?; let alt_ll = -objective(mu); Ok((alt_ll, mu)) } @@ -536,7 +536,7 @@ fn optimize_prob_phased( sum }; - let mu = golden_section_search(objective, 0.0, 1.0, 1e-6)?; + let mu = scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500)?; let alt_ll = -objective(mu); Ok((alt_ll, mu)) } @@ -723,50 +723,6 @@ where Ok(xf) } -/// Golden section search for 1D optimization -/// -/// Simple but robust method for bounded scalar optimization. -/// Equivalent to scipy's minimize_scalar with method='bounded' -#[allow(unused_assignments)] -fn golden_section_search(f: F, a: f64, mut b: f64, tol: f64) -> Result -where - F: Fn(f64) -> f64, -{ - let phi = (1.0 + 5.0_f64.sqrt()) / 2.0; - let inv_phi = 1.0 / phi; - let inv_phi2 = 1.0 / (phi * phi); - - let mut a = a; - let mut h = b - a; - - // Initial points - let mut c = a + inv_phi2 * h; - let mut d = a + inv_phi * h; - let mut fc = f(c); - let mut fd = f(d); - - // Iterate until convergence - while h.abs() > tol { - if fc < fd { - b = d; - d = c; - fd = fc; - h *= inv_phi; - c = a + inv_phi2 * h; - fc = f(c); - } else { - a = c; - c = d; - fc = fd; - h *= inv_phi; - d = a + inv_phi * h; - fd = f(d); - } - } - - Ok(if fc < fd { c } else { d }) -} - /// Nelder-Mead simplex optimization for 2D functions /// /// Used for fitting linear dispersion parameters (d1, d2). @@ -1094,7 +1050,7 @@ fn test_fixed_dispersion( .unwrap_or(f64::INFINITY) }; // Preserve the archived first-SNP reference orientation and domain. - let mu = golden_section_search(objective, 0.0, 1.0, 1e-6)?; + let mu = scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500)?; let alt_ll = -objective(mu); (alt_ll, mu) } else { @@ -1429,10 +1385,10 @@ mod tests { } #[test] - fn test_golden_section() { + fn test_scipy_bounded_minimize_interior_solution() { // Test optimization on simple quadratic let f = |x: f64| (x - 0.7).powi(2); - let min = golden_section_search(f, 0.0, 1.0, 1e-6).unwrap(); + let min = scipy_bounded_minimize(f, 0.0, 1.0, 1e-5, 500).unwrap(); assert!((min - 0.7).abs() < 1e-5); } diff --git a/tests/analysis/fixtures/real_atac_optimizer_reference.json b/tests/analysis/fixtures/real_atac_optimizer_reference.json new file mode 100644 index 0000000..1ccff5d --- /dev/null +++ b/tests/analysis/fixtures/real_atac_optimizer_reference.json @@ -0,0 +1,2860 @@ +{ + "provenance": { + "archive": "41354fc087404ba1fa99542803ee9cb9b7e0e6bf", + "bam": "ENCFF962FMH", + "peaks": "ENCFF452ZBX", + "vcf": "HG001 NISTv3.3.1 GRCh38 phased", + "subset": "real chr21 boundary observations and balanced-first-SNP peaks", + "reference": "Archived get_imbalance with independent archived full-data SciPy dispersion fixed, to isolate allelic-proportion optimization. No Rust output is used.", + "adaptations": [ + "fixed nuisance: replace only balanced-null fitting with archived full-data SciPy estimates", + "linear: explicit scalar/dispersion-array shape repairs", + "phased linear: forward ignored phased flag" + ], + "limitations": "Balanced-first-SNP unphased proportions may select mirrored, equally likely optima; raw mu equality remains checked in the full real-data report.", + "source_checksums": { + "ENCFF962FMH.bam": { + "md5": "9d7b262ae771a5e638473f801840772e", + "bytes": 7681789216 + }, + "HG001.phased.vcf.gz": { + "md5": "3ddb1305f14e39a04c93ba2db3f7247e", + "bytes": 106366697 + }, + "HG001.phased.vcf.gz.tbi": { + "md5": "1eb071673783d96cbbca3d9ca6f19e36", + "bytes": 1519511 + }, + "peaks.bed.gz": { + "md5": "9be61c6cb1158d47516fdedd54cfb333", + "bytes": 4134636 + } + } + }, + "cases": [ + { + "name": "boundary/single/False/0", + "family": "boundary", + "method": "single", + "phased": false, + "pseudocount": 0, + "fixed_dispersion": { + "rho": 0.04104616438385224 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -6.389225553037658, + "alt_ll": -5.874991948573438e-05, + "mu": 0.9999940391390134, + "lrt": 12.778333606236345, + "pval": 0.0003506571602214173, + "fdr_pval": 0.0003506571602214173 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -6.389225553037658, + "alt_ll": -5.874991946264174e-05, + "mu": 5.9608609865491405e-06, + "lrt": 12.77833360623639, + "pval": 0.00035065716022140845, + "fdr_pval": 0.0003506571602214173 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -7.5526012240973905, + "alt_ll": -7.024515342557436e-05, + "mu": 0.9999940391390134, + "lrt": 15.10506195788793, + "pval": 0.00010169024576284532, + "fdr_pval": 0.00023727724011330577 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -7.5526012240973905, + "alt_ll": -7.024515342557436e-05, + "mu": 0.9999940391390134, + "lrt": 15.10506195788793, + "pval": 0.00010169024576284532, + "fdr_pval": 0.00023727724011330577 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -6.389225553037658, + "alt_ll": -5.874991948573438e-05, + "mu": 0.9999940391390134, + "lrt": 12.778333606236345, + "pval": 0.0003506571602214173, + "fdr_pval": 0.0003506571602214173 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -9.263918812887031, + "alt_ll": -8.751774048754157e-05, + "mu": 0.9999940391390134, + "lrt": 18.527662590293087, + "pval": 1.6745618771578033e-05, + "fdr_pval": 0.00011721933140104623 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -6.790196131686823, + "alt_ll": -6.268803245745858e-05, + "mu": 5.9608609865491405e-06, + "lrt": 13.580266887308731, + "pval": 0.00022857573482696643, + "fdr_pval": 0.0003506571602214173 + } + ], + "archived_full_data_fit": { + "python_rho": 0.04104616438385224 + } + }, + { + "name": "boundary/single/False/1", + "family": "boundary", + "method": "single", + "phased": false, + "pseudocount": 1, + "fixed_dispersion": { + "rho": 0.031241338194282212 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.597171990310841, + "alt_ll": -1.1473986582418778, + "mu": 0.9156961652672377, + "lrt": 8.899546664137926, + "pval": 0.002852414634994033, + "fdr_pval": 0.002852414634994033 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.597171990310841, + "alt_ll": -1.1473986582418778, + "mu": 0.08430383473366374, + "lrt": 8.899546664137926, + "pval": 0.002852414634994033, + "fdr_pval": 0.002852414634994033 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.736573736332222, + "alt_ll": -1.1845073074386976, + "mu": 0.9280744517074505, + "lrt": 11.104132857787048, + "pval": 0.0008613555380084625, + "fdr_pval": 0.0020098295886864124 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.736573736332222, + "alt_ll": -1.1845073074386976, + "mu": 0.9280744517074505, + "lrt": 11.104132857787048, + "pval": 0.0008613555380084625, + "fdr_pval": 0.0020098295886864124 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.597171990310841, + "alt_ll": -1.1473986582418778, + "mu": 0.9156961652672377, + "lrt": 8.899546664137926, + "pval": 0.002852414634994033, + "fdr_pval": 0.002852414634994033 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -8.483817804847927, + "alt_ll": -1.2375382896551264, + "mu": 0.9412854790074102, + "lrt": 14.492559030385602, + "pval": 0.0001407142762752784, + "fdr_pval": 0.0009849999339269489 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -5.984979021124989, + "alt_ll": -1.160332184789425, + "mu": 0.07963337356719068, + "lrt": 9.649293672671128, + "pval": 0.0018942440703495743, + "fdr_pval": 0.002852414634994033 + } + ], + "archived_full_data_fit": { + "python_rho": 0.031241338194282212 + } + }, + { + "name": "boundary/single/True/0", + "family": "boundary", + "method": "single", + "phased": true, + "pseudocount": 0, + "fixed_dispersion": { + "rho": 0.041307162864532974 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -6.380341601363806, + "alt_ll": -5.868513188644897e-05, + "mu": 0.9999940391390134, + "lrt": 12.76056583246384, + "pval": 0.0003540037917148921, + "fdr_pval": 0.0003540037917148921 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -6.380341601363806, + "alt_ll": -5.868513188644897e-05, + "mu": 5.9608609865491405e-06, + "lrt": 12.76056583246384, + "pval": 0.0003540037917148921, + "fdr_pval": 0.0003540037917148921 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -7.5403641628088955, + "alt_ll": -7.015322184500405e-05, + "mu": 0.9999940391390134, + "lrt": 15.080588019174101, + "pval": 0.00010301723214466304, + "fdr_pval": 0.00024037354167088043 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -7.5403641628088955, + "alt_ll": -7.015322184500405e-05, + "mu": 0.9999940391390134, + "lrt": 15.080588019174101, + "pval": 0.00010301723214466304, + "fdr_pval": 0.00024037354167088043 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -6.380341601363806, + "alt_ll": -5.868513188644897e-05, + "mu": 0.9999940391390134, + "lrt": 12.76056583246384, + "pval": 0.0003540037917148921, + "fdr_pval": 0.0003540037917148921 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -9.246021344611592, + "alt_ll": -8.737785443813095e-05, + "mu": 0.9999940391390134, + "lrt": 18.491867933514307, + "pval": 1.706307936868434e-05, + "fdr_pval": 0.00011944155558079038 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -6.7802050689212905, + "alt_ll": -6.261440120169937e-05, + "mu": 5.9608609865491405e-06, + "lrt": 13.560284909040178, + "pval": 0.00023102202708643588, + "fdr_pval": 0.0003540037917148921 + } + ], + "archived_full_data_fit": { + "python_rho": 0.041307162864532974 + } + }, + { + "name": "boundary/single/True/1", + "family": "boundary", + "method": "single", + "phased": true, + "pseudocount": 1, + "fixed_dispersion": { + "rho": 0.03143939447095322 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.590394401920598, + "alt_ll": -1.1483895581132888, + "mu": 0.9156172386975225, + "lrt": 8.884009687614618, + "pval": 0.002876790416394797, + "fdr_pval": 0.002876790416394797 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.590394401920598, + "alt_ll": -1.1483895581132888, + "mu": 0.08438276130295859, + "lrt": 8.884009687614618, + "pval": 0.002876790416394797, + "fdr_pval": 0.002876790416394797 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.726891923863885, + "alt_ll": -1.1856208329780253, + "mu": 0.9279946875397128, + "lrt": 11.08254218177172, + "pval": 0.0008714424933497622, + "fdr_pval": 0.0020333658178161122 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.726891923863885, + "alt_ll": -1.1856208329780253, + "mu": 0.9279946875397128, + "lrt": 11.08254218177172, + "pval": 0.0008714424933497622, + "fdr_pval": 0.0020333658178161122 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.590394401920598, + "alt_ll": -1.1483895581132888, + "mu": 0.9156172386975225, + "lrt": 8.884009687614618, + "pval": 0.002876790416394797, + "fdr_pval": 0.002876790416394797 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -8.46901393882688, + "alt_ll": -1.238817635425435, + "mu": 0.9412058705174338, + "lrt": 14.46039260680289, + "pval": 0.00014313787117334125, + "fdr_pval": 0.0010019650982133889 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -5.977254607315782, + "alt_ll": -1.1613662182049467, + "mu": 0.0797126593628677, + "lrt": 9.63177677822167, + "pval": 0.0019123951599815383, + "fdr_pval": 0.002876790416394797 + } + ], + "archived_full_data_fit": { + "python_rho": 0.03143939447095322 + } + }, + { + "name": "boundary/linear/False/0", + "family": "boundary", + "method": "linear", + "phased": false, + "pseudocount": 0, + "fixed_dispersion": { + "linear_d1": -2.593059736583777, + "linear_d2": -0.010492431501324003, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.760760836871878, + "alt_ll": -5.403463685560439e-05, + "mu": 0.9999940391390134, + "lrt": 11.521413604470045, + "pval": 0.0006879905267612963, + "fdr_pval": 0.0006879905267612963 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.760760836871878, + "alt_ll": -5.403463685560439e-05, + "mu": 5.9608609865491405e-06, + "lrt": 11.521413604470045, + "pval": 0.0006879905267612963, + "fdr_pval": 0.0006879905267612963 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.76757910442724, + "alt_ll": -6.418607752323169e-05, + "mu": 0.9999940391390134, + "lrt": 13.535029836699433, + "pval": 0.00023415166085433029, + "fdr_pval": 0.0005463538753267707 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.76757910442724, + "alt_ll": -6.418607752323169e-05, + "mu": 0.9999940391390134, + "lrt": 13.535029836699433, + "pval": 0.00023415166085433029, + "fdr_pval": 0.0005463538753267707 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.760760836871878, + "alt_ll": -5.403463685560439e-05, + "mu": 0.9999940391390134, + "lrt": 11.521413604470045, + "pval": 0.0006879905267612963, + "fdr_pval": 0.0006879905267612963 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -8.291370765538963, + "alt_ll": -7.973514018111416e-05, + "mu": 0.9999940391390134, + "lrt": 16.582582060797563, + "pval": 4.657683078357323e-05, + "fdr_pval": 0.00032603781548501263 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -6.106138698146697, + "alt_ll": -5.750332318399387e-05, + "mu": 5.9608609865491405e-06, + "lrt": 12.212162389647027, + "pval": 0.00047478970679398677, + "fdr_pval": 0.0006879905267612963 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.593059736583777, + "python_slope": -0.010492431501324003 + } + }, + { + "name": "boundary/linear/False/1", + "family": "boundary", + "method": "linear", + "phased": false, + "pseudocount": 1, + "fixed_dispersion": { + "linear_d1": -2.975483969206107, + "linear_d2": -0.008125105440394378, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.1955904121383245, + "alt_ll": -1.2089534582944177, + "mu": 0.9105705969830463, + "lrt": 7.973273907687814, + "pval": 0.00474730010636262, + "fdr_pval": 0.00474730010636262 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.1955904121383245, + "alt_ll": -1.208953458294392, + "mu": 0.08942940301675331, + "lrt": 7.973273907687865, + "pval": 0.004747300106362484, + "fdr_pval": 0.00474730010636262 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.209991197647268, + "alt_ll": -1.2479189349492277, + "mu": 0.9233158555279374, + "lrt": 9.92414452539608, + "pval": 0.001631246482824818, + "fdr_pval": 0.0038062417932579087 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.209991197647268, + "alt_ll": -1.2479189349492277, + "mu": 0.9233158555279374, + "lrt": 9.92414452539608, + "pval": 0.001631246482824818, + "fdr_pval": 0.0038062417932579087 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.1955904121383245, + "alt_ll": -1.2089534582944177, + "mu": 0.9105705969830463, + "lrt": 7.973273907687814, + "pval": 0.00474730010636262, + "fdr_pval": 0.00474730010636262 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -7.7895130267974135, + "alt_ll": -1.300144735168458, + "mu": 0.9371887789608115, + "lrt": 12.978736583257911, + "pval": 0.0003150484858043847, + "fdr_pval": 0.002205339400630693 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -5.540111147939907, + "alt_ll": -1.222744984782235, + "mu": 0.0846441826505917, + "lrt": 8.634732326315344, + "pval": 0.0032981369619646747, + "fdr_pval": 0.00474730010636262 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.9592337583253183, + "python_slope": -0.008125105440394378 + } + }, + { + "name": "boundary/linear/True/0", + "family": "boundary", + "method": "linear", + "phased": true, + "pseudocount": 0, + "fixed_dispersion": { + "linear_d1": -2.5862627684737776, + "linear_d2": -0.0105182950874047, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.750665732409811, + "alt_ll": -5.395671651164946e-05, + "mu": 0.9999940391390134, + "lrt": 11.501223551386598, + "pval": 0.0006955039201319832, + "fdr_pval": 0.0006955039201319845 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.750665732409811, + "alt_ll": -5.395671651342582e-05, + "mu": 5.9608609865491405e-06, + "lrt": 11.501223551386595, + "pval": 0.0006955039201319845, + "fdr_pval": 0.0006955039201319845 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.754413049497881, + "alt_ll": -6.408167225657735e-05, + "mu": 0.9999940391390134, + "lrt": 13.508697935651249, + "pval": 0.00023746024524887906, + "fdr_pval": 0.0005540739055807178 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.754413049497881, + "alt_ll": -6.408167225657735e-05, + "mu": 0.9999940391390134, + "lrt": 13.508697935651249, + "pval": 0.00023746024524887906, + "fdr_pval": 0.0005540739055807178 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.750665732409811, + "alt_ll": -5.395671651164946e-05, + "mu": 0.9999940391390134, + "lrt": 11.501223551386598, + "pval": 0.0006955039201319832, + "fdr_pval": 0.0006955039201319845 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -8.273403375022747, + "alt_ll": -7.958790521911396e-05, + "mu": 0.9999940391390134, + "lrt": 16.546647574235056, + "pval": 4.7467829431069906e-05, + "fdr_pval": 0.0003322748060174893 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -6.095005896635046, + "alt_ll": -5.7416547379673943e-05, + "mu": 5.9608609865491405e-06, + "lrt": 12.189896960175332, + "pval": 0.0004804903731737427, + "fdr_pval": 0.0006955039201319845 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.5862627684737776, + "python_slope": -0.0105182950874047 + } + }, + { + "name": "boundary/linear/True/1", + "family": "boundary", + "method": "linear", + "phased": true, + "pseudocount": 1, + "fixed_dispersion": { + "linear_d1": -2.9682839533839394, + "linear_d2": -0.008153245836530314, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 16419450, + "ref": "A", + "alt": "G", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_16419450" + }, + { + "chrom": "chr21", + "pos": 19667014, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 0, + "alt_count": 12, + "other_count": 0, + "region": "chr21_19667014" + }, + { + "chrom": "chr21", + "pos": 42381603, + "ref": "C", + "alt": "A", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42381603" + }, + { + "chrom": "chr21", + "pos": 42389309, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 15, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389309" + }, + { + "chrom": "chr21", + "pos": 42389385, + "ref": "G", + "alt": "C", + "GT": "0|1", + "ref_count": 12, + "alt_count": 0, + "other_count": 0, + "region": "chr21_42389385" + }, + { + "chrom": "chr21", + "pos": 45483107, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 20, + "alt_count": 0, + "other_count": 0, + "region": "chr21_45483107" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21_46295649" + } + ], + "expected": [ + { + "region": "chr21_16419450", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.1874939257202275, + "alt_ll": -1.2102578684520156, + "mu": 0.9104567402190756, + "lrt": 7.954472114536424, + "pval": 0.0047968697957990756, + "fdr_pval": 0.0047968697957990756 + }, + { + "region": "chr21_19667014", + "ref_count": 0, + "alt_count": 12, + "N": 12, + "snp_count": 1, + "null_ll": -5.1874939257202275, + "alt_ll": -1.2102578684520156, + "mu": 0.08954325978262004, + "lrt": 7.954472114536424, + "pval": 0.0047968697957990756, + "fdr_pval": 0.0047968697957990756 + }, + { + "region": "chr21_42381603", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.1989096271204325, + "alt_ll": -1.2493196867622505, + "mu": 0.9232053615637866, + "lrt": 9.899179880716364, + "pval": 0.0016535244511698482, + "fdr_pval": 0.0038582237193963125 + }, + { + "region": "chr21_42389309", + "ref_count": 15, + "alt_count": 0, + "N": 15, + "snp_count": 1, + "null_ll": -6.1989096271204325, + "alt_ll": -1.2493196867622505, + "mu": 0.9232053615637866, + "lrt": 9.899179880716364, + "pval": 0.0016535244511698482, + "fdr_pval": 0.0038582237193963125 + }, + { + "region": "chr21_42389385", + "ref_count": 12, + "alt_count": 0, + "N": 12, + "snp_count": 1, + "null_ll": -5.1874939257202275, + "alt_ll": -1.2102578684520156, + "mu": 0.9104567402190756, + "lrt": 7.954472114536424, + "pval": 0.0047968697957990756, + "fdr_pval": 0.0047968697957990756 + }, + { + "region": "chr21_45483107", + "ref_count": 20, + "alt_count": 0, + "N": 20, + "snp_count": 1, + "null_ll": -7.7735334086592704, + "alt_ll": -1.3016520140508776, + "mu": 0.9370848672619511, + "lrt": 12.943762789216786, + "pval": 0.0003209891670852516, + "fdr_pval": 0.002246924169596761 + }, + { + "region": "chr21_46295649", + "ref_count": 0, + "alt_count": 13, + "N": 13, + "snp_count": 1, + "null_ll": -5.531021056825821, + "alt_ll": -1.2240849095076465, + "mu": 0.0847568789913965, + "lrt": 8.61387229463635, + "pval": 0.003336122833595065, + "fdr_pval": 0.0047968697957990756 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.9519774617108787, + "python_slope": -0.008153245836530314 + } + }, + { + "name": "balanced_anchor/single/False/0", + "family": "balanced_anchor", + "method": "single", + "phased": false, + "pseudocount": 0, + "fixed_dispersion": { + "rho": 0.03295621518540146 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.730375447080839, + "alt_ll": -4.699791043887814, + "mu": 0.41804483019409894, + "lrt": 0.06116880638604982, + "pval": 0.8046580410471728, + "fdr_pval": 0.8046580410471728 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -20.05985517962762, + "alt_ll": -16.211054204245176, + "mu": 0.2550470457103756, + "lrt": 7.697601950764884, + "pval": 0.005529424020491886, + "fdr_pval": 0.011058848040983772 + } + ], + "archived_full_data_fit": { + "python_rho": 0.03295621518540146 + } + }, + { + "name": "balanced_anchor/single/False/1", + "family": "balanced_anchor", + "method": "single", + "phased": false, + "pseudocount": 1, + "fixed_dispersion": { + "rho": 0.028099536389707032 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.7493742671525645, + "alt_ll": -4.740788728734382, + "mu": 0.5565148187766159, + "lrt": 0.017171076836364207, + "pval": 0.8957448865315235, + "fdr_pval": 0.8957448865315235 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -18.784723355306976, + "alt_ll": -16.021364256101982, + "mu": 0.2934713090368672, + "lrt": 5.526718198409988, + "pval": 0.018728201248774133, + "fdr_pval": 0.037456402497548266 + } + ], + "archived_full_data_fit": { + "python_rho": 0.028099536389707032 + } + }, + { + "name": "balanced_anchor/single/True/0", + "family": "balanced_anchor", + "method": "single", + "phased": true, + "pseudocount": 0, + "fixed_dispersion": { + "rho": 0.033056123608989116 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.7301187627048265, + "alt_ll": -4.118217657407499, + "mu": 0.38179142066123356, + "lrt": 1.2238022105946555, + "pval": 0.2686157553087597, + "fdr_pval": 0.2686157553087597 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -20.053415540712095, + "alt_ll": -12.810678949704371, + "mu": 0.25128088994567566, + "lrt": 14.485473182015447, + "pval": 0.00014124458999520496, + "fdr_pval": 0.0002824891799904099 + } + ], + "archived_full_data_fit": { + "python_rho": 0.033056123608989116 + } + }, + { + "name": "balanced_anchor/single/True/1", + "family": "balanced_anchor", + "method": "single", + "phased": true, + "pseudocount": 1, + "fixed_dispersion": { + "rho": 0.02818183091705621 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.749254004335004, + "alt_ll": -4.194251526845608, + "mu": 0.3942713717876904, + "lrt": 1.1100049549787911, + "pval": 0.2920808714350239, + "fdr_pval": 0.2920808714350239 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -18.77987837775621, + "alt_ll": -12.67297087907981, + "mu": 0.28726793301366754, + "lrt": 12.213814997352799, + "pval": 0.0004743693170833306, + "fdr_pval": 0.0009487386341666612 + } + ], + "archived_full_data_fit": { + "python_rho": 0.02818183091705621 + } + }, + { + "name": "balanced_anchor/linear/False/0", + "family": "balanced_anchor", + "method": "linear", + "phased": false, + "pseudocount": 0, + "fixed_dispersion": { + "linear_d1": -2.2896600988853835, + "linear_d2": -0.013397521759567543, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.744634953565037, + "alt_ll": -4.744634953565037, + "mu": 0.5000000000000017, + "lrt": -0.0, + "pval": 1.0, + "fdr_pval": 1.0 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -17.966232263157128, + "alt_ll": -15.922549744741294, + "mu": 0.26486392399336306, + "lrt": 4.087365036831667, + "pval": 0.04320494704484253, + "fdr_pval": 0.08640989408968507 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.2896600988853835, + "python_slope": -0.013397521759567543 + } + }, + { + "name": "balanced_anchor/linear/False/1", + "family": "balanced_anchor", + "method": "linear", + "phased": false, + "pseudocount": 1, + "fixed_dispersion": { + "linear_d1": -2.5581565149445686, + "linear_d2": -0.011773137230599792, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.7846650377208135, + "alt_ll": -4.784665037720812, + "mu": 0.5000000000000001, + "lrt": 3.552713678800501e-15, + "pval": 0.9999999524423742, + "fdr_pval": 0.9999999524423742 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -17.347274194860468, + "alt_ll": -15.985020795752241, + "mu": 0.3071512458291038, + "lrt": 2.724506798216453, + "pval": 0.09881864251465043, + "fdr_pval": 0.19763728502930086 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.534610240483369, + "python_slope": -0.011773137230599792 + } + }, + { + "name": "balanced_anchor/linear/True/0", + "family": "balanced_anchor", + "method": "linear", + "phased": true, + "pseudocount": 0, + "fixed_dispersion": { + "linear_d1": -2.288664881048436, + "linear_d2": -0.013380676923467512, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.744772385907499, + "alt_ll": -4.3474897457811235, + "mu": 0.38736461820875234, + "lrt": 0.7945652802527512, + "pval": 0.37272324505208654, + "fdr_pval": 0.37272324505208654 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -17.963444990605318, + "alt_ll": -12.671604709944702, + "mu": 0.25273415191599713, + "lrt": 10.58368056132123, + "pval": 0.001140902933929827, + "fdr_pval": 0.002281805867859654 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.288664881048436, + "python_slope": -0.013380676923467512 + } + }, + { + "name": "balanced_anchor/linear/True/1", + "family": "balanced_anchor", + "method": "linear", + "phased": true, + "pseudocount": 1, + "fixed_dispersion": { + "linear_d1": -2.557020377999981, + "linear_d2": -0.011759819173369684, + "linear_depth_center": 0.0, + "linear_depth_scale": 1.0 + }, + "counts": [ + { + "chrom": "chr21", + "pos": 37166764, + "ref": "A", + "alt": "G", + "GT": "1|0", + "ref_count": 7, + "alt_count": 7, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 37167055, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 13, + "other_count": 0, + "region": "chr21:37166752-37167128" + }, + { + "chrom": "chr21", + "pos": 46294770, + "ref": "A", + "alt": "T", + "GT": "1|0", + "ref_count": 5, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46294784, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295192, + "ref": "T", + "alt": "G", + "GT": "1|0", + "ref_count": 9, + "alt_count": 16, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295382, + "ref": "T", + "alt": "C", + "GT": "1|0", + "ref_count": 2, + "alt_count": 9, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295569, + "ref": "C", + "alt": "G", + "GT": "0|1", + "ref_count": 10, + "alt_count": 2, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295649, + "ref": "G", + "alt": "C", + "GT": "1|0", + "ref_count": 0, + "alt_count": 13, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295680, + "ref": "C", + "alt": "T", + "GT": "1|0", + "ref_count": 0, + "alt_count": 5, + "other_count": 0, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295862, + "ref": "T", + "alt": "C", + "GT": "0|1", + "ref_count": 7, + "alt_count": 3, + "other_count": 1, + "region": "chr21:46294723-46295998" + }, + { + "chrom": "chr21", + "pos": 46295949, + "ref": "G", + "alt": "A", + "GT": "0|1", + "ref_count": 4, + "alt_count": 1, + "other_count": 0, + "region": "chr21:46294723-46295998" + } + ], + "expected": [ + { + "region": "chr21:37166752-37167128", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "null_ll": -4.7848305371927236, + "alt_ll": -4.402304147994078, + "mu": 0.39804403318979276, + "lrt": 0.7650527783972905, + "pval": 0.38175187956066203, + "fdr_pval": 0.38175187956066203 + }, + { + "region": "chr21:46294723-46295998", + "ref_count": 33, + "alt_count": 48, + "N": 81, + "snp_count": 6, + "null_ll": -17.34507316816004, + "alt_ll": -12.824000565869431, + "mu": 0.29032495767393324, + "lrt": 9.042145204581221, + "pval": 0.0026382587926049032, + "fdr_pval": 0.0052765175852098065 + } + ], + "archived_full_data_fit": { + "python_intercept": -2.533500739653242, + "python_slope": -0.011759819173369684 + } + } + ] +} diff --git a/tests/analysis/test_real_atac_optimizer.py b/tests/analysis/test_real_atac_optimizer.py new file mode 100644 index 0000000..e566e63 --- /dev/null +++ b/tests/analysis/test_real_atac_optimizer.py @@ -0,0 +1,53 @@ +"""Conditional bulk inference against archived Python on real ATAC observations. + +The independently frozen Python fixture fixes the nuisance dispersion to isolate +allelic-proportion optimization. Full-data comparisons separately retain strict +mu checks: this test does not certify orientation for symmetric unphased peaks. +""" + +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from scripts.compare_archive_analysis import compare_frames + +REFERENCE = json.loads( + (Path(__file__).parent / "fixtures/real_atac_optimizer_reference.json").read_text() +) + + +@pytest.mark.rust +@pytest.mark.parametrize("case", REFERENCE["cases"], ids=lambda case: case["name"]) +def test_real_atac_conditional_optimizer_matches_python(tmp_path, case): + import wasp2_rust + + counts = pd.DataFrame(case["counts"]) + count_file = tmp_path / "counts.tsv" + counts.to_csv(count_file, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(count_file), + method=case["method"], + phased=case["phased"], + pseudocount=case["pseudocount"], + region_col="region", + **case["fixed_dispersion"], + ) + actual = pd.DataFrame(output["results"]) + expected = pd.DataFrame(case["expected"]) + if case["family"] == "balanced_anchor" and not case["phased"]: + # A balanced anchor makes the unphased objective symmetric. Check all + # statistics and either equally likely optimum, without choosing a side. + retained = counts.loc[counts.ref_count + counts.alt_count >= 10] + first = retained.groupby("region", sort=False).first() + assert (first.ref_count == first.alt_count).all() + comparison = compare_frames(expected.drop(columns="mu"), actual.drop(columns="mu")) + expected_mu = expected.set_index("region").mu + actual_mu = actual.set_index("region").mu.reindex(expected_mu.index) + same = np.isclose(actual_mu, expected_mu, rtol=5e-5, atol=5e-5) + mirrored = np.isclose(actual_mu, 1 - expected_mu, rtol=5e-5, atol=5e-5) + assert (same | mirrored).all() + else: + comparison = compare_frames(expected, actual) + assert comparison["status"] == "PASS", comparison From 091d052677575fc4b0c8d98946a6860aa7b4a1e6 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:22:05 -0700 Subject: [PATCH 14/22] test: verify symmetric bulk optima while preserving strict comparisons --- CHANGELOG.md | 3 + README.md | 6 + scripts/compare_archive_analysis.py | 153 +++++++++++++++++ tests/analysis/test_bulk_equivalence.py | 184 +++++++++++++++++++++ tests/analysis/test_real_atac_optimizer.py | 73 ++++++-- 5 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 tests/analysis/test_bulk_equivalence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3522e..3ada03f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Bulk archive validation separately verifies symmetric unphased optima using + retained anchor counts and independent Python objective evaluations, while + preserving strict comparisons, raw proportions, and numerical tolerances. - Allelic-proportion optimization uses archived Python bounded Brent defaults for single-SNP, phased, and unphased bulk tests, including empty-allele inputs with pseudocount 0. Balanced first-SNP unphased objectives can still select diff --git a/README.md b/README.md index cdeaa0d..95fb2e0 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,12 @@ has equal reference and alternate counts has a symmetric likelihood: `mu` and `1 - mu` are equally likely. Numerical libraries may select opposite optima; the reported direction is not identifiable in that case. +Archive validation reports strict numerical matching separately from statistical +equivalence. A mirrored unphased proportion qualifies only with a balanced first +retained SNP, complementary proportions, independently verified equal archived +likelihoods, and passing counts, dispersion and all other statistical checks. +Reports retain the raw proportion differences and strict failures. + Feature analysis preserves one SNV in every overlapping feature. `peak` remains an alias for `feature`: diff --git a/scripts/compare_archive_analysis.py b/scripts/compare_archive_analysis.py index 9059852..d054941 100644 --- a/scripts/compare_archive_analysis.py +++ b/scripts/compare_archive_analysis.py @@ -19,6 +19,7 @@ import tempfile import types import warnings +from collections.abc import Callable from pathlib import Path import numpy as np @@ -179,6 +180,158 @@ def compare_frames(expected: pd.DataFrame, observed: pd.DataFrame, key: str = "r } +def prepare_bulk_reference_counts( + counts: pd.DataFrame, + *, + min_count: int, + pseudocount: int, + effective_phased: bool, + region_col: str | None = None, +) -> pd.DataFrame: + """Reproduce archived filtering/deduplication while preserving the anchor order. + + Input counts are raw. Returned counts include pseudocounts and N, as used + by the archived objective. Phase fallback must already have been resolved. + """ + frame = counts.copy() + if region_col is None: + frame["region"] = frame["chrom"].astype(str) + "_" + frame["pos"].astype(str) + elif region_col != "region": + frame["region"] = frame[region_col] + frame[["ref_count", "alt_count"]] = ( + frame[["ref_count", "alt_count"]].astype("int64") + pseudocount + ) + frame["N"] = frame["ref_count"] + frame["alt_count"] + frame = frame.loc[frame["N"] >= min_count + 2 * pseudocount].copy() + columns = ["chrom", "pos", "ref_count", "alt_count", "N", "region"] + if effective_phased: + frame["GT"] = frame["GT"].str.split("|", regex=False).str[0].astype("uint8") + columns.append("GT") + return frame[columns].drop_duplicates() + + +def compare_bulk_equivalence( + expected: pd.DataFrame, + observed: pd.DataFrame, + *, + retained_counts: pd.DataFrame, + effective_phased: bool, + reference_log_likelihood: Callable[[str, float], float], + dispersion_comparison: dict, +) -> dict: + """Keep strict results; independently verify mirrored unphased bulk optima. + + The caller supplies the ordered, filtered/deduplicated objective observations, + a Python reference objective, and the independently checked nuisance fit. + This helper never edits results or applies to single-cell comparisons. + """ + required = {"region", "ref_count", "alt_count", "N", "snp_count", *STAT_COLUMNS} + if ( + expected.empty + or not required.issubset(expected.columns) + or not required.issubset(observed.columns) + ): + return { + "strict_comparison": {"status": "FAIL", "reason": "missing required bulk outputs"}, + "statistical_equivalence": "FAIL", + "failures": ["missing required bulk outputs"], + "regions": [], + } + strict = compare_frames(expected, observed) + failures = [] + evidence = [] + if dispersion_comparison.get("status") != "PASS": + failures.append("dispersion comparison did not pass") + if expected["region"].duplicated().any() or observed["region"].duplicated().any(): + failures.append("duplicate tested identities") + if strict.get("reason") or any(item != "mu" for item in strict.get("failures", [])): + failures.append("strict differences other than mu") + if failures: + return { + "strict_comparison": strict, + "statistical_equivalence": "FAIL", + "failures": failures, + "regions": evidence, + } + actual = observed.set_index("region") + for row in expected.itertuples(index=False): + identity = row.region + python_mu = float(row.mu) + rust_mu = float(actual.loc[identity, "mu"]) + finite = np.isfinite(python_mu) and np.isfinite(rust_mu) + valid = finite and 0 <= python_mu <= 1 and 0 <= rust_mu <= 1 + same = valid and np.isclose(python_mu, rust_mu, rtol=5e-5, atol=5e-5) + columns = {"region", "ref_count", "alt_count", "N"} + rows = ( + retained_counts.loc[retained_counts["region"] == identity] + if columns.issubset(retained_counts.columns) + else pd.DataFrame() + ) + symmetric = bool( + not effective_phased + and len(rows) > 1 + and len(rows) == row.snp_count + and rows.iloc[0]["ref_count"] == rows.iloc[0]["alt_count"] + and np.isfinite(rows[["ref_count", "alt_count", "N"]].to_numpy()).all() + and (rows[["ref_count", "alt_count", "N"]].to_numpy() >= 0).all() + and (rows["N"] == rows["ref_count"] + rows["alt_count"]).all() + ) + item = { + "region": identity, + "python_mu": python_mu if finite else None, + "rust_mu": rust_mu if finite else None, + "symmetric_unphased_anchor": symmetric, + "retained_observations": len(rows), + "strict_mu_match": bool(same), + } + if len(rows) and np.isfinite(rows[["ref_count", "alt_count", "N"]].to_numpy()).all(): + item["first_retained_counts"] = { + "ref_count": int(rows.iloc[0]["ref_count"]), + "alt_count": int(rows.iloc[0]["alt_count"]), + "N": int(rows.iloc[0]["N"]), + } + if same: + item["status"] = "STRICT_MATCH" + elif not valid or not symmetric: + item["status"] = "FAIL" + failures.append(f"{identity}: mu mismatch without a valid symmetric anchor") + elif not np.isclose(rust_mu, 1 - python_mu, rtol=5e-5, atol=5e-5): + item["status"] = "FAIL" + failures.append(f"{identity}: proportions are not complementary") + else: + try: + values = np.array( + [ + reference_log_likelihood(identity, mu) + for mu in [python_mu, rust_mu, 1 - python_mu] + ], + dtype=float, + ) + reported = np.array([row.alt_ll, actual.loc[identity, "alt_ll"]], dtype=float) + equal = ( + np.isfinite(values).all() + and np.allclose(values, values[0], rtol=5e-5, atol=5e-5) + and np.allclose(values[:2], reported, rtol=5e-5, atol=5e-5) + ) + item["reference_log_likelihoods"] = ( + values.tolist() if np.isfinite(values).all() else None + ) + item["reference_evaluation_order"] = ["python_mu", "rust_mu", "1-python_mu"] + item["status"] = "SYMMETRIC_EQUIVALENT" if equal else "FAIL" + if not equal: + failures.append(f"{identity}: reference likelihoods do not agree") + except Exception as exc: + item["status"] = "FAIL" + failures.append(f"{identity}: reference evaluation failed: {exc}") + evidence.append(item) + return { + "strict_comparison": strict, + "statistical_equivalence": "FAIL" if failures else "PASS", + "failures": failures, + "regions": evidence, + } + + def run_comparison(archive: dict[str, types.ModuleType]) -> dict: import wasp2_rust from analysis.as_analysis_sc import get_imbalance_sc diff --git a/tests/analysis/test_bulk_equivalence.py b/tests/analysis/test_bulk_equivalence.py new file mode 100644 index 0000000..bae17c0 --- /dev/null +++ b/tests/analysis/test_bulk_equivalence.py @@ -0,0 +1,184 @@ +"""Verification must distinguish symmetric optima from arbitrary mu mismatches.""" + +import numpy as np +import pandas as pd +import pytest +from scripts.compare_archive_analysis import ( + compare_bulk_equivalence, + prepare_bulk_reference_counts, +) + + +def example(): + expected = pd.DataFrame( + [ + { + "region": "peak", + "ref_count": 12, + "alt_count": 20, + "N": 32, + "snp_count": 2, + "mu": 0.3, + "null_ll": -5.0, + "alt_ll": -4.0, + "lrt": 2.0, + "pval": 0.157, + "fdr_pval": 0.157, + } + ] + ) + observed = expected.copy() + observed["mu"] = 0.7 + counts = pd.DataFrame( + [ + {"chrom": "chr21", "pos": 1, "region": "peak", "ref_count": 7, "alt_count": 7}, + {"chrom": "chr21", "pos": 2, "region": "peak", "ref_count": 5, "alt_count": 13}, + ] + ) + retained = prepare_bulk_reference_counts( + counts, min_count=10, pseudocount=0, effective_phased=False, region_col="region" + ) + return expected, observed, retained + + +def verify(expected, observed, retained, **kwargs): + return compare_bulk_equivalence( + expected, + observed, + retained_counts=retained, + effective_phased=kwargs.pop("effective_phased", False), + reference_log_likelihood=kwargs.pop("objective", lambda region, mu: -4.0), + dispersion_comparison=kwargs.pop("dispersion", {"status": "PASS"}), + **kwargs, + ) + + +def test_mirrored_optimum_keeps_strict_failure_and_records_evidence(): + expected, observed, retained = example() + before = observed.copy(deep=True) + result = verify(expected, observed, retained) + assert result["strict_comparison"]["status"] == "FAIL" + assert result["strict_comparison"]["failures"] == ["mu"] + assert result["statistical_equivalence"] == "PASS" + evidence = result["regions"][0] + assert evidence["status"] == "SYMMETRIC_EQUIVALENT" + assert evidence["reference_log_likelihoods"] == [-4.0, -4.0, -4.0] + pd.testing.assert_frame_equal(observed, before) + + +@pytest.mark.parametrize( + "failure", + [ + "asymmetric", + "phased", + "not_complementary", + "nonfinite_mu", + "wrong_likelihood", + "nonfinite_objective", + "incorrect_reported_likelihood", + "counts", + "dispersion", + "missing_anchor", + "missing_mu", + "different_regions", + "wrong_observation_count", + "invalid_retained_depth", + ], +) +def test_invalid_equivalence_is_rejected(failure): + expected, observed, retained = example() + kwargs = {} + if failure == "asymmetric": + retained.loc[0, "ref_count"] += 1 + elif failure == "phased": + kwargs["effective_phased"] = True + elif failure == "not_complementary": + observed["mu"] = 0.8 + elif failure == "nonfinite_mu": + observed["mu"] = np.nan + elif failure == "wrong_likelihood": + kwargs["objective"] = lambda region, mu: -4.0 + mu + elif failure == "nonfinite_objective": + kwargs["objective"] = lambda region, mu: np.nan + elif failure == "incorrect_reported_likelihood": + kwargs["objective"] = lambda region, mu: -8.0 + elif failure == "counts": + observed["ref_count"] += 1 + elif failure == "dispersion": + kwargs["dispersion"] = {"status": "FAIL"} + elif failure == "missing_anchor": + retained = retained.drop(columns="ref_count") + elif failure == "missing_mu": + observed = observed.drop(columns="mu") + elif failure == "different_regions": + observed["region"] = "other" + elif failure == "wrong_observation_count": + retained = pd.concat([retained, retained.iloc[[1]]]) + elif failure == "invalid_retained_depth": + retained.loc[0, "N"] += 1 + assert verify(expected, observed, retained, **kwargs)["statistical_equivalence"] == "FAIL" + + +def test_matching_mu_stays_strict_without_symmetry_evidence(): + expected, _, retained = example() + retained.loc[0, "ref_count"] += 1 + result = verify(expected, expected.copy(), retained, effective_phased=True) + assert result["strict_comparison"]["status"] == "PASS" + assert result["statistical_equivalence"] == "PASS" + assert result["regions"][0]["status"] == "STRICT_MATCH" + + +@pytest.mark.parametrize("pc", [0, 1]) +@pytest.mark.parametrize("phased", [False, True]) +def test_retained_anchor_is_selected_after_filtering_and_phase_aware_deduplication(pc, phased): + counts = pd.DataFrame( + [ + { + "chrom": "chr21", + "pos": 10, + "region": "peak", + "ref_count": 1, + "alt_count": 2, + "GT": "0|1", + }, + { + "chrom": "chr21", + "pos": 20, + "region": "peak", + "ref_count": 5, + "alt_count": 5, + "GT": "0|1", + }, + { + "chrom": "chr21", + "pos": 20, + "region": "peak", + "ref_count": 5, + "alt_count": 5, + "GT": "0|1", + }, + { + "chrom": "chr21", + "pos": 20, + "region": "peak", + "ref_count": 5, + "alt_count": 5, + "GT": "1|0", + }, + { + "chrom": "chr21", + "pos": 30, + "region": "peak", + "ref_count": 3, + "alt_count": 8, + "GT": "0|1", + }, + ] + ) + retained = prepare_bulk_reference_counts( + counts, min_count=10, pseudocount=pc, effective_phased=phased, region_col="region" + ) + assert retained.iloc[0].pos == 20 + assert retained.iloc[0].ref_count == retained.iloc[0].alt_count == 5 + pc + assert retained.pos.tolist() == ([20, 20, 30] if phased else [20, 30]) + assert "GT" in retained if phased else "GT" not in retained diff --git a/tests/analysis/test_real_atac_optimizer.py b/tests/analysis/test_real_atac_optimizer.py index e566e63..b684f99 100644 --- a/tests/analysis/test_real_atac_optimizer.py +++ b/tests/analysis/test_real_atac_optimizer.py @@ -8,10 +8,15 @@ import json from pathlib import Path -import numpy as np import pandas as pd import pytest -from scripts.compare_archive_analysis import compare_frames +from scipy.special import expit +from scripts.compare_archive_analysis import ( + adapt_linear_reference, + compare_bulk_equivalence, + load_archive, + prepare_bulk_reference_counts, +) REFERENCE = json.loads( (Path(__file__).parent / "fixtures/real_atac_optimizer_reference.json").read_text() @@ -36,18 +41,52 @@ def test_real_atac_conditional_optimizer_matches_python(tmp_path, case): ) actual = pd.DataFrame(output["results"]) expected = pd.DataFrame(case["expected"]) - if case["family"] == "balanced_anchor" and not case["phased"]: - # A balanced anchor makes the unphased objective symmetric. Check all - # statistics and either equally likely optimum, without choosing a side. - retained = counts.loc[counts.ref_count + counts.alt_count >= 10] - first = retained.groupby("region", sort=False).first() - assert (first.ref_count == first.alt_count).all() - comparison = compare_frames(expected.drop(columns="mu"), actual.drop(columns="mu")) - expected_mu = expected.set_index("region").mu - actual_mu = actual.set_index("region").mu.reindex(expected_mu.index) - same = np.isclose(actual_mu, expected_mu, rtol=5e-5, atol=5e-5) - mirrored = np.isclose(actual_mu, 1 - expected_mu, rtol=5e-5, atol=5e-5) - assert (same | mirrored).all() - else: - comparison = compare_frames(expected, actual) - assert comparison["status"] == "PASS", comparison + retained = prepare_bulk_reference_counts( + counts, + min_count=10, + pseudocount=case["pseudocount"], + effective_phased=output["effective_phased"], + region_col="region", + ) + modules = load_archive() + if case["method"] == "linear": + adapt_linear_reference(modules) + archive = modules["as_analysis"] + + def reference_likelihood(region, mu): + rows = retained.loc[retained.region == region] + ref = rows.ref_count.to_numpy() + depth = rows.N.to_numpy() + fixed = case["fixed_dispersion"] + rho = ( + fixed["rho"] + if case["method"] == "single" + else expit( + fixed["linear_d1"] + + fixed["linear_d2"] + * ( + (depth - 2 * case["pseudocount"] - fixed["linear_depth_center"]) + / fixed["linear_depth_scale"] + ) + ) + ) + if len(rows) == 1: + value = archive.opt_prob(mu, rho, ref[0], depth[0]) + else: + value = archive.opt_unphased_dp(mu, rho, ref[:1], depth[:1], ref[1:], depth[1:]) + return -float(value) + + # Fixed nuisance parameters are identical by construction in this conditional + # test; the full-data runner separately verifies fitted dispersion predictions. + comparison = compare_bulk_equivalence( + expected, + actual, + retained_counts=retained, + effective_phased=output["effective_phased"], + reference_log_likelihood=reference_likelihood, + dispersion_comparison={"status": "PASS", "source": "identical fixed nuisance"}, + ) + assert comparison["statistical_equivalence"] == "PASS", comparison + if comparison["strict_comparison"]["status"] != "PASS": + assert comparison["strict_comparison"]["failures"] == ["mu"] + assert any(row["status"] == "SYMMETRIC_EQUIVALENT" for row in comparison["regions"]) From a920f5265ad5a9f3ea774f6eb24101783602c91a Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:05:43 -0700 Subject: [PATCH 15/22] fix: reproduce SciPy bulk likelihood arithmetic and fitted proportions --- CHANGELOG.md | 10 +- README.md | 4 + pyproject.toml | 2 +- rust/licenses/NUMPY-LICENSE.txt | 30 ++ rust/licenses/SCIPY-LICENSE.txt | 30 ++ rust/src/analysis.rs | 290 +++++++++----- rust/src/lib.rs | 1 + rust/src/scipy_compat.rs | 369 ++++++++++++++++++ tests/analysis/test_scipy_numerical_kernel.py | 122 ++++++ 9 files changed, 759 insertions(+), 99 deletions(-) create mode 100644 rust/licenses/NUMPY-LICENSE.txt create mode 100644 rust/licenses/SCIPY-LICENSE.txt create mode 100644 rust/src/scipy_compat.rs create mode 100644 tests/analysis/test_scipy_numerical_kernel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ada03f..fa4ab01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Bulk beta-binomial calculations use a SciPy 1.13.1-compatible Cephes kernel, + reference FMA arithmetic, and NumPy float64 reductions. Unphased inference + follows archived probability accumulation when safe and uses log-space fallback + when products lose range. Linear fits preserve adjusted-depth coefficients in + the existing four-field representation to avoid conversion-rounding changes. + Strict real ATAC comparisons include raw allelic proportions. - Bulk archive validation separately verifies symmetric unphased optima using retained anchor counts and independent Python objective evaluations, while preserving strict comparisons, raw proportions, and numerical tolerances. @@ -13,8 +19,8 @@ All notable changes to WASP2 will be documented in this file. with pseudocount 0. Balanced first-SNP unphased objectives can still select mirrored proportions across numerical libraries; no tie-breaking rule is added. - Linear dispersion follows archived Python single-start Nelder-Mead fitting - on pseudocounted depth, then converts coefficients for reusable raw-depth - standardization. Equal depths use a unit scale; testing no longer applies + on pseudocounted depth, retains coefficients with a pseudocount depth-origin + shift for exact reusable prediction. Equal depths use a unit scale; testing no longer applies the fitting-only predictor clip. - Retain distinct repeated SNP count observations in donor analysis and apply Python phase-aware deduplication after depth filtering. SNV outputs aggregate diff --git a/README.md b/README.md index 95fb2e0..b551cbd 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,10 @@ fit for every included donor; testing and BH correction remain donor-local. Both `single` and `linear` dispersion models are supported. Linear fits record and reuse `linear_d1`, `linear_d2`, `linear_depth_center`, and `linear_depth_scale` together. +New fits preserve the original adjusted-depth coefficients: `linear_depth_center` +is `-2 * pseudocount` and `linear_depth_scale` is 1. This avoids rounding from +converting the predictor to mean/std coordinates. Existing fixed parameter sets +continue to use their supplied center and scale. Allelic-proportion fitting follows archived Python bounded Brent defaults for SNP, phased, and unphased bulk tests. An unphased region whose first retained SNP diff --git a/pyproject.toml b/pyproject.toml index 1a520d6..ef6a9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ module-name = "wasp2_rust" python-packages = ["counting", "mapping", "analysis", "wasp2"] bindings = "pyo3" strip = false -include = ["LICENSE", "README.md"] +include = ["LICENSE", "README.md", "rust/licenses/SCIPY-LICENSE.txt", "rust/licenses/NUMPY-LICENSE.txt"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/rust/licenses/NUMPY-LICENSE.txt b/rust/licenses/NUMPY-LICENSE.txt new file mode 100644 index 0000000..014d51c --- /dev/null +++ b/rust/licenses/NUMPY-LICENSE.txt @@ -0,0 +1,30 @@ +Copyright (c) 2005-2023, NumPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the NumPy Developers nor the names of any + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rust/licenses/SCIPY-LICENSE.txt b/rust/licenses/SCIPY-LICENSE.txt new file mode 100644 index 0000000..1171176 --- /dev/null +++ b/rust/licenses/SCIPY-LICENSE.txt @@ -0,0 +1,30 @@ +Copyright (c) 2001-2002 Enthought, Inc. 2003-2024, SciPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index fbb3e16..ec55b4a 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -1,3 +1,4 @@ +use crate::scipy_compat::{betabinom_logpmf, numpy_sum}; /// WASP2 Analysis Module - Beta-binomial Allelic Imbalance Detection /// /// Rust implementation of the Python analysis stage (src/analysis/as_analysis.py) @@ -6,8 +7,6 @@ /// Performance target: 3-5x speedup over Python (2.7s → 0.5-0.9s) use anyhow::{Context, Result}; use rayon::prelude::*; -use rv::dist::BetaBinomial; -use rv::traits::HasDensity; use statrs::distribution::{ChiSquared, ContinuousCDF}; use std::collections::{HashMap, HashSet}; @@ -276,12 +275,9 @@ fn effective_phased(variants: &[VariantCounts], requested_phased: bool) -> bool /// Numerically stable implementation that avoids overflow for large |x|. #[inline] fn expit(x: f64) -> f64 { - if x >= 0.0 { - 1.0 / (1.0 + (-x).exp()) - } else { - let e = x.exp(); - e / (1.0 + e) - } + // Preserve SciPy's expression, including its rounding for negative inputs. + // IEEE exp overflow gives infinity and the quotient remains the valid 0. + 1.0 / (1.0 + (-x).exp()) } // ============================================================================ @@ -308,13 +304,7 @@ pub fn opt_prob(prob: f64, rho: f64, k: u32, n: u32) -> Result { let alpha = prob * (1.0 - rho) / rho; let beta = (1.0 - prob) * (1.0 - rho) / rho; - // Create beta-binomial distribution (rv uses: n as u32, alpha, beta) - let bb = - BetaBinomial::new(n, alpha, beta).context("Failed to create beta-binomial distribution")?; - - // Return negative log-likelihood (rv uses reference for ln_f, k as u64) - let log_pmf = bb.ln_f(&(k as u64)); - Ok(-log_pmf) + Ok(-betabinom_logpmf(k, n, alpha, beta)?) } /// Calculate beta-binomial log-likelihood for array of counts @@ -326,15 +316,12 @@ pub fn betabinom_logpmf_sum( alpha: f64, beta: f64, ) -> Result { - let mut sum = 0.0; - - for (k, n) in ref_counts.iter().zip(n_array.iter()) { - let bb = BetaBinomial::new(*n, alpha, beta) - .context("Failed to create beta-binomial distribution")?; - sum += bb.ln_f(&(*k as u64)); - } - - Ok(sum) + let values: Vec<_> = ref_counts + .iter() + .zip(n_array) + .map(|(&k, &n)| betabinom_logpmf(k, n, alpha, beta)) + .collect::>()?; + Ok(numpy_sum(&values)) } // ============================================================================ @@ -364,37 +351,8 @@ fn optimize_dispersion(ref_counts: &[u32], n_array: &[u32]) -> Result { scipy_bounded_minimize(objective, 0.0, 1.0, 1e-5, 500) } -fn depth_standardization(raw_depths: &[u32]) -> Result<(f64, f64)> { - if raw_depths.is_empty() { - anyhow::bail!("cannot standardize linear depth without observations"); - } - - // Welford's algorithm avoids cancellation when depths are large and tightly clustered. - let mut center = 0.0; - let mut squared_deviations = 0.0; - for (index, &depth) in raw_depths.iter().enumerate() { - let depth = depth as f64; - let count = (index + 1) as f64; - let delta = depth - center; - center += delta / count; - squared_deviations += delta * (depth - center); - } - - if !center.is_finite() { - anyhow::bail!("linear depth center is non-finite"); - } - let variance = squared_deviations / raw_depths.len() as f64; - let scale = variance.sqrt(); - if !scale.is_finite() { - anyhow::bail!("linear depth scale is non-finite"); - } - // A unit scale represents constant depths without changing predictions. - // The archive fits these inputs rather than rejecting them. - Ok((center, if scale > 0.0 { scale } else { 1.0 })) -} - -/// Fit the archived raw, pseudocounted-depth predictor from (0, 0), then -/// express it using reusable standardized raw-depth parameters. +/// Fit the archived pseudocounted-depth predictor without converting its +/// coefficients. The reusable raw-depth origin is shifted by the pseudocount. fn optimize_dispersion_linear( ref_counts: &[u32], n_array: &[u32], @@ -403,14 +361,16 @@ fn optimize_dispersion_linear( if ref_counts.len() != n_array.len() || ref_counts.len() != raw_depths.len() { anyhow::bail!("linear dispersion inputs have inconsistent lengths"); } - let (depth_center, depth_scale) = depth_standardization(raw_depths)?; + if raw_depths.is_empty() { + anyhow::bail!("cannot fit linear dispersion without observations"); + } let objective = |params: [f64; 2]| -> f64 { let (d1, d2) = (params[0], params[1]); if !d1.is_finite() || !d2.is_finite() { return f64::INFINITY; } - let mut neg_ll = 0.0; + let mut values = Vec::with_capacity(ref_counts.len()); for (&ref_count, &n) in ref_counts.iter().zip(n_array.iter()) { let predictor = d1 + f64::from(n) * d2; @@ -421,15 +381,14 @@ fn optimize_dispersion_linear( let alpha = 0.5 * (1.0 - rho) / rho; // Beta-binomial log-pmf with α = β (null: p=0.5) - if let Ok(bb) = rv::dist::BetaBinomial::new(n, alpha, alpha) { - use rv::traits::HasDensity; - neg_ll -= bb.ln_f(&(ref_count as u64)); + if let Ok(log_pmf) = betabinom_logpmf(ref_count, n, alpha, alpha) { + values.push(log_pmf); } else { return f64::INFINITY; } } - neg_ll + -numpy_sum(&values) }; // Match scipy.optimize.minimize(method="Nelder-Mead", x0=(0, 0)): @@ -443,9 +402,9 @@ fn optimize_dispersion_linear( { anyhow::bail!("linear fit requires a consistent pseudocount offset"); } - let d1 = intercept + slope * (depth_center + offset); - let d2 = slope * depth_scale; - Ok((d1, d2, depth_center, depth_scale)) + // Nraw - (-offset) evaluates the original adjusted depth exactly. Avoid + // mean/std coefficient conversion, whose roundtrip can change a tied basin. + Ok((intercept, slope, -offset, 1.0)) } /// Optimize probability parameter for alternative model @@ -582,21 +541,24 @@ pub fn optimize_prob_unphased_dp( // Average orientations only after the first SNP, as in archived Python. // For each SNP: // log(0.5 * p1 + 0.5 * p2) = ln(0.5) + logaddexp(ln(p1), ln(p2)) - let orientation_log_likelihood = |ref_count: u32, n: u32, rho: f64| -> Result { + let orientation_log_likelihood = |ref_count: u32, n: u32, rho: f64| -> Result<(f64, f64)> { let rho = clamp_rho(rho); let alpha1 = prob * (1.0 - rho) / rho; let beta1 = (1.0 - prob) * (1.0 - rho) / rho; - let bb1 = BetaBinomial::new(n, alpha1, beta1) - .context("Failed to create beta-binomial for orientation 1")?; - let lp1 = bb1.ln_f(&(ref_count as u64)); - - let bb2 = BetaBinomial::new(n, beta1, alpha1) - .context("Failed to create beta-binomial for orientation 2")?; - let lp2 = bb2.ln_f(&(ref_count as u64)); - Ok((0.5_f64).ln() + logaddexp(lp1, lp2)) + let lp1 = betabinom_logpmf(ref_count, n, alpha1, beta1)?; + // Python calls opt_prob(1-prob), recomputing both shape parameters. + // Swapping alpha/beta avoids a subtraction and changes last-bit rounding. + let opposite_prob = 1.0 - prob; + let alpha2 = opposite_prob * (1.0 - rho) / rho; + let beta2 = (1.0 - opposite_prob) * (1.0 - rho) / rho; + let lp2 = betabinom_logpmf(ref_count, n, alpha2, beta2)?; + Ok((lp1, lp2)) }; - let mut log_likelihood = -opt_prob(prob, first_disp, first_ref, first_n)?; + let first_nll = opt_prob(prob, first_disp, first_ref, first_n)?; + let mut log_likelihood = -first_nll; + let mut previous_probability = 1.0; + let mut product_is_normal = true; for (i, (&ref_count, &n)) in phase_ref.iter().zip(phase_n.iter()).enumerate() { let rho = if phase_disp.len() > 1 { @@ -604,9 +566,21 @@ pub fn optimize_prob_unphased_dp( } else { phase_disp[0] }; - log_likelihood += orientation_log_likelihood(ref_count, n, rho)?; + let (lp1, lp2) = orientation_log_likelihood(ref_count, n, rho)?; + log_likelihood += (0.5_f64).ln() + logaddexp(lp1, lp2); + if product_is_normal { + // Preserve the archived sequential probability recurrence when safe. + let phase1 = previous_probability * lp1.exp(); + let phase2 = previous_probability * lp2.exp(); + previous_probability = 0.5 * phase1 + 0.5 * phase2; + product_is_normal = previous_probability.is_normal(); + } } + if product_is_normal { + return Ok(first_nll - previous_probability.ln()); + } + // Preserve stable inference when the archive's product loses range. if !log_likelihood.is_finite() { return Ok(f64::INFINITY); } @@ -615,16 +589,18 @@ pub fn optimize_prob_unphased_dp( /// Numerically stable two-argument log-sum-exp: ln(exp(a) + exp(b)). /// -/// Equivalent to NumPy's `np.logaddexp`. Subtracting the max before exponentiating -/// avoids overflow/underflow. If the max is non-finite (e.g. -inf when both -/// inputs are -inf), the max is returned directly. +/// Follows NumPy's `np.logaddexp` arithmetic with log1p of the smaller +/// exponential, avoiding overflow and retaining small contributions. #[inline] fn logaddexp(a: f64, b: f64) -> f64 { - let m = a.max(b); - if !m.is_finite() { - m + // NumPy uses log1p for the smaller exponential; log(1 + exp(...)) + // rounds differently and can alter a symmetric optimizer's branch. + if a == b { + a + std::f64::consts::LN_2 + } else if a > b { + a + (b - a).exp().ln_1p() } else { - m + ((a - m).exp() + (b - m).exp()).ln() + b + (a - b).exp().ln_1p() } } @@ -632,6 +608,23 @@ fn logaddexp(a: f64, b: f64) -> f64 { fn scipy_bounded_minimize(f: F, x1: f64, x2: f64, xatol: f64, maxfun: usize) -> Result where F: Fn(f64) -> f64, +{ + scipy_bounded_minimize_traced(f, x1, x2, xatol, maxfun, |_, _, _, _, _| {}) +} + +/// Diagnostic hook records candidate, objective, updated interval and acceptance. +/// The regular wrapper supplies an empty hook; no production output is changed. +fn scipy_bounded_minimize_traced( + f: F, + x1: f64, + x2: f64, + xatol: f64, + maxfun: usize, + mut trace: T, +) -> Result +where + F: Fn(f64) -> f64, + T: FnMut(f64, f64, f64, f64, bool), { if !x1.is_finite() || !x2.is_finite() || x1 > x2 { anyhow::bail!("invalid bounded optimization interval"); @@ -644,6 +637,7 @@ where let (mut rat, mut e) = (0.0_f64, 0.0_f64); let mut fx = f(xf); let (mut ffulc, mut fnfc) = (fx, fx); + trace(xf, fx, a, b, true); let mut fu = f64::INFINITY; let mut calls = 1_usize; let mut xm = 0.5 * (a + b); @@ -681,7 +675,8 @@ where let x = xf + direction * rat.abs().max(tol1); fu = f(x); calls += 1; - if fu <= fx { + let accepted = fu <= fx; + if accepted { if x >= xf { a = xf; } else { @@ -710,6 +705,7 @@ where } } + trace(x, fu, a, b, accepted); xm = 0.5 * (a + b); tol1 = sqrt_eps * xf.abs() + xatol / 3.0; tol2 = 2.0 * tol1; @@ -1020,10 +1016,7 @@ fn test_fixed_dispersion( .zip(region_rho.iter()) { let alpha = 0.5 * (1.0 - rho) / rho; - let bb = rv::dist::BetaBinomial::new(n, alpha, alpha) - .context("Failed to create beta-binomial for null")?; - use rv::traits::HasDensity; - null_ll += bb.ln_f(&(ref_count as u64)); + null_ll += betabinom_logpmf(ref_count, n, alpha, alpha)?; } let all_gt = indices.iter().all(|&i| variants[i].gt.is_some()); @@ -1357,6 +1350,19 @@ mod tests { } } + #[test] + fn test_expit_matches_scipy_expression() { + for (x, expected) in [ + (-0.7, 0.3318122278318339), + (-2.3, 0.09112296101485616), + (-2.5, 0.07585818002124355), + ] { + assert_eq!(expit(x), expected); + } + assert_eq!(expit(-1000.0), 0.0); + assert_eq!(expit(1000.0), 1.0); + } + #[test] fn test_linear_prediction_is_not_clipped_at_fit_guard() { let parameters = DispersionParameters::Linear { @@ -1368,11 +1374,6 @@ mod tests { assert!((parameters.rho_at_depth(1) - expit(12.0)).abs() < 1e-15); } - #[test] - fn test_depth_standardization_accepts_constant_depth() { - assert_eq!(depth_standardization(&[20, 20, 20]).unwrap(), (20.0, 1.0)); - } - #[test] fn test_fdr_correction() { let pvals = vec![0.01, 0.05, 0.1, 0.5]; @@ -1384,6 +1385,97 @@ mod tests { } } + #[test] + fn test_linear_real_optimizer_trace() { + let counts = [6, 10, 3, 11, 1, 8]; + let depths = [12, 27, 13, 14, 15, 12]; + let parameters = DispersionParameters::Linear { + d1: -2.5581565149445686, + d2: -0.011773137230599792, + depth_center: 0.0, + depth_scale: 1.0, + }; + let rho: Vec<_> = depths + .iter() + .map(|n| parameters.rho_at_depth(n - 2)) + .collect(); + for mu in [0.3819660112501051, 0.6180339887498948] { + let forward: Vec<_> = counts + .iter() + .zip(&depths) + .zip(&rho) + .map(|((&k, &n), &r)| -opt_prob(mu, r, k, n).unwrap()) + .collect(); + let reverse: Vec<_> = counts + .iter() + .zip(&depths) + .zip(&rho) + .map(|((&k, &n), &r)| -opt_prob(1.0 - mu, r, k, n).unwrap()) + .collect(); + eprintln!("mu={mu:.17}, rho={rho:?}, forward={forward:?}, reverse={reverse:?}"); + } + let objective = |mu| { + optimize_prob_unphased_dp(mu, &rho, counts[0], depths[0], &counts[1..], &depths[1..]) + .unwrap() + }; + let mut evaluations = Vec::new(); + let mu = scipy_bounded_minimize_traced( + objective, + 0.0, + 1.0, + 1e-5, + 500, + |x, fx, a, b, accepted| evaluations.push((x, fx, a, b, accepted)), + ) + .unwrap(); + eprintln!("linear fixed-rho mu={mu:.17}, trace={evaluations:?}"); + assert!((mu - 0.3071512458291038).abs() < 5e-5); + } + + #[test] + fn test_real_unphased_optimizer_trace_matches_python_basin() { + // Archived SciPy 1.13.1 outcomes, fixed nuisance to isolate arithmetic. + let examples = [ + (vec![7, 5], vec![14, 18], 0.41804483019409894), + ( + vec![5, 9, 2, 10, 0, 7], + vec![10, 25, 11, 12, 13, 10], + 0.2550470457103756, + ), + ]; + for (ref_counts, depths, expected_mu) in examples { + let rho = [0.03295621518540146]; + let objective = |mu| { + optimize_prob_unphased_dp( + mu, + &rho, + ref_counts[0], + depths[0], + &ref_counts[1..], + &depths[1..], + ) + .unwrap() + }; + let mut evaluations = Vec::new(); + let mu = scipy_bounded_minimize_traced( + objective, + 0.0, + 1.0, + 1e-5, + 500, + |x, fx, a, b, accepted| evaluations.push((x, fx, a, b, accepted)), + ) + .unwrap(); + assert!( + (mu - expected_mu).abs() < 5e-5, + "mu={mu}, trace={evaluations:?}" + ); + assert!((evaluations[0].0 - 0.3819660112501051).abs() < 1e-15); + assert!((evaluations[1].0 - 0.6180339887498948).abs() < 1e-15); + eprintln!("fixed-rho optimizer mu={mu:.17}, trace={evaluations:?}"); + } + } + #[test] fn test_scipy_bounded_minimize_interior_solution() { // Test optimization on simple quadratic @@ -1628,7 +1720,7 @@ mod tests { } #[test] - fn test_linear_fit_reports_raw_depth_standardization() { + fn test_linear_fit_preserves_adjusted_depth_predictor() { let variants = vec![ vc_peak(100, 8, 2, None), vc_peak(200, 10, 10, None), @@ -1642,8 +1734,14 @@ mod tests { }; let fit = fit_imbalance_dispersion(variants, &config).unwrap(); let (center, scale) = fit.parameters.linear_depth_standardization().unwrap(); - assert!((center - 20.0).abs() < 1e-12); - assert!((scale - (200.0_f64 / 3.0).sqrt()).abs() < 1e-12); + assert_eq!((center, scale), (-6.0, 1.0)); + let (intercept, slope) = fit.parameters.linear_params().unwrap(); + for depth in [10, 20, 30] { + assert_eq!( + fit.parameters.rho_at_depth(depth), + clamp_rho(expit(intercept + (depth + 6) as f64 * slope)) + ); + } } #[test] @@ -1658,7 +1756,7 @@ mod tests { let fit = fit_imbalance_dispersion(variants, &config).unwrap(); assert_eq!( fit.parameters.linear_depth_standardization(), - Some((10.0, 1.0)) + Some((-4.0, 1.0)) ); assert!(fit.parameters.rho_at_depth(10).is_finite()); } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cca73cd..72cdd06 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -19,6 +19,7 @@ mod cigar_utils; // Shared CIGAR-aware position mapping utilities mod mapping_filter; mod multi_sample; mod read_pairer; +mod scipy_compat; mod seq_decode; mod unified_pipeline; mod vcf_to_bed; diff --git a/rust/src/scipy_compat.rs b/rust/src/scipy_compat.rs new file mode 100644 index 0000000..59bdb7d --- /dev/null +++ b/rust/src/scipy_compat.rs @@ -0,0 +1,369 @@ +//! Positive-domain beta-binomial arithmetic from SciPy 1.13.1. +//! +//! Sources: scipy/special/special/cephes/{gamma,beta,polevl}.h and +//! scipy/stats/_discrete_distns.py at tag v1.13.1. Preserve coefficients, +//! branch boundaries, and arithmetic order: algebraic rearrangement can +//! change which of two symmetric optima a bounded optimizer selects. +//! +//! Copyright (c) 2001-2002 Enthought, Inc. 2003-2024, SciPy Developers. +//! Cephes Gamma: Copyright 1984, 1987, 1989, 1992 Stephen L. Moshier. +//! Cephes Beta: Copyright 1984, 1987 Stephen L. Moshier. +//! See the distributed third-party SciPy license for redistribution terms. + +// These literals intentionally preserve the upstream decimal coefficients. +#![allow(clippy::excessive_precision)] + +use anyhow::{bail, Result}; + +const GAMMA_P: [f64; 7] = [ + 1.60119522476751861407E-4, + 1.19135147006586384913E-3, + 1.04213797561761569935E-2, + 4.76367800457137231464E-2, + 2.07448227648435975150E-1, + 4.94214826801497100753E-1, + 9.99999999999999996796E-1, +]; +const GAMMA_Q: [f64; 8] = [ + -2.31581873324120129819E-5, + 5.39605580493303397842E-4, + -4.45641913851797240494E-3, + 1.18139785222060435552E-2, + 3.58236398605498653373E-2, + -2.34591795718243348568E-1, + 7.14304917030273074085E-2, + 1.00000000000000000320E0, +]; +const GAMMA_STIR: [f64; 5] = [ + 7.87311395793093628397E-4, + -2.29549961613378126380E-4, + -2.68132617805781232825E-3, + 3.47222221605458667310E-3, + 8.33333333333482257126E-2, +]; +const GAMMA_A: [f64; 5] = [ + 8.11614167470508450300E-4, + -5.95061904284301438324E-4, + 7.93650340457716943945E-4, + -2.77777777730099687205E-3, + 8.33333333333331927722E-2, +]; +const GAMMA_B: [f64; 6] = [ + -1.37825152569120859100E3, + -3.88016315134637840924E4, + -3.31612992738871184744E5, + -1.16237097492762307383E6, + -1.72173700820839662146E6, + -8.53555664245765465627E5, +]; +const GAMMA_C: [f64; 6] = [ + -3.51815701436523470549E2, + -1.70642106651881159223E4, + -2.20528590553854454839E5, + -1.13933444367982507207E6, + -2.53252307177582951285E6, + -2.01889141433532773231E6, +]; +const MAXGAM: f64 = 171.624376956302725; + +fn polevl(x: f64, coefficients: &[f64]) -> f64 { + let mut result = coefficients[0]; + for &coefficient in &coefficients[1..] { + // The pinned reference build contracts Cephes Horner steps to FMA. + result = result.mul_add(x, coefficient); + } + result +} + +fn p1evl(x: f64, coefficients: &[f64]) -> f64 { + let mut result = x + coefficients[0]; + for &coefficient in &coefficients[1..] { + // The pinned reference build contracts Cephes Horner steps to FMA. + result = result.mul_add(x, coefficient); + } + result +} + +fn stirf(x: f64) -> f64 { + if x >= MAXGAM { + return f64::INFINITY; + } + let mut w = 1.0 / x; + w = w.mul_add(polevl(w, &GAMMA_STIR), 1.0); + let mut y = x.exp(); + if x > 143.01608 { + let v = x.powf(0.5 * x - 0.25); + y = v * (v / y); + } else { + y = x.powf(x - 0.5) / y; + } + 2.50662827463100050242 * y * w +} + +fn gamma_positive(mut x: f64) -> f64 { + if !x.is_finite() { + return x; + } + if x > 33.0 { + return stirf(x); + } + let mut z = 1.0; + while x >= 3.0 { + x -= 1.0; + z *= x; + } + while x < 2.0 { + if x < 1e-9 { + return z / (0.5772156649015329_f64.mul_add(x, 1.0) * x); + } + z /= x; + x += 1.0; + } + if x == 2.0 { + return z; + } + x -= 2.0; + z * polevl(x, &GAMMA_P) / polevl(x, &GAMMA_Q) +} + +fn lgamma_positive(mut x: f64) -> f64 { + if !x.is_finite() { + return x; + } + if x < 13.0 { + let mut z = 1.0; + let mut p = 0.0; + let mut u = x; + while u >= 3.0 { + p -= 1.0; + u = x + p; + z *= u; + } + while u < 2.0 { + if u == 0.0 { + return f64::INFINITY; + } + z /= u; + p += 1.0; + u = x + p; + } + if u == 2.0 { + return z.ln(); + } + p -= 2.0; + x += p; + p = x * polevl(x, &GAMMA_B) / p1evl(x, &GAMMA_C); + return z.ln() + p; + } + if x > 2.556348e305 { + return f64::INFINITY; + } + let mut q = (x - 0.5).mul_add(x.ln(), -x) + 0.91893853320467274178; + if x > 1.0e8 { + return q; + } + let p = 1.0 / (x * x); + if x >= 1000.0 { + q += ((7.9365079365079365079365e-4 * p - 2.7777777777777777777778e-3) * p + + 0.0833333333333333333333) + / x; + } else { + q += polevl(p, &GAMMA_A) / x; + } + q +} + +// Preserve the upstream operand order rather than rewriting as +=. +#[allow(clippy::assign_op_pattern)] +fn betaln_positive(mut a: f64, mut b: f64) -> f64 { + if a < b { + std::mem::swap(&mut a, &mut b); + } + if a > 1e6 * b && a > 1e6 { + let mut r = lgamma_positive(b); + r -= b * a.ln(); + r += b * (1.0 - b) / (2.0 * a); + r += b * (1.0 - b) * (1.0 - 2.0 * b) / (12.0 * a * a); + r += -b * b * (1.0 - b) * (1.0 - b) / (12.0 * a * a * a); + return r; + } + let mut y = a + b; + if y > MAXGAM || a > MAXGAM || b > MAXGAM { + y = lgamma_positive(y); + y = lgamma_positive(b) - y; + y = lgamma_positive(a) + y; + return y; + } + y = gamma_positive(y); + a = gamma_positive(a); + b = gamma_positive(b); + if y == 0.0 { + return f64::INFINITY; + } + if (a.abs() - y.abs()).abs() > (b.abs() - y.abs()).abs() { + y = b / y; + y *= a; + } else { + y = a / y; + y *= b; + } + y.abs().ln() +} + +/// NumPy 1.26.4 contiguous float64 pairwise reduction (128-value blocks). +/// Source: numpy/core/src/umath/loops_utils.h.src; see NUMPY-LICENSE.txt. +pub(crate) fn numpy_sum(values: &[f64]) -> f64 { + let n = values.len(); + if n < 8 { + return values.iter().fold(-0.0, |sum, &value| sum + value); + } + if n <= 128 { + let mut lanes = [0.0; 8]; + lanes.copy_from_slice(&values[..8]); + let end = n - n % 8; + for block in values[8..end].chunks_exact(8) { + for (lane, &value) in lanes.iter_mut().zip(block) { + *lane += value; + } + } + let mut result = ((lanes[0] + lanes[1]) + (lanes[2] + lanes[3])) + + ((lanes[4] + lanes[5]) + (lanes[6] + lanes[7])); + for &value in &values[end..] { + result += value; + } + return result; + } + let mut middle = n / 2; + middle -= middle % 8; + numpy_sum(&values[..middle]) + numpy_sum(&values[middle..]) +} + +/// SciPy's beta-binomial log PMF, preserving its three betaln evaluations. +pub(crate) fn betabinom_logpmf(k: u32, n: u32, alpha: f64, beta: f64) -> Result { + if !alpha.is_finite() || !beta.is_finite() || alpha <= 0.0 || beta <= 0.0 { + bail!("beta-binomial shape parameters must be finite and positive"); + } + if k > n { + return Ok(f64::NEG_INFINITY); + } + if n == 0 { + return Ok(0.0); + } + let k = f64::from(k); + let n = f64::from(n); + let combiln = -(n + 1.0).ln() - betaln_positive(n - k + 1.0, k + 1.0); + Ok(combiln + betaln_positive(k + alpha, n - k + beta) - betaln_positive(alpha, beta)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(actual: f64, expected: f64) { + assert!((actual - expected).abs() <= 5e-14 * expected.abs().max(1.0)); + } + + #[test] + fn scipy_1131_gamma_and_loggamma_branches() { + // Generated with scipy.special.gamma/gammaln in SciPy 1.13.1. + for (x, gamma, lgamma) in [ + (1e-12, 999999999999.4226, 27.63102111592797), + (0.1, 9.513507698668732, 2.252712651734206), + (2.5, 1.329340388179137, 0.2846828704729192), + (13.0, 479001600.0, 19.98721449566189), + (33.01, 2.724528906472356e35, 81.59277378960284), + (143.02, 2.976421070788908e245, 565.2240693793439), + (171.0, 7.257415615308e306, 706.5730622457875), + ] { + close(gamma_positive(x), gamma); + close(lgamma_positive(x), lgamma); + } + close(lgamma_positive(1000.0), 5905.220423209181); + close(lgamma_positive(1e8), 1742068066.1038349); + } + + #[test] + fn scipy_1131_beta_branches() { + for (a, b, expected) in [ + (0.1, 0.2, 2.680978479346915), + (30.0, 40.0, -48.30174909591612), + (80.0, 100.0, -124.629853209889), + (1e8, 0.2, -2.160072325559689), + (1e-8, 10.0, 18.42068071566268), + (500.0, 500.0, -694.988722485713), + ] { + close(betaln_positive(a, b), expected); + close(betaln_positive(b, a), expected); + } + } + + #[test] + fn numpy_1264_reduction_boundaries() { + // Independently generated np.sum(float64) references, including cancellation. + for (n, expected) in [ + (7, 1e16), + (8, 0.0), + (16, 1e16), + (127, 1.0000000000000004e16), + (128, 0.0), + (129, 0.0), + (256, 1e16), + (502, 1.0000000000000004e16), + (513, 0.0), + ] { + let values: Vec<_> = (0..n) + .map(|i| match i % 3 { + 0 => 1e16, + 1 => -1e16, + _ => 1.0, + }) + .collect(); + assert_eq!(numpy_sum(&values), expected); + } + } + + #[test] + fn real_probe_components() { + let a = 8.977092566062371; + let b = 5.5481483259805575; + let x = 8.0 + a; + let y = 4.0 + b; + eprintln!( + "betaln={:?}, {:?}, {:?}; gamma={:?}, {:?}, {:?}; numerator shapes={:?},{:?}", + betaln_positive(5.0, 9.0), + betaln_positive(x, y), + betaln_positive(a, b), + gamma_positive(x), + gamma_positive(y), + gamma_positive(x + y), + x, + y + ); + assert_eq!(gamma_positive(x), 19621650968466.152); + assert_eq!(gamma_positive(y), 132624.78469324365); + close(betaln_positive(x, y), -17.307163194505247); + } + + #[test] + fn scipy_1131_beta_binomial_expression() { + for (k, n, a, b, expected) in [ + (0, 10, 0.1, 0.2, -0.656035869515514), + (5, 10, 1.0, 1.0, -2.3978952727983707), + (100, 200, 50.0, 50.0, -3.4271728015926755), + (12, 50, 1e7, 1e7, -9.134989546611905), + (3, 12, 0.0001, 0.5, -10.168874588765625), + ] { + close(betabinom_logpmf(k, n, a, b).unwrap(), expected); + } + } + + #[test] + fn beta_binomial_support_and_parameter_validation() { + assert_eq!(betabinom_logpmf(0, 0, 1.0, 2.0).unwrap(), 0.0); + assert_eq!(betabinom_logpmf(2, 1, 1.0, 2.0).unwrap(), f64::NEG_INFINITY); + for shape in [0.0, -1.0, f64::INFINITY, f64::NAN] { + assert!(betabinom_logpmf(0, 1, shape, 1.0).is_err()); + assert!(betabinom_logpmf(0, 1, 1.0, shape).is_err()); + } + } +} diff --git a/tests/analysis/test_scipy_numerical_kernel.py b/tests/analysis/test_scipy_numerical_kernel.py new file mode 100644 index 0000000..58f44a5 --- /dev/null +++ b/tests/analysis/test_scipy_numerical_kernel.py @@ -0,0 +1,122 @@ +"""Strict Python compatibility and independent numerical-stability checks. + +The frozen real-data expectations were generated by archived Python, not Rust. +The high-depth checks use SciPy's log PMF as an independent mathematical oracle; +they do not claim compatibility with the archive's underflowing product loop. +""" + +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from scipy.stats import betabinom +from scripts.compare_archive_analysis import compare_frames + +REFERENCE = json.loads( + (Path(__file__).parent / "fixtures/real_atac_optimizer_reference.json").read_text() +) + + +@pytest.mark.rust +@pytest.mark.parametrize("case", REFERENCE["cases"], ids=lambda case: case["name"]) +def test_frozen_real_atac_outputs_match_python_strictly(tmp_path, case): + import wasp2_rust + + counts_file = tmp_path / "counts.tsv" + pd.DataFrame(case["counts"]).to_csv(counts_file, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(counts_file), + method=case["method"], + phased=case["phased"], + pseudocount=case["pseudocount"], + region_col="region", + **case["fixed_dispersion"], + ) + comparison = compare_frames(pd.DataFrame(case["expected"]), pd.DataFrame(output["results"])) + assert comparison["status"] == "PASS", comparison + + +@pytest.mark.rust +@pytest.mark.parametrize("rho", [1e-8, 0.1, 0.99]) +@pytest.mark.parametrize("ref_count,total", [(0, 10), (10, 10), (1, 1000), (500, 1000)]) +def test_fixed_dispersion_null_logpmf_matches_scipy(tmp_path, rho, ref_count, total): + import wasp2_rust + + counts_file = tmp_path / "counts.tsv" + pd.DataFrame( + [ + { + "chrom": "chr1", + "pos": 101, + "ref": "A", + "alt": "G", + "region": "peak", + "ref_count": ref_count, + "alt_count": total - ref_count, + } + ] + ).to_csv(counts_file, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(counts_file), + method="single", + phased=False, + pseudocount=0, + region_col="region", + rho=rho, + ) + actual = output["results"][0]["null_ll"] + shape = 0.5 * (1.0 - rho) / rho + expected = betabinom.logpmf(ref_count, total, shape, shape) + assert np.isfinite(actual) + assert np.isclose(actual, expected, rtol=5e-5, atol=5e-5) + + +@pytest.mark.rust +def test_many_high_depth_unphased_observations_remain_finite(tmp_path): + import wasp2_rust + + # Unique positions ensure these are distinct retained observations. A long + # product underflows even though the equivalent log likelihood is finite. + total = 100_000 + ref_counts = np.arange(200, dtype=np.int64) + 2000 + counts_file = tmp_path / "counts.tsv" + pd.DataFrame( + { + "chrom": "chr1", + "pos": np.arange(200) + 101, + "ref": "A", + "alt": "G", + "region": "peak", + "ref_count": ref_counts, + "alt_count": total - ref_counts, + } + ).to_csv(counts_file, sep="\t", index=False) + rho = 0.1 + output = wasp2_rust.analyze_imbalance_run( + str(counts_file), + method="single", + phased=False, + pseudocount=0, + region_col="region", + rho=rho, + ) + result = output["results"][0] + assert result["snp_count"] == len(ref_counts) + assert all(np.isfinite(result[key]) for key in ["mu", "null_ll", "alt_ll", "pval"]) + assert 0.0 < result["mu"] < 1.0 + shape = 0.5 * (1.0 - rho) / rho + expected_null = np.sum(betabinom.logpmf(ref_counts, total, shape, shape)) + assert np.isclose(result["null_ll"], expected_null, rtol=5e-5, atol=5e-5) + + alpha = result["mu"] * (1.0 - rho) / rho + beta = (1.0 - result["mu"]) * (1.0 - rho) / rho + forward = betabinom.logpmf(ref_counts, total, alpha, beta) + reverse = betabinom.logpmf(ref_counts, total, beta, alpha) + expected_alt = forward[0] + np.sum(np.logaddexp(forward[1:], reverse[1:]) - np.log(2.0)) + assert np.isclose(result["alt_ll"], expected_alt, rtol=5e-5, atol=5e-5) + product = 1.0 + for p1, p2 in zip(np.exp(forward[1:]), np.exp(reverse[1:]), strict=True): + product = 0.5 * (product * p1) + 0.5 * (product * p2) + assert product == 0.0 From a4f77d02b2aafba2661b62d6b6835204ec7d51f6 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:10:30 -0700 Subject: [PATCH 16/22] ci: fetch archived references and support current Clippy --- .github/workflows/ci.yml | 2 ++ rust/src/scipy_compat.rs | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0852ff4..08831c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + # Runtime reference tests read the pinned pre-Rust archive commit. + fetch-depth: 0 - name: Install system dependencies run: | diff --git a/rust/src/scipy_compat.rs b/rust/src/scipy_compat.rs index 59bdb7d..e83f49b 100644 --- a/rust/src/scipy_compat.rs +++ b/rust/src/scipy_compat.rs @@ -65,6 +65,9 @@ const GAMMA_C: [f64; 6] = [ -2.01889141433532773231E6, ]; const MAXGAM: f64 = 171.624376956302725; +// Retain the pinned Cephes literal on Rust versions without EULER_GAMMA. +#[allow(clippy::approx_constant)] +const CEPHES_EULER_GAMMA: f64 = 0.5772156649015329; fn polevl(x: f64, coefficients: &[f64]) -> f64 { let mut result = coefficients[0]; @@ -114,7 +117,7 @@ fn gamma_positive(mut x: f64) -> f64 { } while x < 2.0 { if x < 1e-9 { - return z / (0.5772156649015329_f64.mul_add(x, 1.0) * x); + return z / (CEPHES_EULER_GAMMA.mul_add(x, 1.0) * x); } z /= x; x += 1.0; @@ -221,7 +224,7 @@ pub(crate) fn numpy_sum(values: &[f64]) -> f64 { let mut lanes = [0.0; 8]; lanes.copy_from_slice(&values[..8]); let end = n - n % 8; - for block in values[8..end].chunks_exact(8) { + for block in values[8..end].as_chunks::<8>().0 { for (lane, &value) in lanes.iter_mut().zip(block) { *lane += value; } From cb7b83436ec8e3e4f9d1b2addbccdb0470a464f4 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:01:39 -0700 Subject: [PATCH 17/22] fix: close bulk genotype and dispersion parity gaps with CLI and real-data validation --- .github/workflows/ci.yml | 4 + .github/workflows/docker.yml | 25 +++- .github/workflows/release.yml | 19 ++- CHANGELOG.md | 15 +- Dockerfile | 2 + README.md | 15 +- docs/BULK_PARITY_VALIDATION.md | 87 +++++++++++ rust/src/analysis.rs | 91 +++++++----- rust/src/lib.rs | 16 ++- scripts/container_smoke_test.sh | 69 ++++++++- src/analysis/__main__.py | 4 +- src/analysis/run_analysis.py | 2 +- tests/analysis/fixtures/public_atac/README.md | 22 +++ .../fixtures/public_atac/metadata.json | 29 ++++ .../fixtures/public_atac/peak_counts.tsv.gz | Bin 0 -> 7776 bytes .../fixtures/public_atac/snp_counts.tsv.gz | Bin 0 -> 153096 bytes tests/analysis/test_bulk_edge_parity.py | 109 ++++++++++++++ tests/analysis/test_donor_archive_parity.py | 61 ++++++-- tests/analysis/test_public_atac_matrix.py | 136 ++++++++++++++++++ 19 files changed, 631 insertions(+), 75 deletions(-) create mode 100644 docs/BULK_PARITY_VALIDATION.md create mode 100644 tests/analysis/fixtures/public_atac/README.md create mode 100644 tests/analysis/fixtures/public_atac/metadata.json create mode 100644 tests/analysis/fixtures/public_atac/peak_counts.tsv.gz create mode 100644 tests/analysis/fixtures/public_atac/snp_counts.tsv.gz create mode 100644 tests/analysis/test_bulk_edge_parity.py create mode 100644 tests/analysis/test_public_atac_matrix.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08831c3..6277af5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,10 @@ jobs: source .venv/bin/activate python -c "import wasp2_rust; print('wasp2_rust loaded:', wasp2_rust.__file__)" + - name: Pin historical numerical reference for the full real ATAC matrix + if: matrix.python-version == '3.11' + run: uv pip install --python .venv/bin/python numpy==1.26.4 scipy==1.13.1 + - name: Run tests run: | source .venv/bin/activate diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fa44297..6674dd5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,18 +19,28 @@ permissions: jobs: build: name: Container ${{ matrix.platform }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: - platform: [linux/amd64, linux/arm64] + include: + - {runner: ubuntu-24.04, platform: linux/amd64} + - {runner: ubuntu-24.04-arm, platform: linux/arm64} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - name: Read package version + id: version + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import tomllib + with open("rust/Cargo.toml", "rb") as handle: + print("value=" + tomllib.load(handle)["package"]["version"]) + PY - name: Build local test image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -38,13 +48,20 @@ jobs: load: true platforms: ${{ matrix.platform }} tags: wasp2:ci - build-args: VERSION=0+ci.${{ github.sha }} + build-args: | + VERSION=${{ steps.version.outputs.value }} + REVISION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Smoke-test image env: PLATFORM: ${{ matrix.platform }} - run: docker run --rm --platform "$PLATFORM" wasp2:ci /opt/wasp2/scripts/container_smoke_test.sh + EXPECTED_VERSION: ${{ steps.version.outputs.value }} + EXPECTED_REVISION: ${{ github.sha }} + run: | + test "$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' wasp2:ci)" = "$EXPECTED_VERSION" + test "$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' wasp2:ci)" = "$EXPECTED_REVISION" + docker run --rm --platform "$PLATFORM" -e EXPECTED_WASP2_VERSION="$EXPECTED_VERSION" wasp2:ci /opt/wasp2/scripts/container_smoke_test.sh required: name: Required / Container diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ce9154..1215048 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,14 +281,21 @@ jobs: load: true platforms: ${{ matrix.platform }} tags: wasp2:release-smoke-${{ matrix.tag }} - build-args: VERSION=${{ needs.validate.outputs.version }} + build-args: | + VERSION=${{ needs.validate.outputs.version }} + REVISION=${{ github.sha }} cache-from: type=gha,scope=release-${{ matrix.tag }} cache-to: type=gha,mode=max,scope=release-${{ matrix.tag }} - name: Smoke-test local release image env: IMAGE: wasp2:release-smoke-${{ matrix.tag }} PLATFORM: ${{ matrix.platform }} - run: docker run --rm --platform "$PLATFORM" "$IMAGE" /opt/wasp2/scripts/container_smoke_test.sh + EXPECTED_VERSION: ${{ needs.validate.outputs.version }} + EXPECTED_REVISION: ${{ github.sha }} + run: | + test "$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$IMAGE")" = "$EXPECTED_VERSION" + test "$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE")" = "$EXPECTED_REVISION" + docker run --rm --platform "$PLATFORM" -e EXPECTED_WASP2_VERSION="$EXPECTED_VERSION" "$IMAGE" /opt/wasp2/scripts/container_smoke_test.sh singularity-smoke: name: Singularity smoke @@ -311,7 +318,9 @@ jobs: context: . platforms: linux/amd64 tags: wasp2:release-singularity - build-args: VERSION=${{ needs.validate.outputs.version }} + build-args: | + VERSION=${{ needs.validate.outputs.version }} + REVISION=${{ github.sha }} outputs: type=docker,dest=${{ runner.temp }}/wasp2-docker.tar cache-from: type=gha,scope=release-amd64 cache-to: type=gha,mode=max,scope=release-amd64 @@ -373,7 +382,9 @@ jobs: tags: | ${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.version }} ${{ env.IMAGE_NAME }}:latest - build-args: VERSION=${{ needs.validate.outputs.version }} + build-args: | + VERSION=${{ needs.validate.outputs.version }} + REVISION=${{ github.sha }} provenance: mode=max sbom: true - name: Smoke-test published architectures diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4ab01..b190464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Bulk phasing retains the original genotype tokens for archived table-wide + checks, parses numeric first alleles consistently, and rejects malformed mixed + phased input instead of silently changing a region's likelihood. Interior + dispersion values are no longer clipped to the endpoint regularization range. +- Full 32-run public ATAC comparisons execute archived Python in pinned Linux + CI, including CLI inference and donor routes. Heterogeneous donor checks cover + both dispersion models, phasing modes and pseudocounts with independent fits. +- Container checks exercise reordered ATAC headers, region grouping, phased + likelihoods and raw counts. Images carry package-version and source-revision + labels; smoke tests require a matching Python/Rust distribution version. +- CLI help now reports the actual donor-local pseudocount default of 1. - Bulk beta-binomial calculations use a SciPy 1.13.1-compatible Cephes kernel, reference FMA arithmetic, and NumPy float64 reductions. Unphased inference follows archived probability accumulation when safe and uses log-space fallback @@ -57,8 +68,8 @@ All notable changes to WASP2 will be documented in this file. ### Changed - VCF conversion now selects biallelic SNVs by default; multi-allelic records require the explicit `--include-multiallelic` option. -- Phased inference accepts only numeric VCF heterozygote orientation (`0|1` and `1|0`) for parity - with the archived Python implementation; allele-string genotypes do not imply cross-SNV phase. +- Phased inference follows archived numeric first-allele parsing after table-wide + genotype checks; allele-string genotypes do not imply cross-SNV phase. - `--phased` and `--region_col` are forwarded to the Rust backend; unsupported `--groupby` requests fail closed and direct users to `--region_col `. - SNV analysis is unphased by contract, while feature analysis supports phased and unphased diff --git a/Dockerfile b/Dockerfile index 726f34a..e86fa6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,7 @@ FROM python:3.11-slim-bookworm # Version: keep in sync with rust/Cargo.toml (single source of truth) # Run scripts/check-version-consistency.sh to verify ARG VERSION=1.5.0 +ARG REVISION=unknown LABEL org.opencontainers.image.source="https://github.com/mcvickerlab/WASP2" LABEL org.opencontainers.image.description="WASP2: Allele-specific analysis of NGS data with Rust acceleration" @@ -77,6 +78,7 @@ LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.vendor="mcvickerlab" LABEL org.opencontainers.image.title="WASP2" LABEL org.opencontainers.image.version="${VERSION}" +LABEL org.opencontainers.image.revision="${REVISION}" LABEL maintainer="Jeff Jaureguy " # Install runtime deps + temporary build deps for pybedtools C++ extension diff --git a/README.md b/README.md index b551cbd..326e04d 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ wasp2-analyze find-imbalance cohort_counts \ ``` `--dispersion-scope per-donor` is the default. `--dispersion-scope global` -fits one nuisance model over unique eligible donor-SNV rows and reuses that exact +fits one nuisance model over retained eligible donor-feature-SNV observations and reuses that exact fit for every included donor; testing and BH correction remain donor-local. Both `single` and `linear` dispersion models are supported. Linear fits record and reuse `linear_d1`, `linear_d2`, `linear_depth_center`, and @@ -172,6 +172,19 @@ donor and allele identity; coordinate-based IDs are synthesized only when the column is absent. Dispersion and provenance metadata report optimizer status only when Rust provides it; `parameters_returned` does not assert convergence. +Phasing checks run after depth filtering. Missing GT, identical GT tokens, or +absence of numeric `0|1`/`1|0` genotypes selects unphased inference. Otherwise, +every retained GT must have a numeric first allele: mixed `0|0`/`1|1` tokens +follow archived Python's first-allele behavior, while malformed mixed tokens +(such as `0/1` or `A|G`) raise an error. For phased inference, prepare a table of +truly phased heterozygous SNPs; otherwise omit `--phased`. + +Valid interior dispersion values are retained without clipping. Exact rho +endpoints and probability-product underflow use documented finite extensions +because archived Python is undefined there. The full public ATAC comparison +and its reference adaptations are described in +[the validation report](docs/BULK_PARITY_VALIDATION.md). + ## Single-Cell Example ```bash diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md new file mode 100644 index 0000000..f322944 --- /dev/null +++ b/docs/BULK_PARITY_VALIDATION.md @@ -0,0 +1,87 @@ +# Bulk Python/Rust compatibility validation + +Validation date: 2026-10-14. Changes are proposed in PR #133, which includes +the compatibility changes from PR #135. Neither PR is merged or released. + +## Reference and data + +The reference is archived **Python**, commit +`41354fc087404ba1fa99542803ee9cb9b7e0e6bf`, with NumPy 1.26.4 and SciPy 1.13.1. +Tests execute its functions; Rust outputs are not used as expected results. + +Public real ATAC inputs are GM12878 experiment ENCSR637XSC, replicate-2 +BAM ENCFF962FMH, peaks ENCFF452ZBX, and matched HG001 GIAB NISTv3.3.1 +GRCh38 genotypes. Source MD5 checks passed. Chromosome 21 contains 1,760,836 +alignment records, 27,182 eligible SNPs and 556 peak-overlapping SNP rows. +Counting matches the unmodified archived function at thread settings 1 and 4. +The full counting CLI independently reproduced the 556 peak rows and memberships. +Source links, compressed count tables and checksums are in +[the public-data fixture](../tests/analysis/fixtures/public_atac/README.md). + +## Results and reproduction + +- The 32-run matrix passes locally on macOS arm64: 16 legacy runs across + SNP/peak, single/linear, phased/unphased and pseudocount 0/1, plus 16 donor + feature runs across global/per-donor dispersion. Reported raw mu is compared. +- Counts require exact agreement. Statistical tolerances remain + `rtol=atol=5e-5`; no proportion flipping or region-specific exceptions occur. +- Every legacy matrix case also executes CLI inference and checks its output. + Mixed-genotype tests compare the actual archive's successful and error paths. +- Heterogeneous donor tests independently fit dispersion with Python and compare + donor effects, chi-square LRT p-values and donor-local BH correction. They cover + overlapping features, both dispersion models, phase modes and pseudocounts. +- Local suite: 443 Python tests passed, 82 skipped, 1 deselected; 132 Rust tests + passed, 7 ignored. Ruff, formatting and diff checks pass. Rust 1.88 Clippy + passes with the pre-existing `uninlined_format_args` lint disabled. + +In a full clone with the built extension and pinned numerical dependencies: + +```bash +python -m pytest tests/analysis/test_public_atac_matrix.py \ + tests/analysis/test_bulk_edge_parity.py \ + tests/analysis/test_donor_archive_parity.py -q +``` + +Python 3.11 Linux CI pins the numerical dependencies and runs the complete real +matrix. Other Python versions run the remaining compatibility regressions. +Final-head Linux and container outcomes must be reviewed separately in GitHub +Actions; local results do not establish those outcomes. + +## Deliberate extensions and reference repairs + +Global/per-donor dispersion is the intended extension. Shared dispersion does +not pool effect tests or multiple-testing correction across donors. Archived +linear array shapes require explicit reference adapters; phased linear tests +explicitly forward the phase flag that the archived linear wrapper ignored. + +Interior rho values are preserved, including tested values from `1e-12` to +`1-1e-12`. Exact endpoints retain a finite regularization because archived +beta-binomial shapes are undefined there. Long probability products use +log-space fallback after losing normal range; this extends the archive's +underflowing arithmetic rather than reproducing its failure. + +Archived phasing checks use distinct original GT tokens and the presence of +`0|1`/`1|0`, then parse every retained first allele. Numeric homozygous tokens +follow that behavior. Malformed mixed tokens raise an error instead of silently +changing a region's likelihood. One public peak SNP has `0/1`: the unfiltered +phased command is rejected by both implementations. Phased comparisons explicitly +select truly phased heterozygous SNPs; unphased comparisons retain all SNPs. + +## Integration and release gates + +Review compatibility PR #135 first, then refresh #133 against the resulting dev +branch and rerun required checks. No merge or tag is included in this validation. +All active version metadata currently uses 1.5.0. CI/release image builds pass +the package version and source SHA to OCI labels; container smoke tests verify +those labels and Python/Rust package versions and exercise ATAC grouping/phasing. + +Review the exact final head's tests and containers before integration. A release +dry run remains necessary after the reviewed code reaches the permitted release +branch, including the separate Singularity preflight. Existing published images +and packages will remain old until a release is actually published. + +These comparisons cover one real donor/chromosome and controlled heterogeneous +donors. They do not establish biological calibration, a new WASP remapping pass, +or universal bitwise agreement across numerical environments. A symmetric +unphased likelihood has no identifiable biological direction even when its +reported mu agrees numerically with the pinned reference. diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index ec55b4a..020ce92 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -23,9 +23,10 @@ pub struct VariantCounts { pub ref_count: u32, pub alt_count: u32, pub region: String, - /// Genotype phase relative to the reference allele: 0 = ref|alt, 1 = alt|ref. - /// `None` when genotypes are absent or unphased. Required by phased analysis. + /// First numeric allele, parsed as in archived Python after phasing checks. pub gt: Option, + /// Original GT token for Python's table-wide diversity/heterozygosity checks. + pub raw_gt: Option, } /// Deduplication key for matching Python's `df[keep_cols].drop_duplicates()`. @@ -202,11 +203,11 @@ impl Default for AnalysisConfig { // Rho Parameter Bounds (Issue #228) // ============================================================================ -/// Epsilon for clamping rho parameter to avoid division by zero. -/// Matches Python's RHO_EPSILON in as_analysis.py. +/// Regularization for exact endpoints, where archived beta-binomial shapes +/// are undefined. Interior values retain the archived parameter unchanged. const RHO_EPSILON: f64 = 1e-10; -/// Clamp rho to safe range (epsilon, 1-epsilon) to prevent division by zero. +/// Regularize exact endpoints without clipping valid interior dispersion. /// /// The beta-binomial parameterization uses alpha = mu * (1-rho) / rho, which /// causes division by zero when rho=0 and produces zero alpha/beta when rho=1. @@ -219,7 +220,13 @@ const RHO_EPSILON: f64 = 1e-10; /// implementation (`as_analysis.py`) supports optional warning via `warn=True`. #[inline] fn clamp_rho(rho: f64) -> f64 { - rho.clamp(RHO_EPSILON, 1.0 - RHO_EPSILON) + if rho <= 0.0 { + RHO_EPSILON + } else if rho >= 1.0 { + 1.0 - RHO_EPSILON + } else { + rho + } } /// Deduplicate variants to match Python's `df[keep_cols].drop_duplicates()`. @@ -247,27 +254,30 @@ fn deduplicate_variants(variants: Vec, phased: bool) -> Vec bool { +fn effective_phased(variants: &[VariantCounts], requested_phased: bool) -> Result { if !requested_phased { - return false; - } - - let mut saw_ref_alt = false; - let mut saw_alt_ref = false; - for variant in variants { - match variant.gt { - Some(0) => saw_ref_alt = true, - Some(1) => saw_alt_ref = true, - _ => {} - } + return Ok(false); } - - if saw_ref_alt && saw_alt_ref { - true - } else { + let unique: HashSet<_> = variants.iter().map(|v| v.raw_gt.as_deref()).collect(); + let informative = variants + .iter() + .any(|v| matches!(v.raw_gt.as_deref(), Some("0|1" | "1|0"))); + if unique.len() <= 1 || !informative { eprintln!("No informative numeric phased GT found: switching to unphased model"); - false + return Ok(false); + } + // Once Python enables phasing it parses every retained GT, and fails on + // malformed mixed input. Do not silently change likelihoods per region. + for variant in variants { + anyhow::ensure!( + matches!(variant.gt, Some(0 | 1)), + "Invalid GT {:?} at {}:{} for phased analysis; provide numeric phased genotypes or run unphased", + variant.raw_gt, + variant.chrom, + variant.pos + ); } + Ok(true) } /// Sigmoid function (inverse logit): expit(x) = 1 / (1 + e^-x) @@ -1092,7 +1102,10 @@ struct PreparedAnalysis { effective_phased: bool, } -fn prepare_analysis(variants: Vec, config: &AnalysisConfig) -> PreparedAnalysis { +fn prepare_analysis( + variants: Vec, + config: &AnalysisConfig, +) -> Result { let filtered: Vec = variants .into_iter() .map(|mut v| { @@ -1108,7 +1121,7 @@ fn prepare_analysis(variants: Vec, config: &AnalysisConfig) -> Pr eprintln!("Processing {} variants after filtering", filtered.len()); - let phased = effective_phased(&filtered, config.phased); + let phased = effective_phased(&filtered, config.phased)?; // Deduplicate variants for Python parity. // Python's get_imbalance() does: df[keep_cols].drop_duplicates() @@ -1122,10 +1135,10 @@ fn prepare_analysis(variants: Vec, config: &AnalysisConfig) -> Pr before - deduped.len() ); } - PreparedAnalysis { + Ok(PreparedAnalysis { variants: deduped, effective_phased: phased, - } + }) } fn remove_result_pseudocounts(results: &mut [ImbalanceResult], pseudocount: u32) { @@ -1182,7 +1195,7 @@ pub fn fit_imbalance_dispersion( variants: Vec, config: &AnalysisConfig, ) -> Result { - let prepared = prepare_analysis(variants, config); + let prepared = prepare_analysis(variants, config)?; if prepared.variants.is_empty() { anyhow::bail!("no variants passed the count threshold for dispersion fitting"); } @@ -1221,7 +1234,7 @@ pub fn analyze_imbalance_with_fixed_dispersion( exact_snv_pvalues: bool, ) -> Result { let parameters = parameters.validate_for(config.method)?; - let prepared = prepare_analysis(variants, config); + let prepared = prepare_analysis(variants, config)?; analyze_prepared(prepared, config, parameters, exact_snv_pvalues) } @@ -1229,7 +1242,7 @@ pub fn analyze_imbalance_detailed( variants: Vec, config: &AnalysisConfig, ) -> Result { - let prepared = prepare_analysis(variants, config); + let prepared = prepare_analysis(variants, config)?; let n_observations = prepared.variants.len(); if n_observations == 0 { return Ok(AnalysisOutput { @@ -1329,6 +1342,8 @@ mod tests { assert!((clamp_rho(0.5) - 0.5).abs() < 1e-15); assert!((clamp_rho(-1.0) - RHO_EPSILON).abs() < 1e-15); assert!((clamp_rho(2.0) - (1.0 - RHO_EPSILON)).abs() < 1e-15); + assert_eq!(clamp_rho(1e-12), 1e-12); + assert_eq!(clamp_rho(1.0 - 1e-12), 1.0 - 1e-12); } #[test] @@ -1556,6 +1571,7 @@ mod tests { alt_count: ac, region: "peak1".to_string(), gt, + raw_gt: gt.map(|g| if g == 0 { "0|1" } else { "1|0" }.to_string()), } } @@ -1572,19 +1588,22 @@ mod tests { } #[test] - fn test_effective_phased_requires_both_numeric_orientations() { + fn test_effective_phased_follows_python_table_checks() { let both = vec![vc_peak(100, 18, 4, Some(0)), vc_peak(200, 5, 15, Some(1))]; - assert!(effective_phased(&both, true)); - assert!(!effective_phased(&both, false)); + assert!(effective_phased(&both, true).unwrap()); + assert!(!effective_phased(&both, false).unwrap()); let none = vec![vc_peak(100, 18, 4, None), vc_peak(200, 5, 15, None)]; - assert!(!effective_phased(&none, true)); + assert!(!effective_phased(&none, true).unwrap()); let same = vec![vc_peak(100, 18, 4, Some(0)), vc_peak(200, 12, 8, Some(0))]; - assert!(!effective_phased(&same, true)); + assert!(!effective_phased(&same, true).unwrap()); let partial = vec![vc_peak(100, 18, 4, Some(0)), vc_peak(200, 12, 8, None)]; - assert!(!effective_phased(&partial, true)); + assert!(effective_phased(&partial, true).is_err()); + let mut numeric = same; + numeric[1].raw_gt = Some("0|0".to_string()); + assert!(effective_phased(&numeric, true).unwrap()); } #[test] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 72cdd06..81e0e06 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -33,11 +33,10 @@ use bam_counter_sc::BamCounterSC; use mapping_filter::filter_bam_wasp; fn parse_numeric_phased_gt(gt: &str) -> Option { - match gt { - "0|1" => Some(0), - "1|0" => Some(1), - _ => None, - } + // Python parses the first allele after its table-wide phasing checks. + // Retain homozygous numeric GT too; the original string determines whether + // the table is informative, not the number of distinct first alleles. + gt.split('|').next()?.parse::().ok() } // ============================================================================ @@ -465,6 +464,7 @@ fn read_analysis_variants( alt_count, region, gt, + raw_gt: gt_idx.map(|idx| fields[idx].to_string()), }); } Ok(variants) @@ -1251,11 +1251,13 @@ mod tests { use super::parse_numeric_phased_gt; #[test] - fn test_parse_numeric_phased_gt_accepts_only_aho_numeric_hets() { + fn test_parse_numeric_phased_gt_follows_python_first_allele() { assert_eq!(parse_numeric_phased_gt("0|1"), Some(0)); assert_eq!(parse_numeric_phased_gt("1|0"), Some(1)); - for gt in ["A|G", "G|A", "0/1", "0|0", "1|1", "./.", ".|."] { + assert_eq!(parse_numeric_phased_gt("0|0"), Some(0)); + assert_eq!(parse_numeric_phased_gt("1|1"), Some(1)); + for gt in ["A|G", "G|A", "0/1", "./.", ".|."] { assert_eq!( parse_numeric_phased_gt(gt), None, diff --git a/scripts/container_smoke_test.sh b/scripts/container_smoke_test.sh index 8981e3d..7a52d13 100755 --- a/scripts/container_smoke_test.sh +++ b/scripts/container_smoke_test.sh @@ -53,7 +53,7 @@ echo "" # ───────────────────────────────────────────────────────────────────────────── # Test 1: CLI binaries exist and print version # ───────────────────────────────────────────────────────────────────────────── -echo "[1/4] Version checks..." +echo "[1/5] Version checks..." for cmd in wasp2-count wasp2-map wasp2-analyze; do if $cmd --version > /dev/null 2>&1; then @@ -64,18 +64,32 @@ for cmd in wasp2-count wasp2-map wasp2-analyze; do FAIL=$((FAIL + 1)) fi done +python - <<'PY' +import os +from importlib.metadata import version +import wasp2 +import wasp2_rust + +installed = version("wasp2") +assert wasp2.__version__ == wasp2_rust.__version__ == installed +expected = os.environ.get("EXPECTED_WASP2_VERSION", installed) +assert installed == expected, (installed, expected) +print(f" PASS: Python, Rust and distribution versions agree: {installed}") +PY +PASS=$((PASS + 1)) echo "" # ───────────────────────────────────────────────────────────────────────────── # Test 2: Python imports # ───────────────────────────────────────────────────────────────────────────── -echo "[2/4] Python imports..." +echo "[2/5] Python imports..." if python -c "import wasp2_rust; print('Rust extension OK')" 2>/dev/null; then echo " PASS: wasp2_rust import" PASS=$((PASS + 1)) else - echo " INFO: wasp2_rust not available (Python-only mode)" + echo " FAIL: wasp2_rust import" + FAIL=$((FAIL + 1)) fi if python -c "from counting.run_counting import run_count_variants; print('counting OK')" 2>/dev/null; then @@ -88,9 +102,9 @@ fi echo "" # ───────────────────────────────────────────────────────────────────────────── -# Test 3: Count variants with real data +# Test 3: Count variants with the bundled synthetic BAM/VCF # ───────────────────────────────────────────────────────────────────────────── -echo "[3/4] Count variants with real data..." +echo "[3/5] Count variants with bundled BAM/VCF..." if [[ -f "$DATA_DIR/sample1.bam" && -f "$DATA_DIR/variants.vcf.gz" ]]; then if wasp2-count count-variants \ @@ -120,7 +134,7 @@ echo "" # ───────────────────────────────────────────────────────────────────────────── # Test 4: External tools (samtools, bcftools, bedtools) # ───────────────────────────────────────────────────────────────────────────── -echo "[4/4] External tool availability..." +echo "[4/5] External tool availability..." for tool in samtools bcftools bedtools; do if command -v $tool > /dev/null 2>&1; then @@ -133,6 +147,49 @@ for tool in samtools bcftools bedtools; do done echo "" +# Exercise the original ATAC failure: reordered columns, nonnumeric region IDs, +# and both phase orientations. Compare phased and unphased CLI runs to catch +# silently ignored --phased as well as positional parsing and ignored grouping. +echo "[5/5] ATAC region analysis..." +python - "$TMP_DIR/regions.tsv" <<'PY' +import csv +import sys + +with open(sys.argv[1], "w") as handle: + writer = csv.writer(handle, delimiter="\t") + writer.writerow(["ref", "alt", "region", "chrom", "start", "pos", "GT", "ref_count", "alt_count"]) + for pos, gt, ref, alt in [(101, "0|1", 18, 2), (201, "1|0", 3, 17), + (301, "0|1", 22, 18), (401, "1|0", 25, 15)]: + region = "chr1:100-250" if pos < 300 else "chr1:300-450" + writer.writerow(["A", "G", region, "chr1", pos - 1, pos, gt, ref, alt]) +PY +wasp2-analyze find-imbalance "$TMP_DIR/regions.tsv" --phased \ + --region_col region --out_file "$TMP_DIR/phased.tsv" +wasp2-analyze find-imbalance "$TMP_DIR/regions.tsv" \ + --region_col region --out_file "$TMP_DIR/unphased.tsv" +python - "$TMP_DIR/phased.tsv" "$TMP_DIR/unphased.tsv" <<'PY' +import csv +import math +import sys + +def read(path): + with open(path) as handle: + return {row["region"]: row for row in csv.DictReader(handle, delimiter="\t")} + +phased, unphased = map(read, sys.argv[1:]) +assert set(phased) == set(unphased) == {"chr1:100-250", "chr1:300-450"} +for region, counts in [("chr1:100-250", (21, 19)), ("chr1:300-450", (47, 33))]: + for results in (phased, unphased): + row = results[region] + assert (int(row["ref_count"]), int(row["alt_count"])) == counts + assert int(row["snp_count"]) == 2 + assert all(math.isfinite(float(row[key])) for key in ["mu", "null_ll", "alt_ll", "pval", "fdr_pval"]) +assert any(abs(float(phased[k]["alt_ll"]) - float(unphased[k]["alt_ll"])) > 1e-6 for k in phased) +print(" PASS: region grouping, raw counts and phased likelihood through CLI") +PY +PASS=$((PASS + 1)) +echo "" + # ───────────────────────────────────────────────────────────────────────────── # Summary # ───────────────────────────────────────────────────────────────────────────── diff --git a/src/analysis/__main__.py b/src/analysis/__main__.py index 13ff437..a34b230 100644 --- a/src/analysis/__main__.py +++ b/src/analysis/__main__.py @@ -94,8 +94,8 @@ def find_imbalance( "--pseudo", "--pseudocount", help=( - "Pseudocount added when measuring allelic imbalance. Defaults to 0 for " - "--scope per-donor and 1 for the legacy route." + "Pseudocount added when measuring allelic imbalance. Defaults to 1 " + "for both donor-local and legacy analysis." ), ), ] = None, diff --git a/src/analysis/run_analysis.py b/src/analysis/run_analysis.py index 2b1fb7d..fea4542 100644 --- a/src/analysis/run_analysis.py +++ b/src/analysis/run_analysis.py @@ -160,7 +160,7 @@ def run_ai_analysis( min_count : int | None, optional Minimum total count threshold, by default 10. pseudocount : int | None, optional - Pseudocount to add. Defaults to 0 for donor-local analysis and 1 for + Pseudocount to add. Defaults to 1 for donor-local analysis and for the legacy route. phased : bool | None, optional Use phased genotype information, by default False. diff --git a/tests/analysis/fixtures/public_atac/README.md b/tests/analysis/fixtures/public_atac/README.md new file mode 100644 index 0000000..0a413be --- /dev/null +++ b/tests/analysis/fixtures/public_atac/README.md @@ -0,0 +1,22 @@ +# Public ATAC bulk compatibility data + +These compressed tables contain measured allele counts from GRCh38 chromosome 21 +of GM12878 ATAC experiment ENCSR637XSC, replicate-2 BAM +[ENCFF962FMH](https://www.encodeproject.org/files/ENCFF962FMH/), intersected with +[ENCFF452ZBX peaks](https://www.encodeproject.org/files/ENCFF452ZBX/) and matched +[HG001 GIAB NISTv3.3.1 phased genotypes](https://ftp-trace.ncbi.nlm.nih.gov/ReferenceSamples/giab/release/NA12878_HG001/NISTv3.3.1/GRCh38/). + +Source downloads passed MD5 checks. Counting was independently compared with the +unmodified archived Python function at thread settings 1 and 4. There are 27,182 +SNP rows and 556 peak-overlapping rows; the complete counting CLI reproduced all +556 peak rows exactly. `metadata.json` records source and table checksums. + +Tests execute archived Python commit `41354fc` on these inputs rather than storing +Rust outputs as expected results. Phased comparisons select truly phased SNPs. +Linear-reference shape repairs and forwarding of its ignored phased flag are +explicit in the test. Strict statistical tolerances remain `rtol=atol=5e-5`; +counts must match exactly. Sixteen legacy runs plus sixteen donor feature runs +form the 32-run matrix, with NumPy 1.26.4 and SciPy 1.13.1 pinned in Python 3.11 CI. + +This is one donor and one chromosome, using published aligned reads. It does not +validate a new WASP remapping pass, multi-donor calibration or biological claims. diff --git a/tests/analysis/fixtures/public_atac/metadata.json b/tests/analysis/fixtures/public_atac/metadata.json new file mode 100644 index 0000000..fd10e59 --- /dev/null +++ b/tests/analysis/fixtures/public_atac/metadata.json @@ -0,0 +1,29 @@ +{ + "experiment": "ENCSR637XSC", + "bam": "ENCFF962FMH", + "peaks": "ENCFF452ZBX", + "genotypes": "HG001 GIAB NISTv3.3.1 GRCh38", + "chromosome": "chr21", + "archive_commit": "41354fc087404ba1fa99542803ee9cb9b7e0e6bf", + "reference_environment": { + "numpy": "1.26.4", + "scipy": "1.13.1" + }, + "sources": { + "bam_md5": "9d7b262ae771a5e638473f801840772e", + "vcf_md5": "3ddb1305f14e39a04c93ba2db3f7247e", + "peaks_md5": "9be61c6cb1158d47516fdedd54cfb333" + }, + "counts": { + "snv": { + "file": "snp_counts.tsv.gz", + "sha256": "d024e628c1e3b734317f04c59e5af680d9c13ce45a6a507cc05f48255677f043", + "rows": 27182 + }, + "peak": { + "file": "peak_counts.tsv.gz", + "sha256": "4d6769bb4725b5ad973dddf9fb2dfe3f091725ecbbeebb70be476408c965a9b3", + "rows": 556 + } + } +} diff --git a/tests/analysis/fixtures/public_atac/peak_counts.tsv.gz b/tests/analysis/fixtures/public_atac/peak_counts.tsv.gz new file mode 100644 index 0000000000000000000000000000000000000000..91b8050d9fd5bdc398806e130e3ca80d32b73c47 GIT binary patch literal 7776 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 literal 0 HcmV?d00001 diff --git a/tests/analysis/fixtures/public_atac/snp_counts.tsv.gz b/tests/analysis/fixtures/public_atac/snp_counts.tsv.gz new file mode 100644 index 0000000000000000000000000000000000000000..c8fa38f1364635fc0eaa139fcc6f4fdd00c05168 GIT binary patch literal 153096 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

=H6ks0NF~>V0NGjV` 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 Lg|t>1%?u0xM?_gE literal 0 HcmV?d00001 diff --git a/tests/analysis/test_bulk_edge_parity.py b/tests/analysis/test_bulk_edge_parity.py new file mode 100644 index 0000000..a1dba09 --- /dev/null +++ b/tests/analysis/test_bulk_edge_parity.py @@ -0,0 +1,109 @@ +"""Execute archived Python on mixed GT and interior dispersion boundaries.""" + +import contextlib +import io + +import numpy as np +import pandas as pd +import pytest +from scipy.stats import betabinom +from scripts.compare_archive_analysis import bulk_counts, compare_frames, load_archive + +from analysis.run_analysis_per_donor import run_per_donor_analysis + + +@pytest.mark.rust +@pytest.mark.parametrize("gt", ["0|0", "1|1"]) +@pytest.mark.parametrize("pc", [0, 1]) +@pytest.mark.parametrize("scope", [None, "per-donor", "global"]) +def test_mixed_numeric_genotypes_match_python(tmp_path, gt, pc, scope): + import wasp2_rust + + frame = bulk_counts().drop(columns="gene") + frame["GT"] = "0|1" if gt == "0|0" else "1|0" + frame.loc[frame.index[1::2], "GT"] = gt + with contextlib.redirect_stdout(io.StringIO()): + expected = load_archive()["as_analysis"].get_imbalance( + frame.copy(), phased=True, region_col="region", pseudocount=pc + ) + path = tmp_path / "counts.tsv" + if scope is None: + frame.to_csv(path, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(path), phased=True, region_col="region", pseudocount=pc + ) + assert output["effective_phased"] is True + actual = pd.DataFrame(output["results"]) + else: + frame.assign(donor_id="A").to_csv(path, sep="\t", index=False) + paths = run_per_donor_analysis( + path, + tmp_path / "result.tsv", + unit="feature", + region_col="region", + phased=True, + pseudocount=pc, + dispersion_scope=scope, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + comparison = compare_frames(expected, actual) + assert comparison["status"] == "PASS", comparison + + +@pytest.mark.rust +@pytest.mark.parametrize("gt", ["0/1", "A|G", ".|."]) +@pytest.mark.parametrize("scope", [None, "per-donor", "global"]) +def test_malformed_mixed_genotypes_fail_like_python(tmp_path, gt, scope): + import wasp2_rust + + frame = bulk_counts().drop(columns="gene") + frame.loc[0, "GT"] = gt + with contextlib.redirect_stdout(io.StringIO()), pytest.raises((ValueError, TypeError)): + load_archive()["as_analysis"].get_imbalance(frame.copy(), phased=True, region_col="region") + path = tmp_path / "counts.tsv" + if scope is None: + frame.to_csv(path, sep="\t", index=False) + with pytest.raises(RuntimeError, match="Invalid GT"): + wasp2_rust.analyze_imbalance_run(str(path), phased=True, region_col="region") + else: + frame.assign(donor_id="A").to_csv(path, sep="\t", index=False) + with pytest.raises((ValueError, TypeError)): + run_per_donor_analysis( + path, + tmp_path / "result.tsv", + unit="feature", + region_col="region", + phased=True, + dispersion_scope=scope, + min_donor_observations=4, + ) + + +@pytest.mark.rust +@pytest.mark.parametrize("rho", [1e-12, 1e-10, 1e-8, 1.0 - 1e-12]) +@pytest.mark.parametrize("ref_count", [0, 10, 20]) +def test_interior_dispersion_is_not_clipped(tmp_path, rho, ref_count): + import wasp2_rust + + path = tmp_path / "counts.tsv" + pd.DataFrame( + [ + { + "chrom": "chr1", + "pos": 101, + "ref": "A", + "alt": "G", + "region": "peak", + "ref_count": ref_count, + "alt_count": 20 - ref_count, + } + ] + ).to_csv(path, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(path), pseudocount=0, region_col="region", rho=rho + ) + shape = 0.5 * (1.0 - rho) / rho + expected = betabinom.logpmf(ref_count, 20, shape, shape) + assert output["rho"] == rho + np.testing.assert_allclose(output["results"][0]["null_ll"], expected, rtol=5e-5, atol=5e-5) diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index 0653cc4..e1bedcb 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -77,15 +77,17 @@ def test_donor_default_pseudocount_matches_python(tmp_path): @pytest.mark.parametrize("dispersion_scope", ["global", "per-donor"]) @pytest.mark.parametrize("phased", [False, True]) @pytest.mark.parametrize("pc", [0, 1]) -def test_overlapping_donor_features_match_python(tmp_path, dispersion_scope, phased, pc): +@pytest.mark.parametrize("model", ["single", "linear"]) +def test_overlapping_donor_features_match_python(tmp_path, dispersion_scope, phased, pc, model): """Preserve Python observation weights with heterogeneous donors and overlaps.""" import contextlib import io import numpy as np - from scipy.optimize import OptimizeResult, minimize_scalar + from scipy.optimize import OptimizeResult, minimize, minimize_scalar + from scipy.special import expit from scipy.stats import betabinom - from scripts.compare_archive_analysis import load_archive + from scripts.compare_archive_analysis import adapt_linear_reference, load_archive first = bulk_counts().drop(columns="gene") second = first.copy() @@ -108,6 +110,7 @@ def test_overlapping_donor_features_match_python(tmp_path, dispersion_scope, pha tmp_path / "results.tsv", unit="feature", region_col="region", + model=model, dispersion_scope=dispersion_scope, phased=phased, pseudocount=pc, @@ -116,27 +119,46 @@ def test_overlapping_donor_features_match_python(tmp_path, dispersion_scope, pha actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) fits = pd.read_csv(paths["dispersion"], sep="\t").set_index("donor_id") qc = pd.read_csv(paths["qc"], sep="\t").set_index("donor_id") - archive = load_archive()["as_analysis"] + modules = load_archive() + if model == "linear": + adapt_linear_reference(modules) + archive = modules["as_analysis"] + if model == "linear" and phased: + original_parse = archive.parse_opt + archive.parse_opt = lambda df, disp=None, phased=False: original_parse( + df, disp=disp, phased=True + ) original_optimizer = archive.minimize_scalar - global_rho = None - if dispersion_scope == "global": - ref = retained["ref_count"].to_numpy() + pc - depth = retained["ref_count"].to_numpy() + retained["alt_count"].to_numpy() + 2 * pc + + def independent_fit(frame): + ref = frame["ref_count"].to_numpy() + pc + depth = frame["ref_count"].to_numpy() + frame["alt_count"].to_numpy() + 2 * pc + if model == "linear": + return minimize( + archive.opt_linear, x0=(0, 0), method="Nelder-Mead", args=(ref, depth) + ).x def balanced_null(rho): shape = 0.5 * (1 - rho) / rho return -np.sum(betabinom.logpmf(ref, depth, shape, shape)) - global_rho = minimize_scalar(balanced_null, method="bounded", bounds=(0, 1)).x + return minimize_scalar(balanced_null, method="bounded", bounds=(0, 1)).x + + shared_fit = independent_fit(retained) if dispersion_scope == "global" else None + if shared_fit is not None and model == "single": def shared_null_optimizer(fun, *args, **kwargs): # Only replace the balanced-null dispersion fit. Effect optimizers # still execute the archived Python functions separately per donor. if len(kwargs.get("args", ())) == 2: - return OptimizeResult(x=global_rho) + return OptimizeResult(x=shared_fit) return original_optimizer(fun, *args, **kwargs) archive.minimize_scalar = shared_null_optimizer + elif shared_fit is not None: + # The archive calls minimize only for linear nuisance fitting. Effects + # still use its original minimize_scalar and phased/unphased objective. + archive.minimize = lambda *args, **kwargs: OptimizeResult(x=shared_fit) for donor, base in donors.items(): with contextlib.redirect_stdout(io.StringIO()): expected = archive.get_imbalance( @@ -144,6 +166,7 @@ def shared_null_optimizer(fun, *args, **kwargs): region_col="region", phased=phased, pseudocount=pc, + method=model, ) comparison = compare_frames(expected, actual.loc[actual["donor_id"] == donor]) assert comparison["status"] == "PASS", comparison @@ -151,8 +174,22 @@ def shared_null_optimizer(fun, *args, **kwargs): assert fits.loc[donor, "donor_eligible_snv_observations"] == 12 assert qc.loc[donor, "eligible_snv_observations"] == 12 assert qc.loc[donor, "eligible_test_rows"] == 14 - if global_rho is not None: - np.testing.assert_allclose(fits.loc[donor, "rho"], global_rho, rtol=5e-5, atol=5e-5) + expected_fit = shared_fit if shared_fit is not None else independent_fit(base) + if model == "single": + np.testing.assert_allclose(fits.loc[donor, "rho"], expected_fit, rtol=5e-5, atol=5e-5) + else: + depth = base["ref_count"].to_numpy() + base["alt_count"].to_numpy() + fit = fits.loc[donor] + actual_rho = expit( + fit.linear_d1 + + ((depth - fit.linear_depth_center) / fit.linear_depth_scale) * fit.linear_d2 + ) + expected_rho = expit(expected_fit[0] + (depth + 2 * pc) * expected_fit[1]) + np.testing.assert_allclose(actual_rho, expected_rho, rtol=5e-5, atol=5e-5) + if dispersion_scope == "global": + assert fits["fit_id"].nunique() == 1 + else: + assert fits["fit_id"].nunique() == 2 @pytest.mark.rust diff --git a/tests/analysis/test_public_atac_matrix.py b/tests/analysis/test_public_atac_matrix.py new file mode 100644 index 0000000..e43c6fc --- /dev/null +++ b/tests/analysis/test_public_atac_matrix.py @@ -0,0 +1,136 @@ +"""Repeat the full 32-run real ATAC matrix against executed archived Python. + +Count tables are frozen measurements from public reads, not Rust-generated +expectations. Linux Python 3.11 CI pins the original numerical environment. +""" + +import contextlib +import gzip +import hashlib +import io +import json +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import scipy +from scipy.special import expit +from scripts.compare_archive_analysis import adapt_linear_reference, compare_frames, load_archive + +from analysis.run_analysis_per_donor import run_per_donor_analysis + +FIXTURES = Path(__file__).parent / "fixtures/public_atac" +METADATA = json.loads((FIXTURES / "metadata.json").read_text()) + + +@pytest.mark.rust +@pytest.mark.skipif( + (np.__version__, scipy.__version__) != ("1.26.4", "1.13.1"), + reason="Full historical matrix uses the pinned NumPy/SciPy environment (Python 3.11 CI)", +) +@pytest.mark.parametrize("unit", ["snv", "peak"]) +@pytest.mark.parametrize("model", ["single", "linear"]) +@pytest.mark.parametrize("phased", [False, True]) +@pytest.mark.parametrize("pc", [0, 1]) +def test_full_public_atac_matrix(tmp_path, unit, model, phased, pc): + import wasp2_rust + + source = METADATA["counts"][unit] + raw = gzip.decompress((FIXTURES / source["file"]).read_bytes()) + assert hashlib.sha256(raw).hexdigest() == source["sha256"] + frame = pd.read_csv(io.BytesIO(raw), sep="\t") + assert len(frame) == source["rows"] + if phased: + frame = frame.loc[frame["GT"].isin(["0|1", "1|0"])].copy() + region = "region" if unit == "peak" else None + modules = load_archive(METADATA["archive_commit"]) + if model == "linear": + adapt_linear_reference(modules) + archive = modules["as_analysis"] + fitted = [] + optimizer_name = "minimize_scalar" if model == "single" else "minimize" + original_optimizer = getattr(archive, optimizer_name) + + def capture_fit(*args, **kwargs): + result = original_optimizer(*args, **kwargs) + if not fitted: + fitted.append(result.x) + return result + + setattr(archive, optimizer_name, capture_fit) + if model == "linear" and phased: + # Explicit reference repair: archived linear_model ignores this flag. + original_parse = archive.parse_opt + archive.parse_opt = lambda df, disp=None, phased=False: original_parse( + df, disp=disp, phased=True + ) + with contextlib.redirect_stdout(io.StringIO()): + expected = archive.get_imbalance( + frame.copy(), method=model, phased=phased, region_col=region, pseudocount=pc + ) + expected = expected.rename(columns={"variant": "region"}) + path = tmp_path / "counts.tsv" + frame.to_csv(path, sep="\t", index=False) + output = wasp2_rust.analyze_imbalance_run( + str(path), method=model, phased=phased, region_col=region, pseudocount=pc + ) + comparison = compare_frames(expected, pd.DataFrame(output["results"])) + assert comparison["status"] == "PASS", comparison + + def check_dispersion(fit): + if model == "single": + np.testing.assert_allclose(fit["rho"], fitted[0], rtol=5e-5, atol=5e-5) + else: + depth = (frame["ref_count"] + frame["alt_count"]).to_numpy() + depth = depth[depth >= 10] + actual = expit( + fit["linear_d1"] + + ((depth - fit["linear_depth_center"]) / fit["linear_depth_scale"]) + * fit["linear_d2"] + ) + expected_rho = expit(fitted[0][0] + (depth + 2 * pc) * fitted[0][1]) + np.testing.assert_allclose(actual, expected_rho, rtol=5e-5, atol=5e-5) + + check_dispersion(output) + cli_path = tmp_path / "cli.tsv" + command = [ + sys.executable, + "-c", + "from analysis.__main__ import app; app()", + "find-imbalance", + str(path), + "--model", + model, + "--pseudocount", + str(pc), + "--out_file", + str(cli_path), + ] + command += ["--region_col", "region"] if region else ["--per-variant"] + if phased: + command.append("--phased") + subprocess.run(command, check=True, capture_output=True, text=True) + comparison = compare_frames(expected, pd.read_csv(cli_path, sep="\t")) + assert comparison["status"] == "PASS", ("CLI", comparison) + if unit == "peak": + frame.assign(donor_id="HG001").to_csv(path, sep="\t", index=False) + for scope in ["global", "per-donor"]: + paths = run_per_donor_analysis( + path, + tmp_path / f"{scope}.tsv", + unit="feature", + model=model, + region_col="region", + phased=phased, + pseudocount=pc, + dispersion_scope=scope, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename( + columns={"feature_id": "region"} + ) + comparison = compare_frames(expected, actual) + assert comparison["status"] == "PASS", (scope, comparison) + check_dispersion(pd.read_csv(paths["dispersion"], sep="\t").iloc[0]) From a727dc7681017d462c760bb8b3d3a4dbad9a3462 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:12:14 -0700 Subject: [PATCH 18/22] test: preserve archived recurrence and diagnose Linux numerical arithmetic --- .github/workflows/ci.yml | 4 + docs/BULK_PARITY_VALIDATION.md | 12 ++ scripts/compare_archive_analysis.py | 9 +- scripts/diagnose_bulk_numerics.py | 136 ++++++++++++++++++++ tests/analysis/test_donor_archive_parity.py | 11 +- tests/analysis/test_public_atac_matrix.py | 8 +- tests/analysis/test_reference_adapters.py | 40 ++++++ 7 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 scripts/diagnose_bulk_numerics.py create mode 100644 tests/analysis/test_reference_adapters.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6277af5..1e54ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,10 @@ jobs: if: matrix.python-version == '3.11' run: uv pip install --python .venv/bin/python numpy==1.26.4 scipy==1.13.1 + - name: Diagnose platform numerical arithmetic + if: matrix.python-version == '3.11' + run: .venv/bin/python scripts/diagnose_bulk_numerics.py + - name: Run tests run: | source .venv/bin/activate diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md index f322944..cc35156 100644 --- a/docs/BULK_PARITY_VALIDATION.md +++ b/docs/BULK_PARITY_VALIDATION.md @@ -47,12 +47,24 @@ matrix. Other Python versions run the remaining compatibility regressions. Final-head Linux and container outcomes must be reviewed separately in GitHub Actions; local results do not establish those outcomes. +The first full Linux run at `cb7b834` exposed six dynamic-reference failures: +three public unphased peak cases differed in raw mu, and three heterogeneous +per-donor linear cases differed in fitted statistics. Earlier frozen regressions +passed, so they did not establish full Linux compatibility. Both native amd64 +and arm64 container builds, ATAC smoke tests and label checks passed on that head. +These strict failures remain integration gates while platform arithmetic is +investigated. `scripts/diagnose_bulk_numerics.py` compares fused/unfused kernels +and NumPy scalar/vector arithmetic without modifying production source. + ## Deliberate extensions and reference repairs Global/per-donor dispersion is the intended extension. Shared dispersion does not pool effect tests or multiple-testing correction across donors. Archived linear array shapes require explicit reference adapters; phased linear tests explicitly forward the phase flag that the archived linear wrapper ignored. +The shape adapter preserves the original sequential probability recurrence; +exact scalar/vector recurrence tests guard against replacing it with a +mathematically equivalent but differently rounded product reduction. Interior rho values are preserved, including tested values from `1e-12` to `1-1e-12`. Exact endpoints retain a finite regularization because archived diff --git a/scripts/compare_archive_analysis.py b/scripts/compare_archive_analysis.py index d054941..0778ab7 100644 --- a/scripts/compare_archive_analysis.py +++ b/scripts/compare_archive_analysis.py @@ -80,7 +80,14 @@ def aligned_dp(prob, disp, first_ref, first_n, phase_ref, phase_n): first_ll = scalar_prob(prob, first_disp, first_ref[0], first_n[0]) p1 = np.atleast_1d(scalar_prob(prob, phase_disp, phase_ref, phase_n, log=False)) p2 = np.atleast_1d(scalar_prob(1 - prob, phase_disp, phase_ref, phase_n, log=False)) - return float(first_ll - np.log(np.prod(0.5 * p1 + 0.5 * p2))) + # Preserve the archive's arithmetic as well as its likelihood. Reducing + # pre-mixed probabilities with np.prod changes last-bit rounding. + previous = 1 + for forward, reverse in zip(p1, p2, strict=True): + forward_combined = previous * forward + reverse_combined = previous * reverse + previous = 0.5 * forward_combined + 0.5 * reverse_combined + return float(first_ll - np.log(previous)) module.opt_prob = scalar_prob module.opt_unphased_dp = aligned_dp diff --git a/scripts/diagnose_bulk_numerics.py b/scripts/diagnose_bulk_numerics.py new file mode 100644 index 0000000..06fd5c2 --- /dev/null +++ b/scripts/diagnose_bulk_numerics.py @@ -0,0 +1,136 @@ +"""Compare fused/unfused Rust Cephes arithmetic with the installed SciPy. + +Builds two isolated probe modules from the production source in a temporary +crate. The repository's source and numerical policy are never modified. +""" + +from __future__ import annotations + +import csv +import hashlib +import io +import json +import math +import os +import platform +import subprocess +import tempfile +from pathlib import Path + +import numpy as np +import scipy +from scipy.special import gamma, gammaln +from scipy.stats import betabinom + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + source = (ROOT / "rust/src/scipy_compat.rs").read_text() + plain = source + replacements = { + "result.mul_add(x, coefficient)": "result * x + coefficient", + "w.mul_add(polevl(w, &GAMMA_STIR), 1.0)": "w * polevl(w, &GAMMA_STIR) + 1.0", + "CEPHES_EULER_GAMMA.mul_add(x, 1.0)": "CEPHES_EULER_GAMMA * x + 1.0", + "(x - 0.5).mul_add(x.ln(), -x)": "(x - 0.5) * x.ln() - x", + } + for old, new in replacements.items(): + assert old in plain, old + plain = plain.replace(old, new) + wrapper = "\npub(crate) fn gamma_probe(x: f64) -> (f64, f64) { (gamma_positive(x), lgamma_positive(x)) }\n" + rust_main = r""" +mod fused; +mod plain; +fn main() { + for x in [0.1, 9.548148325980558, 16.97709256606237, 1000.0, 1e8, 5e11] { + let f = fused::gamma_probe(x); + let p = plain::gamma_probe(x); + println!("gamma,{x:?},{:?},{:?},{:?},{:?}", f.0, p.0, f.1, p.1); + } + for rho in [1e-12, 1e-9, 0.01, 0.1, 0.99] { + for k in [0, 10, 20] { + let a = 0.5 * (1.0-rho)/rho; + let f = fused::betabinom_logpmf(k, 20, a, a).unwrap(); + let p = plain::betabinom_logpmf(k, 20, a, a).unwrap(); + println!("pmf,{k},20,{a:?},{a:?},{f:?},{p:?}"); + } + } + let (a,b) = (8.977092566062371,5.5481483259805575); + println!("pmf,8,12,{a:?},{b:?},{:?},{:?}", + fused::betabinom_logpmf(8,12,a,b).unwrap(), + plain::betabinom_logpmf(8,12,a,b).unwrap()); + // Keep both reduction functions live when compiling with warning checks. + assert_eq!(fused::numpy_sum(&[1.0,2.0]),plain::numpy_sum(&[1.0,2.0])); +} +""" + with tempfile.TemporaryDirectory(prefix="wasp2-numerical-probe-") as directory: + temp = Path(directory) + (temp / "src").mkdir() + (temp / "Cargo.toml").write_text( + '[package]\nname="wasp2-numerical-probe"\nversion="0.0.0"\nedition="2021"\n' + '[dependencies]\nanyhow="1"\n' + ) + (temp / "src/fused.rs").write_text(source + wrapper) + (temp / "src/plain.rs").write_text(plain + wrapper) + (temp / "src/main.rs").write_text(rust_main) + env = dict(os.environ) + env["CARGO_TARGET_DIR"] = str(ROOT / "rust/target/numerical-probe") + completed = subprocess.run( + ["cargo", "run", "--release", "--manifest-path", str(temp / "Cargo.toml")], + env=env, + capture_output=True, + text=True, + check=True, + ) + rows = [] + logs = [] + for row in csv.reader(io.StringIO(completed.stdout)): + if row[0] == "gamma": + x, fg, pg, fl, pl = map(float, row[1:]) + rows.append( + { + "kind": "gamma", + "x": x, + "python_gamma": float(gamma(x)), + "fused_gamma": fg, + "plain_gamma": pg, + "python_lgamma": float(gammaln(x)), + "fused_lgamma": fl, + "plain_lgamma": pl, + } + ) + else: + k, n, a, b, fused, plain_value = map(float, row[1:]) + reference = float(betabinom.logpmf(k, n, a, b)) + logs.append(reference) + rows.append( + { + "kind": "logpmf", + "k": k, + "n": n, + "alpha": a, + "beta": b, + "python": reference, + "fused": fused, + "plain": plain_value, + } + ) + values = np.array(logs * 32) + scalar_exp = np.array([math.exp(x) for x in values]) + vector_exp = np.exp(values) + numpy_so = Path(np.core._multiarray_umath.__file__) + report = { + "platform": platform.platform(), + "numpy": np.__version__, + "scipy": scipy.__version__, + "numpy_binary_sha256": hashlib.sha256(numpy_so.read_bytes()).hexdigest(), + "numpy_cpu_features": np.core._multiarray_umath.__cpu_features__, + "scalar_vector_exp_differences": int(np.count_nonzero(scalar_exp != vector_exp)), + "scalar_vector_exp_max_delta": float(np.max(np.abs(scalar_exp - vector_exp))), + "rows": rows, + } + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/analysis/test_donor_archive_parity.py b/tests/analysis/test_donor_archive_parity.py index e1bedcb..542f51f 100644 --- a/tests/analysis/test_donor_archive_parity.py +++ b/tests/analysis/test_donor_archive_parity.py @@ -169,12 +169,19 @@ def shared_null_optimizer(fun, *args, **kwargs): method=model, ) comparison = compare_frames(expected, actual.loc[actual["donor_id"] == donor]) - assert comparison["status"] == "PASS", comparison + expected_fit = shared_fit if shared_fit is not None else independent_fit(base) + assert comparison["status"] == "PASS", json.dumps( + { + "donor": donor, + "comparison": comparison, + "python_fit": np.asarray(expected_fit).tolist(), + "rust_fit": fits.loc[donor].to_dict(), + } + ) assert fits.loc[donor, "fit_observations"] == (28 if dispersion_scope == "global" else 14) assert fits.loc[donor, "donor_eligible_snv_observations"] == 12 assert qc.loc[donor, "eligible_snv_observations"] == 12 assert qc.loc[donor, "eligible_test_rows"] == 14 - expected_fit = shared_fit if shared_fit is not None else independent_fit(base) if model == "single": np.testing.assert_allclose(fits.loc[donor, "rho"], expected_fit, rtol=5e-5, atol=5e-5) else: diff --git a/tests/analysis/test_public_atac_matrix.py b/tests/analysis/test_public_atac_matrix.py index e43c6fc..62d0e59 100644 --- a/tests/analysis/test_public_atac_matrix.py +++ b/tests/analysis/test_public_atac_matrix.py @@ -78,7 +78,13 @@ def capture_fit(*args, **kwargs): str(path), method=model, phased=phased, region_col=region, pseudocount=pc ) comparison = compare_frames(expected, pd.DataFrame(output["results"])) - assert comparison["status"] == "PASS", comparison + assert comparison["status"] == "PASS", json.dumps( + { + "comparison": comparison, + "python_fit": np.asarray(fitted[0]).tolist(), + "rust_fit": {key: output[key] for key in ["rho", "linear_d1", "linear_d2"]}, + } + ) def check_dispersion(fit): if model == "single": diff --git a/tests/analysis/test_reference_adapters.py b/tests/analysis/test_reference_adapters.py new file mode 100644 index 0000000..d4465ea --- /dev/null +++ b/tests/analysis/test_reference_adapters.py @@ -0,0 +1,40 @@ +"""Shape repairs must preserve the original probability recurrence exactly.""" + +import numpy as np +import pytest +from scripts.compare_archive_analysis import adapt_linear_reference, load_archive + + +@pytest.mark.parametrize("mu", [0.06, 0.25, 0.38196601125, 0.7, 0.94]) +@pytest.mark.parametrize("rho", [0.03, 0.99]) +def test_scalar_shape_adapter_preserves_archive_arithmetic(mu, rho): + modules = load_archive() + original = modules["as_analysis"].opt_unphased_dp + first_ref, first_n = np.array([10]), np.array([20]) + rest_ref, rest_n = np.array([3, 18, 0]), np.array([20, 24, 10]) + expected = original(mu, rho, first_ref, first_n, rest_ref, rest_n) + adapt_linear_reference(modules) + actual = modules["as_analysis"].opt_unphased_dp(mu, rho, first_ref, first_n, rest_ref, rest_n) + assert actual == expected + + +@pytest.mark.parametrize("mu", [0.06, 0.25, 0.38196601125, 0.7, 0.94]) +def test_vector_adapter_only_slices_dispersion(mu): + modules = load_archive() + probability = modules["as_analysis"].opt_prob + dispersions = np.array([0.01, 0.03, 0.1, 0.99]) + first_ref, first_n = np.array([10]), np.array([20]) + rest_ref, rest_n = np.array([3, 18, 0]), np.array([20, 24, 10]) + first = probability(mu, dispersions[0], first_ref[0], first_n[0]) + forward = probability(mu, dispersions[1:], rest_ref, rest_n, log=False) + reverse = probability(1 - mu, dispersions[1:], rest_ref, rest_n, log=False) + previous = 1 + for p1, p2 in zip(forward, reverse, strict=True): + phase1, phase2 = previous * p1, previous * p2 + previous = 0.5 * phase1 + 0.5 * phase2 + expected = float(first - np.log(previous)) + adapt_linear_reference(modules) + actual = modules["as_analysis"].opt_unphased_dp( + mu, dispersions, first_ref, first_n, rest_ref, rest_n + ) + assert actual == expected From 9d3006dda691f05ee6166c2ef306548ebbd54831 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:18:20 -0700 Subject: [PATCH 19/22] fix: match measured SciPy contraction policy on Linux x86_64 --- CHANGELOG.md | 3 +++ docs/BULK_PARITY_VALIDATION.md | 4 ++++ rust/src/scipy_compat.rs | 38 +++++++++++++++++++++++-------- scripts/diagnose_bulk_numerics.py | 18 ++++++--------- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b190464..579250a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Cephes arithmetic follows the measured reference compiler policy: separate + multiplication/addition for Linux x86_64 and fused operations for the macOS + arm64 reference. Platform probes distinguish this from statistical-model changes. - Bulk phasing retains the original genotype tokens for archived table-wide checks, parses numeric first alleles consistently, and rejects malformed mixed phased input instead of silently changing a region's likelihood. Interior diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md index cc35156..9627ffc 100644 --- a/docs/BULK_PARITY_VALIDATION.md +++ b/docs/BULK_PARITY_VALIDATION.md @@ -55,6 +55,10 @@ and arm64 container builds, ATAC smoke tests and label checks passed on that hea These strict failures remain integration gates while platform arithmetic is investigated. `scripts/diagnose_bulk_numerics.py` compares fused/unfused kernels and NumPy scalar/vector arithmetic without modifying production source. +The probe confirms that the pinned Linux x86_64 SciPy build uses separate +multiply/add arithmetic at the tested Cephes components, while the macOS arm64 +build uses fused arithmetic. Rust now follows that measured compiler policy; +the full strict matrix must verify the resulting inference on each platform. ## Deliberate extensions and reference repairs diff --git a/rust/src/scipy_compat.rs b/rust/src/scipy_compat.rs index e83f49b..6e181f6 100644 --- a/rust/src/scipy_compat.rs +++ b/rust/src/scipy_compat.rs @@ -69,11 +69,26 @@ const MAXGAM: f64 = 171.624376956302725; #[allow(clippy::approx_constant)] const CEPHES_EULER_GAMMA: f64 = 0.5772156649015329; +/// Reproduce the measured reference compiler's contraction policy. The pinned +/// manylinux x86_64 SciPy wheel evaluates Cephes operations separately, whereas +/// the macOS arm64 reference contracts these expressions. This is independent +/// of the dataset and preserves the original arithmetic on other targets. +#[inline] +fn cephes_mul_add(a: f64, b: f64, c: f64) -> f64 { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + a * b + c + } + #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + { + a.mul_add(b, c) + } +} + fn polevl(x: f64, coefficients: &[f64]) -> f64 { let mut result = coefficients[0]; for &coefficient in &coefficients[1..] { - // The pinned reference build contracts Cephes Horner steps to FMA. - result = result.mul_add(x, coefficient); + result = cephes_mul_add(result, x, coefficient); } result } @@ -81,8 +96,7 @@ fn polevl(x: f64, coefficients: &[f64]) -> f64 { fn p1evl(x: f64, coefficients: &[f64]) -> f64 { let mut result = x + coefficients[0]; for &coefficient in &coefficients[1..] { - // The pinned reference build contracts Cephes Horner steps to FMA. - result = result.mul_add(x, coefficient); + result = cephes_mul_add(result, x, coefficient); } result } @@ -92,7 +106,7 @@ fn stirf(x: f64) -> f64 { return f64::INFINITY; } let mut w = 1.0 / x; - w = w.mul_add(polevl(w, &GAMMA_STIR), 1.0); + w = cephes_mul_add(w, polevl(w, &GAMMA_STIR), 1.0); let mut y = x.exp(); if x > 143.01608 { let v = x.powf(0.5 * x - 0.25); @@ -117,7 +131,7 @@ fn gamma_positive(mut x: f64) -> f64 { } while x < 2.0 { if x < 1e-9 { - return z / (CEPHES_EULER_GAMMA.mul_add(x, 1.0) * x); + return z / (cephes_mul_add(CEPHES_EULER_GAMMA, x, 1.0) * x); } z /= x; x += 1.0; @@ -161,7 +175,7 @@ fn lgamma_positive(mut x: f64) -> f64 { if x > 2.556348e305 { return f64::INFINITY; } - let mut q = (x - 0.5).mul_add(x.ln(), -x) + 0.91893853320467274178; + let mut q = cephes_mul_add(x - 0.5, x.ln(), -x) + 0.91893853320467274178; if x > 1.0e8 { return q; } @@ -342,8 +356,14 @@ mod tests { x, y ); - assert_eq!(gamma_positive(x), 19621650968466.152); - assert_eq!(gamma_positive(y), 132624.78469324365); + // Independently measured SciPy 1.13.1 values in both reference builds. + let expected = if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + (19621650968466.156, 132624.78469324368) + } else { + (19621650968466.152, 132624.78469324365) + }; + assert_eq!(gamma_positive(x), expected.0); + assert_eq!(gamma_positive(y), expected.1); close(betaln_positive(x, y), -17.307163194505247); } diff --git a/scripts/diagnose_bulk_numerics.py b/scripts/diagnose_bulk_numerics.py index 06fd5c2..6aec26f 100644 --- a/scripts/diagnose_bulk_numerics.py +++ b/scripts/diagnose_bulk_numerics.py @@ -27,16 +27,12 @@ def main() -> None: source = (ROOT / "rust/src/scipy_compat.rs").read_text() - plain = source - replacements = { - "result.mul_add(x, coefficient)": "result * x + coefficient", - "w.mul_add(polevl(w, &GAMMA_STIR), 1.0)": "w * polevl(w, &GAMMA_STIR) + 1.0", - "CEPHES_EULER_GAMMA.mul_add(x, 1.0)": "CEPHES_EULER_GAMMA * x + 1.0", - "(x - 0.5).mul_add(x.ln(), -x)": "(x - 0.5) * x.ln() - x", - } - for old, new in replacements.items(): - assert old in plain, old - plain = plain.replace(old, new) + start = source.index("#[inline]\nfn cephes_mul_add") + end = source.index("\n}\n", start) + 3 + implementation = source[start:end] + signature = "#[inline]\nfn cephes_mul_add(a: f64, b: f64, c: f64) -> f64" + fused = source.replace(implementation, signature + " { a.mul_add(b, c) }\n") + plain = source.replace(implementation, signature + " { a * b + c }\n") wrapper = "\npub(crate) fn gamma_probe(x: f64) -> (f64, f64) { (gamma_positive(x), lgamma_positive(x)) }\n" rust_main = r""" mod fused; @@ -70,7 +66,7 @@ def main() -> None: '[package]\nname="wasp2-numerical-probe"\nversion="0.0.0"\nedition="2021"\n' '[dependencies]\nanyhow="1"\n' ) - (temp / "src/fused.rs").write_text(source + wrapper) + (temp / "src/fused.rs").write_text(fused + wrapper) (temp / "src/plain.rs").write_text(plain + wrapper) (temp / "src/main.rs").write_text(rust_main) env = dict(os.environ) From 625252de730f8092540e54b5c8a83648b61fc58d Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:25:46 -0700 Subject: [PATCH 20/22] test: compare conditional inference with native archived Python --- docs/BULK_PARITY_VALIDATION.md | 9 ++- scripts/compare_archive_analysis.py | 60 +++++++++++++++++++ scripts/diagnose_bulk_numerics.py | 12 ++++ tests/analysis/test_scipy_numerical_kernel.py | 23 +++++-- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md index 9627ffc..9739a8e 100644 --- a/docs/BULK_PARITY_VALIDATION.md +++ b/docs/BULK_PARITY_VALIDATION.md @@ -30,7 +30,7 @@ Source links, compressed count tables and checksums are in - Heterogeneous donor tests independently fit dispersion with Python and compare donor effects, chi-square LRT p-values and donor-local BH correction. They cover overlapping features, both dispersion models, phase modes and pseudocounts. -- Local suite: 443 Python tests passed, 82 skipped, 1 deselected; 132 Rust tests +- Local suite: 458 Python tests passed, 82 skipped, 1 deselected; 132 Rust tests passed, 7 ignored. Ruff, formatting and diff checks pass. Rust 1.88 Clippy passes with the pre-existing `uninlined_format_args` lint disabled. @@ -59,6 +59,13 @@ The probe confirms that the pinned Linux x86_64 SciPy build uses separate multiply/add arithmetic at the tested Cephes components, while the macOS arm64 build uses fused arithmetic. Rust now follows that measured compiler policy; the full strict matrix must verify the resulting inference on each platform. +At `9d3006d`, all 32 executed-reference Linux matrix runs and heterogeneous donor +comparisons pass. A remaining frozen Mac expectation and its duplicate Rust +trace check must be validated against independently executed Linux Python. +Conditional tests now execute the native archived oracle at identical supplied +dispersion parameters; the original Mac snapshot is unchanged and also checked +in its pinned environment. Matching version numbers alone does not establish +identical compiler/library arithmetic across platforms. ## Deliberate extensions and reference repairs diff --git a/scripts/compare_archive_analysis.py b/scripts/compare_archive_analysis.py index 0778ab7..7cfadec 100644 --- a/scripts/compare_archive_analysis.py +++ b/scripts/compare_archive_analysis.py @@ -25,7 +25,9 @@ import numpy as np import pandas as pd from anndata import AnnData +from scipy.optimize import OptimizeResult from scipy.sparse import csr_matrix +from scipy.special import expit ROOT = Path(__file__).resolve().parents[1] ARCHIVE_REF = "41354fc" @@ -93,6 +95,64 @@ def aligned_dp(prob, disp, first_ref, first_n, phase_ref, phase_n): module.opt_unphased_dp = aligned_dp +def fixed_bulk_reference(case: dict, ref: str = ARCHIVE_REF) -> pd.DataFrame: + """Execute archived inference with supplied nuisance parameters, on this platform. + + Only nuisance fitting is replaced. The archived data preparation, effect + optimizer, likelihood recurrence, LRT and BH functions remain the oracle. + Linear predictions evaluate the supplied four-field API directly to avoid + a coefficient-conversion roundtrip. Forwarding its ignored phase flag and + scalar/array shape repairs are the same explicit reference adaptations. + """ + modules = load_archive(ref) + archive = modules["as_analysis"] + fixed = case["fixed_dispersion"] + pc = case["pseudocount"] + if case["method"] == "single": + original = archive.minimize_scalar + + def fixed_scalar_optimizer(fun, *args, **kwargs): + if len(kwargs.get("args", ())) == 2: + return OptimizeResult(x=fixed["rho"]) + return original(fun, *args, **kwargs) + + archive.minimize_scalar = fixed_scalar_optimizer + else: + adapt_linear_reference(modules) + original_model = archive.linear_model + original_parse = archive.parse_opt + + def fixed_linear_model(frame, region_col, phased=False): + # frame already has the archive's pseudocounts, filtering and dedup. + depth = frame["N"].to_numpy() - 2 * pc + prediction = expit( + fixed["linear_d1"] + + ((depth - fixed["linear_depth_center"]) / fixed["linear_depth_scale"]) + * fixed["linear_d2"] + ) + # The original model obtains coefficients from minimize, then calls + # expit once to build this vector. Supply the fixed vector there. + archive.minimize = lambda *args, **kwargs: OptimizeResult( + x=[fixed["linear_d1"], fixed["linear_d2"]] + ) + archive.expit = lambda _: prediction + archive.parse_opt = lambda df, disp=None, phased=False: original_parse( + df, disp=disp, phased=effective_phase + ) + effective_phase = phased + return original_model(frame, region_col, phased=phased) + + archive.linear_model = fixed_linear_model + with contextlib.redirect_stdout(io.StringIO()): + return archive.get_imbalance( + pd.DataFrame(case["counts"]), + method=case["method"], + phased=case["phased"], + pseudocount=pc, + region_col="region", + ) + + def bulk_counts() -> pd.DataFrame: pairs = [ (18, 2), diff --git a/scripts/diagnose_bulk_numerics.py b/scripts/diagnose_bulk_numerics.py index 6aec26f..9b3c7c6 100644 --- a/scripts/diagnose_bulk_numerics.py +++ b/scripts/diagnose_bulk_numerics.py @@ -14,6 +14,7 @@ import os import platform import subprocess +import sys import tempfile from pathlib import Path @@ -126,6 +127,17 @@ def main() -> None: "rows": rows, } print(json.dumps(report, indent=2)) + sys.path.insert(0, str(ROOT)) + from scripts.compare_archive_analysis import fixed_bulk_reference + + reference = json.loads( + (ROOT / "tests/analysis/fixtures/real_atac_optimizer_reference.json").read_text() + ) + # These are newly executed Python outcomes, not adjusted Rust expectations. + fixed_cases = { + case["name"]: fixed_bulk_reference(case).to_dict("records") for case in reference["cases"] + } + print("ARCHIVED_FIXED_NUISANCE_REFERENCES=" + json.dumps(fixed_cases)) if __name__ == "__main__": diff --git a/tests/analysis/test_scipy_numerical_kernel.py b/tests/analysis/test_scipy_numerical_kernel.py index 58f44a5..871612a 100644 --- a/tests/analysis/test_scipy_numerical_kernel.py +++ b/tests/analysis/test_scipy_numerical_kernel.py @@ -1,18 +1,22 @@ """Strict Python compatibility and independent numerical-stability checks. -The frozen real-data expectations were generated by archived Python, not Rust. +Conditional expectations execute archived Python in the current environment. +The original frozen Mac expectations remain unchanged and are additionally +checked in their pinned environment; neither oracle is generated from Rust. The high-depth checks use SciPy's log PMF as an independent mathematical oracle; they do not claim compatibility with the archive's underflowing product loop. """ import json +import platform from pathlib import Path import numpy as np import pandas as pd import pytest +import scipy from scipy.stats import betabinom -from scripts.compare_archive_analysis import compare_frames +from scripts.compare_archive_analysis import compare_frames, fixed_bulk_reference REFERENCE = json.loads( (Path(__file__).parent / "fixtures/real_atac_optimizer_reference.json").read_text() @@ -21,7 +25,7 @@ @pytest.mark.rust @pytest.mark.parametrize("case", REFERENCE["cases"], ids=lambda case: case["name"]) -def test_frozen_real_atac_outputs_match_python_strictly(tmp_path, case): +def test_fixed_real_atac_matches_native_python_and_frozen_mac(tmp_path, case): import wasp2_rust counts_file = tmp_path / "counts.tsv" @@ -34,8 +38,19 @@ def test_frozen_real_atac_outputs_match_python_strictly(tmp_path, case): region_col="region", **case["fixed_dispersion"], ) - comparison = compare_frames(pd.DataFrame(case["expected"]), pd.DataFrame(output["results"])) + expected = fixed_bulk_reference(case) + comparison = compare_frames(expected, pd.DataFrame(output["results"])) assert comparison["status"] == "PASS", comparison + # Preserve the original frozen Mac expectations without applying its + # compiler/library bit patterns to another platform's archived Python. + if (platform.system(), platform.machine(), np.__version__, scipy.__version__) == ( + "Darwin", + "arm64", + "1.26.4", + "1.13.1", + ): + frozen = compare_frames(pd.DataFrame(case["expected"]), pd.DataFrame(output["results"])) + assert frozen["status"] == "PASS", frozen @pytest.mark.rust From 653fd94073026ae99e2e63ca8902e8a3233c8b0e Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:30:28 -0700 Subject: [PATCH 21/22] test: pin independently measured Linux optimizer trace --- docs/BULK_PARITY_VALIDATION.md | 10 +++++++--- rust/src/analysis.rs | 10 +++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md index 9739a8e..d442650 100644 --- a/docs/BULK_PARITY_VALIDATION.md +++ b/docs/BULK_PARITY_VALIDATION.md @@ -60,9 +60,13 @@ multiply/add arithmetic at the tested Cephes components, while the macOS arm64 build uses fused arithmetic. Rust now follows that measured compiler policy; the full strict matrix must verify the resulting inference on each platform. At `9d3006d`, all 32 executed-reference Linux matrix runs and heterogeneous donor -comparisons pass. A remaining frozen Mac expectation and its duplicate Rust -trace check must be validated against independently executed Linux Python. -Conditional tests now execute the native archived oracle at identical supplied +comparisons pass. At `625252d`, Linux Python 3.11 passes 464 tests, 76 skipped, +1 deselected, including all 16 native conditional checks; Python 3.10 and 3.12 +also pass. Independently executed archived Linux Python returns +`mu=0.6928487541708976` for the fixed symmetric linear case where the Mac +reference returns `0.3071512458291038`. The Rust trace test records each measured +platform's raw outcome, without accepting complementary outputs. +Conditional tests execute the native archived oracle at identical supplied dispersion parameters; the original Mac snapshot is unchanged and also checked in its pinned environment. Matching version numbers alone does not establish identical compiler/library arithmetic across platforms. diff --git a/rust/src/analysis.rs b/rust/src/analysis.rs index 020ce92..872323b 100644 --- a/rust/src/analysis.rs +++ b/rust/src/analysis.rs @@ -1444,7 +1444,15 @@ mod tests { ) .unwrap(); eprintln!("linear fixed-rho mu={mu:.17}, trace={evaluations:?}"); - assert!((mu - 0.3071512458291038).abs() < 5e-5); + // Independently executed archived Python at these exact fixed parameters: + // Linux x86_64/SciPy 1.13.1 and macOS arm64/SciPy 1.13.1 respectively. + // Preserve both raw reference outcomes, rather than accepting a flip. + let expected_mu = if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + 0.6928487541708976 + } else { + 0.3071512458291038 + }; + assert!((mu - expected_mu).abs() < 5e-5); } #[test] From c944c04171ee5720c86e389a88b2dbd90427e976 Mon Sep 17 00:00:00 2001 From: Jeff Jaureguy <67065808+Jaureguy760@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:40:49 -0700 Subject: [PATCH 22/22] fix: preserve Python phase decisions when numeric GT keys collapse --- CHANGELOG.md | 3 ++ docs/BULK_PARITY_VALIDATION.md | 8 +++- src/analysis/run_analysis_per_donor.py | 21 ++++++++-- tests/analysis/test_bulk_edge_parity.py | 56 +++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579250a..7aa426b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to WASP2 will be documented in this file. ## [Unreleased] ### Fixed +- Donor feature deduplication uses the parsed first GT allele, as Python does. + Original GT tokens remain available for the backend's pre-dedup phasing + decision; fitting weights, raw counts, QC and provenance reflect retained rows. - Cephes arithmetic follows the measured reference compiler policy: separate multiplication/addition for Linux x86_64 and fused operations for the macOS arm64 reference. Platform probes distinguish this from statistical-model changes. diff --git a/docs/BULK_PARITY_VALIDATION.md b/docs/BULK_PARITY_VALIDATION.md index d442650..4aecb8c6 100644 --- a/docs/BULK_PARITY_VALIDATION.md +++ b/docs/BULK_PARITY_VALIDATION.md @@ -30,7 +30,7 @@ Source links, compressed count tables and checksums are in - Heterogeneous donor tests independently fit dispersion with Python and compare donor effects, chi-square LRT p-values and donor-local BH correction. They cover overlapping features, both dispersion models, phase modes and pseudocounts. -- Local suite: 458 Python tests passed, 82 skipped, 1 deselected; 132 Rust tests +- Local suite: 474 Python tests passed, 82 skipped, 1 deselected; 132 Rust tests passed, 7 ignored. Ruff, formatting and diff checks pass. Rust 1.88 Clippy passes with the pre-existing `uninlined_format_args` lint disabled. @@ -90,7 +90,11 @@ underflowing arithmetic rather than reproducing its failure. Archived phasing checks use distinct original GT tokens and the presence of `0|1`/`1|0`, then parse every retained first allele. Numeric homozygous tokens follow that behavior. Malformed mixed tokens raise an error instead of silently -changing a region's likelihood. One public peak SNP has `0/1`: the unfiltered +changing a region's likelihood. Numeric-GT duplicates are removed using the parsed first allele, while the +original tokens remain available for the backend's pre-dedup phase decision. +Sixteen extra regressions cover both models, both dispersion scopes, both +orientations and pseudocounts, including fitting weights, QC and phase provenance. +One public peak SNP has `0/1`: the unfiltered phased command is rejected by both implementations. Phased comparisons explicitly select truly phased heterozygous SNPs; unphased comparisons retain all SNPs. diff --git a/src/analysis/run_analysis_per_donor.py b/src/analysis/run_analysis_per_donor.py index 96c42f6..6fba1ba 100644 --- a/src/analysis/run_analysis_per_donor.py +++ b/src/analysis/run_analysis_per_donor.py @@ -1020,6 +1020,7 @@ def run_per_donor_analysis( eligible_rows = frame.loc[frame["_total_count"] >= min_count].copy() retained_groups = [] phase_decisions: dict[str, str | None] = {} + phase_source_frames: dict[str, pd.DataFrame] = {} for donor_id, donor_rows in eligible_rows.groupby("donor_id", sort=False): fallback_reason = _phase_fallback_reason(donor_rows, phased) phase_decisions[str(donor_id)] = fallback_reason @@ -1027,8 +1028,20 @@ def run_per_donor_analysis( if normalized_region_col is not None: identity.append(normalized_region_col) if phased and fallback_reason is None: - identity.append("GT") - retained_groups.append(donor_rows.drop_duplicates(identity, keep="first")) + # Python parses the first allele before phase-aware deduplication. + # Distinct raw tokens such as 0|0/0|1 can become the same key. + normalized = donor_rows.loc[:, identity].copy() + # A private object label cannot overwrite a user-supplied column. + normalized[object()] = donor_rows["GT"].str.split("|", n=1).str[0].astype("uint8") + retained = donor_rows.loc[~normalized.duplicated(keep="first")] + if len(retained) != len(donor_rows.drop_duplicates([*identity, "GT"])): + # Preserve the original tokens for Rust's pre-dedup phase check. + # Rust then removes the same normalized duplicates; formatting + # and nuisance fitting use the retained observations below. + phase_source_frames[str(donor_id)] = donor_rows.copy() + else: + retained = donor_rows.drop_duplicates(identity, keep="first") + retained_groups.append(retained) if retained_groups: eligible_rows = pd.concat(retained_groups).sort_index(kind="stable") fit_rows = eligible_rows.copy() @@ -1104,8 +1117,8 @@ def run_per_donor_analysis( phased=phased, ) fallback_reason = phase_decisions[donor_id] - rust_region_col, donor_rows = _prepare_analysis_table( - donor_rows, + rust_region_col, _ = _prepare_analysis_table( + phase_source_frames.get(donor_id, donor_rows), work / "donor_counts.tsv", unit=normalized_unit, region_col=normalized_region_col, diff --git a/tests/analysis/test_bulk_edge_parity.py b/tests/analysis/test_bulk_edge_parity.py index a1dba09..921585f 100644 --- a/tests/analysis/test_bulk_edge_parity.py +++ b/tests/analysis/test_bulk_edge_parity.py @@ -2,6 +2,7 @@ import contextlib import io +import json import numpy as np import pandas as pd @@ -80,6 +81,61 @@ def test_malformed_mixed_genotypes_fail_like_python(tmp_path, gt, scope): ) +@pytest.mark.rust +@pytest.mark.parametrize("scope", ["per-donor", "global"]) +@pytest.mark.parametrize("orientation", ["0|1", "1|0"]) +@pytest.mark.parametrize("model", ["single", "linear"]) +@pytest.mark.parametrize("pc", [0, 1]) +def test_numeric_gt_duplicates_preserve_pre_dedup_phase_decision( + tmp_path, scope, orientation, model, pc +): + frame = bulk_counts().drop(columns="gene") + frame["GT"] = orientation + duplicate = frame.iloc[:1].copy() + duplicate["GT"] = "0|0" if orientation == "0|1" else "1|1" + frame = pd.concat([frame, duplicate], ignore_index=True) + from scripts.compare_archive_analysis import adapt_linear_reference + + modules = load_archive() + if model == "linear": + adapt_linear_reference(modules) + original = modules["as_analysis"].parse_opt + modules["as_analysis"].parse_opt = lambda df, disp=None, phased=False: original( + df, disp=disp, phased=True + ) + with contextlib.redirect_stdout(io.StringIO()): + expected = modules["as_analysis"].get_imbalance( + frame.copy(), + phased=True, + region_col="region", + method=model, + pseudocount=pc, + ) + path = tmp_path / "counts.tsv" + frame.assign(donor_id="A").to_csv(path, sep="\t", index=False) + paths = run_per_donor_analysis( + path, + tmp_path / "result.tsv", + unit="feature", + region_col="region", + phased=True, + model=model, + pseudocount=pc, + dispersion_scope=scope, + min_donor_observations=4, + ) + actual = pd.read_csv(paths["results"], sep="\t").rename(columns={"feature_id": "region"}) + comparison = compare_frames(expected, actual) + assert comparison["status"] == "PASS", comparison + fit = pd.read_csv(paths["dispersion"], sep="\t").iloc[0] + assert fit["fit_observations"] == len(frame) - 1 + qc = pd.read_csv(paths["qc"], sep="\t").iloc[0] + assert qc["input_rows"] == len(frame) + assert qc["eligible_test_rows"] == len(frame) - 1 + provenance = json.loads(paths["provenance"].read_text()) + assert provenance["donors"]["phasing"][0]["effective_phased"] is True + + @pytest.mark.rust @pytest.mark.parametrize("rho", [1e-12, 1e-10, 1e-8, 1.0 - 1e-12]) @pytest.mark.parametrize("ref_count", [0, 10, 20])