Solution — Day 1 microbiome (classical)

Tasks 1.1–1.8 on the lab exercises page. Classical R only (no tidymodels). Uses the Schloss mouse 16S tables from public GitHub URLs and compares PCA + logistic regression, stepAIC, and lasso on Label (Early vs Late) and Sex.

Part 1 — Load and explore

1.1 Load and filter

We merge metadata and OTU counts, transpose to samples × OTUs, and drop OTUs present in fewer than 10% of samples. This prevalence filter removes extremely rare taxa that add noise and widen the design matrix without stable signal.

mic <- load_microbiome(prev = 0.10)
otu_cols <- mic_otu_cols(mic)

cat("Dimensions (rows = samples, cols = metadata + OTUs):\n")
Dimensions (rows = samples, cols = metadata + OTUs):
print(dim(mic))
[1] 282 349
cat("\nLabel counts:\n")

Label counts:
print(table(mic$Label))

Early  Late 
  177   105 
cat("\nSex counts:\n")

Sex counts:
print(table(mic$Sex, useNA = "ifany"))

  F   M 
142 140 
cat("\nUnique mice (Individual):\n")

Unique mice (Individual):
print(length(unique(mic$Individual)))
[1] 13

OTU taxonomy — readable names

OTU columns in the count table are anonymous IDs (seq_5, seq_14, …). A fourth public file, taxonomy.csv, maps each ID to standard ranks (Kingdom → Species). Not every OTU resolves to genus or species — many stop at family (e.g. Bacteroidales_S24-7_group).

tax <- load_otu_taxonomy()

tax |>
  dplyr::filter(otu %in% otu_cols) |>
  dplyr::mutate(
    has_genus = !is.na(Genus) & Genus != "",
    has_family_only = !has_genus & !is.na(Family) & Family != ""
  ) |>
  dplyr::count(has_genus, has_family_only, name = "n_otus") |>
  knitr::kable(caption = "Taxonomic resolution after prevalence filter")
Taxonomic resolution after prevalence filter
has_genus has_family_only n_otus
FALSE FALSE 14
FALSE TRUE 163
TRUE FALSE 167
tax |>
  dplyr::filter(otu %in% otu_cols, !is.na(Genus), Genus != "") |>
  dplyr::select(otu, Phylum, Family, Genus) |>
  dplyr::slice_head(n = 12) |>
  knitr::kable(caption = "Example OTUs with genus-level names")
Example OTUs with genus-level names
otu Phylum Family Genus
seq_7 Bacteroidetes Rikenellaceae Alistipes
seq_14 Firmicutes Lactobacillaceae Lactobacillus
seq_16 Firmicutes Clostridiaceae_1 Candidatus_Arthromitus
seq_5 Bacteroidetes Bacteroidaceae Bacteroides
seq_13 Firmicutes Lactobacillaceae Lactobacillus
seq_18 Firmicutes Lachnospiraceae Lachnospiraceae_NK4A136_group
seq_22 Firmicutes Lachnospiraceae Lachnospiraceae_NK4A136_group
seq_23 Firmicutes Lachnospiraceae Lachnospiraceae_NK4A136_group
seq_31 Firmicutes Lachnospiraceae Lachnospiraceae_NK4A136_group
seq_28 Firmicutes Ruminococcaceae Oscillibacter
seq_25 Firmicutes Ruminococcaceae Oscillibacter
seq_27 Firmicutes Lachnospiraceae Lachnospiraceae_NK4A136_group

We use display labels in the lasso plot below: Genus (seq_id) when available, otherwise Family (seq_id), otherwise the raw OTU ID.

p_label <- ggplot(mic, aes(Label, fill = Label)) +
  geom_bar(show.legend = FALSE) +
  labs(title = "Samples per Label", x = NULL, y = "Count") +
  scale_fill_brewer(palette = "Set2")

p_day <- ggplot(mic, aes(Day, fill = Label)) +
  geom_histogram(bins = 30, alpha = 0.75, position = "identity") +
  labs(title = "Sampling day by community stage", x = "Day", y = "Count") +
  scale_fill_brewer(palette = "Set2")

p_mice <- mic |>
  count(Individual, Label) |>
  ggplot(aes(Individual, n, fill = Label)) +
  geom_col() +
  labs(title = "Longitudinal samples per mouse", x = "Mouse ID", y = "Number of samples") +
  theme(axis.text.x = element_blank()) +
  scale_fill_brewer(palette = "Set2")

p_label + p_day + plot_layout(ncol = 2, widths = c(1, 1.4))

Takeaway: Early and Late are not perfectly balanced, and the same Individual contributes multiple rows. That repeated-measures structure matters later when we discuss validation (Day 2 random split vs Day 4 grouped CV).

1.2 log1p transform

Raw counts are right-skewed and dominated by a few abundant OTUs. log1p(x) = log(1 + x) compresses large values while keeping zeros at zero. We also record library size (total counts per sample) to check whether sequencing depth differs by group.

otu_mat <- as.matrix(mic[, otu_cols])
log_otu <- log1p(otu_mat)
mic$lib_size <- rowSums(otu_mat)
p_lib <- ggplot(mic, aes(lib_size, fill = Label)) +
  geom_histogram(bins = 30, alpha = 0.5, position = "identity") +
  scale_x_log10() +
  labs(title = "Library size after prevalence filter", x = "Total counts (log10 axis)", y = "Samples") +
  scale_fill_brewer(palette = "Set2")

p_raw <- mic |>
  slice_sample(n = min(500, nrow(mic))) |>
  dplyr::select(all_of(sample(otu_cols, min(80, length(otu_cols))))) |>
  pivot_longer(everything(), names_to = "otu", values_to = "count") |>
  ggplot(aes(count)) +
  geom_histogram(bins = 40, fill = "steelblue", color = "white") +
  scale_x_log10() +
  labs(
    title = "Raw count distribution (random OTU subset)",
    x = "Count (log10 axis)",
    y = "OTU × sample entries"
  )

p_lib + p_raw + plot_layout(ncol = 2)

prev_by_otu <- colMeans(otu_mat > 0)
ggplot(tibble(prevalence = prev_by_otu), aes(prevalence)) +
  geom_histogram(bins = 30, fill = "darkorange", color = "white") +
  geom_vline(xintercept = 0.10, linetype = 2, linewidth = 0.8) +
  labs(
    title = "OTU prevalence after filtering",
    subtitle = "Dashed line = 10% threshold used at load time",
    x = "Fraction of samples with count > 0",
    y = "Number of OTUs"
  )

Takeaway: Even after filtering, most OTU × sample entries are still zero (sparse matrix). Distance-based methods and penalised regression behave very differently on this geometry than on the low-dimensional gene toy from the morning lecture.

Part 2 — PCA exploration

1.3 PCA

With p ≫ n, a logistic regression on every OTU is ill-conditioned. PCA rotates the log-transformed OTU table into orthogonal directions of decreasing variance. The first few principal components (PCs) summarise co-abundance patterns across many taxa.

pc <- prcomp(log_otu, center = TRUE, scale. = TRUE)
var_expl <- pc$sdev^2 / sum(pc$sdev^2)
pc_df <- as_tibble(pc$x[, 1:2]) |>
  mutate(
    sample_id = mic$sample_id,
    Label = mic$Label,
    Sex = mic$Sex,
    Day = mic$Day
  )

p_scatter <- ggplot(pc_df, aes(PC1, PC2, color = Label, shape = Sex)) +
  geom_point(alpha = 0.75, size = 2.2) +
  scale_color_brewer(palette = "Set2") +
  labs(title = "PCA of log1p OTU abundances (PC1 vs PC2)")

p_scree <- tibble(
  PC = factor(paste0("PC", seq_along(var_expl)), levels = paste0("PC", seq_along(var_expl))),
  variance = var_expl
) |>
  slice_head(n = 15) |>
  ggplot(aes(PC, variance)) +
  geom_col(fill = "steelblue") +
  labs(
    title = "Variance explained per PC",
    x = NULL,
    y = "Proportion of variance"
  ) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

p_cum <- tibble(
  PC = seq_along(var_expl),
  cumulative = cumsum(var_expl)
) |>
  ggplot(aes(PC, cumulative)) +
  geom_line(linewidth = 1) +
  geom_point() +
  scale_x_continuous(breaks = seq(0, 20, by = 5)) +
  labs(
    title = "Cumulative variance explained",
    x = "Number of PCs",
    y = "Cumulative proportion"
  )

p_scatter

(p_scree | p_cum) + plot_layout(ncol = 2)

cum5 <- sum(var_expl[1:5])
cat(sprintf("First 5 PCs explain %.1f%% of total variance.\n", 100 * cum5))
First 5 PCs explain 42.4% of total variance.

Takeaway: If PC1–PC2 separate Early and Late, a linear classifier in PC space has a fighting chance. If overlap is large, even a flexible method will struggle — especially with in-sample evaluation only.

Part 3 — Label: Early vs Late

1.4–1.6 Three routes to the same question

We compare three strategies on Label:

Approach Predictors Idea
GLM on PCs 5 principal components Compress correlated OTUs, then logistic regression
stepAIC Subset of PCs Let AIC choose how many PCs to keep
Lasso All OTUs (scaled) Penalise coefficients; sparse OTU-level solution
n_pc <- 5
pc_scores <- as.data.frame(pc$x[, seq_len(n_pc), drop = FALSE])
colnames(pc_scores) <- paste0("PC", seq_len(n_pc))
dat_label <- cbind(Label = mic$Label, pc_scores)

label_fits <- fit_label_models(dat_label, log_otu, mic$Label, n_pc = n_pc)
m_glm <- label_fits$glm
m_fwd <- label_fits$forward
m_bwd <- label_fits$backward
cv_fit <- label_fits$lasso

cat("Full GLM on 5 PCs:\n")
Full GLM on 5 PCs:
print(summary(m_glm)$coefficients)
               Estimate Std. Error   z value     Pr(>|z|)
(Intercept) -3.36859050 0.66196186 -5.088798 3.603393e-07
PC1          0.05709940 0.03662666  1.558957 1.190065e-01
PC2         -1.09594304 0.17218274 -6.364999 1.952905e-10
PC3         -0.42286518 0.08410904 -5.027583 4.967002e-07
PC4         -0.05245695 0.07991552 -0.656405 5.115636e-01
PC5         -0.07698606 0.12151958 -0.633528 5.263889e-01
cat("\nstepAIC formulas:\n")

stepAIC formulas:
print(list(forward = formula(m_fwd), backward = formula(m_bwd)))
$forward
Label ~ PC2 + PC3 + PC1
<environment: 0x55cd359906c0>

$backward
Label ~ PC1 + PC2 + PC3
<environment: 0x55cd359906c0>
coef_glm <- broom::tidy(m_glm) |>
  filter(term != "(Intercept)") |>
  mutate(model = "GLM (5 PCs)")

coef_fwd <- broom::tidy(m_fwd) |>
  filter(term != "(Intercept)") |>
  mutate(model = "stepAIC forward")

bind_rows(coef_glm, coef_fwd) |>
  ggplot(aes(term, estimate, fill = model)) +
  geom_col(position = "dodge") +
  geom_hline(yintercept = 0, linetype = 2) +
  labs(
    title = "Log-odds coefficients in PC space",
    subtitle = "Positive estimate → higher log-odds of Late",
    x = NULL,
    y = "Estimate (log-odds scale)"
  ) +
  theme(axis.text.x = element_text(angle = 0))

lasso_coef <- as.matrix(coef(cv_fit, s = "lambda.min"))
nonzero <- lasso_coef[lasso_coef[, 1] != 0, , drop = FALSE]
cat("Non-zero lasso coefficients at lambda.min:\n")
Non-zero lasso coefficients at lambda.min:
print(round(nonzero, 4))
            lambda.min
(Intercept)    -1.8592
seq_3          -0.4797
seq_40         -0.0115
seq_129        -0.1698
seq_100        -0.3001
seq_36          0.2393
seq_80         -0.1030
seq_78         -0.8359
seq_152        -0.2752
seq_56         -0.1409
seq_109         0.4070
seq_89         -1.1984
seq_222        -0.1240
seq_134        -0.0632
seq_288         0.3410
seq_131         0.2930
seq_74          0.5246
seq_268         0.0080
seq_19          1.3070
seq_214        -0.1084
seq_215         0.1063
seq_137         2.3035
seq_249         0.4935
seq_355        -0.1069
seq_240         0.5722
seq_358         0.2141
seq_264         0.4186
n_coef_lasso <- nrow(nonzero) - 1
plot(cv_fit)
title("Lasso CV curve — Label (Early vs Late)", line = 2.5)

lasso_tbl <- tibble(
  otu = rownames(lasso_coef),
  estimate = as.numeric(lasso_coef)
) |>
  filter(otu != "(Intercept)", estimate != 0) |>
  mutate(abs_est = abs(estimate)) |>
  slice_max(abs_est, n = 15) |>
  annotate_otu(tax)

ggplot(lasso_tbl, aes(reorder(display, abs_est), estimate, fill = estimate > 0)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  scale_fill_manual(values = c("TRUE" = "firebrick", "FALSE" = "steelblue")) +
  labs(
    title = "Top lasso OTU coefficients (lambda.min)",
    subtitle = "Labels from taxonomy.csv where available; red = higher abundance pushes toward Late",
    x = NULL,
    y = "Coefficient on scaled log1p OTUs"
  )

met_glm <- in_sample_metrics(mic$Label, predict(m_glm, type = "response"), positive = "Late")
met_fwd <- in_sample_metrics(mic$Label, predict(m_fwd, dat_label, type = "response"), positive = "Late")
met_bwd <- in_sample_metrics(mic$Label, predict(m_bwd, dat_label, type = "response"), positive = "Late")
pred_lasso <- predict(cv_fit, newx = label_fits$x_lasso, s = "lambda.min", type = "response")
met_lasso <- in_sample_metrics(mic$Label, as.numeric(pred_lasso), positive = "Late")

label_summary <- bind_rows(
  model_row("Label", "GLM (5 PCs)", n_pc, met_glm, AIC(m_glm)),
  model_row("Label", "stepAIC forward", length(coef(m_fwd)) - 1, met_fwd, AIC(m_fwd)),
  model_row("Label", "stepAIC backward", length(coef(m_bwd)) - 1, met_bwd, AIC(m_bwd)),
  model_row("Label", "Lasso (all OTUs)", n_coef_lasso, met_lasso, aic = NA_real_)
)

knitr::kable(label_summary, digits = 3, caption = "In-sample performance — Label")
In-sample performance — Label
outcome method n_predictors train_accuracy sensitivity specificity AIC
Label GLM (5 PCs) 5 0.940 0.943 0.938 102.789
Label stepAIC forward 3 0.933 0.924 0.938 99.295
Label stepAIC backward 3 0.933 0.924 0.938 99.295
Label Lasso (all OTUs) 26 1.000 1.000 1.000 NA
conf_to_df <- function(tab, model) {
  as.data.frame.matrix(tab) |>
    rownames_to_column("Predicted") |>
    pivot_longer(-Predicted, names_to = "Observed", values_to = "n") |>
    mutate(model = model)
}

bind_rows(
  conf_to_df(met_glm$confusion, "GLM (5 PCs)"),
  conf_to_df(met_fwd$confusion, "stepAIC forward"),
  conf_to_df(met_lasso$confusion, "Lasso")
) |>
  ggplot(aes(Observed, Predicted, fill = n)) +
  geom_tile(color = "white") +
  geom_text(aes(label = n), color = "white", fontface = "bold") +
  facet_wrap(~ model) +
  scale_fill_gradient(low = "grey85", high = "steelblue") +
  labs(title = "Confusion matrices (training data)", x = "Observed", y = "Predicted")

prob_df <- tibble(
  Label = mic$Label,
  GLM = predict(m_glm, type = "response"),
  stepAIC = predict(m_fwd, dat_label, type = "response"),
  Lasso = as.numeric(pred_lasso)
) |>
  pivot_longer(c(GLM, stepAIC, Lasso), names_to = "model", values_to = "prob_late")

ggplot(prob_df, aes(Label, prob_late, fill = Label)) +
  geom_boxplot(alpha = 0.85, show.legend = FALSE) +
  facet_wrap(~ model) +
  geom_hline(yintercept = 0.5, linetype = 2) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title = "Predicted P(Late) on training samples",
    subtitle = "Dashed line = 0.5 classification threshold",
    x = NULL,
    y = "P(Late)"
  )

Label — comparison

  • Dimensionality: GLM and stepAIC operate on 5 constructed PCs; lasso uses 344 OTU predictors but returns only 26 non-zero coefficients at lambda.min.
  • Interpretability: PC coefficients are rotations of the full OTU table — hard to name a single bacterium. Lasso coefficients map to OTU IDs; join taxonomy.csv for genus/family names (see section above).
  • AIC vs lasso: stepAIC chose Label ~ PC2 + PC3 + PC1 with AIC = 99.3. Lasso uses cross-validated lambda instead of AIC; the two criteria need not agree.
  • Accuracy: All metrics here are in-sample (fit and score on the same rows). Differences between methods can look small; hold-out or grouped CV (Tuesday/Day 4) is the honest check.

Part 4 — Sex: Female vs Male

1.7 Repeat for Sex

Sex prediction is often harder than Early/Late in this cohort: microbiome differences between males and females can be subtle relative to temporal drift. We repeat the same workflow on rows with known Sex, dropping zero-variance OTUs in this subset.

mic_sex <- mic |> filter(Sex %in% c("F", "M"))
otu_sex <- log1p(as.matrix(mic_sex[, otu_cols]))
zv_sex <- apply(otu_sex, 2, sd) > 1e-10
otu_sex <- otu_sex[, zv_sex, drop = FALSE]

pc_sex <- prcomp(otu_sex, center = TRUE, scale. = TRUE)
pc_s <- as.data.frame(pc_sex$x[, seq_len(n_pc), drop = FALSE])
colnames(pc_s) <- paste0("PC", seq_len(n_pc))
dat_sex <- cbind(Sex = factor(mic_sex$Sex, levels = c("F", "M")), pc_s)

sex_fits <- fit_label_models(
  dat_sex |> rename(Label = Sex),
  otu_sex,
  mic_sex$Sex,
  n_pc = n_pc
)
m_sex <- sex_fits$glm
m_sex_fwd <- sex_fits$forward
cv_sex <- sex_fits$lasso
pc_sex_df <- as_tibble(pc_sex$x[, 1:2]) |>
  mutate(Sex = mic_sex$Sex, Label = mic_sex$Label)

ggplot(pc_sex_df, aes(PC1, PC2, color = Sex, shape = Label)) +
  geom_point(alpha = 0.75, size = 2.2) +
  scale_color_brewer(palette = "Dark2") +
  labs(title = "PCA on sex subset (coloured by Sex, shaped by Label)")

met_sex_glm <- in_sample_metrics(mic_sex$Sex, predict(m_sex, type = "response"), positive = "M")
met_sex_fwd <- in_sample_metrics(mic_sex$Sex, predict(m_sex_fwd, dat_sex, type = "response"), positive = "M")
pred_sex_lasso <- predict(cv_sex, newx = sex_fits$x_lasso, s = "lambda.min", type = "response")
met_sex_lasso <- in_sample_metrics(mic_sex$Sex, as.numeric(pred_sex_lasso), positive = "M")
n_coef_sex_lasso <- sum(coef(cv_sex, s = "lambda.min") != 0) - 1

sex_summary <- bind_rows(
  model_row("Sex", "GLM (5 PCs)", n_pc, met_sex_glm, AIC(m_sex)),
  model_row("Sex", "stepAIC forward", length(coef(m_sex_fwd)) - 1, met_sex_fwd, AIC(m_sex_fwd)),
  model_row("Sex", "Lasso (all OTUs)", n_coef_sex_lasso, met_sex_lasso, aic = NA_real_)
)

knitr::kable(sex_summary, digits = 3, caption = "In-sample performance — Sex")
In-sample performance — Sex
outcome method n_predictors train_accuracy sensitivity specificity AIC
Sex GLM (5 PCs) 5 0.631 0.657 0.606 370.166
Sex stepAIC forward 3 0.628 0.643 0.613 368.190
Sex Lasso (all OTUs) 65 1.000 1.000 1.000 NA
bind_rows(label_summary, sex_summary) |>
  mutate(outcome = factor(outcome, levels = c("Label", "Sex"))) |>
  ggplot(aes(method, train_accuracy, fill = outcome)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.7) +
  geom_hline(yintercept = 0.5, linetype = 2) +
  coord_flip() +
  scale_fill_brewer(palette = "Set1") +
  labs(
    title = "Training accuracy — Label vs Sex",
    subtitle = "Dashed line = majority-class baseline if classes were balanced at 50%",
    x = NULL,
    y = "Accuracy",
    fill = "Outcome"
  )

Part 5 — Summary and synthesis

1.8 Summary table

summary_tbl <- bind_rows(label_summary, sex_summary) |>
  dplyr::select(outcome, method, n_predictors, train_accuracy, sensitivity, specificity, AIC)

knitr::kable(summary_tbl, digits = 3, caption = "Full comparison across outcomes and methods")
Full comparison across outcomes and methods
outcome method n_predictors train_accuracy sensitivity specificity AIC
Label GLM (5 PCs) 5 0.940 0.943 0.938 102.789
Label stepAIC forward 3 0.933 0.924 0.938 99.295
Label stepAIC backward 3 0.933 0.924 0.938 99.295
Label Lasso (all OTUs) 26 1.000 1.000 1.000 NA
Sex GLM (5 PCs) 5 0.631 0.657 0.606 370.166
Sex stepAIC forward 3 0.628 0.643 0.613 368.190
Sex Lasso (all OTUs) 65 1.000 1.000 1.000 NA
summary_tbl |>
  dplyr::select(outcome, method, train_accuracy, sensitivity, specificity) |>
  pivot_longer(c(train_accuracy, sensitivity, specificity), names_to = "metric", values_to = "value") |>
  mutate(
    metric = recode(metric,
      train_accuracy = "Accuracy",
      sensitivity = "Sensitivity",
      specificity = "Specificity"
    )
  ) |>
  ggplot(aes(method, metric, fill = value)) +
  geom_tile(color = "white") +
  geom_text(aes(label = sprintf("%.2f", value)), size = 3) +
  facet_wrap(~ outcome, scales = "free_x") +
  scale_fill_gradient2(low = "#d73027", mid = "white", high = "#4575b4", midpoint = 0.5, limits = c(0, 1)) +
  theme(axis.text.x = element_text(angle = 35, hjust = 1)) +
  labs(title = "Metric heatmap (training data)", x = NULL, y = NULL, fill = NULL)

Synthesis — what did we learn?

  1. Two high-dimensional strategies: PCA + GLM builds a small set of uncorrelated predictors; lasso keeps OTU names but needs penalisation. They answer different scientific questions (community axes vs sparse taxa list).

  2. Model selection: stepAIC on PCs trades parsimony for AIC; lasso’s lambda.min trades bias-variance via cross-validation. Neither replaces an external test set.

  3. Outcome difficulty: Compare the Label and Sex panels — if Sex accuracy hovers near 0.5, the models are barely beating chance despite similar machinery.

  4. Next steps (Tuesday): Wrap log1p + normalize in a recipe, use workflow(), and hold out a test set. Day 4: use group_vfold_cv(group = Individual) so the same mouse does not appear in both train and validation.

R version 4.4.3 (2025-02-28)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.4 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
 [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
 [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
[10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   

time zone: UTC
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] patchwork_1.3.2 broom_1.0.13    glmnet_5.0      Matrix_1.7-2   
 [5] MASS_7.3-64     lubridate_1.9.5 forcats_1.0.1   stringr_1.6.0  
 [9] dplyr_1.2.1     purrr_1.2.2     readr_2.2.0     tidyr_1.3.2    
[13] tibble_3.3.1    tidyverse_2.0.0 ggplot2_4.0.3  

loaded via a namespace (and not attached):
 [1] generics_0.1.4     shape_1.4.6.1      stringi_1.8.7      lattice_0.22-6    
 [5] hms_1.1.4          digest_0.6.39      magrittr_2.0.5     evaluate_1.0.5    
 [9] grid_4.4.3         timechange_0.4.0   RColorBrewer_1.1-3 iterators_1.0.14  
[13] fastmap_1.2.0      foreach_1.5.2      jsonlite_2.0.0     backports_1.5.1   
[17] survival_3.8-3     scales_1.4.0       codetools_0.2-20   cli_3.6.6         
[21] crayon_1.5.3       rlang_1.2.0        bit64_4.8.2        splines_4.4.3     
[25] withr_3.0.2        yaml_2.3.12        otel_0.2.0         parallel_4.4.3    
[29] tools_4.4.3        tzdb_0.5.0         curl_7.1.0         vctrs_0.7.3       
[33] R6_2.6.1           lifecycle_1.0.5    bit_4.6.0          vroom_1.7.1       
[37] pkgconfig_2.0.3    pillar_1.11.1      gtable_0.3.6       glue_1.8.1        
[41] Rcpp_1.1.1-1.1     xfun_0.58          tidyselect_1.2.1   knitr_1.51        
[45] farver_2.1.2       htmltools_0.5.9    labeling_0.4.3     rmarkdown_2.31    
[49] compiler_4.4.3     S7_0.2.2