PKD_Subsample_Study

Lakviru Perera

This vignette demonstrates the divergence between standard PERMANOVA (\(F_1\)) and the modified F2 statistic under a deliberately unbalanced, dispersion- heterogeneous subsample of the PKD urinary metabolomics dataset (10 Ctrl / 20 PKD), and cross-checks the result using dispersion diagnostics, ordination, and component-level decomposition.

To keep this vignette fast to build, all permutation tests below use permutations = 99 rather than the permutations = 999 used to generate the results reported in the accompanying JOSS paper. Results here will therefore approximate, but not exactly reproduce, the paper’s reported statistics and p-values. A code snippet reproducing the paper’s exact settings (999 permutations) is included at the end of this vignette for reference.

library(dplyr)
library(vegan)
library(F2Adonis2)
library(kableExtra)
#> 
#> Attaching package: 'kableExtra'
#> The following object is masked from 'package:dplyr':
#> 
#>     group_rows
library(ggplot2)
library(combinat)
#> 
#> Attaching package: 'combinat'
#> The following object is masked from 'package:utils':
#> 
#>     combn

# Load package data objects explicitly
data("pkd_metadata", package = "F2Adonis2")
data("pkd_metabolites", package = "F2Adonis2")

set.seed(527)

# ==============================================================================
# 1. Sub-sample the PKD dataset (10 Ctrl / 20 PKD)
# ==============================================================================
sub_metadata <- bind_rows(
  pkd_metadata %>% filter(Group == "Ctrl") %>% slice_sample(n = 10),
  pkd_metadata %>% filter(Group == "PKD") %>% slice_sample(n = 20)
)
sub_metabolites <- pkd_metabolites[rownames(sub_metadata), ]
dist_sub <- vegdist(sub_metabolites, method = "euclidean")

# ==============================================================================
# 2. Dispersion diagnostics (betadisper)
# ==============================================================================
mod_sub <- betadisper(dist_sub, sub_metadata$Group)
anova_disp <- permutest(mod_sub, permutations = 99)
print(anova_disp)
#> 
#> Permutation test for homogeneity of multivariate dispersions
#> Permutation: free
#> Number of permutations: 99
#> 
#> Response: Distances
#>           Df  Sum Sq Mean Sq      F N.Perm Pr(>F)   
#> Groups     1  321.27  321.27 6.8192     99   0.01 **
#> Residuals 28 1319.17   47.11                        
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

# Spatial hull plot
plot(mod_sub, hull = TRUE, ellipse = FALSE,
     main = "Spatial Dispersion: Sub-sample (10 Ctrl / 20 PKD)")
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.

# Violin + boxplot + jittered points of distance-to-centroid
distances_df <- data.frame(
  distance = mod_sub$distances,
  group = mod_sub$group
)

ggplot(distances_df, aes(x = group, y = distance, fill = group)) +
  geom_violin(alpha = 0.2, trim = TRUE, color = NA) +
  geom_boxplot(width = 0.2, alpha = 0.6, outlier.shape = NA) +
  geom_jitter(width = 0.1, size = 1.5, alpha = 0.5) +
  scale_fill_brewer(palette = "Set2") +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    panel.grid.major.x = element_blank()
  ) +
  labs(
    title = "Dispersion Check: Sub-sampled Data",
    x = "Group",
    y = "Distance to Centroid"
  )
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.

# ==============================================================================
# 3. F1 vs. F2 inference on the subsample
# ==============================================================================
pkd_fit_f1_sub <- adonis2(dist_sub ~ Group, data = sub_metadata,
                          method = "euclidean", permutations = 99)

pkd_fit_f2_sub <- F2_adonis2(dist_sub ~ Group, data = sub_metadata,
                             method = "euclidean", permutations = 99)
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.

f1_p_sub <- pkd_fit_f1_sub["Model", "Pr(>F)"]
f2_p_sub <- pkd_fit_f2_sub["Model", "Pr(>F2)"]

sub_results <- data.frame(
  Sample_Size = "10 Ctrl / 20 PKD",
  F1_Pval = f1_p_sub,
  F2_Pval = f2_p_sub,
  Verdict = ifelse(f2_p_sub > 0.05 & f1_p_sub < 0.05, "Conflicted", "Consistent")
)

sub_results %>%
  mutate(
    F1_Pval = sprintf("%.4f%s", F1_Pval, ifelse(F1_Pval < 0.05, "*", "")),
    F2_Pval = sprintf("%.4f", F2_Pval)
  ) %>%
  kable(
    col.names = c(
      "Subsample Configuration",
      "Standard F1 ($p$)",
      "F2 ($p$)",
      "Verdict"
    ),
    escape = FALSE,
    caption = "Check: Subsampled PKD Analysis"
  ) %>%
  kable_styling(full_width = FALSE)
Check: Subsampled PKD Analysis
Subsample Configuration Standard F1 (\(p\)) F2 (\(p\)) Verdict
10 Ctrl / 20 PKD 0.0500 0.1000 Consistent

print(pkd_fit_f1_sub)
#> Permutation test for adonis under reduced model
#> Permutation: free
#> Number of permutations: 99
#> 
#> adonis2(formula = dist_sub ~ Group, data = sub_metadata, permutations = 99, method = "euclidean")
#>          Df SumOfSqs      R2      F Pr(>F)  
#> Model     1     2406 0.05511 1.6332   0.05 *
#> Residual 28    41250 0.94489                
#> Total    29    43656 1.00000                
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
print(pkd_fit_f2_sub)
#> Permutation test for F2-based PERMANOVA
#>          Df SumOfSqs     F2 Pr(>F2)
#> Model     1     2406 1.4102     0.1
#> Residual 28    41250               
#> Total    29    43656

# F1 permutation null distribution
psub_f1 <- permustats(pkd_fit_f1_sub)
f1_plot_data <- data.frame(F_vals = as.numeric(psub_f1$permutations))
f1_obs_stat <- pkd_fit_f1_sub["Model", "F"]

ggplot(f1_plot_data, aes(x = F_vals)) +
  geom_histogram(bins = 30, fill = "darkgoldenrod", color = "white", alpha = 0.7) +
  geom_vline(xintercept = f1_obs_stat, color = "red", linewidth = 1.2, linetype = "dashed") +
  annotate("text", x = f1_obs_stat, y = Inf, label = paste("Observed F =", round(f1_obs_stat, 3)),
           vjust = 2, hjust = -0.1, color = "red", fontface = "bold") +
  theme_minimal() +
  labs(
    title = "F1 Null Distribution: PKD Subsample",
    x = "Permuted Pseudo-F Statistic",
    y = "Frequency"
  )
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.

# ==============================================================================
# 4. PCA ordination and component-level decomposition
# ==============================================================================
pca_fit <- prcomp(sub_metabolites, scale. = TRUE)
pca_scores <- pca_fit$x[, 1:5]
comp_data <- data.frame(pca_scores, Group = sub_metadata$Group)
var_exp <- (pca_fit$sdev^2 / sum(pca_fit$sdev^2)) * 100

# PCA scores plot (PC1 vs PC2)
ggplot(comp_data, aes(x = PC1, y = PC2, color = Group, fill = Group)) +
  geom_point(size = 3, alpha = 0.6) +
  stat_ellipse(geom = "polygon", alpha = 0.2) +
  theme_minimal() +
  labs(title = "PCA Scores: Subsampled Data",
       subtitle = "Visualizing the overlap of group centroids",
       x = paste0("PC1 (", round(var_exp[1], 1), "%)"),
       y = paste0("PC2 (", round(var_exp[2], 1), "%)"))
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.
Check on a subsampled PKD dataset (10 Ctrl / 20 PKD): dispersion diagnostics, F1 vs. F2 inference, PCA ordination, and component-level decomposition.

# Univariate t-tests on the top 5 PCs
univariate_tests <- lapply(1:5, function(i) {
  test <- t.test(comp_data[[i]] ~ comp_data$Group)
  data.frame(Component = paste0("PC", i), P_Value = test$p.value)
})

do.call(rbind, univariate_tests) %>%
  mutate(P_Value = sprintf("%.4f%s", P_Value, ifelse(P_Value < 0.05, "*", ""))) %>%
  kable(caption = "Decomposition: Univariate Significance of Components",
        booktabs = TRUE) %>%
  kable_styling(latex_options = "hold_position")
Decomposition: Univariate Significance of Components
Component P_Value
PC1 0.1637
PC2 0.2039
PC3 0.2922
PC4 0.7036
PC5 0.3450

# Pairwise F2 sensitivity analysis across all 2D PC planes
pc_pairs <- combn(1:5, 2)

pairwise_results <- lapply(1:ncol(pc_pairs), function(i) {
  pair_idx <- pc_pairs[, i]
  dist_pair <- dist(pca_scores[, pair_idx], method = "euclidean")
  
  fit <- F2_adonis2(dist_pair ~ Group, data = sub_metadata,
                    permutations = 99, bootstrap = FALSE, plot.dist = FALSE)
  
  data.frame(
    Pair = paste0("PC", pair_idx[1], " vs PC", pair_idx[2]),
    F2_Stat = fit["Model", "F2"],
    P_Value = fit["Model", "Pr(>F2)"]
  )
})

do.call(rbind, pairwise_results) %>%
  mutate(P_Value = sprintf("%.4f%s", P_Value, ifelse(P_Value < 0.05, "*", ""))) %>%
  kable(caption = "Pairwise Sensitivity Analysis: F2 on 2D PC Planes",
        booktabs = TRUE) %>%
  kable_styling(latex_options = "hold_position")
Pairwise Sensitivity Analysis: F2 on 2D PC Planes
Pair F2_Stat P_Value
PC1 vs PC2 2.0283445 0.1900
PC1 vs PC3 1.8806934 0.1500
PC1 vs PC4 1.5269454 0.2300
PC1 vs PC5 1.8855097 0.1800
PC2 vs PC3 1.5768491 0.1900
PC2 vs PC4 1.1663937 0.4100
PC2 vs PC5 1.5487551 0.2600
PC3 vs PC4 0.6573406 0.5700
PC3 vs PC5 1.0926440 0.3200
PC4 vs PC5 0.4817221 0.6900

Reproducing the paper’s exact settings

The chunk below reproduces the same analysis using permutations = 999, the setting used to generate the results reported in the accompanying JOSS paper. It is not executed automatically when this vignette is built, since running 999 permutations across the twelve model fits above is unnecessarily slow for routine package documentation. Copy this code into an R session to run it directly.

pkd_fit_f1_sub <- adonis2(dist_sub ~ Group, data = sub_metadata,
                          method = "euclidean", permutations = 999)

pkd_fit_f2_sub <- F2_adonis2(dist_sub ~ Group, data = sub_metadata,
                             method = "euclidean", permutations = 999)

pairwise_results_full <- lapply(1:ncol(pc_pairs), function(i) {
  pair_idx <- pc_pairs[, i]
  dist_pair <- dist(pca_scores[, pair_idx], method = "euclidean")

  fit <- F2_adonis2(dist_pair ~ Group, data = sub_metadata,
                    permutations = 999, bootstrap = FALSE, plot.dist = FALSE)

  data.frame(
    Pair = paste0("PC", pair_idx[1], " vs PC", pair_idx[2]),
    F2_Stat = fit["Model", "F2"],
    P_Value = fit["Model", "Pr(>F2)"]
  )
})

do.call(rbind, pairwise_results_full)