Postpartum Community Remodeling
postpartum.Rmd
Data and Problem Context
How does birth influence the composition of the vaginal microbiome?
This question was first studied by (Costello, DiGiulio, Robaczewska,
Symul, Wong, Shaw, Stevenson, Holmes, Kwon, and Relman, 2022), who made
their data available in this repository. We
will re-analyze these data using mbtransfer
, viewing birth
as an intervention event with the capacity to remodel microbial
community composition. We’ll illustrate the following workflow:
- Construct a
ts_inter
(“time series intervention”) object, which is the expected input of our mainmbtransfer
modeling function. - Evaluate the in- and out-of-sample prediction performance of our model.
- Select significant taxa using mirror statistics.
- Visualize hypothetical trajectories for significant taxa, with an emphasis on potential interactions with subject-level features.
The first three steps nearly parallel those for the diet and aquaculture studies discussed in the other vignettes, but the third follows an implementation specific to this problem context.
library(fs)
library(tidyverse)
library(mbtransfer)
library(patchwork)
library(glue)
th <- theme_bw() +
theme(
panel.grid.minor = element_blank(),
panel.background = element_rect(fill = "#f7f7f7"),
panel.border = element_rect(fill = NA, color = "#0c0c0c", linewidth = 0.6),
axis.text = element_text(size = 8),
axis.title = element_text(size = 12),
legend.position = "bottom"
)
theme_set(th)
set.seed(20230524)
To construct as ts_inter
object, we combine the
following datasets:
-
reads
: Adata.frame
of taxonomic compositions. Rows are samples and columns are taxa. -
samples
: Adata.frame
of sample-level descriptors. This is expected to havesample
andtime
columns.sample
must match the rownames acrossreads
andinterventions
. -
subject_data
: Adata.frame
of subject-level descriptors. This can be used to store information that is not time varying. -
interventions
: Adata.frame
of whose rows are samples and columns are interventions. This can be either a binary matrix of whether a given intervention (column) was present in a sample. An input series can also be continuously valued – see the aquaculture vignette ofr an example.
We created these datasets by lightly processing the raw data from the
published repository. Our processing script is available here. Note that
we converted all the subject-level data to numeric variables –
mbtransfer
does not know how to handle factor inputs, so
they must be coded first.
reads <- read_csv("https://figshare.com/ndownloader/files/40322782/reads.csv") |>
column_to_rownames("sample")
samples <- read_csv("https://figshare.com/ndownloader/files/40322776/samples.csv")
subject_data <- read_csv("https://figshare.com/ndownloader/files/40322773/subject.csv")
interventions <- read_csv("https://figshare.com/ndownloader/files/40322770/interventions.csv") |>
column_to_rownames("sample")
Note that, unlike most microbiome studies, these data has relatively few taxa (29). This reflects the typically low diversity of the vaginal microbiome.
Next, we will construct a ts_inter
object using the
ts_from_dfs
function. In their initial form, the samples
are not evenly sampled – we generally have biweekly sampling, but daily
resolution is available in the period immediately surrounding birth. 0
indicates the birth timepoint, samples with negative timepoints were
collected before birth. Our method is not able to handle nonuniform
temporal sampling, unfortunately, so we downsample to biweekly (14 day)
resolution using the interpolate
function.
ts <- reads |>
ts_from_dfs(interventions, samples, subject_data) |>
interpolate(delta = 14, method = "linear")
ts
#> # A ts_inter object | 29 taxa | 49 subjects | 38.82 ± 10.15 timepoints:
#>
#> S10001:
#> S10001_T1 S10001_T2 S10001_T3 S10001_T4
#> Lactobacillus 13.315 13.776 13.732 14.573 …
#> Gardnerella 0 0 0 0 …
#> Prevotella 5.021 5.17 6.439 0.661 …
#> Bifidobacterium 0 0 5.224 0.838 …
#> ⋮ ⋮ ⋮ ⋮ ⋱
#>
#> S10004:
#> S10004_T1 S10004_T2 S10004_T3 S10004_T4
#> Lactobacillus 13.28 13.068 13.771 14.105 …
#> Gardnerella 9.443 8.109 5.087 7.296 …
#> Prevotella 7.303 6.363 7.138 6.254 …
#> Bifidobacterium 1.72 1.976 2.309 0.452 …
#> ⋮ ⋮ ⋮ ⋮ ⋱
#>
#> S10051:
#> S10051_T1 S10051_T2 S10051_T3 S10051_T4
#> Lactobacillus 15.045 13.799 12.554 12.851 …
#> Gardnerella 0 1.55 3.1 1.55 …
#> Prevotella 5.733 6.434 7.135 7.085 …
#> Bifidobacterium 0 1.928 3.856 1.928 …
#> ⋮ ⋮ ⋮ ⋮ ⋱
#>
#> and 46 more subjects.
Now that we have constructed our interpolated ts_inter
object, it can be helpful to visualize the subject trajectories. We can
use pivot_ts
to convert the ts_inter
object
into a merged data.frame
, and
interaction_barcode
creates a faceted plot for selected
taxa. Each row below is the trajectory for one taxon from one subject.
Multiple taxa for each subject are grouped into the same facet (for an
alternative which groups subjects according to taxa, see the
interaction_hm
function). We’ve further grouped subjects
according to their contraception status.
Prediction
We’ll now fit and evaluate some mbtransfer
models. We
consider two fits – one which uses all the subjects, and another that
estimates using 25/49 of them. In both models, we use taxonomic
(P
) and intervention (Q
) lags of three
timepoints. In these data, that means we can look 6 weeks into the past
to predict current microbiome composition.
The model that uses all samples gives us a sense of the flexibility of the approach. If we think of the model as a denoiser / smoothing according to a subset of predictor composition, intervention, and subject descriptors, how closely can we predict community change? The second model gives us a sense of how well this model might generalize to new subjects. This is a harder question, because microbiome data tends to exhibit high subject-to-subject variability. That said, taxa which are predictable out-of-sample are worth noting, since their effects may be more general.
subject_data(ts) <- subject_data(ts) |>
select(subject, BirthControlYesNo) |>
mutate(BirthControlYesNo = ifelse(BirthControlYesNo == "Yes", 1, 0))
fits <- list()
fits[["in-sample"]] <- mbtransfer(ts, P = 2, Q = 2)
fits[["out-of-sample"]] <- mbtransfer(ts[1:25], P = 2, Q = 2)
The block below computes the in and out of sample prediction errors corresponding to these two models. On test samples, we are allowed to look at timepoints up to week 12. We stop here because some samples have birth events starting on week 13, and we want to predict the effect of an intervention from before any have occurred.
By default, the predict function will fill in any timepoints that are
present in the intervention
but not the abundance
values
slot of each ts_inter
element. This
means that we will extrapolate starting from timepoint all the way to
the end of sampling (roughly, 35 - 45 samples total). Below, we will
compare predictions across different time horizons. Since running
inference can take time, one simple way to speed up
mbtransfer
is to request predictions across shorter time
horizons (e.g., by reducing the number of columns in the
interventions
slot).
ts_preds <- list()
ts_missing <- subset_values(ts, 1:12)
ts_preds[["in-sample"]] <- predict(fits[["in-sample"]], ts_missing)
ts_preds[["out-of-sample"]] <- predict(fits[["out-of-sample"]], ts_missing[26:49])
We compare the true vs. predicted abundances in the block below. Samples with more accurate predictions lie nearer to the diagonal. We’ve split the taxa by abundance – row labels give quantiles of average abundance. Note that each row has a different \(y\) axis. The clearest patterns are that,
- Short time horizons are easier to predict than long ones.
- In all panels, in and out-of-sample performance seem comparable, so we don’t have to be too worried about overfitting.
- More abundant taxa appear slightly easier to predict. This partly reflects the high noise level of the underlying data. For rarer taxa, however, many errors come from the model’s inability to predict exact zeros. This could potentially be improved by altering the model form.
reshaped_preds <- map_dfr(ts_preds, ~ reshape_preds(ts, .), .id = "generalization") |>
filter(h > 0, h < 50) |>
mutate(h = 10 * floor(h / 10))
ggplot(reshaped_preds) +
geom_abline(slope = 1, col = "#400610") +
geom_point(aes(y, y_hat, col = generalization), size = .7, alpha = .6) +
facet_grid(factor(quantile, rev(levels(quantile))) ~ h, scales = "free_y") +
labs(x = expression(y), y = expression(hat(y)), col = "Generalization") +
scale_x_continuous(expand = c(0, 0), n.breaks = 3) +
scale_y_continuous(expand = c(0, 0), n.breaks = 3) +
scale_color_manual(values = c("#A65883", "#6593A6")) +
guides("color" = guide_legend(override.aes = list(size = 4, alpha = 1))) +
theme(
axis.text = element_text(size = 10),
panel.spacing = unit(0, "line"),
strip.text.y = element_text(angle = 0, size = 12),
strip.text.x = element_text(angle = 0, size = 12),
legend.title = element_text(size = 14),
legend.text = element_text(size = 11),
)
reshaped_preds |>
group_by(h, quantile, generalization) |>
summarise(correlation = round(cor(y, y_hat), 4)) |>
pivot_wider(names_from = "generalization", values_from = "correlation") |>
arrange(desc(quantile), h)
#> # A tibble: 20 × 4
#> # Groups: h, quantile [20]
#> h quantile `in-sample` `out-of-sample`
#> <dbl> <fct> <dbl> <dbl>
#> 1 0 (5.48,11.5] 0.785 0.738
#> 2 10 (5.48,11.5] 0.707 0.593
#> 3 20 (5.48,11.5] 0.516 0.322
#> 4 30 (5.48,11.5] 0.522 0.440
#> 5 40 (5.48,11.5] 0.566 0.476
#> 6 0 (2.94,5.48] 0.691 0.600
#> 7 10 (2.94,5.48] 0.597 0.477
#> 8 20 (2.94,5.48] 0.505 0.373
#> 9 30 (2.94,5.48] 0.479 0.271
#> 10 40 (2.94,5.48] 0.504 0.293
#> 11 0 (0.547,2.94] 0.729 0.566
#> 12 10 (0.547,2.94] 0.667 0.508
#> 13 20 (0.547,2.94] 0.614 0.410
#> 14 30 (0.547,2.94] 0.535 0.423
#> 15 40 (0.547,2.94] 0.580 0.387
#> 16 0 [0,0.547] 0.849 0.776
#> 17 10 [0,0.547] 0.772 0.701
#> 18 20 [0,0.547] 0.646 0.469
#> 19 30 [0,0.547] 0.502 0.512
#> 20 40 [0,0.547] 0.545 0.454
Attribution Analysis: Selecting Important Taxa
Given the reasonable performance of the model, it makes sense to try
following-up with some more formal statistical inference. Specifically,
we want to know which taxa are most affected by the intervention, but we
some formal statistical guarantees that we aren’t reading too much into
anything that is just noise. To this end, we have included a
select_taxa
function that performs selective inference
through data splitting, following (Dai, Lin, Xing, and Liu, 2020).
Roughly, we estimate marginal effects of different hypothetical
interventions on each taxa. We do this over. many random splits of the
data, and if a similar marginal effects is detected across the. splits,
then we intuitively suspect that the intervention does influence that
taxa. The algorithm in (Dai, Lin, Xing et al., 2020) formalizes this
intuition in a way that ensures that the false discovery rate is
controlled. See the manuscript accompanying the package for details.
Practically, to implement this selection algorithm, we need to
provide the counterfactual interventions that we want to contrast. This
is done in the step below. The steps
helper function
creates a few example counterfactuals.
ws <- steps(c("birth" = TRUE), lengths = 2:4, L = 4)
ws
#> [[1]]
#> t1 t2 t3 t4
#> birth 0 0 0 0
#>
#> [[2]]
#> t1 t2 t3 t4
#> birth 1 1 0 0
#>
#> [[3]]
#> t1 t2 t3 t4
#> birth 1 1 1 0
#>
#> [[4]]
#> t1 t2 t3 t4
#> birth 1 1 1 1
We’ll contrast the taxa trajectories when the first two timepoints
are and are not assumed to belong to the birth
intervention. By default, we control the FDR at a \(q\)-level of 0.2 – this can be modified
using the qvalue
argument. We have to fit our model over 20
random splits of the data, so this step takes some time. More splits
yields higher power, but small values n_splits
will still
guarantee FDR control, and you will still be able to run the rest of the
vignette even if you set n_splits
to a value like 3 or
4.
#staxa <- select_taxa(ts, ws[[1]], ws[[2]], ~ mbtransfer(., 2, 2), n_splits = 25)
staxa <- readRDS(url("https://github.com/krisrs1128/mbtransfer_demo/raw/main/staxa-postpartum.rds"))
The ms
element of this object includes the raw mirror
statistics that led to the final inference. The larger the value of
these mirror statistics, the stronger the estimated intervention effect.
Mirror statistics that are symmetric around zero suggest that there is
no intervention effect. Visualizing these results, it seems that most
taxa in the data are affected by the intervention. This contrasts with
the other vignettes in this package, where effect seems more
taxa-specific. The result is consistent with the findings of (Costello,
DiGiulio, Robaczewska et al., 2022), though. Delivery dramatically
remodels the vaginal microbiome.
staxa$ms |>
mutate(
taxon = taxa(ts)[taxon],
lag = as.factor(lag),
selected = ifelse(taxon %in% unlist(staxa$taxa), "Selected", "Not Selected")
) |>
ggplot() +
geom_hline(yintercept = 0, size = 2, col = "#d3d3d3") +
geom_boxplot(aes(reorder(taxon, -m), m, fill = lag, col = lag), alpha = 0.8) +
facet_grid(. ~ selected, scales = "free_x", space = "free_x") +
scale_fill_manual(values = c("#c6dbef", "#6baed6", "#2171b5", "#084594")) +
scale_color_manual(values = c("#c6dbef", "#6baed6", "#2171b5", "#084594")) +
labs(y = expression(M[j]), x = "Taxon") +
theme(axis.text.x = element_text(angle = 90, size = 11))
Comparing Counterfactual Trajectories
Mirror statistics can highlight the species that are the most influenced by the intervention, but they do not help us describe the “shape” of an intervention effect. Are there taxa that are immediately impacted, but which recover quickly? Are there others whose effects are delayed? Transfer function models can help to answer this question. The idea is to simulate identical interventions across all subjects using the fitted model to predict the subsequent taxonomic abundance trajectories. Do be cautious with interpretation, though. Simpler models (smaller \(P\) and \(Q\)) have limited expressivity – there are only so many shapes they can approximate. That said, the simulated trajectories often provide a useful summary of intervention effects across heterogeneous populations.
In the block below, we begin the simulation at the timepoint
immediately before the first observed intervention. From here, we
simulate four counterfactual outcomes. We toggle both (1) whether the
birth intervention occurs and (2) the BirthControlYesNo
variable. The reason for simulating the four combinations is to see
whether the learned intervention effect varies among women who do and do
not use birth control following delivery – the original paper
hypothesizes that there may be a difference, based on prior biological
knowledge and a few examples from the data.
ws <- steps(c("birth" = TRUE), lengths = 2, L = 10)
start_ix <- map_dbl(ts, ~ min(which(.@time >= -14))) - 1
sim_bc <- list()
for (i in 0:1) {
bc <- subject_data(ts) |>
mutate(BirthControlYesNo = rep(i, n()))
sim_bc[[i + 1]] <- counterfactual_ts(ts, ws[[1]], ws[[2]], start_ix) |>
map(~ replace_subject(., bc)) |>
map(~ predict(fits[["in-sample"]], .))
}
We visualize a few of the trajectories below. Just so that the figure
is not too large, we’ve restricted attention to just a few of the taxa
that are known to be affected by the intervention. We use the
ribbon_data
helper function to extract the 25 and 75%
quantiles of the simulated trajectories. It’s immediately clear which
taxa become more/less abundant in the population. It seems that
trajectories mainly differ in magnitude, but not in shape – all taxa
begin to recover to pre-intervention levels at approximately the same
rate. Moreover, the simulated BirthControlYesNo
trajectories seem identical, indicating that the model has not learned
any potential interaction. The paper’s discussion is still plausible,
but the effect does not seem so strong across the entire population that
it is considered worth learning by the model.
focus_taxa <- c("Porphyromonas", "Lactobacillus", "Prevotella_6", "L.jensenii", "L.iners", "Atopobium")
rdata <- sim_bc |>
map_dfr(~ ribbon_data(.[[2]], .[[1]], focus_taxa, delta = 7), .id = "BC") |>
mutate(
BC = fct_recode(BC, No = "1", Yes = "2")
) |>
filter(
# time > -25, time < 80,
time > -25,
time %% 14 == 0
)
pal <- c("#F2785C", "#144673")
ribbon_plot(rdata, group = "BC") +
facet_wrap(~ reorder(taxon, median)) +
labs(y = expression(paste(Delta, " Abundance")), x = "Day", fill = "Birth Control Reinitiated", col = "Birth Control Reinitiated") +
scale_color_manual(values = pal) +
scale_fill_manual(values = pal) +
theme(
axis.title = element_text(size = 12),
strip.text = element_text(size = 12)
)
It is always good practice to use model-based summaries to dig deeper into the original data (this is sometimes called “Progressive Disclosure” in the data visualization literature). This is more directed that attempting to visualize all the data at once, but it is also more critical than just reporting model summaries as if they captured all of reality. In the heatmaps below, each row is a different sample, and the rows correspond to the different birth control categories. Similarities across panels help explain why the model did not learn an intervention effect. Moreover, the directions of the effects are generally consistent with the learned effects. Hoever, this is not universal – consider the subjects whose L.iners abundance increased following delivery! We could imagine doing a residual analysis to detect these outlying subjects, in the spirit of (Holmes, 1993).
values_df <- pivot_ts(ts) |>
group_by(taxon) |>
mutate(value = rank(value) / n()) |>
select(-BirthControlYesNo) |>
left_join(subject_data)
taxa_order <- c("Lactobacillus", "L.iners", "L.jensenii", "Atopobium", "Prevotella_6", "Porphyromonas")
interaction_hm(values_df, taxa_order, "BirthControlYesNo", -1, width = 14) +
theme(
axis.text.y = element_blank(),
strip.text = element_text(size = 12)
)
We can merge these into a single figure using the block below.
p1 <- rdata |>
filter(taxon %in% focus_taxa) |>
mutate(taxon = factor(taxon, levels = taxa_order)) |>
ribbon_plot(group = "BC") +
scale_color_manual(values = pal) +
scale_fill_manual(values = pal) +
facet_grid(. ~ taxon) +
labs(title = "(a) Counterfactual Diference at Interventions", y = "Difference") +
theme(
panel.spacing = unit(0, "line"),
axis.text.x = element_text(size = 8),
axis.title.x = element_blank(),
plot.margin = unit(c(0, 0, 0, 0), "null")
)
p2 <- values_df |>
mutate(value = rank(value) / n()) |>
interaction_hm(taxa = taxa_order, "BirthControlYesNo", -1, width = 14) +
scale_color_gradient(low = "#eaf7f7", high = "#037F8C") +
scale_fill_gradient(low = "#eaf7f7", high = "#037F8C") +
labs(x = "Time", y = "Subject", fill = "Rank", color = "Rank", title = "(b) Original Data") +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank()
)
p1 / p2 +
plot_layout(heights = c(1, 2), guides = "collect") &
theme(legend.position = "right")