--- title: "Two-factor designs" author: "Witold E. Wolski" date: "`r Sys.Date()`" output: BiocStyle::html_document vignette: > %\VignetteIndexEntry{5. Two-factor designs} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} editor_options: chunk_output_type: console --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) library(prolfqua) library(dplyr) library(ggplot2) ``` # Purpose This vignette demonstrates the same `build_contrast_analysis()` facade workflow on a dataset with two factors (e.g. treatment and genotype), yielding four subgroups: `C_WT`, `T_WT`, `C_KO`, `T_KO`. We define contrasts: - `T_C_gv_WT = T_WT - C_WT` - `T_C_gv_KO = T_KO - C_KO` - `WT_KO_comp = T_C_gv_WT - T_C_gv_KO` We start from simulated two-factor peptide data, derive a single subgroup factor, and run both protein-input and peptide-input facades with the same contrast specification. See `ContrastFacades.Rmd` for the one-factor introduction. # Simulate two-factor data and encode subgroups ```{r simulate_two_factor_subgroups} dd <- sim_lfq_data_2factor_config( Nprot = 80, with_missing = TRUE, weight_missing = 1, PEPTIDE = TRUE, seed = 7 ) cfg_subgroup <- dd$config$clone(deep = TRUE) data_subgroup <- dd$data |> dplyr::mutate( Condition = dplyr::recode(Treatment, A = "C", B = "T"), Genotype = dplyr::recode(Background, Z = "WT", X = "KO"), Subgroup = paste(Condition, Genotype, sep = "_") ) cfg_subgroup$factors <- c(Subgroup = "Subgroup") cfg_subgroup$factor_depth <- 1 lfq_peptide_2f <- LFQData$new(data_subgroup, cfg_subgroup) lfq_peptide_2f <- lfq_peptide_2f$get_Transformer()$log2()$lfq lfq_protein_2f <- lfq_peptide_2f$get_Aggregator()$aggregate() lfq_peptide_2f$hierarchy_keys() lfq_protein_2f$hierarchy_keys() lfq_protein_2f$nr_children_col() ``` # Define contrasts ```{r contrasts_two_factor} contrasts_2f <- c( T_C_gv_WT = "SubgroupT_WT - SubgroupC_WT", T_C_gv_KO = "SubgroupT_KO - SubgroupC_KO", WT_KO_comp = "T_C_gv_WT - T_C_gv_KO" ) ``` Three contrasts: two within-genotype treatment effects and their difference (interaction-like comparison). # Protein-input facades The following facades require aggregated input. `firth` is included here because it can be fitted directly on aggregated protein input. ```{r facades_protein_two_factor} fa_lm_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "lm" ) fa_limma_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "limma" ) fa_limma_impute_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "limma_impute" ) fa_deqms_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "deqms" ) fa_lm_impute_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "lm_impute" ) fa_firth_protein_2f <- build_contrast_analysis( lfq_protein_2f, "~ Subgroup", contrasts_2f, method = "firth" ) ``` Because all protein-input facades share the same interface and report protein-level contrasts, their outputs can be combined directly. ```{r collect_protein_two_factor} # Proteins missing in the baseline lm facade (used to flag rescued proteins) lm_missing_ids_2f <- fa_lm_2f$get_missing() |> dplyr::select(protein_Id, contrast) |> dplyr::mutate(rescued = TRUE) results_protein_2f <- bind_rows( fa_lm_2f$get_contrasts(), fa_limma_2f$get_contrasts(), fa_limma_impute_2f$get_contrasts(), fa_lm_impute_2f$get_contrasts(), fa_deqms_2f$get_contrasts(), fa_firth_protein_2f$get_contrasts() ) |> dplyr::select(dplyr::any_of(c( "modelName", "estimate_type", "protein_Id", "contrast", "avgAbd", "diff", "FDR", "statistic", "std.error", "df", "p.value", "conf.low", "conf.high", "sigma" ))) |> dplyr::left_join(lm_missing_ids_2f, by = c("protein_Id", "contrast")) |> dplyr::mutate( rescued = dplyr::coalesce(rescued, FALSE), significant = FDR < 0.1 & abs(diff) > 0.5 ) results_protein_2f |> dplyr::count(modelName, name = "n_results") ``` The `modelName` column is the facade key. For facades that combine several underlying result types, such as `lm_impute`, the `estimate_type` column tells you how each row was produced (`observed` or `lod_imputed`). ```{r collect_protein_model_names_2f} results_protein_2f |> dplyr::count(modelName, contrast, estimate_type, name = "n_results") ``` # Protein-level volcano comparison ## Standard facades ```{r volcano_standard_2f, fig.height=10, fig.width=12, fig.cap="Volcano plots for the standard protein-level facades (lm, limma, deqms)."} standard_facades_2f <- c("lm", "limma", "deqms") results_standard_2f <- results_protein_2f |> dplyr::filter(modelName %in% standard_facades_2f) ggplot(results_standard_2f, aes(x = diff, y = -log10(p.value), color = significant)) + geom_point(alpha = 0.6, size = 1.5) + facet_grid(contrast ~ modelName, scales = "free_y") + geom_vline(xintercept = c(-0.5, 0.5), linetype = "dashed", color = "grey60") + geom_hline(yintercept = -log10(0.1), linetype = "dashed", color = "grey60") + scale_color_manual(values = c(`TRUE` = "firebrick", `FALSE` = "grey70")) + labs(x = "log2 fold change", y = "-log10(p.value)", color = "FDR < 0.1\nand |diff| > 0.5") + theme_minimal(base_size = 12) ``` ## Imputation and missingness facades Rescued proteins (missing in plain `lm`) are shown as large red diamonds so they stand out clearly. This group includes the LOD imputation facades (`lm_impute`, `limma_impute`) and the firth logistic regression facade which models missingness directly. ```{r volcano_impute_2f, fig.height=10, fig.width=14, fig.cap="Volcano plots for the imputation and missingness facades. Large red diamonds mark proteins rescued (missing in plain lm)."} impute_facades_2f <- c("lm_impute", "limma_impute", "firth") results_impute_2f <- results_protein_2f |> dplyr::filter(modelName %in% impute_facades_2f) |> dplyr::mutate(modelName = factor(modelName, levels = impute_facades_2f)) ggplot(results_impute_2f, aes(x = diff, y = -log10(p.value))) + geom_point(data = dplyr::filter(results_impute_2f, !rescued), aes(color = significant), alpha = 0.5, size = 1.2) + geom_point(data = dplyr::filter(results_impute_2f, rescued), color = "red", shape = 18, size = 5, alpha = 1) + facet_grid(contrast ~ modelName, scales = "free_y") + geom_vline(xintercept = c(-0.5, 0.5), linetype = "dashed", color = "grey60") + geom_hline(yintercept = -log10(0.1), linetype = "dashed", color = "grey60") + scale_color_manual(values = c(`TRUE` = "firebrick", `FALSE` = "grey70")) + labs(x = "log2 fold change", y = "-log10(p.value)", color = "FDR < 0.1\nand |diff| > 0.5") + theme_minimal(base_size = 12) ``` # Looking at the strongest protein-level hits ```{r top_hits_protein_2f} results_protein_2f |> dplyr::group_by(modelName, contrast) |> dplyr::slice_min(order_by = p.value, n = 5, with_ties = FALSE) |> dplyr::ungroup() |> dplyr::select(modelName, estimate_type, contrast, protein_Id, diff, p.value, FDR) ``` # Proteins that could not be estimated Every facade has a `get_missing()` method that returns the protein x contrast pairs present in the input data but absent from `get_contrasts()`. This makes it easy to see which proteins each method fails on and to compare coverage. ```{r missing_protein_facades_2f} missing_all_2f <- dplyr::bind_rows( fa_lm_2f$get_missing() |> dplyr::mutate(facade = "lm"), fa_limma_2f$get_missing() |> dplyr::mutate(facade = "limma"), fa_limma_impute_2f$get_missing() |> dplyr::mutate(facade = "limma_impute"), fa_lm_impute_2f$get_missing() |> dplyr::mutate(facade = "lm_impute"), fa_deqms_2f$get_missing() |> dplyr::mutate(facade = "deqms"), fa_firth_protein_2f$get_missing() |> dplyr::mutate(facade = "firth") ) missing_all_2f |> dplyr::count(facade, contrast, name = "n_missing") |> knitr::kable(caption = "Number of missing protein x contrast pairs per facade") ``` ## Per-sample intensities of the missing proteins ```{r missing_intensities_2f} missing_proteins_2f <- unique(missing_all_2f$protein_Id) if (length(missing_proteins_2f) > 0) { lfq_protein_2f$data_long() |> dplyr::filter(protein_Id %in% missing_proteins_2f) |> dplyr::select(protein_Id, sampleName, !!rlang::sym(lfq_protein_2f$response())) |> tidyr::pivot_wider(names_from = sampleName, values_from = !!rlang::sym(lfq_protein_2f$response())) |> knitr::kable(digits = 2, caption = "Per-sample intensities of proteins that could not be estimated") } ``` The missing cells (NA) explain why these proteins cannot be estimated — they lack observations in one or more groups. The `lm_impute` and `limma_impute` facades re-fit after imputing individual values with the LOD and borrowing covariance from successful fits. These should have fewer missing proteins than plain `lm` or `limma`. ## Estimates for the missing proteins from `lm_impute` and `limma_impute` For proteins that plain `lm` could not estimate, the imputation-based facades can still produce contrast results. The table below shows these rescued estimates side by side. ```{r rescued_estimates_2f} lm_missing_proteins_2f <- fa_lm_2f$get_missing()$protein_Id |> unique() if (length(lm_missing_proteins_2f) > 0) { rescued_2f <- results_protein_2f |> dplyr::filter( protein_Id %in% lm_missing_proteins_2f, modelName %in% c("lm_impute", "limma_impute") ) |> dplyr::arrange(protein_Id, contrast, modelName) rescued_2f |> knitr::kable( digits = 3, caption = "Contrast estimates from lm_impute and limma_impute for proteins that plain lm could not estimate" ) } else { cat("All proteins were estimable by plain lm — no rescued estimates to show.") } ``` # Peptide-input facades The mixed-effects `lmer_nested` facade and `ropeca_nested` require lower-level measurements below the analysis subject. The `firth_nested` facade is the peptide-input variant of `firth` shown above. All three still return protein-level contrasts. ```{r facades_peptide_two_factor} fa_lmer_2f <- build_contrast_analysis( lfq_peptide_2f, "~ Subgroup + (1 | peptide_Id) + (1 | sampleName)", contrasts_2f, method = "lmer_nested" ) fa_ropeca_2f <- build_contrast_analysis( lfq_peptide_2f, "~ Subgroup", contrasts_2f, method = "ropeca_nested" ) fa_firth_peptide_2f <- build_contrast_analysis( lfq_peptide_2f, "~ Subgroup", contrasts_2f, method = "firth_nested" ) ``` ```{r collect_peptide_two_factor} results_peptide_2f <- bind_rows( fa_lmer_2f$get_contrasts(), fa_ropeca_2f$get_contrasts(), fa_firth_peptide_2f$get_contrasts() ) |> dplyr::select(dplyr::any_of(c( "modelName", "estimate_type", "protein_Id", "contrast", "avgAbd", "diff", "FDR", "statistic", "std.error", "df", "p.value", "conf.low", "conf.high", "sigma" ))) |> dplyr::mutate( significant = FDR < 0.1 & abs(diff) > 0.5 ) results_peptide_2f |> dplyr::count(modelName, name = "n_results") ``` ```{r volcano_peptide_2f, fig.height=10, fig.width=10, fig.cap="Volcano plots for the peptide-level facades. Rows are contrasts, columns are backends."} ggplot(results_peptide_2f, aes(x = diff, y = -log10(p.value), color = significant)) + geom_point(alpha = 0.6, size = 1.2) + facet_grid(contrast ~ modelName, scales = "free_y") + geom_vline(xintercept = c(-0.5, 0.5), linetype = "dashed", color = "grey60") + geom_hline(yintercept = -log10(0.1), linetype = "dashed", color = "grey60") + scale_color_manual(values = c(`TRUE` = "firebrick", `FALSE` = "grey70")) + labs( x = "log2 fold change", y = "-log10(p.value)", color = "FDR < 0.1\nand |diff| > 0.5" ) + theme_minimal(base_size = 12) ``` # Remarks The facades make it easy to benchmark alternative contrast backends without rewriting the analysis pipeline: - the protein-level facades now enforce aggregation before modelling - the peptide-level facades now explicitly require lower-level hierarchy below the analysis subject - the shared facade API still makes it straightforward to compare methods once the data level is chosen consistently The results are comparable at the API level, but the comparison is only meaningful when methods using the same biological unit are plotted together. # Session Info ```{r session_info} sessionInfo() ```