Goal
Take per-study microbiome AnnData objects (one per cohort, each from the 16S amplicon skill), stitch them into a single cross-cohort AnnData with combine_studies, then run per-study DA → cross-study meta-analysis with meta_da. Output is a single feature-level table with combined effect size, combined SE, p-value, BH-FDR, per-study count, and Cochran's I² heterogeneity — distinguishing features whose effect replicates across cohorts from cohort-specific signals.
The skill assumes the meta question is "does this contrast hold across cohorts?", not "is there an effect in any cohort". The latter is just running per-study DA and unioning hits — call that out as the wrong tool.
Quick Workflow
- Load (or build) per-cohort
AnnData— same conventions as the 16S amplicon skill (samples × ASVs, 7-rank SINTAX invar,obs[group_key]for the contrast). - Combine:
ov.micro.combine_studies([a, b, c], study_names=[...], rank='genus', study_key='study') → AnnData— collapses ASVs to a common rank (genus by default), unions features across studies (zero-fills cohort-specific features), tagsobs['study']. - Diagnostic Beta + PCoA on the combined object: confirms whether study effect dominates the disease effect — a useful sanity check before running meta_da. If PC1 colour-maps cleanly by
study, the cohort effect is large and meta_da's random-effects pooling is doing real work. - Per-study top hits (optional, for sanity): run
DA(a_g).deseq2(...)on each study independently, take top-5 by p-value; check whether the planted / known-true features appear in every cohort. - Meta-analysis:
ov.micro.meta_da([a, b, c], study_names=[...], group_key, group_a, group_b, method='deseq2'|'wilcoxon'|'ancombc', rank='genus', min_prevalence=0.1, combine='random_effects'|'fixed_effects') → pd.DataFramewithfeature,combined_lfc,combined_se,z,p_value,fdr_bh,n_studies,I2. - Plot the result: forest-style barh of top features (combined LFC ± 1.96·SE), colour-coding planted vs. cohort-specific vs. shared-noise features.
- Heterogeneity scatter: combined LFC × I² — high-I² features (>0.75) are reproducibly different in direction between cohorts (suspicious); low-I² features (<0.25) are coherent. Use this to flag which "significant" hits actually replicate.
- (Optional) Method × meta benchmark: re-run meta_da with
methodcycled over'wilcoxon' / 'deseq2' / 'ancombc', compare hit counts and the rank of known-true features across methods.
Interface Summary
ov.micro.combine_studies(
studies: Sequence[AnnData],
study_names: Optional[Sequence[str]] = None,
rank: Optional[str] = 'genus', # collapse to this rank first; None keeps ASVs
study_key: str = 'study', # column name in combined obs
min_prevalence: float = 0.0, # additional filter applied after concatenation
) -> AnnData
ov.micro.meta_da(
studies: Sequence[AnnData],
group_key: str,
group_a: Optional[str] = None,
group_b: Optional[str] = None,
method: str = 'deseq2', # 'wilcoxon' | 'deseq2' | 'ancombc'
rank: Optional[str] = 'genus',
min_prevalence: float = 0.1,
combine: str = 'random_effects', # or 'fixed_effects'
study_names: Optional[Sequence[str]] = None,
**method_kwargs,
) -> pd.DataFrame
# columns: feature, combined_lfc, combined_se, z, p_value, fdr_bh,
# n_studies, I2, plus per-study lfc / se / pvalue columns
Boundary
Inside scope:
- Combining 2+ per-study AnnDatas at any taxonomic rank.
- Per-study DA + cross-study inverse-variance pooling with random- or fixed-effects model.
- Cochran's I² heterogeneity diagnostics.
- Diagnostic combined-PCoA to spot study effects.
- Method-comparison sweep for meta-analysis (wilcoxon / deseq2 / ancombc).
Outside scope — separate skill:
- Building the per-study AnnData from FASTQs — see
omicverse-microbiome-16s-amplicon-dada2. - Single-cohort DA method comparison — see
omicverse-microbiome-da-comparison. - Phylogenetic harmonisation across studies (different reference trees) — see
omicverse-microbiome-phylogenyfor the per-study tree, but no cross-study tree alignment is implemented. - Batch correction in the canonical sense (ComBat / Harmony) —
combine_studiesonly zero-fills and tagsobs['study']. For batch correction across cohorts, consider modellingstudyas a covariate in a per-feature mixed model (out of scope forov.micro). - Strain-level meta-analysis — current implementation collapses to a taxonomic rank; species-level cross-cohort matching needs uniform DBs (out of scope).
Branch Selection
combine_studies(rank=...)
rank='genus'(default) — robust across cohorts, the canonical reporting rank for 16S meta-analyses (Nearing 2022, Pasolli 2017). Use this unless you have a specific reason.rank='family'— when even genera don't align well across DBs.rank=None— ASV-level; only useful if every cohort was processed against the same DB version with the same denoiser; rare in practice.
meta_da(method=...)
'deseq2'— default. Per-study NB-GLM, then inverse-variance pooling on the per-study LFCs. Best calibrated whenn>=10/groupper cohort.'wilcoxon'— non-parametric per-study, then pool. Use when one cohort has very smallnwhere NB assumptions break.'ancombc'— compositional bias-corrected per-study, then pool. The most rigorous on compositional data but slowest; flag for reportable results.
combine='random_effects' vs 'fixed_effects'
random_effects(DerSimonian-Laird, default): models a between-study variance component τ² in addition to per-study sampling variance. Conservative when studies disagree (high I²); reduces to fixed-effects when studies agree.fixed_effects: assumes one true effect across studies; tighter CIs but mis-leads when studies actually differ. Use only when between-study heterogeneity is provably zero (rare for 16S).- Default to random-effects for 16S meta-analyses — different cohorts have different recruitment, geography, sample handling.
Cochran's I² interpretation
- I² < 0.25: low heterogeneity — combined effect is well-supported.
- 0.25 ≤ I² < 0.5: moderate; report combined effect but flag.
- 0.5 ≤ I² < 0.75: substantial; combined effect is suspect — does the contrast actually hold across cohorts?
- I² >= 0.75: high; almost certainly a method or covariate problem (or genuine biology that varies across cohorts) — interpret per-study, don't pool.
min_prevalence in meta_da is applied per-study (not after combination), so a feature must clear min_prevalence in at least one study to be tested. Setting it lower (0.05) tests more rare features but inflates FDR-correction burden.
Input Contract
- Per-study AnnDatas all have:
obs[group_key]populated with categorical values; the two contrast levels (group_a,group_b) appear in every study (otherwise that study contributes nothing to the contrast).var[rank]for whatever taxonomic rankcombine_studieswill collapse to. Empty strings (unassigned features) are dropped during collapse.- Integer counts in
.X(DESeq2 mode) or counts that surviverelative=Truerescaling (Wilcoxon mode).
- Sample counts per study can be unequal — the random-effects model handles this; just don't have any cohort with
n<5/group. - Different per-study DBs are OK at the genus level (genus names are largely DB-portable). Below genus, alignment breaks.
Minimal Execution Patterns
import omicverse as ov
import anndata as ad
import matplotlib.pyplot as plt
import numpy as np
ov.plot_set()
# 1) Load per-study AnnDatas
adata_A = ad.read_h5ad('cohort_A_genus.h5ad')
adata_B = ad.read_h5ad('cohort_B_genus.h5ad')
adata_C = ad.read_h5ad('cohort_C_genus.h5ad')
# 2) Combine — zero-fills cohort-specific features, tags obs['study']
adata_all = ov.micro.combine_studies(
[adata_A, adata_B, adata_C],
study_names=['cohort_A', 'cohort_B', 'cohort_C'],
rank='genus',
)
print('combined:', adata_all.shape)
print(adata_all.obs['study'].value_counts())
# 3) Diagnostic PCoA on the combined object
ov.micro.Beta(adata_all).run(metric='braycurtis', rarefy=False)
coords = np.asarray(ov.micro.Ordinate(adata_all, dist_key='braycurtis').pcoa(n=2))
# 4) Meta-analysis
meta = ov.micro.meta_da(
[adata_A, adata_B, adata_C],
study_names=['cohort_A', 'cohort_B', 'cohort_C'],
group_key='disease', group_a='CTRL', group_b='CASE',
method='deseq2',
rank='genus',
min_prevalence=0.1,
combine='random_effects',
)
print(f'features tested cross-cohort: {len(meta)}')
print(f'significant at FDR 0.05 : {(meta["fdr_bh"] < 0.05).sum()}')
print(meta[['feature', 'combined_lfc', 'combined_se', 'z',
'p_value', 'fdr_bh', 'n_studies', 'I2']].head(10))
# Forest-style top hits with 95 % CI (1.96 × combined_se)
top = meta.head(12).copy().iloc[::-1]
ci = 1.96 * top['combined_se'].values
fig, ax = plt.subplots(figsize=(7, 5))
ax.barh(top['feature'], top['combined_lfc'], xerr=ci,
edgecolor='black', linewidth=0.4)
ax.axvline(0, color='k', lw=0.7)
ax.set_xlabel('combined log2 fold-change (CASE / CTRL)')
plt.tight_layout(); plt.show()
# Heterogeneity scatter — flag high-I² hits
sig = meta[meta['fdr_bh'] < 0.05].copy()
fig, ax = plt.subplots(figsize=(7, 4))
ax.scatter(sig['combined_lfc'], sig['I2'], s=50, edgecolors='k', linewidths=0.4)
ax.axhline(0.25, color='grey', lw=0.5, ls='--')
ax.axhline(0.75, color='grey', lw=0.5, ls='--')
ax.set_xlabel('combined log2FC'); ax.set_ylabel("Cochran's I²")
plt.tight_layout(); plt.show()
# Per-method comparison — which method recovers planted signals first?
results = {}
for method in ['wilcoxon', 'deseq2', 'ancombc']:
results[method] = ov.micro.meta_da(
[adata_A, adata_B, adata_C],
study_names=['cohort_A', 'cohort_B', 'cohort_C'],
group_key='disease', group_a='CTRL', group_b='CASE',
method=method, rank='genus', min_prevalence=0.1,
)
import pandas as pd
summary = pd.DataFrame({
'n_tested': {m: len(r) for m, r in results.items()},
'n_sig_fdr_0.05': {m: int((r['fdr_bh'] < 0.05).sum()) for m, r in results.items()},
})
print(summary)
Validation
- After
combine_studies:adata_all.obs['study'].value_counts()matches per-study sample counts; combined feature count is<= sum(per_study_features)(zero-fills shouldn't lose features). - Diagnostic PCoA: PC1 colour-mapped by
studyshould look at least somewhat banded — if not, you have miraculously homogeneous data, but check thatcombine_studiescollapsed correctly (rank actually shared). meta_dareturnsn_studiesper feature — features that only show up in 1–2 studies have weaker pooled inference. Flag rows wheren_studies < len(studies).- I² above 0.75 on a "significant" feature: do not report as a meta-finding. Interpret per-study or note explicitly that the effect is heterogeneous.
- For known-truth validation (planted features in synthetic data, or housekeeping features in real data): they should appear at the top of the per-study DA AND survive meta-analysis with low I² and small p-value. If a planted feature has high I² in meta_da, the synthetic noise wasn't realistic enough.
- Cross-method comparison: for at least 2/3 methods, the planted features should be in the top 3 hits. Methods that completely miss planted features signal a configuration error (wrong contrast, wrong
min_prevalence).
Resource Map
- See
reference.mdfor compact copy-paste snippets per stage. - See
references/source-grounding.mdfor verifiedcombine_studies/meta_dasignatures and the inverse-variance + random-effects implementation details. - For per-study AnnData ingest, see
omicverse-microbiome-16s-amplicon-dada2. - For single-cohort DA method comparison, see
omicverse-microbiome-da-comparison.
Examples
- "Combine three cohorts at the genus level, run a random-effects DESeq2 meta-analysis, and report the top-12 features with 95 % CIs and I² heterogeneity."
- "Compare Wilcoxon / DESeq2 / ANCOM-BC at the meta level — which recovers planted features first?"
- "Diagnose whether a 'significant' meta-hit at FDR 0.05 has acceptable cross-cohort agreement (I²)."
- "Run a fixed-effects meta-analysis only when all three studies agree (low I²)."
References
- Tutorial notebook:
t_16s_meta_analysis.ipynb— synthetic 3-cohort meta-analysis with planted vs. cohort-specific features. - Live API verified — see
references/source-grounding.md.