From 28bd73242dfd385e83c2e332a6f602254b7f36ef Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:17:33 +0000 Subject: [PATCH 1/6] feat(analysis): add two-part count models and occupancy fitter Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 17 +- config/defaults/pipeline.yml | 11 +- docs/occupancy-models.md | 79 ++++++ frontend/src/api/types.ts | 10 +- frontend/src/components/DifferentialPanel.tsx | 13 +- .../src/components/PipelineStages.test.ts | 7 + frontend/src/components/PipelineStages.tsx | 6 +- .../src/components/differentialTable.test.ts | 6 +- frontend/src/components/differentialTable.ts | 6 +- src/analysis/differential.jl | 260 +++++++++++++++++- src/server/routes/config.jl | 4 +- test/unit/test_differential.jl | 57 ++++ 12 files changed, 447 insertions(+), 29 deletions(-) create mode 100644 docs/occupancy-models.md diff --git a/README.md b/README.md index 998fcc15..482ee321 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,27 @@ not exclude samples from this analysis. with a library-size offset. The effect is log2 fold change; positive values indicate greater abundance in the second condition. `offset` selects `tss` (default) or `rle` scaling. +- `zinb`: a zero-inflated negative-binomial model with separate logistic + zero and count components, a count-component library-size offset, a joint + two-degree-of-freedom likelihood-ratio test, and BH correction. +- `hurdle_nb`: a logistic occurrence model plus zero-truncated NB counts for + positive observations. It uses the same joint test and offset convention. + +ZINB and hurdle models are more flexible than NB and can overfit sparse data; +prefer them only when diagnostics support excess zeros. They report a count +process fold change and the zero-process coefficient separately. The public +`MetaManifold.Differential.fit_occupancy` lower-level estimator is available for +explicit repeated-visit detection histories (sites × visits × taxa). It refuses +single-visit histories because occupancy and detection are not separately +identified from one observation without strong external assumptions. See +[occupancy model notes](docs/occupancy-models.md). + - `clr_welch`: Welch's t-test on centred log-ratios after multiplicative zero replacement with `zCompositions::cmultRepl`. The effect is a CLR difference, not a fold change. `replacement_delta` controls the replacement fraction (default 0.65); `offset` is not used. -For either method, `min_prevalence` (default 0) filters taxa by the fraction of +Across the differential methods, `min_prevalence` (default 0) filters taxa by the fraction of samples with nonzero counts. Benjamini–Hochberg adjustment includes only taxa that produced a p-value. Failed or filtered taxa have no substitute p-value; inspect their status and note in the results table. Invalid input or an diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 44b2c882..dbac65e9 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -347,16 +347,15 @@ analysis: # comparing two conditions on the raw read counts, with Benjamini-Hochberg # adjusted p-values. differential: - # The model fitted to each taxon. nb_glm: a negative-binomial GLM on the read - # counts with a library-size offset; the effect is a log2 fold change. clr_welch: - # Welch's t-test on centred log-ratios over the taxa passing min_prevalence, - # after zCompositions::cmultRepl replaces their zeros; the effect is a - # difference in CLR units, not a fold change. + # Per-taxon model: nb_glm (negative-binomial GLM), clr_welch (Welch test on + # zero-replaced CLR values), zinb (zero-inflated NB), or hurdle_nb (logistic + # occurrence plus zero-truncated NB). Two-part models test both group effects + # jointly; their count-process fold change is reported separately. method: nb_glm # How library size enters each model, as a log offset: tss (total reads per # sample) or rle (median-of-ratios over the taxa present in every sample). # rle is refused when no taxon is present in every sample. - # Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size. + # Used by count models (nb_glm, zinb, hurdle_nb); clr_welch ignores it. offset: tss # nb_glm only: where each taxon's negative-binomial theta comes from. ml # (the default) takes the maximum-likelihood estimate MASS::glm.nb fits per diff --git a/docs/occupancy-models.md b/docs/occupancy-models.md new file mode 100644 index 00000000..7e22880c --- /dev/null +++ b/docs/occupancy-models.md @@ -0,0 +1,79 @@ +# Occupancy and two-part abundance models + +The differential-abundance configuration supports `zinb` and `hurdle_nb` in +addition to the existing `nb_glm` and `clr_welch` models. The two new methods +are fixed-effect, per-taxon models with a two-part negative-binomial likelihood. +They use the configured `tss` or `rle` library-size offset in the count +component and apply Benjamini–Hochberg correction to the joint tests. + +## Zero-inflated negative binomial (ZINB) + +For taxon count `Y`, the model has a logistic structural-zero probability +`π`, and an NB count distribution with mean `μ` and dispersion `θ`: + +- `P(Y = 0) = π + (1 - π) NB(0; μ, θ)` +- `P(Y = y > 0) = (1 - π) NB(y; μ, θ)` + +Both the zero component and log-mean count component include a group effect; +the latter includes the log library-size offset. The reported count effect is +the log fold change (and its log2 conversion). The result also reports the +zero-component group log-odds coefficient. The p-value is a joint 2-df +likelihood-ratio test against a model without either group effect. The fits use +base R's `stats::optim` and `stats::dnbinom`, so no extra R modeling package is +required. + +## Hurdle negative binomial + +A logistic component models whether a count is zero. Conditional on a positive +count, the abundance follows an NB distribution truncated at zero. All zeros +are assigned to the occurrence component; unlike ZINB, the count component +cannot produce zero. The same joint test, count-process effect, offset, and +multiple-testing policy apply. + +These are not interchangeable explanations of sparse data. NB overdispersion +can itself produce many zeros; selecting ZINB just because the matrix is sparse +can overfit and make the structural-zero component weakly identified. Compare +models using diagnostics and report the chosen model. Results do not currently +include a formal Vuong comparison or DHARMa residual diagnostic. + +## Repeated-visit occupancy estimator + +The lower-level Julia entry point +`MetaManifold.Differential.fit_occupancy(history, groups, taxa; levels)` accepts +a Boolean array with dimensions **sites × repeat visits × taxa**. It fits an +occupancy probability `ψ` with an intercept and binary group effect and a +detection probability `p` shared across visits. A per-taxon likelihood-ratio +test assesses the group effect in occupancy; p-values are BH-adjusted. The +returned table includes `ψ` in each group and the estimated `p`. + +This is intentionally not selected by `analysis.differential.method` and is +not called by the single-observation differential endpoint: that endpoint has +no repeat-visit design or detection-history input. The estimator refuses fewer +than two visits rather than treating technical or biological samples as repeat +visits. The initial implementation does not yet accept detection covariates +such as library size, batch, or primer, and does not implement arbitrary +occupancy/detection formulas or random effects. A deployment that needs these +should collect a defensible repeated-detection design and supply/validate +covariates before interpreting `ψ` and `p`. + +Occupancy assumes closure over repeat visits: a site's true occupancy does not +change during the detection period. Microbiome sampling is often destructive, +and the meaning of a repeated visit may therefore be questionable. Single-visit +occupancy is not empirically separated into ψ and p without additional strong +assumptions. Interpret results cautiously and document the sampling design. + +## Provenance and limitations + +Pipeline configuration retains the selected two-part method, offset, +prevalence threshold, and dispersion option. The current analysis assumes a +single binary group contrast and has no user-supplied zero/detection formula. +The two-part tests use a 2-df LRT, not an individual-component significance +test; their reported count fold change is only one part of the joint hypothesis. + +References: + +- MacKenzie, D. I. et al. (2002). Estimating site occupancy rates when detection + probabilities are less than one. *Ecology* 83(8), 2248–2255. +- Martin, T. G. et al. (2005). Zero tolerance ecology: improving ecological + inference by modelling the source of zero observations. *Ecology Letters* 8, + 1235–1246. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4ea20add..f20aaf15 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -413,10 +413,12 @@ export interface DifferentialRow { padj: number | null /** nb_glm only. */ dispersion_theta: number | null + /** Group coefficient in the logistic zero/occurrence component (two-part NB only). */ + zero_process_log_odds?: number | null prevalence: number } -export type DifferentialMethod = 'nb_glm' | 'clr_welch' +export type DifferentialMethod = 'nb_glm' | 'clr_welch' | 'zinb' | 'hurdle_nb' /** Which row field holds the effect size, and what to call it. */ export interface DifferentialEffect { @@ -432,12 +434,12 @@ export interface DifferentialResult { groups: { reference: string; contrast: string } n_samples: Record effect: DifferentialEffect - /** Empty for clr_welch, which takes no library-size offset. */ + /** Empty for clr_welch; count models report their size factors. */ size_factors: { sample: string; group: string; factor: number }[] config: { method: DifferentialMethod min_prevalence: number - /** nb_glm only. */ + /** Count-process offset for nb_glm, zinb and hurdle_nb. */ offset?: 'tss' | 'rle' /** nb_glm only: where each taxon's theta comes from. */ dispersion?: 'ml' | 'glmGamPoi' @@ -446,6 +448,8 @@ export interface DifferentialResult { } diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number + /** Safety note for two-part models. */ + model_warning?: string /** clr_welch only. */ zero_replacement?: { method: string; delta: number; zeros_replaced: number; n_taxa_in_composition: number diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index 825fb812..46976592 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -138,6 +138,13 @@ export function DifferentialPanel({ study, runs, option, aggregate }: { + {result.diagnostics.model_warning && ( +
+ Model caution: {result.diagnostics.model_warning} +
+ )} +