---
title: "Differential prevalence analysis with DiPPER"
author:
  - name: Juho Pelto
    affiliation: University of Turku
    email: jepelt@utu.fi
  - name: Leo Lahti
    affiliation: University of Turku
    email: leo.lahti@iki.fi
package: DiPPER
output:
  BiocStyle::html_document:
    toc_float: true
bibliography: references.bib
vignette: >
  %\VignetteIndexEntry{Differential Prevalence Analysis with DiPPER}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)

# Checks whether CmdStan is available and the model-fitting chunks can be
# evaluated only when CmdStan is present. Otherwise the pre-computed fits in
# inst/extdata are used.
cmdstan_ok <- !identical(Sys.getenv("DIPPER_NO_CMDSTAN"), "true") &&
    requireNamespace("instantiate", quietly = TRUE) &&
    instantiate::stan_cmdstan_exists()

cache_file <- function(x) {
    system.file("extdata", x, package = "DiPPER")
}

have_cache <- nzchar(cache_file("fit_hintikka.rds")) &&
    nzchar(cache_file("fit_vatanen.rds"))

# Run the DiPPER models?
run_fit <- cmdstan_ok

# Can results be shown?
show_res <- cmdstan_ok || have_cache
```

# Introduction

Recent research suggests that presence/absence information of taxonomic
features (e.g., species or genera) alone, rather than complete abundance
profiles, can be highly informative when analyzing associations between
microbiomes and external variables such as health outcomes
[@Giliberti2022HostTaxab; @Karwowska2025EffectsData]. In particular,
*differential prevalence analysis (DPA)*, which in its simplest form compares
feature prevalences between two groups (e.g., healthy vs. non-healthy
subjects), represents a compelling complementary or even alternative approach
to traditional differential abundance analysis
[@Pelto2025ElementaryAnalysis; @Nickols2026MaAsLinDiscovery].

DiPPER (**Di**fferential **P**revalence via **P**robabilistic **E**stimation
in **R**) is a Bayesian hierarchical model-based approach designed for
differential prevalence analysis. In addition to the main variable of interest
(e.g., host disease status), it can incorporate other *covariates* (e.g., age,
sex, BMI). It can also be used to analyze *longitudinal* (or repeated measures)
microbiome data.

## Advantages of DiPPER

While differential prevalence analysis can be performed using approaches based
on standard (frequentist) logistic regression, such approaches suffer certain
limitations that DiPPER aims to solve:

* **Robustness in boundary cases:** Boundary cases (or quasi-complete
  separation), where a taxon is completely absent or fully present in one
  study group, are common in microbiome data. Standard approaches may have
  difficulties in these cases. For instance, the default logistic regression
  implementation in R (based on the Wald test) yields non-sensible *p*-values
  and infinite parameter estimates and confidence intervals in boundary cases.
  DiPPER, however, remains robust in these cases, providing finite estimates
  and uncertainty intervals.

* **Applicability to longitudinal data:** The aforementioned boundary cases
  can be even more problematic in longitudinal data modeling. While there are
  robust versions of frequentist logistic regression (e.g., Firth's penalized
  regression [@Firth1993BiasEstimates]), they can be difficult to generalize
  to longitudinal settings. DiPPER, however, provides a single, unified
  robust framework for both cross-sectional and longitudinal analyses.

* **Inherent multiplicity adjustment:** Standard approaches typically calculate
  an unadjusted *p*-value for each feature and subsequently apply a post-hoc
  adjustment (e.g., the Benjamini-Hochberg procedure) to control for multiple
  testing (while leaving the corresponding regression coefficients and
  confidence intervals unadjusted). In contrast, multiplicity adjustment in
  DiPPER occurs naturally via hierarchical shrinkage. The differential
  prevalence parameters are assumed to originate from a common (Asymmetric)
  Laplace prior distribution. This shrinks the estimates toward zero (and toward
  each other), providing effectively multiplicity-adjusted estimates and
  intervals that can directly be used to assign "statistical significance".

## Benchmarking results against alternative methods

We have benchmarked DiPPER against alternative methods using real data from
57 human gut microbiome studies. The performance was compared against three
versions of frequentist logistic regression, DPA implementations in the
MaAsLin3 [@Nickols2026MaAsLinDiscovery] and LDM [@Hu2021AMicrobiome] packages,
and two differential abundance methods (MaAsLin2
[@Mallick2021MultivariableStudies] and LinDA [@Zhou2022LinDA:Data]).

DiPPER demonstrated high sensitivity in detecting potentially differentially
prevalent features while maintaining a well-calibrated family-wise error rate
under the global null hypothesis. Moreover, it outperformed all
alternatives in the replication of findings across independent studies.

## More information

A detailed description of DiPPER, along with comprehensive benchmarking
results, can be found in our pre-print [@Pelto2026DiPPER:Studies].

# Installation

Installing DiPPER is generally straightforward, but it requires dependencies
from both Bioconductor (for handling microbiome data structures) and Stan (for
Bayesian inference).

## Stan backend (cmdstanr)

Unless you already have `cmdstanr` installed, you will need to set it up first.
While configuring the Stan backend can occasionally require some
troubleshooting depending on your operating system, following these steps
should generally ensure a smooth setup.

First, install the `cmdstanr` R package from the Stan R-universe repository.

```{r install_stan, eval=FALSE}
# Install cmdstanr package
install.packages("cmdstanr",
                 repos = c("https://stan-dev.r-universe.dev",
                           getOption("repos")))

# Check C++ toolchain and install the backend
cmdstanr::check_cmdstan_toolchain(fix = TRUE)
cmdstanr::install_cmdstan()
```

## Bioconductor dependencies and DiPPER

Once CmdStan is ready, you can install DiPPER.

*Note: DiPPER compiles its Stan models during installation, which may take a
few minutes and produce verbose output. This is expected, and it is done only
once: afterwards the models are ready to use.*

```{r install_bioc, eval=FALSE}
# Install BiocManager if it is not already installed
if (!requireNamespace("BiocManager", quietly = TRUE)) {
    install.packages("BiocManager")
}

BiocManager::install("DiPPER")
```


# Quick start

Below is a minimal workflow using the built-in `tse_hintikka` dataset, which
compares the gut microbiomes of rats (N = 20 + 20) on a high-fat versus low-fat
diet (`Fat`) while controlling for xylo-oligosaccharide (`XOS`)
supplementation. The dataset originates from
[Hintikka et al. (2021)](https://www.mdpi.com/1660-4601/18/8/4049).

To run DiPPER, we need to provide

- TSE object (`tse = tse_hintikka`)
- Name of the assay (`assay.type = "counts"`)
- Type of data in the assay. As our data consist of sequencing counts we set
  `data.type = "counts"`.
- Model formula: `formula = ~ Fat + XOS`. By default, the first term (here
  `Fat`) is the *variable of interest* (another term can be chosen with
  `var.of.interest`).
- As our data consist of sequencing counts, it is recommended to control for
  sequencing depth by setting `read.depth = TRUE`.

```{r quick_start, eval=run_fit}
library(DiPPER)

# Load dataset (TreeSummarizedExperiment object)
data("tse_hintikka")

# Run DiPPER
fit <- dipper(
    tse = tse_hintikka,
    assay.type = "counts",
    data.type = "counts",
    formula = ~ Fat + XOS,
    read.depth = TRUE
)
```

```{r quick_start_cached, eval=!run_fit && have_cache, echo=FALSE}
# CmdStan is not available on this machine: use the pre-computed fit instead
library(DiPPER)
fit <- readRDS(cache_file("fit_hintikka.rds"))
```

We first print the details of the DiPPER run:

```{r quick_print, eval=show_res}
print(fit)
```

Next, we extract the summarized results as a `data.frame` and print the first
seven rows (the most "significant" genera). Note that the data frame contains
the results only for the variable of interest, which in this case is the `Fat`
variable indicating the diet, low-fat ('Low', the reference level) or high-fat
('High').

```{r quick_results, eval=show_res}
# Extract the results
res <- summary(fit)

# Print the first seven rows
res[1:7, ]
```

The summarized results `data.frame` contains the following columns:

* **`taxon`**: Taxon name (here: genus).
* **`log_or`**: Differential prevalence estimate *for the variable of interest*
  (here: `Fat`), expressed as a log(odds ratio).
* **`lwr`**, **`upr`**: Lower and upper limits of the 95% credible interval for
  the differential prevalence.
* **`significant`**: A logical value indicating whether the 95% credible
  interval excludes zero. This roughly corresponds to traditional "statistical
  significance" based on multiplicity-adjusted *p*-values.
* **`pseudo_q`**: Indicates the highest "significance level" at which the
  result would be considered "significant" (i.e., the credible interval
  excluding zero). It thus bears some resemblance to a multiplicity-adjusted
  two-sided *p*-value (*q*-value), but it does not have any strict statistical
  interpretation.

Lastly, create a forest plot (with 95% credible intervals) showing the
significant genera (determined by the 95% credible intervals). Here, the odds
ratio (OR) values > 1 indicate higher prevalence in the high-fat diet group.

```{r quick_plot, eval=show_res, out.width="100%"}
plot(fit)
```

# More complex example

This example demonstrates a more complex case, where we have longitudinal data.

We use a built-in subset of
the [Vatanen et al. (2016)](https://pmc.ncbi.nlm.nih.gov/articles/PMC4950857/)
dataset. The dataset includes gut microbiome samples from 79 infants at one
or two time points (1.5 and/or 7 months of age). We want to study how
microbe prevalences depend on age point (1.5 months vs 7 months)
while controlling for gender and antibiotics use.

```{r load_vatanen}
library(DiPPER)

# Load dataset (TreeSummarizedExperiment object, agglomerated to genus level)
data("VatanenT_2016_subset")
```

The metadata includes the variables `subject_id` (indicating the infant),
`age_point` (1.5 months or 7 months), `antibiotics` (use of antibiotics:
yes/no), and `gender` (male/female). Note that there are 1-2 measurements per
infant in the data.

```{r vatanen_metadata}
VatanenT_2016_subset |>
    SummarizedExperiment::colData() |>
    head()
```

Note that the abundance data are now proportions (relative abundances):

```{r vatanen_abundance}
vatanen_assay <- SummarizedExperiment::assay(VatanenT_2016_subset,
                                             "relative_abundance")
vatanen_assay[c("Bifidobacterium", "Escherichia", "Veillonella"), 1:5]
```

As we want to study the effect of age point (1.5 months vs 7 months) on the
microbe prevalences, we run DiPPER by setting `age_point` as the first variable
in the formula, making it the variable of interest. As we control for
`antibiotics` use and `gender`, we put them as the second and third variables.
Lastly, as some infants have two measurements, we must
account for the longitudinality by specifying `subject_id` (indicating infant)
as a random intercept term: `(1 | subject_id)`. Our formula is thus
`~ age_point + antibiotics + gender + (1 | subject_id)`.

Furthermore, because the abundance data are proportions (and not counts), we
must set `data.type = "relabundance"`. Moreover, they carry no information about
sequencing depth, so we set `read.depth = FALSE`.

```{r assay_vatanen, eval=run_fit}
fit_long <- dipper(
    tse = VatanenT_2016_subset,
    assay.type = "relative_abundance", # Name of the assay
    data.type = "relabundance",
    formula = ~ age_point + antibiotics + gender + (1 | subject_id),
    read.depth = FALSE
)
```

```{r assay_vatanen_cached, eval=!run_fit && have_cache, echo=FALSE}
# CmdStan is not available on this machine: use the pre-computed fit instead
fit_long <- readRDS(cache_file("fit_vatanen.rds"))
```

Finally, we extract the results (for `age_point`) and create a forest plot
showing only the significant taxa. As expected, the prevalence of several taxa
increases with infant age, although the prevalence of *Staphylococcus*
decreases.

```{r plot_vatanen, eval=show_res, out.width="100%"}
# Print the details of the run
print(fit_long)

# Extract the results for age_point
res_long <- summary(fit_long)
res_long[1:7, ]

# Create a forest plot for significant taxa
plot(fit_long)
```

# Input data and other settings

## Type of abundance data

The type of input abundance data, i.e., `data.type`, must be provided. This is
to enable `dipper()` to derive presence/absence information, and optionally, to
calculate sequencing depth correctly. The following values are accepted:

- **`"counts"`**: sequencing read counts (non-negative integers). The most
  typical format for 16S and shotgun data. It is preferable to use this option
  if you have read count data available, as these data can be used to
  automatically compute and control for the sequencing depth (read depth) in
  the analysis.
- **`"relabundance"`**: relative abundances, that is, proportions between 0
  and 1 whose sample sums are at most 1. Tools such as MetaPhlAn report these
  directly.
- **`"abundance"`**: any other non-negative abundances, for example, CPM or TPM
  values.
- **`"pa"`**: presence/absence data expressed as 0s and 1s. Use this whenever
  you want to define presence and absence values yourself.

Transformed values, such as CLR- or log-transformed assays, are **not**
supported, as DiPPER cannot derive presence/absence information from them.


## Controlling for sequencing (read) depth

Whether a feature/taxon is observed in a sample depends partly on how deeply
the sample was sequenced. Therefore, it is advisable to control for the
sequencing/read depth in the analysis whenever possible. DiPPER can compute the
read depth itself when sequencing count data are provided. The user can also
provide the read depths as a metadata column.

Whether read depth is controlled for is determined by the `read.depth`
argument. For sequencing count data (`data.type = counts`) there is
no default, because DiPPER cannot detect how the data have been
preprocessed.

- **`read.depth = TRUE`** computes read depth as the sample totals from the
  provided sequencing count data. Use this with non-rarefied sequencing counts.
  (Requires `data.type = "counts"`.)
- **`read.depth = "column_name"`** takes the read depths from a metadata
  column. Use this whenever you have computed the read depths yourself.
- **`read.depth = FALSE`** leaves read depth out of the model. Use this
  with rarefied data, or when sequencing depth infromation is not available.
  (The default for every `data.type` other than `"counts"`.)

When `read.depth = TRUE` or a metadata column is provided, `dipper()` applies a
log10 transformation to the read depths and adds the variable to the model.
The variable can be seen in the `print()` output as the `log10_read_depth` term
in the model formula.


## Symmetric or asymmetric hierarchical prior

By default DiPPER uses a hierarchical Asymmetric Laplace prior for the
differential prevalence parameters of interest (`symmetric = FALSE`). This
means that the prior is allowed to be asymmetric and the degree of
asymmetry is estimated from the data. Optionally, the user can set
`symmetric = TRUE` when the prior is fixed to be symmetric around zero.

In most cases, the symmetric and asymmetric option yield rather similar results
but the symmetric option generally provides slightly more conservative results.

In the case of strong asymmetry the prior pulls the differential prevalence
estimates not only towards zero, but also towards each other. Consequently, in
such cases, DiPPER induces a small bias to the estimates (e.g. all estimates
may have the same sign). While this bias may seem suspicious, and it may yield
a few additional false positives, it should also lead to clearly higher power
to detect true effects.

It is generally recommended to first run DiPPER with the default setting
`symmetric = FALSE`. The estimated values of the hyperparameters of the prior
can then be read from the `Hierarchical prior` line of the `print()` output,
where `tau` is the prior scale and `nu` indicates the asymmetry (`nu` is
constrained between 0 and 1, with 0.5 meaning symmetry). Now, if `nu` is
clearly away from 0.5 (e.g. below 0.30 or above 0.70) **and** `tau` is at the
same time small (e.g. < 0.10), the asymmetry of the prior may have a somewhat
strong effect on the results. **If** in such a case the user suspects that the
results are driven by some technical bias in the data, they may consider using
the symmetric version.

# Session Information

```{r session_info}
sessionInfo()
```
