From 1c227f9932166b7cf87941097b934c2cd5d8ecce Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:39:37 -0500 Subject: [PATCH 01/10] update Seurat and Install Fixing vignette for installation cues and removing Seurat references. Also expanding executable chunks. Per issue: https://github.com/Bioconductor/Contributions/issues/4143#issuecomment-4205063424 --- vignettes/immGLIPH.Rmd | 132 ++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/vignettes/immGLIPH.Rmd b/vignettes/immGLIPH.Rmd index 7330e46..762311f 100644 --- a/vignettes/immGLIPH.Rmd +++ b/vignettes/immGLIPH.Rmd @@ -19,6 +19,7 @@ vignette: > knitr::opts_chunk$set( echo = TRUE, message = FALSE, + warning = FALSE, fig.width = 7, fig.height = 5 @@ -51,10 +52,10 @@ the original publications:** ## Installation -immGLIPH can be installed from GitHub: +immGLIPH can be installed from Bioconductor: ```{r eval=FALSE} -devtools::install_github("BorchLab/immGLIPH") +BiocManager::install("immGLIPH") ``` The reference repertoire data (~19 MB) is downloaded automatically the first @@ -74,11 +75,11 @@ library(immGLIPH) ## Integration with the scRepertoire Ecosystem immGLIPH integrates with the -[scRepertoire](https://github.com/BorchLab/scRepertoire) ecosystem through -[immApex](https://github.com/BorchLab/immApex). This means `runGLIPH()` can -directly accept: +[scRepertoire](https://bioconductor.org/packages/scRepertoire/) ecosystem +through [immApex](https://bioconductor.org/packages/immApex/). Both +scRepertoire and immApex are Bioconductor packages and can be installed via +`BiocManager::install()`. This means `runGLIPH()` can directly accept: -- **Seurat** objects with TCR information - **SingleCellExperiment** objects with TCR information - **combineTCR()** output lists from scRepertoire - Standard data frames or character vectors @@ -127,23 +128,24 @@ library(scRepertoire) # After processing with cellranger/etc, combine contigs combined <- combineTCR( contig_list, - samples = c("P1", "P2"), - cells = "T-AB" + samples = c("P1", "P2") ) # Pass scRepertoire output directly to runGLIPH results <- runGLIPH(combined, method = "gliph2") ``` -For **Seurat** or **SingleCellExperiment** objects that already contain TCR -metadata (e.g., added via `scRepertoire::combineExpression()`), immGLIPH -extracts the receptor data automatically using `immApex::getIR()`: +For **SingleCellExperiment** objects that already contain TCR metadata (e.g., +added via `scRepertoire::combineExpression()`), immGLIPH extracts the receptor +data automatically using `immApex::getIR()`. Here is an example using the +bundled `gliph_sce` dataset: ```{r eval=FALSE} -library(Seurat) +library(SingleCellExperiment) +data("gliph_sce") -# Seurat object with TCR info in metadata -results <- runGLIPH(seurat_obj, method = "gliph2", chains = "TRB") +# SingleCellExperiment object with TCR info in colData +results <- runGLIPH(gliph_sce, method = "gliph2", chains = "TRB") ``` # The `runGLIPH()` Function @@ -152,7 +154,7 @@ results <- runGLIPH(seurat_obj, method = "gliph2", chains = "TRB") | Argument | Default | Description | |:---------|:--------|:------------| -| `cdr3_sequences` | -- | Input data (data frame, vector, Seurat, SCE, or list) | +| `cdr3_sequences` | -- | Input data (data frame, vector, SCE, or list) | | `method` | `"gliph2"` | Algorithm preset: `"gliph1"`, `"gliph2"`, or `"custom"` | | `sim_depth` | 1000 | Simulation depth (higher = more reproducible, slower) | | `n_cores` | 1 | Number of parallel cores | @@ -428,17 +430,19 @@ data): ## Example -```{r eval=FALSE} -# Re-score with GLIPH2 formula and higher simulation depth -rescored <- clusterScoring( - cluster_list = res_gliph1$cluster_list, - cdr3_sequences = gliph_input_data[seq_len(200), ], - refdb_beta = "human_v2.0_CD48", - gliph_version = 2, - sim_depth = 500, - n_cores = 1 -) -head(rescored) +```{r} +# Re-score with GLIPH2 formula +if (length(res_gliph1$cluster_list) > 0) { + rescored <- clusterScoring( + cluster_list = res_gliph1$cluster_list, + cdr3_sequences = gliph_input_data[seq_len(200), ], + refdb_beta = ref_df, + gliph_version = 2, + sim_depth = 100, + n_cores = 1 + ) + head(rescored) +} ``` # De Novo TCR Generation with `deNovoTCRs()` @@ -462,22 +466,25 @@ characteristics. ## Example -```{r eval=FALSE} -# Generate de novo TCRs for the top convergence group -de_novo <- deNovoTCRs( - convergence_group_tag = res_gliph1$cluster_properties$tag[1], - clustering_output = res_gliph1, - sims = 10000, - num_tops = 100, - make_figure = TRUE, - n_cores = 1 -) - -# Top predicted sequences -head(de_novo$de_novo_sequences) - -# Positional weight matrix used for generation -head(de_novo$PWM_Scoring) +```{r} +# Generate de novo TCRs for the first convergence group (if any found) +if (length(res_gliph1$cluster_list) > 0) { + de_novo <- deNovoTCRs( + convergence_group_tag = names(res_gliph1$cluster_list)[1], + clustering_output = res_gliph1, + refdb_beta = ref_df, + sims = 10000, + num_tops = 100, + make_figure = FALSE, + n_cores = 1 + ) + + # Top predicted sequences + head(de_novo$de_novo_sequences) + + # Positional weight matrix used for generation + head(de_novo$PWM_Scoring) +} ``` # Network Visualization with `plotNetwork()` @@ -499,24 +506,16 @@ the convergence groups using the visNetwork package. ## Example -```{r eval=FALSE} -plotNetwork( - clustering_output = res_gliph2, - color_info = "total.score", - cluster_min_size = 3, - n_cores = 1 -) -``` - -You can also color nodes by donor/patient: - -```{r eval=FALSE} -plotNetwork( - clustering_output = res_gliph2, - color_info = "patient", - cluster_min_size = 3, - n_cores = 1 -) +```{r} +if (!is.null(res_gliph1$cluster_properties) && + nrow(res_gliph1$cluster_properties) > 0) { + plotNetwork( + clustering_output = res_gliph1, + color_info = "total.score", + cluster_min_size = 2, + n_cores = 1 + ) +} ``` # Loading Saved Results with `loadGLIPH()` @@ -553,9 +552,9 @@ When `result_folder` is specified, `runGLIPH()` writes several output files: ## Accelerated Computation with immApex -When [immApex](https://github.com/BorchLab/immApex) is installed, immGLIPH -automatically uses its C++-accelerated backends for two computationally -intensive steps: +When [immApex](https://bioconductor.org/packages/immApex/) is installed, +immGLIPH automatically uses its C++-accelerated backends for two +computationally intensive steps: 1. **Motif enumeration** (`findMotifs()`): Uses `immApex::calculateMotif()` with OpenMP multithreading instead of the pure-R `stringdist::qgrams()` @@ -563,15 +562,14 @@ intensive steps: 2. **Global Hamming distance network** (GLIPH1 method): Uses `immApex::buildNetwork()` to compute pairwise distances in a single C++ - call, replacing the `foreach`-based parallel loop over - `stringdist::stringdist()`. + call, replacing the parallel loop over `stringdist::stringdist()`. If immApex is not installed, immGLIPH falls back to the original pure-R implementations transparently---no code changes are needed. ```{r eval=FALSE} # Install immApex for performance acceleration -devtools::install_github("BorchLab/immApex") +BiocManager::install("BorchLab/immApex") ``` # Tips and Best Practices @@ -589,7 +587,7 @@ devtools::install_github("BorchLab/immApex") `sim_depth >= 1000`. For exploratory analysis, `sim_depth = 100` is faster. 5. **Parallelization**: For large datasets (>5,000 sequences), set - `n_cores > 1` to use parallel processing. + `n_cores > 1` to use parallel processing via BiocParallel. 6. **Install immApex**: For best performance, install immApex to enable C++-accelerated motif enumeration and network construction (see From 3e31268ae8d7cc97e6520ae77cd003709d7391d9 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:41:22 -0500 Subject: [PATCH 02/10] BiocParallel implementation Adding parallel via BiocParallelParam per issue: https://github.com/Bioconductor/Contributions/issues/4143#issuecomment-4205063424 --- R/clustering.R | 325 ++++++++++++++++++++++++---------------------- R/global-cutoff.R | 29 ++--- R/global-fisher.R | 35 ++--- R/local-fisher.R | 105 +++++---------- R/local-rrs.R | 10 +- R/runGLIPH.R | 76 ++++++----- 6 files changed, 274 insertions(+), 306 deletions(-) diff --git a/R/clustering.R b/R/clustering.R index 56fcbbc..8217701 100644 --- a/R/clustering.R +++ b/R/clustering.R @@ -18,6 +18,7 @@ #' @param public_tcrs Logical. If \code{FALSE}, restrict edges to same donor. #' @param cluster_min_size Integer. Minimum cluster size to retain. #' @param verbose Logical. Print progress messages. +#' @param BPPARAM A \code{BiocParallelParam} object for parallel evaluation. #' #' @return A list with: #' \describe{ @@ -27,7 +28,6 @@ #' \item{save_cluster_list_df}{Data frame for saving cluster members.} #' } #' -#' @import foreach #' @keywords internal .cluster_gliph1 <- function(clone_network, sequences, @@ -38,7 +38,8 @@ global_vgene, public_tcrs, cluster_min_size, - verbose) { + verbose, + BPPARAM) { if (verbose) message("Clustering sequences (GLIPH1.0 method).") @@ -57,7 +58,6 @@ in_local_ids <- integer(0) if (!is.null(clone_network)) { in_network <- unique(c(clone_network$V1, clone_network$V2)) - # Track which sequence indices are in any edge in_local_ids <- which(seqs %in% in_network) } @@ -98,43 +98,44 @@ clone_network[] <- lapply(clone_network, as.character) ## ---- Build tuple network ---- - x <- NULL - temp_clone_network <- foreach::foreach( - x = seq_len(nrow(clone_network)) - ) %dopar% { - act_ids1 <- which(sequences$CDR3b == clone_network[x, 1]) - act_ids2 <- which(sequences$CDR3b == clone_network[x, 2]) - - comb_ids <- expand.grid(act_ids1, act_ids2) - comb_ids <- unique(comb_ids) - - act_infos1 <- sequences[comb_ids[, 1], ] - act_infos2 <- sequences[comb_ids[, 2], ] - - ## Filter for same donor if required - if (is.logical(public_tcrs) && !public_tcrs && patient.info) { - exclude_rows <- act_infos1$patient != act_infos2$patient - act_infos1 <- act_infos1[!exclude_rows, ] - act_infos2 <- act_infos2[!exclude_rows, ] - } + temp_clone_network <- BiocParallel::bplapply( + seq_len(nrow(clone_network)), + function(x) { + act_ids1 <- which(sequences$CDR3b == clone_network[x, 1]) + act_ids2 <- which(sequences$CDR3b == clone_network[x, 2]) + + comb_ids <- expand.grid(act_ids1, act_ids2) + comb_ids <- unique(comb_ids) + + act_infos1 <- sequences[comb_ids[, 1], ] + act_infos2 <- sequences[comb_ids[, 2], ] + + ## Filter for same donor if required + if (is.logical(public_tcrs) && !public_tcrs && patient.info) { + exclude_rows <- act_infos1$patient != act_infos2$patient + act_infos1 <- act_infos1[!exclude_rows, ] + act_infos2 <- act_infos2[!exclude_rows, ] + } - ## Filter global edges for matching V-gene - if (global_vgene && vgene.info && - clone_network[x, 3] == "global" && nrow(act_infos1) > 0) { - exclude_rows <- act_infos1$TRBV != act_infos2$TRBV - act_infos1 <- act_infos1[!exclude_rows, ] - act_infos2 <- act_infos2[!exclude_rows, ] - } + ## Filter global edges for matching V-gene + if (global_vgene && vgene.info && + clone_network[x, 3] == "global" && nrow(act_infos1) > 0) { + exclude_rows <- act_infos1$TRBV != act_infos2$TRBV + act_infos1 <- act_infos1[!exclude_rows, ] + act_infos2 <- act_infos2[!exclude_rows, ] + } - if (nrow(act_infos1) > 0) { - act_infos1 <- do.call(paste, c(act_infos1, sep = "$#$#$")) - act_infos2 <- do.call(paste, c(act_infos2, sep = "$#$#$")) - var_ret <- data.frame(act_infos1, act_infos2) - t(var_ret) - } else { - NULL - } - } + if (nrow(act_infos1) > 0) { + act_infos1 <- do.call(paste, c(act_infos1, sep = "$#$#$")) + act_infos2 <- do.call(paste, c(act_infos2, sep = "$#$#$")) + var_ret <- data.frame(act_infos1, act_infos2) + t(var_ret) + } else { + NULL + } + }, + BPPARAM = BPPARAM + ) temp_clone_network <- data.frame( matrix(unlist(temp_clone_network), ncol = 2, byrow = TRUE), @@ -205,16 +206,18 @@ } if (nrow(part_4_res) > 0) { - save_cluster_list_df <- foreach::foreach( - i = seq_along(part_4_res_list), - .combine = "rbind" - ) %dopar% { - temp <- part_4_res_list[[i]] - cbind( - data.frame(tag = rep(names(part_4_res_list)[i], nrow(temp))), - temp - ) - } + save_parts <- BiocParallel::bplapply( + seq_along(part_4_res_list), + function(i) { + temp <- part_4_res_list[[i]] + cbind( + data.frame(tag = rep(names(part_4_res_list)[i], nrow(temp))), + temp + ) + }, + BPPARAM = BPPARAM + ) + save_cluster_list_df <- do.call(rbind, save_parts) } else { part_4_res <- NULL part_4_res_list <- list() @@ -259,6 +262,7 @@ #' @param boost_local_significance Logical. Whether to boost local p-values #' using germline N-nucleotide information. #' @param verbose Logical. Print progress messages. +#' @param BPPARAM A \code{BiocParallelParam} object for parallel evaluation. #' #' @return A list with: #' \describe{ @@ -270,7 +274,6 @@ #' \item{save_cluster_list_df}{Data frame for saving cluster member details.} #' } #' -#' @import foreach #' @keywords internal .cluster_gliph2 <- function(local_res, global_res, @@ -284,7 +287,8 @@ motif_distance_cutoff, cluster_min_size, boost_local_significance, - verbose) { + verbose, + BPPARAM) { if (verbose) message("Clustering sequences (GLIPH2.0 method).") @@ -449,31 +453,32 @@ ## Load BLOSUM vector for global filtering BlosumVec <- .get_blosum_vec() - i <- NULL - cluster_list <- foreach::foreach( - i = seq_len(nrow(merged_clusters)) - ) %dopar% { - if (merged_clusters$type[i] == "local") { - act_seqs <- unlist(strsplit(merged_clusters$members[i], split = " ")) - return(sequences[sequences$CDR3b %in% act_seqs, ]) - } - if (merged_clusters$type[i] == "global") { - act_seqs <- unlist(strsplit(merged_clusters$members[i], split = " ")) - act_details <- unlist(strsplit(merged_clusters$tag[i], split = "_")) - if (!global_vgene) { - return(data.frame( - sequences[sequences$CDR3b %in% act_seqs, ], - stringsAsFactors = FALSE - )) - } else { - return(data.frame( - sequences[sequences$CDR3b %in% act_seqs & - sequences$TRBV == act_details[2], ], - stringsAsFactors = FALSE - )) + cluster_list <- BiocParallel::bplapply( + seq_len(nrow(merged_clusters)), + function(i) { + if (merged_clusters$type[i] == "local") { + act_seqs <- unlist(strsplit(merged_clusters$members[i], split = " ")) + return(sequences[sequences$CDR3b %in% act_seqs, ]) } - } - } + if (merged_clusters$type[i] == "global") { + act_seqs <- unlist(strsplit(merged_clusters$members[i], split = " ")) + act_details <- unlist(strsplit(merged_clusters$tag[i], split = "_")) + if (!global_vgene) { + return(data.frame( + sequences[sequences$CDR3b %in% act_seqs, ], + stringsAsFactors = FALSE + )) + } else { + return(data.frame( + sequences[sequences$CDR3b %in% act_seqs & + sequences$TRBV == act_details[2], ], + stringsAsFactors = FALSE + )) + } + } + }, + BPPARAM = BPPARAM + ) names(cluster_list) <- merged_clusters$tag ## Update local cluster sizes @@ -516,90 +521,100 @@ if (!is.null(merged_clusters) && length(cluster_list) > 0) { ## Local edges if (local_similarities && any(merged_clusters$type == "local")) { - local_clone_network <- foreach::foreach( - i = which(merged_clusters$type == "local") - ) %dopar% { - temp_members <- cluster_list[[i]]$CDR3b - if (structboundaries) { - temp_members_frags <- substr( - temp_members, - boundary_size + 1, - nchar(temp_members) - boundary_size + local_edge_list <- BiocParallel::bplapply( + which(merged_clusters$type == "local"), + function(i) { + temp_members <- cluster_list[[i]]$CDR3b + if (structboundaries) { + temp_members_frags <- substr( + temp_members, + boundary_size + 1, + nchar(temp_members) - boundary_size + ) + } else { + temp_members_frags <- temp_members + } + + motif_name <- strsplit(names(cluster_list)[i], split = "_")[[1]][[1]] + temp_pos <- stringr::str_locate(temp_members_frags, motif_name) + + if (length(temp_members) >= 2) { + combn_ids <- t(utils::combn(seq_along(temp_members), m = 2)) + } else { + combn_ids <- t(utils::combn(rep(1, 2), m = 2)) + } + temp_df <- data.frame( + V1 = temp_members[combn_ids[, 1]], + V2 = temp_members[combn_ids[, 2]], + type = rep("local", nrow(combn_ids)), + cluster_tag = rep(names(cluster_list)[i], nrow(combn_ids)), + stringsAsFactors = FALSE ) - } else { - temp_members_frags <- temp_members - } - motif_name <- strsplit(names(cluster_list)[i], split = "_")[[1]][[1]] - temp_pos <- stringr::str_locate(temp_members_frags, motif_name) + ## Restrict by positional distance + temp_df <- unique(temp_df[ + abs(temp_pos[combn_ids[, 1], 1] - + temp_pos[combn_ids[, 2], 1]) < motif_distance_cutoff, ]) - if (length(temp_members) >= 2) { - combn_ids <- t(utils::combn(seq_along(temp_members), m = 2)) - } else { - combn_ids <- t(utils::combn(rep(1, 2), m = 2)) - } - temp_df <- data.frame( - V1 = temp_members[combn_ids[, 1]], - V2 = temp_members[combn_ids[, 2]], - type = rep("local", nrow(combn_ids)), - cluster_tag = rep(names(cluster_list)[i], nrow(combn_ids)), + temp_df + }, + BPPARAM = BPPARAM + ) + local_clone_network <- do.call(rbind, local_edge_list) + if (is.null(local_clone_network)) { + local_clone_network <- data.frame( + V1 = character(0), V2 = character(0), + type = character(0), cluster_tag = character(0), stringsAsFactors = FALSE ) - - ## Restrict by positional distance - temp_df <- unique(temp_df[ - abs(temp_pos[combn_ids[, 1], 1] - - temp_pos[combn_ids[, 2], 1]) < motif_distance_cutoff, ]) - - t(temp_df) } - local_clone_network <- data.frame( - matrix(unlist(local_clone_network), ncol = 4, byrow = TRUE), - stringsAsFactors = FALSE - ) - colnames(local_clone_network) <- c("V1", "V2", "type", "cluster_tag") clone_network <- local_clone_network } ## Global edges if (global_similarities && any(merged_clusters$type == "global")) { - global_clone_network <- foreach::foreach( - i = which(merged_clusters$type == "global") - ) %dopar% { - temp_members <- cluster_list[[i]]$CDR3b - - if (length(temp_members) >= 2) { - combn_ids <- t(utils::combn(seq_along(temp_members), m = 2)) - } else { - combn_ids <- t(utils::combn(rep(1, 2), m = 2)) - } - temp_df <- data.frame( - V1 = temp_members[combn_ids[, 1]], - V2 = temp_members[combn_ids[, 2]], - type = rep("global", nrow(combn_ids)), - cluster_tag = rep(names(cluster_list)[i], nrow(combn_ids)), - stringsAsFactors = FALSE - ) + global_edge_list <- BiocParallel::bplapply( + which(merged_clusters$type == "global"), + function(i) { + temp_members <- cluster_list[[i]]$CDR3b + + if (length(temp_members) >= 2) { + combn_ids <- t(utils::combn(seq_along(temp_members), m = 2)) + } else { + combn_ids <- t(utils::combn(rep(1, 2), m = 2)) + } + temp_df <- data.frame( + V1 = temp_members[combn_ids[, 1]], + V2 = temp_members[combn_ids[, 2]], + type = rep("global", nrow(combn_ids)), + cluster_tag = rep(names(cluster_list)[i], nrow(combn_ids)), + stringsAsFactors = FALSE + ) - ## BLOSUM62 filtering at variable position - if (!all_aa_interchangeable) { - tag_name <- names(cluster_list)[i] - temp_pos <- stringr::str_locate(tag_name, "%") - if (structboundaries) temp_pos <- temp_pos + boundary_size - temp_df <- unique(temp_df[ - paste0( - substr(temp_df$V1, temp_pos[1], temp_pos[1]), - substr(temp_df$V2, temp_pos[1], temp_pos[1]) - ) %in% BlosumVec, ]) - } + ## BLOSUM62 filtering at variable position + if (!all_aa_interchangeable) { + tag_name <- names(cluster_list)[i] + temp_pos <- stringr::str_locate(tag_name, "%") + if (structboundaries) temp_pos <- temp_pos + boundary_size + temp_df <- unique(temp_df[ + paste0( + substr(temp_df$V1, temp_pos[1], temp_pos[1]), + substr(temp_df$V2, temp_pos[1], temp_pos[1]) + ) %in% BlosumVec, ]) + } - t(temp_df) - } - global_clone_network <- data.frame( - matrix(unlist(global_clone_network), ncol = 4, byrow = TRUE), - stringsAsFactors = FALSE + temp_df + }, + BPPARAM = BPPARAM ) - colnames(global_clone_network) <- c("V1", "V2", "type", "cluster_tag") + global_clone_network <- do.call(rbind, global_edge_list) + if (is.null(global_clone_network)) { + global_clone_network <- data.frame( + V1 = character(0), V2 = character(0), + type = character(0), cluster_tag = character(0), + stringsAsFactors = FALSE + ) + } if (is.null(clone_network)) { clone_network <- global_clone_network @@ -636,16 +651,18 @@ ) merged_clusters$OvE[is.infinite(merged_clusters$OvE)] <- 0 - save_cluster_list_df <- foreach::foreach( - i = seq_along(cluster_list), - .combine = "rbind" - ) %dopar% { - temp <- cluster_list[[i]] - cbind( - data.frame(tag = rep(names(cluster_list)[i], nrow(temp))), - temp - ) - } + save_parts <- BiocParallel::bplapply( + seq_along(cluster_list), + function(i) { + temp <- cluster_list[[i]] + cbind( + data.frame(tag = rep(names(cluster_list)[i], nrow(temp))), + temp + ) + }, + BPPARAM = BPPARAM + ) + save_cluster_list_df <- do.call(rbind, save_parts) } list( diff --git a/R/global-cutoff.R b/R/global-cutoff.R index cb55bb8..6ec6896 100644 --- a/R/global-cutoff.R +++ b/R/global-cutoff.R @@ -11,9 +11,9 @@ #' considered globally similar. #' @param global_vgene logical. If \code{TRUE}, global connections are #' restricted to sequence pairs that share a V gene. -#' @param no_cores numeric. Number of cores registered with the parallel -#' backend (used only for documentation; the function relies on a -#' pre-registered \code{foreach} backend). +#' @param BPPARAM A \code{\link[BiocParallel]{BiocParallelParam}} object +#' controlling parallel evaluation (e.g. +#' \code{BiocParallel::MulticoreParam()}). #' @param verbose logical. If \code{TRUE}, progress messages are printed. #' #' @return A list with two elements: @@ -26,13 +26,12 @@ #' } #' #' @keywords internal -#' @import foreach .global_cutoff <- function(seqs, motif_region, sequences, gccutoff, global_vgene, - no_cores, + BPPARAM, verbose) { if (verbose) message("Searching for global similarities (cutoff method).") @@ -44,9 +43,9 @@ gccutoff, global_vgene, verbose)) } - ## ---- Fallback: stringdist + foreach implementation ------------------------ + ## ---- Fallback: stringdist + BiocParallel implementation ------------------------ .global_cutoff_stringdist(seqs, motif_region, sequences, - gccutoff, global_vgene, no_cores, verbose) + gccutoff, global_vgene, BPPARAM, verbose) } #' immApex-accelerated global cutoff via buildNetwork() @@ -118,11 +117,11 @@ ) } -#' stringdist + foreach fallback for global cutoff +#' stringdist + BiocParallel fallback for global cutoff #' @return A list with edge data and excluded sequence IDs. #' @keywords internal .global_cutoff_stringdist <- function(seqs, motif_region, sequences, - gccutoff, global_vgene, no_cores, + gccutoff, global_vgene, BPPARAM, verbose) { ## Prepare V-gene lookup vectors when V-gene filtering is requested @@ -137,7 +136,7 @@ ## Parallel loop: for every sequence compute Hamming distance to all ## others and identify neighbours within gccutoff ## ------------------------------------------------------------------ - res <- foreach::foreach(i = seq_along(seqs)) %dopar% { + res <- BiocParallel::bplapply(seq_along(seqs), function(i) { not_in_global_ids <- c() global_con <- c() @@ -176,17 +175,13 @@ } list(not_in_global_ids = not_in_global_ids, global_con = global_con) - } + }, BPPARAM = BPPARAM) ## ------------------------------------------------------------------ ## Collect results from parallel workers ## ------------------------------------------------------------------ - not_in_global_ids <- c() - global_con <- c() - for (i in seq_along(res)) { - not_in_global_ids <- c(not_in_global_ids, res[[i]]$not_in_global_ids) - global_con <- rbind(global_con, res[[i]]$global_con) - } + not_in_global_ids <- unlist(lapply(res, `[[`, "not_in_global_ids")) + global_con <- do.call(rbind, lapply(res, `[[`, "global_con")) ## Build edge data frame if (!is.null(global_con)) { diff --git a/R/global-fisher.R b/R/global-fisher.R index 1a1d4c2..e5cc6ba 100644 --- a/R/global-fisher.R +++ b/R/global-fisher.R @@ -22,7 +22,8 @@ #' a V-gene. #' @param all_aa_interchangeable Logical. If \code{FALSE}, only pairs whose #' variable-position amino acids have BLOSUM62 >= 0 are kept. -#' @param no_cores Integer. Number of registered parallel cores. +#' @param BPPARAM A \code{\link[BiocParallel]{BiocParallelParam}} object +#' controlling parallel evaluation. #' @param verbose Logical. Print progress messages. #' #' @return A list with elements: @@ -35,7 +36,6 @@ #' similarities were found.} #' } #' -#' @import foreach #' @keywords internal .global_fisher <- function(seqs, motif_region, @@ -46,7 +46,7 @@ boundary_size, global_vgene, all_aa_interchangeable, - no_cores, + BPPARAM, verbose) { ## Load BLOSUM62 compatible amino acid pairs @@ -81,11 +81,8 @@ sample_seqs$tag <- sample_seqs$struct ## Expand: for each position, replace that position with "%" - i <- NULL - exp_sample_seqs <- foreach::foreach( - i = seq_len(max(sample_seqs$nchar)), - .combine = rbind - ) %dopar% { + exp_sample_seqs <- do.call(rbind, BiocParallel::bplapply( + seq_len(max(sample_seqs$nchar)), function(i) { temp_part <- sample_seqs[sample_seqs$nchar >= i, ] temp_part$pos <- i temp_part$aa_at_pos <- substr(temp_part$struct, i, i) @@ -95,7 +92,7 @@ dup_check <- duplicated(temp_part$tag) | duplicated(temp_part$tag, fromLast = TRUE) temp_part[dup_check, ] - } + }, BPPARAM = BPPARAM)) ## Early exit if nothing duplicated if (is.null(exp_sample_seqs) || nrow(exp_sample_seqs) == 0) { @@ -131,16 +128,14 @@ reference_seqs$aa_at_pos <- "" reference_seqs$tag <- reference_seqs$struct - exp_reference_seqs <- foreach::foreach( - i = seq_len(min(max(reference_seqs$nchar), max(sample_seqs$nchar))), - .combine = rbind - ) %dopar% { + exp_reference_seqs <- do.call(rbind, BiocParallel::bplapply( + seq_len(min(max(reference_seqs$nchar), max(sample_seqs$nchar))), function(i) { temp_part <- reference_seqs[reference_seqs$nchar >= i, ] temp_part$pos <- i temp_part$aa_at_pos <- substr(temp_part$struct, i, i) substr(temp_part$tag, i, i) <- "%" temp_part[temp_part$tag %in% unq_sample_struct, ] - } + }, BPPARAM = BPPARAM)) ## ----------------------------------------------------------------- ## Step 3: Compute struct frequencies and enrichment @@ -177,10 +172,8 @@ all.x = TRUE ) - edges <- foreach::foreach( - i = seq_len(nrow(sample_stats)), - .combine = rbind - ) %dopar% { + edges <- do.call(rbind, BiocParallel::bplapply( + seq_len(nrow(sample_stats)), function(i) { act_members <- unique( exp_sample_seqs[exp_sample_seqs$tag == sample_stats$tag[i], ] ) @@ -214,7 +207,7 @@ act_members$aa_at_pos[combn_ids[, 2]]) %in% BlosumVec) } ret[keep, ] - } + }, BPPARAM = BPPARAM)) ## ----------------------------------------------------------------- ## Step 5: Build cluster network @@ -242,7 +235,7 @@ "position", "TRBV") cm_splitted$position <- as.numeric(cm_splitted$position) - cluster_list_raw <- foreach::foreach(i = seq_len(cm$no)) %dopar% { + cluster_list_raw <- BiocParallel::bplapply(seq_len(cm$no), function(i) { csize <- cm$csize[i] member_df <- unique(cm_splitted[which(cm$membership == i), ]) num_cdr3s <- length(unique(member_df$CDR3b)) @@ -251,7 +244,7 @@ paste(unique(member_df$CDR3b), collapse = " "), paste(sort(unique(member_df$aa_at_position)), collapse = ""), member_df$TRBV[1]) - } + }, BPPARAM = BPPARAM) cluster_list <- data.frame( matrix(unlist(cluster_list_raw), ncol = 6, byrow = TRUE), diff --git a/R/local-fisher.R b/R/local-fisher.R index cff066c..196fc75 100644 --- a/R/local-fisher.R +++ b/R/local-fisher.R @@ -27,7 +27,8 @@ #' @param motif_distance_cutoff Numeric. Maximum positional distance for motifs #' to be grouped together. Not used directly in this function but kept for #' interface consistency. -#' @param no_cores Numeric. Number of cores to use for parallel motif finding. +#' @param BPPARAM A \linkS4class{BiocParallelParam} object specifying the +#' parallel backend. Defaults to \code{BiocParallel::SerialParam()}. #' @param verbose Logical. If \code{TRUE}, print status messages via #' \code{message()}. #' @@ -39,7 +40,6 @@ #' associated statistics.} #' } #' -#' @import foreach #' @keywords internal .local_fisher <- function(motif_region, refseqs_motif_region, @@ -52,7 +52,7 @@ lcminove, discontinuous_motifs, motif_distance_cutoff, - no_cores, + BPPARAM, verbose) { ## ----------------------------------------------------------- @@ -69,7 +69,7 @@ motif.lengths = motif_length, min.depth = 1L, discontinuous = discontinuous_motifs, - nthreads = no_cores + nthreads = BiocParallel::bpnworkers(BPPARAM) ) colnames(ref_motifs_df)[colnames(ref_motifs_df) == "frequency"] <- "count" @@ -79,50 +79,33 @@ motif.lengths = motif_length, min.depth = 1L, discontinuous = discontinuous_motifs, - nthreads = no_cores + nthreads = BiocParallel::bpnworkers(BPPARAM) ) colnames(motifs_df)[colnames(motifs_df) == "frequency"] <- "count" } else { - ## ---- Fallback: foreach chunking with stringdist backend ---- + ## ---- Fallback: BiocParallel chunking with stringdist backend ---- if (verbose) message("Finding motifs in reference sequences...") - # Divide the sequences equally among all cores - overhang <- length(refseqs_motif_region) %% no_cores - id_list <- list() - last_id <- 0 - next_id <- 0 - steps <- (length(refseqs_motif_region) - overhang) / no_cores - - for (i in seq_len(no_cores)) { - next_id <- last_id + steps - if (overhang > 0) { - next_id <- next_id + 1 - overhang <- overhang - 1 - } - id_list[[i]] <- (last_id + 1):next_id - last_id <- next_id - } + # Divide the sequences equally among all workers + no_cores_count <- BiocParallel::bpnworkers(BPPARAM) + id_list <- split( + seq_along(refseqs_motif_region), + cut(seq_along(refseqs_motif_region), no_cores_count, labels = FALSE) + ) # Receive all motifs in the reference sequences - ref_motifs_list <- foreach::foreach(i = seq_len(no_cores)) %dopar% { - return(findMotifs(seqs = refseqs_motif_region[id_list[[i]]], - q = motif_length, - discontinuous = discontinuous_motifs)) - } + ref_motifs_list <- BiocParallel::bplapply(id_list, function(ids) { + findMotifs(seqs = refseqs_motif_region[ids], + q = motif_length, + discontinuous = discontinuous_motifs) + }, BPPARAM = BPPARAM) # Convert the list into a more manageable data frame - ref_motifs_df <- NULL - for (i in seq_len(no_cores)) { - if (i == 1) { - ref_motifs_df <- ref_motifs_list[[i]] - } else { - ref_motifs_df <- merge(x = ref_motifs_df, - y = ref_motifs_list[[i]], - by = "motif", - all = TRUE) - } - } + ref_motifs_df <- Reduce( + function(x, y) merge(x, y, by = "motif", all = TRUE), + ref_motifs_list + ) ref_motifs_df[is.na(ref_motifs_df)] <- 0 ref_motifs_df <- data.frame( motif = ref_motifs_df$motif, @@ -131,42 +114,24 @@ if (verbose) message("Finding motifs in sample sequences...") - # Divide the sequences equally among all cores - overhang <- length(motif_region) %% no_cores - id_list <- list() - last_id <- 0 - next_id <- 0 - steps <- (length(motif_region) - overhang) / no_cores - - for (i in seq_len(no_cores)) { - next_id <- last_id + steps - if (overhang > 0) { - next_id <- next_id + 1 - overhang <- overhang - 1 - } - id_list[[i]] <- (last_id + 1):next_id - last_id <- next_id - } + # Divide the sequences equally among all workers + id_list <- split( + seq_along(motif_region), + cut(seq_along(motif_region), no_cores_count, labels = FALSE) + ) # Receive all motifs in the sample sequences - motifs_list <- foreach::foreach(i = seq_len(no_cores)) %dopar% { - return(findMotifs(seqs = motif_region[id_list[[i]]], - q = motif_length, - discontinuous = discontinuous_motifs)) - } + motifs_list <- BiocParallel::bplapply(id_list, function(ids) { + findMotifs(seqs = motif_region[ids], + q = motif_length, + discontinuous = discontinuous_motifs) + }, BPPARAM = BPPARAM) # Convert the list into a more manageable data frame - motifs_df <- NULL - for (i in seq_len(no_cores)) { - if (i == 1) { - motifs_df <- motifs_list[[i]] - } else { - motifs_df <- merge(x = motifs_df, - y = motifs_list[[i]], - by = "motif", - all = TRUE) - } - } + motifs_df <- Reduce( + function(x, y) merge(x, y, by = "motif", all = TRUE), + motifs_list + ) motifs_df[is.na(motifs_df)] <- 0 motifs_df <- data.frame( motif = motifs_df$motif, diff --git a/R/local-rrs.R b/R/local-rrs.R index 803e419..2de4967 100644 --- a/R/local-rrs.R +++ b/R/local-rrs.R @@ -26,7 +26,8 @@ #' CDR3 length distribution. #' @param vgene_stratify Logical. Whether to stratify random subsamples by #' V-gene usage distribution. -#' @param no_cores Integer. Number of cores for parallel execution. +#' @param BPPARAM A \code{\link[BiocParallel]{BiocParallelParam}} object +#' controlling parallel execution (default: \code{BiocParallel::bpparam()}). #' @param verbose Logical. If \code{TRUE}, emit progress messages. #' @param motif_lengths_list List. Pre-computed CDR3 length counts from the #' sample. @@ -52,7 +53,6 @@ #' \code{selected_motifs} but for every motif found.} #' } #' -#' @import foreach #' @keywords internal .local_rrs <- function(motif_region, refseqs_motif_region, @@ -66,7 +66,7 @@ discontinuous_motifs, cdr3_len_stratify, vgene_stratify, - no_cores, + BPPARAM, verbose, motif_lengths_list, ref_motif_lengths_id_list, @@ -86,7 +86,7 @@ ## ---- Step 2: Repeated random sampling from the reference DB ----------- if (verbose) message("Running ", sim_depth, " random sampling iterations.") - res <- foreach::foreach(i = seq_len(sim_depth), .inorder = FALSE) %dopar% { + res <- BiocParallel::bplapply(seq_len(sim_depth), function(i) { motif_sample <- getRandomSubsample( cdr3_len_stratify = cdr3_len_stratify, vgene_stratify = vgene_stratify, @@ -109,7 +109,7 @@ sim <- merge(discovery, sim, by = "motif", all.x = TRUE) sim$V1.y[is.na(sim$V1.y)] <- 0 sim$V1.y - } + }, BPPARAM = BPPARAM) ## ---- Step 3: Build sample log ----------------------------------------- if (verbose) message("Building sample log.") diff --git a/R/runGLIPH.R b/R/runGLIPH.R index 8df3996..6ddf189 100644 --- a/R/runGLIPH.R +++ b/R/runGLIPH.R @@ -223,7 +223,6 @@ #' n_cores = 1 #' ) #' -#' @import foreach #' @export runGLIPH <- function(cdr3_sequences, method = c("gliph2", "gliph1", "custom"), @@ -384,7 +383,7 @@ runGLIPH <- function(cdr3_sequences, } ## ---- Set up parallel ---- - no_cores <- .setup_parallel(n_cores) + BPPARAM <- .setup_parallel(n_cores) ## ================================================================ ## Part 1: Local similarities @@ -453,14 +452,14 @@ runGLIPH <- function(cdr3_sequences, discontinuous_motifs = discontinuous_motifs, cdr3_len_stratify = cdr3_len_stratify, vgene_stratify = vgene_stratify, - no_cores = no_cores, verbose = verbose, motif_lengths_list = motif_lengths_list, ref_motif_lengths_id_list = ref_motif_lengths_id_list, motif_region_vgenes_list = motif_region_vgenes_list, ref_motif_vgenes_id_list = ref_motif_vgenes_id_list, lengths_vgenes_list = lengths_vgenes_list, - ref_lengths_vgenes_list = ref_lengths_vgenes_list + ref_lengths_vgenes_list = ref_lengths_vgenes_list, + BPPARAM = BPPARAM ) sample_log <- rrs_result$sample_log @@ -480,8 +479,8 @@ runGLIPH <- function(cdr3_sequences, lcminove = lcminove, discontinuous_motifs = discontinuous_motifs, motif_distance_cutoff = motif_distance_cutoff, - no_cores = no_cores, - verbose = verbose + verbose = verbose, + BPPARAM = BPPARAM ) selected_motifs <- fisher_result$selected_motifs @@ -490,30 +489,30 @@ runGLIPH <- function(cdr3_sequences, ## Build local clone network from selected motifs if (!is.null(selected_motifs) && nrow(selected_motifs) > 0) { - i <- NULL - local_clone_network <- foreach::foreach( - i = seq_len(nrow(selected_motifs)), - .combine = rbind - ) %dopar% { - all_ids <- grep( - pattern = selected_motifs$motif[i], - x = all_motif_region, - value = FALSE - ) - if (length(all_ids) >= 2) { - combn_ids <- t(utils::combn(all_ids, m = 2)) - } else { - combn_ids <- t(utils::combn(rep(1, 2), m = 2)) - } - temp_df <- data.frame( - V1 = combn_ids[, 1], - V2 = combn_ids[, 2], - type = rep("local", nrow(combn_ids)), - tag = rep(selected_motifs$motif[i], nrow(combn_ids)), - stringsAsFactors = FALSE - ) - temp_df - } + local_net_parts <- BiocParallel::bplapply( + seq_len(nrow(selected_motifs)), + function(i) { + all_ids <- grep( + pattern = selected_motifs$motif[i], + x = all_motif_region, + value = FALSE + ) + if (length(all_ids) >= 2) { + combn_ids <- t(utils::combn(all_ids, m = 2)) + } else { + combn_ids <- t(utils::combn(rep(1, 2), m = 2)) + } + data.frame( + V1 = combn_ids[, 1], + V2 = combn_ids[, 2], + type = rep("local", nrow(combn_ids)), + tag = rep(selected_motifs$motif[i], nrow(combn_ids)), + stringsAsFactors = FALSE + ) + }, + BPPARAM = BPPARAM + ) + local_clone_network <- do.call(rbind, local_net_parts) ## Apply motif distance cutoff if (!is.null(local_clone_network) && nrow(local_clone_network) > 0) { @@ -563,8 +562,8 @@ runGLIPH <- function(cdr3_sequences, sequences = sequences, gccutoff = gccutoff, global_vgene = global_vgene, - no_cores = no_cores, - verbose = verbose + verbose = verbose, + BPPARAM = BPPARAM ) global_clone_network <- cutoff_result$edges @@ -585,8 +584,8 @@ runGLIPH <- function(cdr3_sequences, boundary_size = boundary_size, global_vgene = global_vgene, all_aa_interchangeable = all_aa_interchangeable, - no_cores = no_cores, - verbose = verbose + verbose = verbose, + BPPARAM = BPPARAM ) global_res <- fisher_global_result$cluster_list @@ -640,7 +639,8 @@ runGLIPH <- function(cdr3_sequences, global_vgene = global_vgene, public_tcrs = public_tcrs, cluster_min_size = cluster_min_size, - verbose = verbose + verbose = verbose, + BPPARAM = BPPARAM ) cluster_properties <- gliph1_result$cluster_properties @@ -716,7 +716,8 @@ runGLIPH <- function(cdr3_sequences, motif_distance_cutoff = motif_distance_cutoff, cluster_min_size = cluster_min_size, boost_local_significance = boost_local_significance, - verbose = verbose + verbose = verbose, + BPPARAM = BPPARAM ) cluster_properties <- gliph2_result$merged_clusters @@ -755,9 +756,6 @@ runGLIPH <- function(cdr3_sequences, } } - ## ---- Stop parallel ---- - .stop_parallel() - ## ================================================================ ## Save results ## ================================================================ From 1d830d7f0ed3343b576d9ea162d9aed43d53edda Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:41:46 -0500 Subject: [PATCH 03/10] updating unit tests for BiocParallel --- tests/testthat/test-clustering.R | 81 ++++++++++++++++++---------- tests/testthat/test-global-fisher.R | 13 ++--- tests/testthat/test-globalCutoff.R | 27 +++++----- tests/testthat/test-local-fisher.R | 44 ++++++++------- tests/testthat/test-local-rrs.R | 15 +++--- tests/testthat/test-utils-parallel.R | 62 +++++++++------------ 6 files changed, 120 insertions(+), 122 deletions(-) diff --git a/tests/testthat/test-clustering.R b/tests/testthat/test-clustering.R index 1c9fa95..5e95f05 100644 --- a/tests/testthat/test-clustering.R +++ b/tests/testthat/test-clustering.R @@ -1,8 +1,5 @@ # Tests for .cluster_gliph1() and .cluster_gliph2() -# Register sequential backend for %dopar% -foreach::registerDoSEQ() - # ---- Helpers ---------------------------------------------------------------- .make_cluster_data <- function() { @@ -44,7 +41,8 @@ test_that(".cluster_gliph1 returns list with expected elements", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -79,7 +77,8 @@ test_that(".cluster_gliph1 forms connected components correctly", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_s3_class(result$cluster_properties, "data.frame") @@ -112,7 +111,8 @@ test_that(".cluster_gliph1 filters by cluster_min_size", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 3, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) if (!is.null(result$cluster_properties)) { @@ -135,7 +135,8 @@ test_that(".cluster_gliph1 returns NULL for empty edge list", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 2, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_null(result$cluster_properties) @@ -165,7 +166,8 @@ test_that(".cluster_gliph1 adds singletons from not_in_global_ids", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) # The clone_network should contain singleton rows @@ -204,7 +206,8 @@ test_that(".cluster_gliph2 returns list with expected elements", { motif_distance_cutoff = 1, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -242,7 +245,8 @@ test_that(".cluster_gliph2 clone_network has expected edge types", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) if (!is.null(result$clone_network)) { @@ -283,7 +287,8 @@ test_that(".cluster_gliph2 cluster_min_size filters small clusters", { motif_distance_cutoff = 10, cluster_min_size = 5, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) # Cluster of size 2 should be eliminated by min_size = 5 @@ -310,7 +315,8 @@ test_that(".cluster_gliph2 handles no local and no global similarities", { motif_distance_cutoff = 1, cluster_min_size = 2, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_null(result$merged_clusters) @@ -347,7 +353,8 @@ test_that(".cluster_gliph2 handles global results only", { motif_distance_cutoff = 1, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -384,7 +391,8 @@ test_that(".cluster_gliph2 with global_vgene FALSE for global clusters", { motif_distance_cutoff = 1, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -436,7 +444,8 @@ test_that(".cluster_gliph2 merges local and global clusters", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -478,7 +487,8 @@ test_that(".cluster_gliph2 applies BLOSUM62 filtering when all_aa_interchangeabl motif_distance_cutoff = 1, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -509,7 +519,8 @@ test_that(".cluster_gliph1 restricts edges to same donor when public_tcrs is FAL global_vgene = FALSE, public_tcrs = FALSE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -537,7 +548,8 @@ test_that(".cluster_gliph1 filters global edges by V-gene when global_vgene is T global_vgene = TRUE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -566,7 +578,8 @@ test_that(".cluster_gliph1 prints message when verbose is TRUE", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = TRUE + verbose = TRUE, + BPPARAM = BiocParallel::SerialParam() ), "GLIPH1" ) @@ -587,7 +600,8 @@ test_that(".cluster_gliph1 creates singleton network from NULL clone_network", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_true(!is.null(result$clone_network)) @@ -624,7 +638,8 @@ test_that(".cluster_gliph2 works with structboundaries FALSE", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -660,7 +675,8 @@ test_that(".cluster_gliph2 generates save_cluster_list_df", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) if (!is.null(result$merged_clusters) && length(result$cluster_list) > 0) { @@ -700,7 +716,8 @@ test_that(".cluster_gliph2 prints message when verbose is TRUE", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = TRUE + verbose = TRUE, + BPPARAM = BiocParallel::SerialParam() ), "GLIPH2" ) @@ -734,7 +751,8 @@ test_that(".cluster_gliph1 works without patient info", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -771,7 +789,8 @@ test_that(".cluster_gliph2 includes TRBV in tag when global_vgene is TRUE", { motif_distance_cutoff = 1, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) if (!is.null(result$merged_clusters)) { @@ -809,7 +828,8 @@ test_that(".cluster_gliph2 handles infinite OvE values", { motif_distance_cutoff = 10, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -850,7 +870,8 @@ test_that(".cluster_gliph1 handles duplicate cluster names with suffixes", { global_vgene = FALSE, public_tcrs = TRUE, cluster_min_size = 1, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) expect_type(result, "list") @@ -891,7 +912,8 @@ test_that(".cluster_gliph2 motif_distance_cutoff = 0 eliminates positionally dis motif_distance_cutoff = 0, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) result_lenient <- immGLIPH:::.cluster_gliph2( @@ -907,7 +929,8 @@ test_that(".cluster_gliph2 motif_distance_cutoff = 0 eliminates positionally dis motif_distance_cutoff = 100, cluster_min_size = 1, boost_local_significance = FALSE, - verbose = FALSE + verbose = FALSE, + BPPARAM = BiocParallel::SerialParam() ) # Strict cutoff should have fewer or equal edges diff --git a/tests/testthat/test-global-fisher.R b/tests/testthat/test-global-fisher.R index 1c22b9b..4104ca6 100644 --- a/tests/testthat/test-global-fisher.R +++ b/tests/testthat/test-global-fisher.R @@ -1,8 +1,5 @@ # Tests for .global_fisher() -# Register sequential backend for %dopar% -foreach::registerDoSEQ() - # ---- Helper: small synthetic data ------------------------------------------ .make_global_fisher_data <- function() { @@ -62,7 +59,7 @@ test_that(".global_fisher returns list with cluster_list and global_similarities boundary_size = 3, global_vgene = FALSE, all_aa_interchangeable = TRUE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -86,7 +83,7 @@ test_that(".global_fisher cluster_list has expected columns when similarities fo boundary_size = 3, global_vgene = FALSE, all_aa_interchangeable = TRUE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -129,7 +126,7 @@ test_that(".global_fisher returns FALSE for no structural matches", { boundary_size = 3, global_vgene = FALSE, all_aa_interchangeable = TRUE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -165,7 +162,7 @@ test_that(".global_fisher with global_vgene restricts to same V-gene", { boundary_size = 3, global_vgene = TRUE, all_aa_interchangeable = TRUE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -179,7 +176,7 @@ test_that(".global_fisher with global_vgene restricts to same V-gene", { boundary_size = 3, global_vgene = FALSE, all_aa_interchangeable = TRUE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) diff --git a/tests/testthat/test-globalCutoff.R b/tests/testthat/test-globalCutoff.R index 2be07f4..9f0b5c8 100644 --- a/tests/testthat/test-globalCutoff.R +++ b/tests/testthat/test-globalCutoff.R @@ -1,8 +1,5 @@ # Tests for .global_cutoff() and related functions -# Register a sequential backend for internal functions that use %dopar% -foreach::registerDoSEQ() - # ---- .global_cutoff_stringdist ----------------------------------------------- test_that(".global_cutoff_stringdist returns correct structure", { @@ -21,7 +18,7 @@ test_that(".global_cutoff_stringdist returns correct structure", { sequences = sequences, gccutoff = 5, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -50,7 +47,7 @@ test_that(".global_cutoff_stringdist returns empty for zero cutoff on different sequences = sequences, gccutoff = 0, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -75,7 +72,7 @@ test_that(".global_cutoff dispatches correctly", { sequences = sequences, gccutoff = 5, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -105,7 +102,7 @@ test_that("immApex buildNetwork backend matches stringdist backend", { sequences = sequences, gccutoff = 3, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -146,7 +143,7 @@ test_that(".global_cutoff_stringdist returns zero edges for single sequence", { sequences = sequences, gccutoff = 5, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -170,7 +167,7 @@ test_that(".global_cutoff_stringdist finds edges for identical sequences", { sequences = sequences, gccutoff = 0, global_vgene = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -195,11 +192,11 @@ test_that(".global_cutoff_stringdist with global_vgene restricts to same V-gene" result_same <- immGLIPH:::.global_cutoff_stringdist( seqs = seqs, motif_region = motif_region, sequences = sequences_same, - gccutoff = 5, global_vgene = TRUE, no_cores = 1, verbose = FALSE + gccutoff = 5, global_vgene = TRUE, BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) result_diff <- immGLIPH:::.global_cutoff_stringdist( seqs = seqs, motif_region = motif_region, sequences = sequences_diff, - gccutoff = 5, global_vgene = TRUE, no_cores = 1, verbose = FALSE + gccutoff = 5, global_vgene = TRUE, BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) # Same V-gene should find the edge; different V-genes should not @@ -217,7 +214,7 @@ test_that(".global_cutoff not_in_global_ids tracks isolated sequences", { result <- immGLIPH:::.global_cutoff_stringdist( seqs = seqs, motif_region = motif_region, sequences = sequences, - gccutoff = 0, global_vgene = FALSE, no_cores = 1, verbose = FALSE + gccutoff = 0, global_vgene = FALSE, BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) # not_in_global_ids should contain indices of sequences without global edges @@ -239,7 +236,7 @@ test_that(".global_cutoff_stringdist prints messages when verbose is TRUE", { expect_message( immGLIPH:::.global_cutoff_stringdist( seqs = seqs, motif_region = motif_region, sequences = sequences, - gccutoff = 5, global_vgene = FALSE, no_cores = 1, verbose = TRUE + gccutoff = 5, global_vgene = FALSE, BPPARAM = BiocParallel::SerialParam(), verbose = TRUE ), "global" ) @@ -257,7 +254,7 @@ test_that(".global_cutoff prints verbose dispatch message", { expect_message( immGLIPH:::.global_cutoff( seqs = seqs, motif_region = motif_region, sequences = sequences, - gccutoff = 5, global_vgene = FALSE, no_cores = 1, verbose = TRUE + gccutoff = 5, global_vgene = FALSE, BPPARAM = BiocParallel::SerialParam(), verbose = TRUE ), "global" ) @@ -333,7 +330,7 @@ test_that(".global_cutoff_stringdist finds edges with high cutoff", { result <- immGLIPH:::.global_cutoff_stringdist( seqs = seqs, motif_region = motif_region, sequences = sequences, - gccutoff = 10, global_vgene = FALSE, no_cores = 1, verbose = FALSE + gccutoff = 10, global_vgene = FALSE, BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) expect_true(nrow(result$edges) > 0) diff --git a/tests/testthat/test-local-fisher.R b/tests/testthat/test-local-fisher.R index 61d1f57..e338b68 100644 --- a/tests/testthat/test-local-fisher.R +++ b/tests/testthat/test-local-fisher.R @@ -1,7 +1,5 @@ # Tests for .local_fisher() -# Register sequential backend for %dopar% -foreach::registerDoSEQ() # ---- Helper: small synthetic data ------------------------------------------ @@ -55,7 +53,7 @@ test_that(".local_fisher returns list with selected_motifs and all_motifs", { lcminove = c(1, 1), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -78,7 +76,7 @@ test_that(".local_fisher all_motifs has expected columns", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -104,7 +102,7 @@ test_that(".local_fisher p-values are numeric and in [0, 1]", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -127,7 +125,7 @@ test_that(".local_fisher OvE is numeric", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -144,14 +142,14 @@ test_that(".local_fisher kmer_mindepth filters out rare motifs", { seqs = d$sample_seqs, refseqs = d$ref_seqs, sequences = d$sequences, motif_length = 2, kmer_mindepth = 1, lcminp = 1.0, lcminove = 0, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) result_high <- immGLIPH:::.local_fisher( motif_region = d$motif_region, refseqs_motif_region = d$ref_motif_region, seqs = d$sample_seqs, refseqs = d$ref_seqs, sequences = d$sequences, motif_length = 2, kmer_mindepth = 5, lcminp = 1.0, lcminove = 0, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) # Higher mindepth should yield fewer or equal selected motifs @@ -171,14 +169,14 @@ test_that(".local_fisher lcminp filters out high p-value motifs", { seqs = d$sample_seqs, refseqs = d$ref_seqs, sequences = d$sequences, motif_length = 2, kmer_mindepth = 1, lcminp = 1e-10, lcminove = 0, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) result_lenient <- immGLIPH:::.local_fisher( motif_region = d$motif_region, refseqs_motif_region = d$ref_motif_region, seqs = d$sample_seqs, refseqs = d$ref_seqs, sequences = d$sequences, motif_length = 2, kmer_mindepth = 1, lcminp = 1.0, lcminove = 0, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) expect_true(nrow(result_strict$selected_motifs) <= @@ -195,7 +193,7 @@ test_that(".local_fisher lcminove filters by fold change per motif length", { motif_length = c(2, 3), kmer_mindepth = 1, lcminp = 1.0, lcminove = c(1e6, 1e6), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) expect_equal(nrow(result$selected_motifs), 0) @@ -210,7 +208,7 @@ test_that(".local_fisher includes discontinuous motifs when enabled", { seqs = d$sample_seqs, refseqs = d$ref_seqs, sequences = d$sequences, motif_length = 2, kmer_mindepth = 1, lcminp = 1.0, lcminove = 0, discontinuous_motifs = TRUE, motif_distance_cutoff = 1, - no_cores = 1, verbose = FALSE + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) disc <- result$all_motifs[grep("\\.", result$all_motifs$motif), ] @@ -237,7 +235,7 @@ test_that(".local_fisher works via immApex when available", { lcminove = c(1, 1), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -266,7 +264,7 @@ test_that(".local_fisher immApex path with discontinuous motifs", { lcminove = c(0, 0), discontinuous_motifs = TRUE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -291,7 +289,7 @@ test_that(".local_fisher prints messages when verbose is TRUE", { lcminove = c(1, 1), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = TRUE ), "motif" @@ -314,7 +312,7 @@ test_that(".local_fisher works with single lcminove value for multiple motif_len lcminove = 1, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -338,7 +336,7 @@ test_that(".local_fisher avgRef is normalized to sample set size", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -362,7 +360,7 @@ test_that(".local_fisher topRef column is numeric", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -385,7 +383,7 @@ test_that(".local_fisher returns empty selected_motifs when all below kmer_minde lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -412,7 +410,7 @@ test_that(".local_fisher correctly applies per-length lcminove thresholds", { lcminove = c(1e6, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -439,7 +437,7 @@ test_that(".local_fisher works with motif_length = 4", { lcminove = 0, discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -466,7 +464,7 @@ test_that(".local_fisher all_motifs counts are positive", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) @@ -489,7 +487,7 @@ test_that(".local_fisher num_in_ref is non-negative", { lcminove = c(0, 0), discontinuous_motifs = FALSE, motif_distance_cutoff = 1, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE ) diff --git a/tests/testthat/test-local-rrs.R b/tests/testthat/test-local-rrs.R index 1617c39..b5e20bd 100644 --- a/tests/testthat/test-local-rrs.R +++ b/tests/testthat/test-local-rrs.R @@ -1,8 +1,5 @@ # Tests for .local_rrs() -# Register sequential backend for %dopar% -foreach::registerDoSEQ() - # ---- Helper: small synthetic data ------------------------------------------ .make_rrs_data <- function() { @@ -54,7 +51,7 @@ test_that(".local_rrs returns list with sample_log, selected_motifs, all_motifs" discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), @@ -88,7 +85,7 @@ test_that(".local_rrs sample_log has sim_depth + 1 rows", { discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), @@ -119,7 +116,7 @@ test_that(".local_rrs all_motifs has expected columns", { discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), @@ -151,7 +148,7 @@ test_that(".local_rrs p-values are in (0, 1]", { discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), @@ -177,7 +174,7 @@ test_that(".local_rrs kmer_mindepth filters correctly", { motif_length = 2, sim_depth = 5, kmer_mindepth = 5, lcminp = 1.0, lcminove = 0, discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, verbose = FALSE, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), motif_region_vgenes_list = list(), ref_motif_vgenes_id_list = list(), lengths_vgenes_list = list(), ref_lengths_vgenes_list = list() @@ -198,7 +195,7 @@ test_that(".local_rrs strict lcminp yields fewer selected motifs", { motif_length = 2, sim_depth = 5, kmer_mindepth = 1, lcminp = lcminp, lcminove = 0, discontinuous_motifs = FALSE, cdr3_len_stratify = FALSE, vgene_stratify = FALSE, - no_cores = 1, verbose = FALSE, + BPPARAM = BiocParallel::SerialParam(), verbose = FALSE, motif_lengths_list = list(), ref_motif_lengths_id_list = list(), motif_region_vgenes_list = list(), ref_motif_vgenes_id_list = list(), lengths_vgenes_list = list(), ref_lengths_vgenes_list = list() diff --git a/tests/testthat/test-utils-parallel.R b/tests/testthat/test-utils-parallel.R index b0a8a24..dd2f968 100644 --- a/tests/testthat/test-utils-parallel.R +++ b/tests/testthat/test-utils-parallel.R @@ -1,56 +1,42 @@ -# Tests for .setup_parallel() and .stop_parallel() +# Tests for .setup_parallel() -# ---- .setup_parallel with n_cores = 1 ---------------------------------------- +# ---- .setup_parallel with n_cores = 1 returns SerialParam -------------------- -test_that(".setup_parallel with 1 core returns 1", { +test_that(".setup_parallel with 1 core returns a SerialParam", { result <- immGLIPH:::.setup_parallel(1) - expect_equal(result, 1L) + expect_s4_class(result, "SerialParam") }) -# ---- .setup_parallel with NULL auto-detects ----------------------------------- +# ---- .setup_parallel with NULL returns a valid param ------------------------- -test_that(".setup_parallel with NULL returns at least 1", { +test_that(".setup_parallel with NULL returns a valid BiocParallelParam", { result <- immGLIPH:::.setup_parallel(NULL) - expect_true(result >= 1L) - expect_true(result <= parallel::detectCores()) - # Clean up - immGLIPH:::.stop_parallel() + expect_true(is(result, "BiocParallelParam")) }) -# ---- .setup_parallel clamps to valid range ------------------------------------ +# ---- .setup_parallel with multiple cores on non-Windows ---------------------- -test_that(".setup_parallel clamps excessive core count", { - max_cores <- parallel::detectCores() - result <- immGLIPH:::.setup_parallel(max_cores + 100) - expect_true(result <= max_cores) - expect_true(result >= 1L) - # Clean up - immGLIPH:::.stop_parallel() +test_that(".setup_parallel returns MulticoreParam on non-Windows with multiple cores", { + skip_on_os("windows") + skip_if(parallel::detectCores() < 3, + "Need at least 3 cores to test MulticoreParam") + result <- immGLIPH:::.setup_parallel(2) + expect_s4_class(result, "MulticoreParam") }) -test_that(".setup_parallel clamps negative core count to 1", { - result <- immGLIPH:::.setup_parallel(-5) - expect_equal(result, 1L) -}) +# ---- .setup_parallel clamps to valid range ----------------------------------- -test_that(".setup_parallel clamps zero to 1", { - result <- immGLIPH:::.setup_parallel(0) - expect_equal(result, 1L) +test_that(".setup_parallel clamps excessive core count", { + result <- immGLIPH:::.setup_parallel(9999) + expect_true(is(result, "BiocParallelParam")) }) -# ---- .stop_parallel runs without error ---------------------------------------- - -test_that(".stop_parallel executes without error", { - immGLIPH:::.setup_parallel(1) - expect_no_error(immGLIPH:::.stop_parallel()) +test_that(".setup_parallel clamps negative core count to SerialParam", { + result <- immGLIPH:::.setup_parallel(-5) + expect_s4_class(result, "SerialParam") }) -# ---- Sequential fallback on Windows or 1 core --------------------------------- - -test_that(".setup_parallel registers sequential backend for single core", { - result <- immGLIPH:::.setup_parallel(1) - expect_equal(result, 1L) - # After registerDoSEQ, foreach should still work - res <- foreach::foreach(i = 1:3, .combine = c) %dopar% { i * 2 } - expect_equal(res, c(2, 4, 6)) +test_that(".setup_parallel clamps zero to SerialParam", { + result <- immGLIPH:::.setup_parallel(0) + expect_s4_class(result, "SerialParam") }) From 3e03a59f63347d17cd9a3fbd7d6a13af72f20069 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:43:02 -0500 Subject: [PATCH 04/10] finishing biocparallelization --- R/clusterScoring.R | 431 +++++++++++++++++++++++++++------------------ R/plotNetwork.R | 39 ++-- R/utils-parallel.R | 32 ++-- 3 files changed, 285 insertions(+), 217 deletions(-) diff --git a/R/clusterScoring.R b/R/clusterScoring.R index ed6733f..d4a57d9 100644 --- a/R/clusterScoring.R +++ b/R/clusterScoring.R @@ -100,7 +100,6 @@ #' @references Glanville, Jacob, et al. #' "Identifying specificity groups in the T cell receptor repertoire." Nature 547.7661 (2017): 94. #' @references https://github.com/immunoengineer/gliph -#' @import foreach #' @export clusterScoring <- function(cluster_list, cdr3_sequences, @@ -111,85 +110,124 @@ clusterScoring <- function(cluster_list, gliph_version = 1, sim_depth = 1000, hla_cutoff = 0.1, - n_cores = 1){ + n_cores = 1) { ################################################################## ## Unit-testing ## ################################################################## ### cluster_list - if(!is.list(cluster_list)) stop("parameter 'cluster_list' has to be an object of class 'list'.") + if (!is.list(cluster_list)) { + stop("parameter 'cluster_list' has to be an object of class 'list'.") + } ### cdr3_sequences - if(is.atomic(cdr3_sequences)) cdr3_sequences <- data.frame("CDR3b" = cdr3_sequences) - if(!is.data.frame(cdr3_sequences)) stop("parameter 'cdr3_sequences' has to be an object of class 'data.frame'.") + if (is.atomic(cdr3_sequences)) { + cdr3_sequences <- data.frame("CDR3b" = cdr3_sequences) + } + if (!is.data.frame(cdr3_sequences)) { + stop("parameter 'cdr3_sequences' has to be an object of class 'data.frame'.") + } cdr3_sequences[] <- lapply(cdr3_sequences, as.character) ### refdb_beta - if(!is.data.frame(refdb_beta)){ + if (!is.data.frame(refdb_beta)) { valid_names <- .valid_reference_names() - if(length(refdb_beta) != 1 || !is.character(refdb_beta)){ + if (length(refdb_beta) != 1 || !is.character(refdb_beta)) { stop("refdb_beta must be a data frame or one of: ", paste(sQuote(valid_names), collapse = ", ")) - } else if(!(refdb_beta %in% valid_names)){ + } else if (!(refdb_beta %in% valid_names)) { stop("refdb_beta must be a data frame or one of: ", paste(sQuote(valid_names), collapse = ", ")) } } ### v_usage_freq - if(!is.null(v_usage_freq)){ - if(is.data.frame(v_usage_freq)){ - if(ncol(v_usage_freq) < 2) stop("v_usage_freq has to be a data frame containing V-gene information in the first column and the corresponding frequency in a naive T-cell repertoire in the second column.") - if(nrow(v_usage_freq) < 1) stop("v_usage_freq has to contain at least one row.") - if(suppressWarnings(any(is.na(as.numeric(v_usage_freq[,2])))) == TRUE){ - stop("The second column of v_usage_freq must contain the frequency of the corresponding V-gene in the first column in a naive T-cell repertoire.") - } else v_usage_freq[,2] <- as.numeric(v_usage_freq[,2]) - - } else {stop("v_usage_freq has to be a data frame containing V-gene information in the first column and the corresponding frequency in a naive T-cell repertoire in the second column.")} + if (!is.null(v_usage_freq)) { + if (is.data.frame(v_usage_freq)) { + if (ncol(v_usage_freq) < 2) { + stop("v_usage_freq has to be a data frame containing V-gene ", + "information in the first column and the corresponding ", + "frequency in a naive T-cell repertoire in the second column.") + } + if (nrow(v_usage_freq) < 1) { + stop("v_usage_freq has to contain at least one row.") + } + if (suppressWarnings(any(is.na(as.numeric(v_usage_freq[, 2]))))) { + stop("The second column of v_usage_freq must contain the frequency ", + "of the corresponding V-gene in the first column in a naive ", + "T-cell repertoire.") + } else { + v_usage_freq[, 2] <- as.numeric(v_usage_freq[, 2]) + } + } else { + stop("v_usage_freq has to be a data frame containing V-gene ", + "information in the first column and the corresponding ", + "frequency in a naive T-cell repertoire in the second column.") + } } ### cdr3_length_freq - if(!is.null(cdr3_length_freq)){ - if(is.data.frame(cdr3_length_freq)){ - if(ncol(cdr3_length_freq) < 2) stop("cdr3_length_freq has to be a data frame containing CDR3 lengths in the first column and the corresponding frequency in a naive T-cell repertoire in the second column.") - if(nrow(cdr3_length_freq) < 1) stop("cdr3_length_freq has to contain at least one row.") - if(suppressWarnings(any(is.na(as.numeric(cdr3_length_freq[,2])))) == TRUE){ - stop("The second column of cdr3_length_freq must contain the frequency of the corresponding CDR3 length in the first column in a naive T-cell repertoire.") - } else cdr3_length_freq[,2] <- as.numeric(cdr3_length_freq[,2]) - - } else {stop("cdr3_length_freq has to be a data frame containing CDR3 lengths in the first column and the corresponding frequency in a naive T-cell repertoire in the second column.")} + if (!is.null(cdr3_length_freq)) { + if (is.data.frame(cdr3_length_freq)) { + if (ncol(cdr3_length_freq) < 2) { + stop("cdr3_length_freq has to be a data frame containing CDR3 ", + "lengths in the first column and the corresponding frequency ", + "in a naive T-cell repertoire in the second column.") + } + if (nrow(cdr3_length_freq) < 1) { + stop("cdr3_length_freq has to contain at least one row.") + } + if (suppressWarnings(any(is.na(as.numeric(cdr3_length_freq[, 2]))))) { + stop("The second column of cdr3_length_freq must contain the ", + "frequency of the corresponding CDR3 length in the first ", + "column in a naive T-cell repertoire.") + } else { + cdr3_length_freq[, 2] <- as.numeric(cdr3_length_freq[, 2]) + } + } else { + stop("cdr3_length_freq has to be a data frame containing CDR3 ", + "lengths in the first column and the corresponding frequency ", + "in a naive T-cell repertoire in the second column.") + } } ### ref_cluster_size - if(!(ref_cluster_size %in% c("original", "simulated") || !is.character(ref_cluster_size) || length(ref_cluster_size) > 1)){ + if (!(ref_cluster_size %in% c("original", "simulated") || + !is.character(ref_cluster_size) || + length(ref_cluster_size) > 1)) { stop("ref_cluster_size has to be either 'original' or 'simulated'.") } ### gliph_version - if(!(gliph_version %in% c(1,2))) stop("gliph_version has to be either 1 or 2.") + if (!(gliph_version %in% c(1, 2))) { + stop("gliph_version has to be either 1 or 2.") + } ### sim_depth - if(!is.numeric(sim_depth)) stop("sim_depth has to be numeric") - if(length(sim_depth) > 1) stop("sim_depth has to be a single number") - if(sim_depth < 1) stop("sim_depth must be at least 1") + if (!is.numeric(sim_depth)) stop("sim_depth has to be numeric") + if (length(sim_depth) > 1) stop("sim_depth has to be a single number") + if (sim_depth < 1) stop("sim_depth must be at least 1") sim_depth <- round(sim_depth) ### hla_cutoff - if(!is.numeric(hla_cutoff)) stop("hla_cutoff has to be numeric") - if(length(hla_cutoff) > 1) stop("hla_cutoff has to be a single number") - if(hla_cutoff > 1 || hla_cutoff < 0) stop("hla_cutoff must be between 0 and 1") + if (!is.numeric(hla_cutoff)) stop("hla_cutoff has to be numeric") + if (length(hla_cutoff) > 1) stop("hla_cutoff has to be a single number") + if (hla_cutoff > 1 || hla_cutoff < 0) { + stop("hla_cutoff must be between 0 and 1") + } ### n_cores - if(is.null(n_cores)) n_cores <- max(1, parallel::detectCores()-2) else { - if(!is.numeric(n_cores)) stop("n_cores has to be numeric") - if(length(n_cores) > 1) stop("n_cores has to be a single number") - if(n_cores < 1) stop("n_cores must be at least 1") - - n_cores <- max(1, min(n_cores, parallel::detectCores()-2)) + if (is.null(n_cores)) { + n_cores <- max(1, parallel::detectCores() - 2) + } else { + if (!is.numeric(n_cores)) stop("n_cores has to be numeric") + if (length(n_cores) > 1) stop("n_cores has to be a single number") + if (n_cores < 1) stop("n_cores must be at least 1") + n_cores <- max(1, min(n_cores, parallel::detectCores() - 2)) } ### Early return for empty cluster_list (after all validation) - if(length(cluster_list) == 0) { + if (length(cluster_list) == 0) { message("No clusters to score.") return(data.frame()) } @@ -199,62 +237,71 @@ clusterScoring <- function(cluster_list, ################################################################# ### Which scores can be calculated from the dataset? - score_names <- c("network.size.score","cdr3.length.score") - if("TRBV" %in% colnames(cdr3_sequences)){ + score_names <- c("network.size.score", "cdr3.length.score") + if ("TRBV" %in% colnames(cdr3_sequences)) { vgene_info <- TRUE score_names <- c(score_names, "vgene.score") - } else vgene_info <- FALSE - if("counts" %in% colnames(cdr3_sequences)) { + } else { + vgene_info <- FALSE + } + if ("counts" %in% colnames(cdr3_sequences)) { counts_info <- TRUE cdr3_sequences$counts <- as.numeric(cdr3_sequences$counts) cdr3_sequences$counts[is.na(cdr3_sequences$counts)] <- 1 score_names <- c(score_names, "clonal.expansion.score") - } else counts_info <- FALSE - if("patient" %in% colnames(cdr3_sequences)) patient_info <- TRUE else patient_info <- FALSE - if("HLA" %in% colnames(cdr3_sequences)) hla_info <- TRUE else hla_info <- FALSE - if(hla_info == TRUE && patient_info == TRUE){ - if("patient" %in% colnames(cdr3_sequences) && "HLA" %in% colnames(cdr3_sequences)){ - cdr3_sequences <- cdr3_sequences[cdr3_sequences$HLA != "" & !is.na(cdr3_sequences$HLA),] - if(nrow(cdr3_sequences > 0)) score_names <- c(score_names, "hla.score", "lowest.hlas") else hla_info <- FALSE + } else { + counts_info <- FALSE + } + patient_info <- "patient" %in% colnames(cdr3_sequences) + hla_info <- "HLA" %in% colnames(cdr3_sequences) + if (hla_info && patient_info) { + if ("patient" %in% colnames(cdr3_sequences) && + "HLA" %in% colnames(cdr3_sequences)) { + cdr3_sequences <- cdr3_sequences[ + cdr3_sequences$HLA != "" & !is.na(cdr3_sequences$HLA), ] + if (nrow(cdr3_sequences) > 0) { + score_names <- c(score_names, "hla.score", "lowest.hlas") + } else { + hla_info <- FALSE + } } } ### load or generate reference tables from reference database - # ref_cluster_sizes: data frame containing the cluster size in the first and the probability of observing a cluster with this size - # in a sample from the reference database in the second column - # vgene_ref_frequencies: vector containing the (relative) frequencies of v gene usage - # cdr3_length_ref_frequencies: vector containing the (relative) frequencies of CDR3b lengths utils::data(ref_cluster_sizes, envir = environment(), package = "immGLIPH") ref_cluster_sizes <- ref_cluster_sizes[[ref_cluster_size]] - if(is.character(refdb_beta) && refdb_beta %in% .valid_reference_names()){ + if (is.character(refdb_beta) && refdb_beta %in% .valid_reference_names()) { reference_list <- .get_reference_list() refdb_name <- refdb_beta vgene_ref_frequencies <- reference_list[[refdb_name]]$vgene_frequencies$freq cdr3_length_ref_frequencies <- reference_list[[refdb_name]]$cdr3_length_frequencies$freq } else { # User-provided data frame: compute frequencies from the data - if("TRBV" %in% colnames(refdb_beta)){ + if ("TRBV" %in% colnames(refdb_beta)) { vgene_ref_frequencies <- as.data.frame(table(refdb_beta$TRBV)) - vgene_ref_frequencies <- vgene_ref_frequencies$Freq/sum(vgene_ref_frequencies$Freq) + vgene_ref_frequencies <- vgene_ref_frequencies$Freq / + sum(vgene_ref_frequencies$Freq) } else { reference_list <- .get_reference_list() vgene_ref_frequencies <- reference_list[["gliph_reference"]]$vgene_frequencies$freq } cdr3_length_ref_frequencies <- as.data.frame(table(nchar(refdb_beta$CDR3b))) - cdr3_length_ref_frequencies <- cdr3_length_ref_frequencies$Freq/sum(cdr3_length_ref_frequencies$Freq) + cdr3_length_ref_frequencies <- cdr3_length_ref_frequencies$Freq / + sum(cdr3_length_ref_frequencies$Freq) } - if(!is.null(v_usage_freq)) vgene_ref_frequencies <- as.numeric(v_usage_freq[,2]) - if(!is.null(cdr3_length_freq)) cdr3_length_ref_frequencies <- as.numeric(cdr3_length_freq[,2]) + if (!is.null(v_usage_freq)) { + vgene_ref_frequencies <- as.numeric(v_usage_freq[, 2]) + } + if (!is.null(cdr3_length_freq)) { + cdr3_length_ref_frequencies <- as.numeric(cdr3_length_freq[, 2]) + } ### Obtain the distribution of all patients and HLA alleles in the sample - # all_patients: vector containing all unique patient indices - # all_hlas: vector containing all unique HLA alleles - # all_patient_hlas: list whose elements are named after the patients and contain the patient's HLA alleles in a vector. all_patient_hlas <- c() - if(hla_info == TRUE && patient_info == TRUE){ - cdr3_sequences$patient <- gsub(":.*", "",cdr3_sequences$patient) + if (hla_info && patient_info) { + cdr3_sequences$patient <- gsub(":.*", "", cdr3_sequences$patient) all_patients <- sort(unique(cdr3_sequences$patient)) all_patients <- all_patients[!is.na(all_patients)] all_hlas <- unlist(strsplit(unique(cdr3_sequences$HLA), ",")) @@ -263,116 +310,134 @@ clusterScoring <- function(cluster_list, num_patients <- length(all_patients) num_HLAs <- length(all_hlas) - all_patient_hlas <- lapply(all_patients, function(x){ - sort(unique(gsub(":.*", "", unlist(strsplit(cdr3_sequences$HLA[cdr3_sequences$patient == x][1], ",")), perl = TRUE))) + all_patient_hlas <- lapply(all_patients, function(x) { + sort(unique(gsub( + ":.*", "", + unlist(strsplit( + cdr3_sequences$HLA[cdr3_sequences$patient == x][1], "," + )), + perl = TRUE + ))) }) names(all_patient_hlas) <- all_patients all_hlas <- data.frame(HLA = all_hlas) - all_hlas$counts <- apply(all_hlas, 1, function(x){ - val <- 0 - for(pat in all_patients){ - if(x %in% all_patient_hlas[[pat]]) val <- val+1 - } - val - }) + all_hlas$counts <- vapply(all_hlas$HLA, function(hla) { + sum(vapply(all_patient_hlas, function(pat_hlas) { + hla %in% pat_hlas + }, logical(1))) + }, numeric(1)) } ################################################################# ## Scoring ## ################################################################# - .setup_parallel(n_cores) + BPPARAM <- .setup_parallel(n_cores) - actCluster <- NULL - res <- foreach::foreach(actCluster = seq_along(cluster_list)) %dopar% { + res <- BiocParallel::bplapply(seq_along(cluster_list), function(actCluster) { ### Get sequences and information of current cluster act_seq_infos <- cluster_list[[actCluster]] - num_members <- nrow(act_seq_infos) # number of ALL members - ori_num_members <- length(unique(act_seq_infos$CDR3b)) # number of all unique CDR3b sequences + num_members <- nrow(act_seq_infos) + ori_num_members <- length(unique(act_seq_infos$CDR3b)) all_scores <- c() ### Get network size score from lookup table score_network_size <- 1 - nearest_sample_size <- order(abs(1-as.numeric(colnames(ref_cluster_sizes)[-1])/nrow(cdr3_sequences)))[1] - if(ori_num_members > 100){ - score_network_size <- ref_cluster_sizes[100,nearest_sample_size+1] + nearest_sample_size <- order( + abs(1 - as.numeric(colnames(ref_cluster_sizes)[-1]) / + nrow(cdr3_sequences)) + )[1] + if (ori_num_members > 100) { + score_network_size <- ref_cluster_sizes[100, nearest_sample_size + 1] } else { - score_network_size <- ref_cluster_sizes[ori_num_members,nearest_sample_size+1] + score_network_size <- ref_cluster_sizes[ + ori_num_members, nearest_sample_size + 1] } all_scores <- c(all_scores, score_network_size) ### Enrichment of CDR3 length (spectratype) within cluster - score_cdr3_length <- c() - # calculate score of sample (product of all frequencies) pick_freqs <- data.frame(table(nchar(unique(act_seq_infos$CDR3b)))) colnames(pick_freqs) <- c("object", "probs") - pick_freqs$probs <- pick_freqs$probs/ori_num_members + pick_freqs$probs <- pick_freqs$probs / ori_num_members sample_score <- round(prod(pick_freqs$probs), digits = 3) # generate random subsamples - random_subsample <- list() - for(i in seq_len(sim_depth)){ - random_subsample[[i]] <- sample.int(n = length(cdr3_length_ref_frequencies), size = ori_num_members, - prob = cdr3_length_ref_frequencies, replace = TRUE) - } + random_subsample <- lapply(seq_len(sim_depth), function(i) { + sample.int( + n = length(cdr3_length_ref_frequencies), + size = ori_num_members, + prob = cdr3_length_ref_frequencies, + replace = TRUE + ) + }) - # calculate score of subsamples (product of all frequencies) - pick_freqs <- stringdist::seq_qgrams(.list = random_subsample)[,-1]/ori_num_members + # calculate score of subsamples + pick_freqs <- stringdist::seq_qgrams( + .list = random_subsample + )[, -1] / ori_num_members pick_freqs[pick_freqs == 0] <- 1 - pick_freqs <- round(exp(colSums(log(pick_freqs))), digits = 3) # vectorized way to calculate the product of each column - if(gliph_version == 1){ - score_cdr3_length <- sum(pick_freqs >= sample_score)/sim_depth + pick_freqs <- round(exp(colSums(log(pick_freqs))), digits = 3) + if (gliph_version == 1) { + score_cdr3_length <- sum(pick_freqs >= sample_score) / sim_depth } else { - score_cdr3_length <- sum(pick_freqs > sample_score)/sim_depth + score_cdr3_length <- sum(pick_freqs > sample_score) / sim_depth + } + if (score_cdr3_length == 0) { + score_cdr3_length <- 1 / sim_depth } - if(score_cdr3_length == 0) score_cdr3_length <- 1/sim_depth # minimum score of 1/sim_depth all_scores <- c(all_scores, score_cdr3_length) ### Enrichment of v genes within cluster score_vgene <- c() - if(vgene_info == TRUE){ - - # calculate score of sample (product of all frequencies) + if (vgene_info) { pick_freqs <- data.frame(table(act_seq_infos$TRBV)) colnames(pick_freqs) <- c("object", "probs") - pick_freqs$probs <- pick_freqs$probs/num_members + pick_freqs$probs <- pick_freqs$probs / num_members sample_score <- round(prod(pick_freqs$probs), digits = 3) - # generate random subsamples - random_subsample <- list() - for(i in seq_len(sim_depth)){ - random_subsample[[i]] <- sample.int(n = length(vgene_ref_frequencies), size = num_members, - prob = vgene_ref_frequencies, replace = TRUE) - } - - # calculate score of subsamples (product of all frequencies) - pick_freqs <- stringdist::seq_qgrams(.list = random_subsample)[,-1]/num_members + random_subsample <- lapply(seq_len(sim_depth), function(i) { + sample.int( + n = length(vgene_ref_frequencies), + size = num_members, + prob = vgene_ref_frequencies, + replace = TRUE + ) + }) + + pick_freqs <- stringdist::seq_qgrams( + .list = random_subsample + )[, -1] / num_members pick_freqs[pick_freqs == 0] <- 1 - pick_freqs <- round(exp(colSums(log(pick_freqs))), digits = 3) # vectorized way to calculate the product of each column - if(gliph_version == 1){ - score_vgene <- sum(pick_freqs >= sample_score)/sim_depth + pick_freqs <- round(exp(colSums(log(pick_freqs))), digits = 3) + if (gliph_version == 1) { + score_vgene <- sum(pick_freqs >= sample_score) / sim_depth } else { - score_vgene <- sum(pick_freqs > sample_score)/sim_depth + score_vgene <- sum(pick_freqs > sample_score) / sim_depth } - if(score_vgene == 0) score_vgene <- 1/sim_depth # minimum score of 1/sim_depth + if (score_vgene == 0) score_vgene <- 1 / sim_depth all_scores <- c(all_scores, score_vgene) } ### Enrichment of clonal expansion within cluster score_clonal_expansion <- c() - if(counts_info == TRUE){ - sample_score <- sum(as.numeric(act_seq_infos$counts))/num_members - counter <- 0 - for(i in seq_len(sim_depth)){ - random_subsample <- sample(x = cdr3_sequences$counts, size = num_members, replace = FALSE) - test_score <- sum(as.numeric(random_subsample))/num_members - if(test_score>=sample_score) counter <- counter+1 + if (counts_info) { + sample_score <- sum(as.numeric(act_seq_infos$counts)) / num_members + test_scores <- vapply(seq_len(sim_depth), function(i) { + random_subsample <- sample( + x = cdr3_sequences$counts, size = num_members, replace = FALSE + ) + sum(as.numeric(random_subsample)) / num_members + }, numeric(1)) + counter <- sum(test_scores >= sample_score) + if (counter == 0) { + score_clonal_expansion <- 1 / sim_depth + } else { + score_clonal_expansion <- counter / sim_depth } - if(counter == 0) score_clonal_expansion <- 1/sim_depth else score_clonal_expansion <- counter/sim_depth score_clonal_expansion <- round(score_clonal_expansion, digits = 3) all_scores <- c(all_scores, score_clonal_expansion) @@ -381,77 +446,95 @@ clusterScoring <- function(cluster_list, ### Enrichment of common HLA among donor TCR contributors in cluster score_hla <- c() lowest_hla <- "" - if(hla_info == TRUE && patient_info == TRUE){ - act_seq_infos <- act_seq_infos[act_seq_infos$HLA != "" & !is.na(act_seq_infos$HLA),] - - if(nrow(act_seq_infos) > 0){ - act_seq_infos$patient <- gsub(":.*", "",act_seq_infos$patient) - - score_hla <- 1 - for(act_hla in seq_len(num_HLAs)){ - crg_patient_count <- length(unique(act_seq_infos$patient)) - crg_patient_hla_count <- sum(unlist(lapply(all_patient_hlas[unique(act_seq_infos$patient)], function(x){ - if(all_hlas$HLA[act_hla] %in% x) 1 else 0 - }))) - if(crg_patient_hla_count > 1){ - act_Prob <- sum(choose(all_hlas$counts[act_hla], crg_patient_hla_count:crg_patient_count)*choose(num_patients-all_hlas$counts[act_hla], crg_patient_count-crg_patient_hla_count:crg_patient_count)/choose(num_patients, crg_patient_count)) - if(act_Prob 0) { + act_seq_infos$patient <- gsub(":.*", "", act_seq_infos$patient) + + score_hla <- 1 + for (act_hla in seq_len(num_HLAs)) { + crg_patient_count <- length(unique(act_seq_infos$patient)) + crg_patient_hla_count <- sum(vapply( + all_patient_hlas[unique(act_seq_infos$patient)], + function(x) { + if (all_hlas$HLA[act_hla] %in% x) 1L else 0L + }, integer(1) + )) + if (crg_patient_hla_count > 1) { + act_Prob <- sum( + choose( + all_hlas$counts[act_hla], + crg_patient_hla_count:crg_patient_count + ) * + choose( + num_patients - all_hlas$counts[act_hla], + crg_patient_count - + crg_patient_hla_count:crg_patient_count + ) / + choose(num_patients, crg_patient_count) + ) + if (act_Prob < score_hla) score_hla <- act_Prob + if (act_Prob < hla_cutoff) { + hla_str <- paste0( + all_hlas$HLA[act_hla], + " [(", crg_patient_hla_count, "/", + crg_patient_count, ") vs (", + all_hlas$counts[act_hla], "/", num_patients, + ") = ", + formatC(act_Prob, digits = 1, format = "e"), + "]" + ) + if (lowest_hla == "") { + lowest_hla <- hla_str + } else { + lowest_hla <- paste(lowest_hla, ",", hla_str) + } } } } - - } } else { - score_hla <- 1 + score_hla <- 1 } all_scores <- c(all_scores, score_hla) } ### Total score - if(gliph_version == 1) score_final <- prod(all_scores)*0.001*64 else if(gliph_version == 2) score_final <- prod(all_scores) + if (gliph_version == 1) { + score_final <- prod(all_scores) * 0.001 * 64 + } else { + score_final <- prod(all_scores) + } ### Output all_scores <- c(score_final, all_scores) all_scores <- formatC(all_scores, digits = 1, format = "e") output <- c(names(cluster_list)[actCluster], all_scores) - if(hla_info == TRUE && patient_info == TRUE){ + if (hla_info && patient_info) { output <- c(output, lowest_hla) } output - } - - .stop_parallel() + }, BPPARAM = BPPARAM) - res <- data.frame(matrix(unlist(res), ncol = 2+length(score_names), byrow = TRUE)) + res <- data.frame( + matrix(unlist(res), ncol = 2 + length(score_names), byrow = TRUE) + ) colnames(res) <- c("leader.tag", "total.score", score_names) - for(i in c("total.score", score_names)) if(i != "lowest.hlas") res[,i] <- as.numeric(res[,i]) + for (i in c("total.score", score_names)) { + if (i != "lowest.hlas") res[, i] <- as.numeric(res[, i]) + } - # set all theoretical numeric values (actually characters) to numeric values - if(is.data.frame(res)){ - for(i in seq_len(ncol(res))){ - if(suppressWarnings(any(is.na(as.numeric(res[,i])))) == FALSE) res[,i] <- as.numeric(res[,i]) + # set all theoretical numeric values to numeric + if (is.data.frame(res)) { + for (i in seq_len(ncol(res))) { + if (!suppressWarnings(any(is.na(as.numeric(res[, i]))))) { + res[, i] <- as.numeric(res[, i]) + } } } - # Closing time! - return(res[,-1]) + return(res[, -1]) } diff --git a/R/plotNetwork.R b/R/plotNetwork.R index 33c9b07..6e88fc9 100644 --- a/R/plotNetwork.R +++ b/R/plotNetwork.R @@ -55,7 +55,7 @@ #' plotNetwork(clustering_output = res, #' n_cores = 1) #' -#' @import viridis foreach grDevices +#' @import viridis grDevices #' @export plotNetwork <- function(clustering_output = NULL, @@ -127,7 +127,7 @@ plotNetwork <- function(clustering_output = NULL, ################################################################# ### Initiate parallelization - .setup_parallel(n_cores) + BPPARAM <- .setup_parallel(n_cores) ### Get cluster_list and cluster_properties with at least cluster_min_size members # cluster_list: contains all members of the cluster and the sequence specific additional information (e.g. patient, count, etc.) @@ -148,7 +148,7 @@ plotNetwork <- function(clustering_output = NULL, cluster_properties <- cluster_properties[hold_ids,] ### Pool sequence and cluster specific information (identical sequences in differenct clusters are treated as different entities) - cluster_data_frame <- data.frame(foreach::foreach(i = seq_along(cluster_list), .combine = rbind) %dopar% { + cluster_data_frame <- do.call(rbind, BiocParallel::bplapply(seq_along(cluster_list), function(i) { temp_df <- cluster_list[[i]] temp_adds <- unlist(cluster_properties[i,]) temp_adds <- data.frame(matrix(rep(temp_adds, times = nrow(temp_df)), nrow = nrow(temp_df), byrow = TRUE), stringsAsFactors = FALSE) @@ -156,7 +156,8 @@ plotNetwork <- function(clustering_output = NULL, temp_df <- cbind(temp_adds, temp_df) return(temp_df) - }, stringsAsFactors = FALSE) + }, BPPARAM = BPPARAM)) + cluster_data_frame <- data.frame(cluster_data_frame, stringsAsFactors = FALSE) cluster_data_frame[] <- lapply(cluster_data_frame, as.character) for(i in seq_len(ncol(cluster_data_frame))){ if(suppressWarnings(any(is.na(as.numeric(cluster_data_frame[,i])))) == FALSE) cluster_data_frame[,i] <- as.numeric(cluster_data_frame[,i]) @@ -195,8 +196,7 @@ plotNetwork <- function(clustering_output = NULL, if("TRBV" %in% colnames(cluster_data_frame)) vgene.info <- TRUE ### Get connections between different tuples consisting of CDR3b sequence, v gene and donor - x <- NULL - clone_network <- foreach::foreach(x = seq_len(nrow(ori_clone_net))) %dopar% { + clone_network <- BiocParallel::bplapply(seq_len(nrow(ori_clone_net)), function(x) { # Identify all tuples with correpsonding CDR3b sequence act_ids1 <- cluster_data_frame$ID[cluster_data_frame$CDR3b == ori_clone_net[x,1]] act_ids2 <- cluster_data_frame$ID[cluster_data_frame$CDR3b == ori_clone_net[x,2]] @@ -220,9 +220,8 @@ plotNetwork <- function(clustering_output = NULL, var_ret <- data.frame(comb_ids, stringsAsFactors = FALSE) var_ret <- cbind(var_ret, rep(ori_clone_net[x,3], nrow(var_ret))) return(unlist(t(var_ret))) - t(var_ret) } else return(NULL) - } + }, BPPARAM = BPPARAM) clone_network <- data.frame(matrix(unlist(clone_network), ncol = 3, byrow = TRUE), stringsAsFactors = FALSE) colnames(clone_network) <- c("from", "to", "label") clone_network$from <- as.numeric(clone_network$from) @@ -233,7 +232,7 @@ plotNetwork <- function(clustering_output = NULL, ### local connections if(parameters$local_similarities == TRUE){ ### Get local connections between different tuples consisting of CDR3b sequence, v gene and donor - local_clone_network <- foreach::foreach(i = which(cluster_properties$type == "local")) %dopar% { + local_clone_network <- BiocParallel::bplapply(which(cluster_properties$type == "local"), function(i) { # Get IDs, members and details of current cluster temp_ids <- cluster_data_frame$ID[cluster_data_frame$tag == cluster_properties$tag[i]] @@ -270,7 +269,7 @@ plotNetwork <- function(clustering_output = NULL, temp_df <- temp_df[cluster_data_frame$tag[temp_df$from] == cluster_data_frame$tag[temp_df$to],] temp_df <- t(temp_df) return(unlist(temp_df)) - } + }, BPPARAM = BPPARAM) ### If available, set clone network to the local network if(length(local_clone_network) == 0) parameters$local_similarities == FALSE else { @@ -290,7 +289,7 @@ plotNetwork <- function(clustering_output = NULL, BlosumVec <- .get_blosum_vec() ### Get local connections between different tuples consisting of CDR3b sequence, v gene and donor - global_clone_network <- foreach::foreach(i = which(cluster_properties$type == "global")) %dopar% { + global_clone_network <- BiocParallel::bplapply(which(cluster_properties$type == "global"), function(i) { # Get IDs and members of current cluster temp_ids <- cluster_data_frame$ID[cluster_data_frame$tag == cluster_properties$tag[i]] @@ -314,7 +313,7 @@ plotNetwork <- function(clustering_output = NULL, } temp_df <- t(temp_df) return(unlist(temp_df)) - } + }, BPPARAM = BPPARAM) if(parameters$all_aa_interchangeable == FALSE) message("Restrict global connections to sequences with a BLOSUM62 value for the amino acid substitution greater or equal to zero.") ### Append global network to the clone network @@ -348,8 +347,7 @@ plotNetwork <- function(clustering_output = NULL, if("TRBV" %in% colnames(cluster_data_frame)) vgene.info <- TRUE ### Get connections between different tuples consisting of CDR3b sequence, v gene and donor - x <- NULL - clone_network <- foreach::foreach(x = seq_len(nrow(ori_clone_net))) %dopar% { + clone_network <- BiocParallel::bplapply(seq_len(nrow(ori_clone_net)), function(x) { # Identify all tuples with correpsonding CDR3b sequence act_ids1 <- cluster_data_frame$ID[cluster_data_frame$CDR3b == ori_clone_net[x,1]] act_ids2 <- cluster_data_frame$ID[cluster_data_frame$CDR3b == ori_clone_net[x,2]] @@ -373,9 +371,8 @@ plotNetwork <- function(clustering_output = NULL, var_ret <- data.frame(comb_ids, stringsAsFactors = FALSE) var_ret <- cbind(var_ret, rep(ori_clone_net[x,3], nrow(var_ret))) return(unlist(t(var_ret))) - t(var_ret) } else return(NULL) - } + }, BPPARAM = BPPARAM) if(parameters$positional_motifs == TRUE) message("Restrict local similarities to sequences with identical N-terminal motif position.") if(parameters$public_tcrs == FALSE && patient.info == TRUE) message("Restrict similarities to sequences obtained from identical donor.") if(parameters$global_vgene == TRUE && vgene.info == TRUE) message("Restrict global similarities to sequences with identical v gene.") @@ -390,7 +387,7 @@ plotNetwork <- function(clustering_output = NULL, ### local connections if(parameters$local_similarities == TRUE){ ### Get local connections between different tuples consisting of CDR3b sequence, v gene and donor - local_clone_network <- foreach::foreach(i = which(cluster_properties$type == "local")) %dopar% { + local_clone_network <- BiocParallel::bplapply(which(cluster_properties$type == "local"), function(i) { # Get IDs, members and details of current cluster temp_ids <- cluster_data_frame$ID[cluster_data_frame$tag == cluster_properties$tag[i]] @@ -427,7 +424,7 @@ plotNetwork <- function(clustering_output = NULL, temp_df <- temp_df[cluster_data_frame$tag[temp_df$from] == cluster_data_frame$tag[temp_df$to],] temp_df <- t(temp_df) return(unlist(temp_df)) - } + }, BPPARAM = BPPARAM) ### If available, set clone network to the local network if(length(local_clone_network) == 0) parameters$local_similarities == FALSE else { @@ -447,7 +444,7 @@ plotNetwork <- function(clustering_output = NULL, BlosumVec <- .get_blosum_vec() ### Get local connections between different tuples consisting of CDR3b sequence, v gene and donor - global_clone_network <- foreach::foreach(i = which(cluster_properties$type == "global")) %dopar% { + global_clone_network <- BiocParallel::bplapply(which(cluster_properties$type == "global"), function(i) { # Get IDs and members of current cluster temp_ids <- cluster_data_frame$ID[cluster_data_frame$tag == cluster_properties$tag[i]] @@ -471,7 +468,7 @@ plotNetwork <- function(clustering_output = NULL, } temp_df <- t(temp_df) return(unlist(temp_df)) - } + }, BPPARAM = BPPARAM) if(parameters$all_aa_interchangeable == FALSE) message("Restrict global connections to sequences with a BLOSUM62 value for the amino acid substitution greater or equal to zero.") ### Append global network to the clone network @@ -700,8 +697,6 @@ plotNetwork <- function(clustering_output = NULL, lenodes <- data.frame(label = leg.info[,1], color = leg.info[,2], shape="dot", size=10) } - .stop_parallel() - message("Drawing the graph.") ret <- visNetwork::visLegend(visNetwork::visOptions(visNetwork::visIgraphLayout(visNetwork::visNetwork(nodes = nodes, edges = edges), layout = "layout_components"), diff --git a/R/utils-parallel.R b/R/utils-parallel.R index 19c6219..180e2ce 100644 --- a/R/utils-parallel.R +++ b/R/utils-parallel.R @@ -1,15 +1,14 @@ -#' Setup parallel backend +#' Create a BiocParallel backend parameter object #' -#' On Unix-like systems (macOS, Linux) \code{doParallel} uses forked -#' processes that inherit the loaded package namespace. On Windows, PSOCK -#' clusters are used instead; these require loading the package on each -#' worker via \code{library()}, which fails during \code{R CMD check} -#' because the package is not yet installed in the standard library path. -#' To avoid this, we fall back to sequential execution (\code{registerDoSEQ}) -#' when only one core is requested or when running on Windows. +#' Returns a \code{\link[BiocParallel]{BiocParallelParam}} suitable for the +#' current platform and requested number of cores. On Unix-like systems +#' (macOS, Linux) with more than one core, a +#' \code{\link[BiocParallel]{MulticoreParam}} is returned. On Windows or +#' when only one core is requested, a +#' \code{\link[BiocParallel]{SerialParam}} is used instead. #' -#' @param n_cores Number of cores. NULL auto-detects. -#' @return The actual number of cores being used +#' @param n_cores Number of cores. \code{NULL} auto-detects. +#' @return A \code{BiocParallelParam} object. #' @keywords internal .setup_parallel <- function(n_cores) { if (is.null(n_cores)) { @@ -18,17 +17,8 @@ n_cores <- max(1L, min(n_cores, parallel::detectCores() - 1L)) if (n_cores <= 1L || .Platform$OS.type == "windows") { - foreach::registerDoSEQ() - n_cores <- 1L + BiocParallel::SerialParam() } else { - doParallel::registerDoParallel(n_cores) + BiocParallel::MulticoreParam(workers = n_cores) } - n_cores -} - -#' Stop parallel backend -#' @return NULL (invisibly). Called for side effect. -#' @keywords internal -.stop_parallel <- function() { - doParallel::stopImplicitCluster() } From 1c592fbaff3806efbbf70dfe603d2d04f8824201 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:43:14 -0500 Subject: [PATCH 05/10] Update utils-input.R Handling immApex install --- R/utils-input.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/utils-input.R b/R/utils-input.R index 8652872..604f08b 100644 --- a/R/utils-input.R +++ b/R/utils-input.R @@ -15,7 +15,7 @@ if (!requireNamespace("immApex", quietly = TRUE)) { stop("The immApex package is required to extract TCR data from ", class(input)[1], " objects.\n", - "Install with: devtools::install_github('BorchLab/immApex')", + "Install with: BiocManager::install('BorchLab/immApex')", call. = FALSE) } ir_data <- immApex::getIR( @@ -32,7 +32,7 @@ if (!requireNamespace("immApex", quietly = TRUE)) { stop("The immApex package is required for list input from ", "combineTCR/combineBCR.\n", - "Install with: devtools::install_github('BorchLab/immApex')", + "Install with: BiocManager::install('BorchLab/immApex')", call. = FALSE) } ir_data <- immApex::getIR( From 454310e3005846a177fcc4bac8ed4baf30b32730 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:43:50 -0500 Subject: [PATCH 06/10] sampling handling update --- R/deNovoTCRs.R | 312 ++++++++++++++++++++--------------------- R/getRandomSubsample.R | 119 ++++++++++------ 2 files changed, 229 insertions(+), 202 deletions(-) diff --git a/R/deNovoTCRs.R b/R/deNovoTCRs.R index c266614..2e72e8d 100644 --- a/R/deNovoTCRs.R +++ b/R/deNovoTCRs.R @@ -96,7 +96,6 @@ #' @references Glanville, Jacob, et al. #' "Identifying specificity groups in the T cell receptor repertoire." Nature 547.7661 (2017): 94. #' @references https://github.com/immunoengineer/gliph -#' @import foreach #' @export deNovoTCRs <- function(convergence_group_tag, result_folder = "", @@ -116,66 +115,66 @@ deNovoTCRs <- function(convergence_group_tag, ################################################################## ### convergence_group_tag - if(!is.character(convergence_group_tag)) stop("convergence_group_tag has to be a character object") - if(length(convergence_group_tag) > 1) stop("convergence_group_tag has to be a single character string") + if (!is.character(convergence_group_tag)) stop("convergence_group_tag has to be a character object") + if (length(convergence_group_tag) > 1) stop("convergence_group_tag has to be a single character string") ### result_folder and clustering_output - if(!is.character(result_folder)) stop("result_folder has to be a character object") - if(length(result_folder) > 1) stop("result_folder has to be a single path") + if (!is.character(result_folder)) stop("result_folder has to be a character object") + if (length(result_folder) > 1) stop("result_folder has to be a single path") save_results <- FALSE - if(result_folder != ""){ - if(substr(result_folder,nchar(result_folder),nchar(result_folder)) != "/") result_folder <- paste0(result_folder,"/") + if (result_folder != "") { + if (substr(result_folder, nchar(result_folder), nchar(result_folder)) != "/") result_folder <- paste0(result_folder, "/") if (!dir.exists(result_folder)) dir.create(result_folder) save_results <- TRUE } else { - if(!is.list(clustering_output)) stop("If 'result_folder' = \"\" the output list of clustering must be given by 'clustering_output'.") + if (!is.list(clustering_output)) stop("If 'result_folder' = \"\" the output list of clustering must be given by 'clustering_output'.") } ### refdb_beta # if(!(refdb_beta %in% c("gliph_reference", "human_v1.0_CD4", "human_v1.0_CD8", "human_v1.0_CD48", "human_v2.0_CD4", # "human_v2.0_CD8", "human_v2.0_CD48", "mouse_v1.0_CD4", "mouse_v1.0_CD8", "mouse_v1.0_CD48")) && # !is.data.frame(refdb_beta)){ - if(!is.data.frame(refdb_beta)){ - if(length(refdb_beta) != 1 || !is.character(refdb_beta)){ + if (!is.data.frame(refdb_beta)) { + if (length(refdb_beta) != 1 || !is.character(refdb_beta)) { stop("refdb_beta has to be a data frame (containing CDR3b sequences in the first column and optional V-gene information in the second column) or the value 'gliph_reference'") - } else if(!(refdb_beta %in% c("gliph_reference"))){ + } else if (!(refdb_beta %in% c("gliph_reference"))) { stop("refdb_beta has to be a data frame (containing CDR3b sequences in the first column and optional V-gene information in the second column) or the value 'gliph_reference'") } } ### accept_sequences_with_C_F_start_end - if(!is.logical(accept_sequences_with_C_F_start_end)) stop("accept_sequences_with_C_F_start_end has to be logical") + if (!is.logical(accept_sequences_with_C_F_start_end)) stop("accept_sequences_with_C_F_start_end has to be logical") ### normalization - if(!is.logical(normalization)) stop("normalization has to be logical") + if (!is.logical(normalization)) stop("normalization has to be logical") ### sims - if(!is.numeric(sims)) stop("sims has to be numeric") - if(length(sims) > 1) stop("sims has to be a single number") - if(sims < 1) stop("sims must be at least 1") + if (!is.numeric(sims)) stop("sims has to be numeric") + if (length(sims) > 1) stop("sims has to be a single number") + if (sims < 1) stop("sims must be at least 1") sims <- round(sims) ### num_tops - if(!is.numeric(num_tops)) stop("num_tops has to be numeric") - if(length(num_tops) > 1) stop("num_tops has to be a single number") - if(num_tops < 1) stop("num_tops must be at least 1") + if (!is.numeric(num_tops)) stop("num_tops has to be numeric") + if (length(num_tops) > 1) stop("num_tops has to be a single number") + if (num_tops < 1) stop("num_tops must be at least 1") num_tops <- round(num_tops) ### min_length - if(!is.numeric(min_length)) stop("min_length has to be numeric") - if(length(min_length) > 1) stop("min_length has to be a single number") - if(min_length < 1) stop("min_length must be at least 1") + if (!is.numeric(min_length)) stop("min_length has to be numeric") + if (length(min_length) > 1) stop("min_length has to be a single number") + if (min_length < 1) stop("min_length must be at least 1") min_length <- round(min_length) ### make_figure - if(!is.logical(make_figure)) stop("make_figure has to be logical") + if (!is.logical(make_figure)) stop("make_figure has to be logical") ### n_cores - if(is.null(n_cores)) n_cores <- max(1, parallel::detectCores()-2) else { - if(!is.numeric(n_cores)) stop("n_cores has to be numeric") - if(length(n_cores) > 1) stop("n_cores has to be a single number") - if(n_cores < 1) stop("n_cores must be at least 1") + if (is.null(n_cores)) n_cores <- max(1, parallel::detectCores() - 2) else { + if (!is.numeric(n_cores)) stop("n_cores has to be numeric") + if (length(n_cores) > 1) stop("n_cores has to be a single number") + if (n_cores < 1) stop("n_cores must be at least 1") - n_cores <- max(1, min(n_cores, parallel::detectCores()-2)) + n_cores <- max(1, min(n_cores, parallel::detectCores() - 2)) } # Amino acids one letter code @@ -187,22 +186,22 @@ deNovoTCRs <- function(convergence_group_tag, ### load convergence groups from file or from input and only use specified convergence group message("Loading convergence group with tag ", convergence_group_tag, ".") - if(is.null(clustering_output)){ + if (is.null(clustering_output)) { clustering_output <- loadGLIPH(result_folder = result_folder) crg <- clustering_output$cluster_list } else { clustering_output <- clustering_output crg <- clustering_output$cluster_list } - if(!(convergence_group_tag %in% names(crg))) stop("Could not find convergence group with tag ",convergence_group_tag, " in clustering results stored in", result_folder, ".") + if (!(convergence_group_tag %in% names(crg))) stop("Could not find convergence group with tag ", convergence_group_tag, " in clustering results stored in", result_folder, ".") crg <- crg[[convergence_group_tag]] all_crg_cdr3_seqs <- crg$CDR3b ### Filter sequences for a minimun sequenc length excluded <- which(nchar(all_crg_cdr3_seqs) < min_length) - if(length(excluded) > 0){ + if (length(excluded) > 0) { all_crg_cdr3_seqs <- all_crg_cdr3_seqs[-excluded] - if(length(all_crg_cdr3_seqs) > 0){ + if (length(all_crg_cdr3_seqs) > 0) { warning(length(excluded), " sequences of the convergence group were excluded from the further procedure due to falling below a minimum length of ", min_length, ".", call. = FALSE) } else { stop("No sequences of the convergence group are of minimum length of ", min_length, ". For further procedure, adjust the parameter 'min_length'") @@ -214,7 +213,7 @@ deNovoTCRs <- function(convergence_group_tag, ################################################################# ### Initiate parallelization - .setup_parallel(n_cores) + BPPARAM <- .setup_parallel(n_cores) # get all sequences in cluster crg_cdr3_seqs <- all_crg_cdr3_seqs @@ -224,31 +223,31 @@ deNovoTCRs <- function(convergence_group_tag, ref_vgenes <- c() refseqs <- c() v_gene_norm <- normalization - if(normalization == TRUE){ + if (normalization == TRUE) { - if(is.data.frame(refdb_beta)) { + if (is.data.frame(refdb_beta)) { refseqs <- refdb_beta refseqs[] <- lapply(refseqs, as.character) - if(ncol(refseqs) > 1){ + if (ncol(refseqs) > 1) { message("Notification: First column of reference database is considered as cdr3 sequences.") } - if(ncol(refseqs) > 1 && v_gene_norm == TRUE){ + if (ncol(refseqs) > 1 && v_gene_norm == TRUE) { message("Notification: Second column of reference database is considered as V-gene information.") - } else if(v_gene_norm == TRUE){ + } else if (v_gene_norm == TRUE) { warning("Beta sequence reference database is missing column containing V-genes. Without V-gene information normalization may be inaccurate.", call. = FALSE) v_gene_norm <- FALSE } - if(ncol(refseqs) == 1) refseqs <- cbind(refseqs, rep("", nrow(refseqs))) + if (ncol(refseqs) == 1) refseqs <- cbind(refseqs, rep("", nrow(refseqs))) - refseqs <- refseqs[, c(1,2)] + refseqs <- refseqs[, c(1, 2)] colnames(refseqs) <- c("CDR3b", "TRBV") refseqs <- unique(refseqs) - if(accept_sequences_with_C_F_start_end) refseqs <- refseqs[grep(pattern = "^C.*F$",x = refseqs$CDR3b,perl = TRUE),] - refseqs <- refseqs[which(nchar(refseqs$CDR3b) >= min_length),] - refseqs <- refseqs[grep("^[ACDEFGHIKLMNOPQRSTUVWY]*$", refseqs$CDR3b),] + if (accept_sequences_with_C_F_start_end) refseqs <- refseqs[grep(pattern = "^C.*F$", x = refseqs$CDR3b, perl = TRUE), ] + refseqs <- refseqs[which(nchar(refseqs$CDR3b) >= min_length), ] + refseqs <- refseqs[grep("^[ACDEFGHIKLMNOPQRSTUVWY]*$", refseqs$CDR3b), ] - if(nrow(refseqs) == 0){ + if (nrow(refseqs) == 0) { normalization <- FALSE v_gene_norm <- FALSE warning("No reference sequences with a minimum length of ", min_length, " given. Normalization therefore not possible. Adjust min_length to enable normalization.", call. = FALSE) @@ -258,29 +257,29 @@ deNovoTCRs <- function(convergence_group_tag, } } else { reference_list <- NULL - utils::data("reference_list",envir = environment(), package = "immGLIPH") + utils::data("reference_list", envir = environment(), package = "immGLIPH") refseqs <- as.data.frame(reference_list[[refdb_beta]]$refseqs) refseqs[] <- lapply(refseqs, as.character) - if(ncol(refseqs) > 1){ + if (ncol(refseqs) > 1) { message("Notification: First column of reference database is considered as cdr3 sequences.") } - if(ncol(refseqs) > 1 && v_gene_norm == TRUE){ + if (ncol(refseqs) > 1 && v_gene_norm == TRUE) { message("Notification: Second column of reference database is considered as V-gene information.") - } else if(v_gene_norm == TRUE){ + } else if (v_gene_norm == TRUE) { warning("Beta sequence reference database is missing column containing V-genes. Without V-gene information normalization may be inaccurate.", call. = FALSE) v_gene_norm <- FALSE } - if(ncol(refseqs) == 1) refseqs <- cbind(refseqs, rep("", nrow(refseqs))) + if (ncol(refseqs) == 1) refseqs <- cbind(refseqs, rep("", nrow(refseqs))) - refseqs <- refseqs[, c(1,2)] + refseqs <- refseqs[, c(1, 2)] colnames(refseqs) <- c("CDR3b", "TRBV") refseqs <- unique(refseqs) - if(accept_sequences_with_C_F_start_end) refseqs <- refseqs[grep(pattern = "^C.*F$",x = refseqs$CDR3b,perl = TRUE),] - refseqs <- refseqs[which(nchar(refseqs$CDR3b) >= min_length),] - refseqs <- refseqs[grep("^[ACDEFGHIKLMNPQRSTVWY]*$", refseqs$CDR3b),] + if (accept_sequences_with_C_F_start_end) refseqs <- refseqs[grep(pattern = "^C.*F$", x = refseqs$CDR3b, perl = TRUE), ] + refseqs <- refseqs[which(nchar(refseqs$CDR3b) >= min_length), ] + refseqs <- refseqs[grep("^[ACDEFGHIKLMNPQRSTVWY]*$", refseqs$CDR3b), ] - if(nrow(refseqs) == 0){ + if (nrow(refseqs) == 0) { normalization <- FALSE v_gene_norm <- FALSE warning("No reference sequences with a minimum length of ", min_length, " given. Normalization therefore not possible. Adjust min_length to enable normalization.", call. = FALSE) @@ -291,9 +290,9 @@ deNovoTCRs <- function(convergence_group_tag, } # load V genes of cluster members - if("TRBV" %in% colnames(crg) && v_gene_norm == TRUE){ + if ("TRBV" %in% colnames(crg) && v_gene_norm == TRUE) { v_genes <- crg$TRBV - } else if(v_gene_norm == TRUE){ + } else if (v_gene_norm == TRUE) { v_gene_norm <- FALSE warning("Without V-gene information of sample sequences normalization may be inaccurate.", call. = FALSE) } else warning("Without V-gene restriction normalization may be inaccurate.", call. = FALSE) @@ -310,22 +309,22 @@ deNovoTCRs <- function(convergence_group_tag, message("Calculating positional weight matrix of convergence group.") # initialize the matrix - crg_pwm_scoring <- as.data.frame(matrix(rep(0, min_length*length(aa_code)), ncol = length(aa_code))) + crg_pwm_scoring <- as.data.frame(matrix(rep(0, min_length * length(aa_code)), ncol = length(aa_code))) colnames(crg_pwm_scoring) <- aa_code # for every position determine the amino acid frequency - for(i in seq_len(nrow(crg_pwm_scoring))){ + for (i in seq_len(nrow(crg_pwm_scoring))) { aa_freqs <- rep(0, length(aa_code)) act_letters <- substr(crg_cdr3_seqs, i, i) - for(j in seq_along(aa_freqs)){ + for (j in seq_along(aa_freqs)) { aa_freqs[j] <- sum(act_letters == aa_code[j]) } # Pseudocounts of 0.5% per aa zeroFreqs <- which(aa_freqs == 0) - aa_freqs <- aa_freqs/sum(aa_freqs)*(1-length(zeroFreqs)*0.005) + aa_freqs <- aa_freqs / sum(aa_freqs) * (1 - length(zeroFreqs) * 0.005) aa_freqs[zeroFreqs] <- 0.005 - crg_pwm_scoring[i,] <- aa_freqs + crg_pwm_scoring[i, ] <- aa_freqs } ### Calculate scores of convergence group members, only use first min_length N-terminal positions @@ -333,61 +332,58 @@ deNovoTCRs <- function(convergence_group_tag, message("Calculating scores of convergence group members.") # is parallelization necessary? - if(crg_num_seqs > 10000){ + if (crg_num_seqs > 10000) { # distribute sequences equally to all cores - distribute <- lapply(seq_len(n_cores), function(x){return(((x-1)*floor(length(crg_cdr3_seqs)/n_cores)+1):(x*floor(length(crg_cdr3_seqs)/n_cores)))}) + distribute <- lapply(seq_len(n_cores), function(x) {return(((x - 1) * floor(length(crg_cdr3_seqs) / n_cores) + 1):(x * floor(length(crg_cdr3_seqs) / n_cores)))}) # calculate the score - crg_cdr3_scores <- foreach::foreach(i = seq_along(distribute), .combine = c) %dopar% { - temp_scores <- rep(1, length(distribute[[i]])) - temp_seqs <- crg_cdr3_seqs[distribute[[i]]] - for(i in seq_len(nrow(crg_pwm_scoring))){ - temp_scores <- temp_scores*unlist(crg_pwm_scoring[i, substr(temp_seqs, i, i)]) + crg_cdr3_scores <- unlist(BiocParallel::bplapply(seq_along(distribute), function(idx) { + temp_scores <- rep(1, length(distribute[[idx]])) + temp_seqs <- crg_cdr3_seqs[distribute[[idx]]] + for (k in seq_len(nrow(crg_pwm_scoring))) { + temp_scores <- temp_scores * unlist(crg_pwm_scoring[k, substr(temp_seqs, k, k)]) } - - return(temp_scores) - } + temp_scores + }, BPPARAM = BPPARAM)) } else { # calculate the score - for(i in seq_len(nrow(crg_pwm_scoring))){ - crg_cdr3_scores <- crg_cdr3_scores*unlist(crg_pwm_scoring[i, substr(crg_cdr3_seqs, i, i)]) + for (i in seq_len(nrow(crg_pwm_scoring))) { + crg_cdr3_scores <- crg_cdr3_scores * unlist(crg_pwm_scoring[i, substr(crg_cdr3_seqs, i, i)]) } } ### Normalize scores of convergence group members, only use first 10 N-terminal positions # calculate the probability that a score at least this high occurs in the reference database - if(normalization == TRUE){ + if (normalization == TRUE) { message("Normalizing scores of convergence group members.") # Calculate scores of reference database (analogue to sample sequences) refseq_scores <- rep(1, length(refseqs)) - if(length(refseqs) > 10000){ - distribute <- lapply(seq_len(n_cores), function(x){return(((x-1)*floor(length(refseqs)/n_cores)+1):(x*floor(length(refseqs)/n_cores)))}) - - refseq_scores <- foreach::foreach(i = seq_along(distribute), .combine = c) %dopar% { - temp_scores <- rep(1, length(distribute[[i]])) - temp_seqs <- refseqs[distribute[[i]]] - for(i in seq_len(nrow(crg_pwm_scoring))){ - temp_scores <- temp_scores*unlist(crg_pwm_scoring[i, substr(temp_seqs, i, i)]) + if (length(refseqs) > 10000) { + distribute <- lapply(seq_len(n_cores), function(x) {return(((x - 1) * floor(length(refseqs) / n_cores) + 1):(x * floor(length(refseqs) / n_cores)))}) + + refseq_scores <- unlist(BiocParallel::bplapply(seq_along(distribute), function(idx) { + temp_scores <- rep(1, length(distribute[[idx]])) + temp_seqs <- refseqs[distribute[[idx]]] + for (k in seq_len(nrow(crg_pwm_scoring))) { + temp_scores <- temp_scores * unlist(crg_pwm_scoring[k, substr(temp_seqs, k, k)]) } - - return(temp_scores) - } + temp_scores + }, BPPARAM = BPPARAM)) } else { - for(i in seq_len(nrow(crg_pwm_scoring))){ - refseq_scores <- refseq_scores*unlist(crg_pwm_scoring[i, substr(refseqs, i, i)]) + for (i in seq_len(nrow(crg_pwm_scoring))) { + refseq_scores <- refseq_scores * unlist(crg_pwm_scoring[i, substr(refseqs, i, i)]) } } # Calculate normalized scores, if requested restrict score comparison to identical V genes - crg_cdr3_norm_scores <- foreach::foreach(i = seq_len(crg_num_seqs), .combine = c) %dopar% { + crg_cdr3_norm_scores <- unlist(BiocParallel::bplapply(seq_len(crg_num_seqs), function(idx) { v_gene_penalty <- rep(0, length(refseqs)) - if(v_gene_norm == TRUE){ - v_gene_penalty[ref_vgenes != v_genes[i]] <- -2 + if (v_gene_norm) { + v_gene_penalty[ref_vgenes != v_genes[idx]] <- -2 } - - return(sum((refseq_scores + v_gene_penalty) >= crg_cdr3_scores[i])/length(refseqs)) - } + sum((refseq_scores + v_gene_penalty) >= crg_cdr3_scores[idx]) / length(refseqs) + }, BPPARAM = BPPARAM)) } ### Create global PWM of convergence group @@ -397,33 +393,33 @@ deNovoTCRs <- function(convergence_group_tag, crg_pwm_predicting_list <- list() # get the probability of this CDR3b length to occur - crg_len_prob <- data.frame(length = unique(crg_cdr3_lens),probability = rep(0, length(unique(crg_cdr3_lens)))) - for(i in seq_len(nrow(crg_len_prob))){ - crg_len_prob$probability[i] <- sum(crg_cdr3_lens == crg_len_prob$length[i])/crg_num_seqs + crg_len_prob <- data.frame(length = unique(crg_cdr3_lens), probability = rep(0, length(unique(crg_cdr3_lens)))) + for (i in seq_len(nrow(crg_len_prob))) { + crg_len_prob$probability[i] <- sum(crg_cdr3_lens == crg_len_prob$length[i]) / crg_num_seqs } # receive the positional dependent amino acid frequencies for every CDR3b length - for(len in unique(crg_cdr3_lens)){ - crg_pwm_predicting <- as.data.frame(matrix(rep(0,len*length(aa_code)), ncol = length(aa_code))) + for (len in unique(crg_cdr3_lens)) { + crg_pwm_predicting <- as.data.frame(matrix(rep(0, len * length(aa_code)), ncol = length(aa_code))) colnames(crg_pwm_predicting) <- aa_code seqs <- crg_cdr3_seqs[crg_cdr3_lens == len] - for(i in seq_len(len)){ + for (i in seq_len(len)) { aa_freqs <- rep(0, length(aa_code)) act_letters <- substr(seqs, i, i) - for(j in seq_along(aa_freqs)){ + for (j in seq_along(aa_freqs)) { aa_freqs[j] <- sum(act_letters == aa_code[j]) } # Pseudocounts of 0.5% per aa zeroFreqs <- which(aa_freqs == 0) - aa_freqs <- aa_freqs/sum(aa_freqs)*(1-length(zeroFreqs)*0.005) + aa_freqs <- aa_freqs / sum(aa_freqs) * (1 - length(zeroFreqs) * 0.005) aa_freqs[zeroFreqs] <- 0.005 - crg_pwm_predicting[i,] <- aa_freqs + crg_pwm_predicting[i, ] <- aa_freqs } # if de novo sequences should only start with C and end with F, correct the PWM for this positions - if(accept_sequences_with_C_F_start_end == TRUE){ + if (accept_sequences_with_C_F_start_end == TRUE) { crg_pwm_predicting[1, ] <- rep(0, ncol(crg_pwm_predicting)) crg_pwm_predicting[nrow(crg_pwm_predicting), ] <- rep(0, ncol(crg_pwm_predicting)) crg_pwm_predicting$'C'[1] <- 1 @@ -438,74 +434,73 @@ deNovoTCRs <- function(convergence_group_tag, message("Creating ", sims, " de novo sequences.") # randomly select the length of the sequences - de_novo_lens <- sample(x = c(crg_len_prob$length, 0), size = sims, prob = c(crg_len_prob$probability,0), replace = TRUE) + de_novo_lens <- sample(x = c(crg_len_prob$length, 0), size = sims, prob = c(crg_len_prob$probability, 0), replace = TRUE) de_novo_seqs <- rep("", sims) # based on the PWM randomly create new sequences - for(len in crg_len_prob$length){ + for (len in crg_len_prob$length) { inds <- which(de_novo_lens == len) - for(i in seq_len(len)){ - rands <- aa_code[sample.int(n = length(aa_code), size = length(inds), prob = crg_pwm_predicting_list[[paste("Length", len)]][i,], replace = TRUE)] + for (i in seq_len(len)) { + rands <- aa_code[sample.int(n = length(aa_code), size = length(inds), prob = crg_pwm_predicting_list[[paste("Length", len)]][i, ], replace = TRUE)] de_novo_seqs[inds] <- paste(de_novo_seqs[inds], rands, sep = "") } } de_novo_seqs <- unique(de_novo_seqs) - if(accept_sequences_with_C_F_start_end) de_novo_seqs <- grep(pattern = "^C.*F$",x = de_novo_seqs ,perl = TRUE,value = TRUE) + if (accept_sequences_with_C_F_start_end) de_novo_seqs <- grep(pattern = "^C.*F$", x = de_novo_seqs, perl = TRUE, value = TRUE) de_novo_lens <- nchar(de_novo_seqs) # Score de_novo seqs as performed above message("Calculating scores of de novo sequences.") de_novo_seqs_scores <- rep(1, length(de_novo_seqs)) - if(length(de_novo_seqs) > 10000){ - distribute <- lapply(seq_len(n_cores), function(x){return(((x-1)*floor(length(de_novo_seqs)/n_cores)+1):(x*floor(length(de_novo_seqs)/n_cores)))}) + if (length(de_novo_seqs) > 10000) { + distribute <- lapply(seq_len(n_cores), function(x) {return(((x - 1) * floor(length(de_novo_seqs) / n_cores) + 1):(x * floor(length(de_novo_seqs) / n_cores)))}) - de_novo_seqs_scores <- foreach::foreach(i = seq_along(distribute), .combine = c) %dopar% { - temp_scores <- rep(1, length(distribute[[i]])) - temp_seqs <- de_novo_seqs[distribute[[i]]] - for(i in seq_len(nrow(crg_pwm_scoring))){ - temp_scores <- temp_scores*unlist(crg_pwm_scoring[i, substr(temp_seqs, i, i)]) + de_novo_seqs_scores <- unlist(BiocParallel::bplapply(seq_along(distribute), function(idx) { + temp_scores <- rep(1, length(distribute[[idx]])) + temp_seqs <- de_novo_seqs[distribute[[idx]]] + for (k in seq_len(nrow(crg_pwm_scoring))) { + temp_scores <- temp_scores * unlist(crg_pwm_scoring[k, substr(temp_seqs, k, k)]) } - - return(temp_scores) - } + temp_scores + }, BPPARAM = BPPARAM)) } else { - for(i in seq_len(nrow(crg_pwm_scoring))){ - de_novo_seqs_scores <- de_novo_seqs_scores*unlist(crg_pwm_scoring[i, substr(de_novo_seqs, i, i)]) + for (i in seq_len(nrow(crg_pwm_scoring))) { + de_novo_seqs_scores <- de_novo_seqs_scores * unlist(crg_pwm_scoring[i, substr(de_novo_seqs, i, i)]) } } de_novo_seqs_scores <- as.numeric(formatC(de_novo_seqs_scores, digits = 1, format = "e")) # Normalize de_novo seqs scores as performed above - if(normalization == TRUE){ + if (normalization == TRUE) { message("Normalizing scores of de novo sequences.") - de_novo_norm_scores <- foreach::foreach(i = seq_along(de_novo_seqs), .combine = c) %dopar% { - return(sum(refseq_scores >= de_novo_seqs_scores[i])/length(refseqs)) - } + de_novo_norm_scores <- unlist(BiocParallel::bplapply(seq_along(de_novo_seqs), function(idx) { + sum(refseq_scores >= de_novo_seqs_scores[idx]) / length(refseqs) + }, BPPARAM = BPPARAM)) de_novo_norm_scores <- as.numeric(formatC(de_novo_norm_scores, digits = 1, format = "e")) } # sort sequences based on score (normalized scores are prioritized) - if(normalization == TRUE){ - order_ids <- order(de_novo_norm_scores, decreasing=FALSE) + if (normalization == TRUE) { + order_ids <- order(de_novo_norm_scores, decreasing = FALSE) de_novo_seqs <- de_novo_seqs[order_ids] de_novo_lens <- de_novo_lens[order_ids] de_novo_seqs_scores <- de_novo_seqs_scores[order_ids] de_novo_norm_scores <- de_novo_norm_scores[order_ids] - } else{ - order_ids <- order(de_novo_seqs_scores, decreasing=TRUE) + } else { + order_ids <- order(de_novo_seqs_scores, decreasing = TRUE) de_novo_seqs <- de_novo_seqs[order_ids] de_novo_lens <- de_novo_lens[order_ids] de_novo_seqs_scores <- de_novo_seqs_scores[order_ids] } # get num_tops heighest scored sequences - if(length(de_novo_seqs) < num_tops) num_tops <- length(de_novo_seqs) + if (length(de_novo_seqs) < num_tops) num_tops <- length(de_novo_seqs) de_novo_seqs <- de_novo_seqs[seq_len(num_tops)] de_novo_lens <- de_novo_lens[seq_len(num_tops)] de_novo_seqs_scores <- de_novo_seqs_scores[seq_len(num_tops)] - if(normalization == TRUE){ + if (normalization == TRUE) { de_novo_norm_scores <- de_novo_norm_scores[seq_len(num_tops)] de_novo <- data.frame(length = de_novo_lens, seqs = de_novo_seqs, norm_score = de_novo_norm_scores, score = de_novo_seqs_scores) } else { @@ -514,11 +509,11 @@ deNovoTCRs <- function(convergence_group_tag, ### sort cluster sequences based on their score connected_inds <- seq_along(crg_cdr3_seqs) - if(normalization == FALSE){ + if (normalization == FALSE) { crg_cdr3_seqs <- crg_cdr3_seqs[order(crg_cdr3_scores, decreasing = TRUE)] connected_inds <- connected_inds[order(crg_cdr3_scores, decreasing = TRUE)] crg_cdr3_scores <- crg_cdr3_scores[order(crg_cdr3_scores, decreasing = TRUE)] - } else{ + } else { crg_cdr3_seqs <- crg_cdr3_seqs[order(crg_cdr3_norm_scores, decreasing = FALSE)] connected_inds <- connected_inds[order(crg_cdr3_norm_scores, decreasing = FALSE)] crg_cdr3_scores <- crg_cdr3_scores[order(crg_cdr3_norm_scores, decreasing = FALSE)] @@ -526,26 +521,26 @@ deNovoTCRs <- function(convergence_group_tag, } ### set all theoretical numeric values (actually characters) to numeric values - if(is.data.frame(de_novo)){ - for(i in seq_len(ncol(de_novo))){ - if(suppressWarnings(any(is.na(as.numeric(de_novo[,i])))) == FALSE) de_novo[,i] <- as.numeric(de_novo[,i]) + if (is.data.frame(de_novo)) { + for (i in seq_len(ncol(de_novo))) { + if (suppressWarnings(any(is.na(as.numeric(de_novo[, i])))) == FALSE) de_novo[, i] <- as.numeric(de_novo[, i]) } } - if(is.data.frame(crg_cdr3_scores)){ - for(i in seq_len(ncol(crg_cdr3_scores))){ - if(suppressWarnings(any(is.na(as.numeric(crg_cdr3_scores[,i])))) == FALSE) crg_cdr3_scores[,i] <- as.numeric(crg_cdr3_scores[,i]) + if (is.data.frame(crg_cdr3_scores)) { + for (i in seq_len(ncol(crg_cdr3_scores))) { + if (suppressWarnings(any(is.na(as.numeric(crg_cdr3_scores[, i])))) == FALSE) crg_cdr3_scores[, i] <- as.numeric(crg_cdr3_scores[, i]) } } - if(normalization == TRUE){ - if(is.data.frame(crg_cdr3_norm_scores)){ - for(i in seq_len(ncol(crg_cdr3_norm_scores))){ - if(suppressWarnings(any(is.na(as.numeric(crg_cdr3_norm_scores[,i])))) == FALSE) crg_cdr3_norm_scores[,i] <- as.numeric(crg_cdr3_norm_scores[,i]) + if (normalization == TRUE) { + if (is.data.frame(crg_cdr3_norm_scores)) { + for (i in seq_len(ncol(crg_cdr3_norm_scores))) { + if (suppressWarnings(any(is.na(as.numeric(crg_cdr3_norm_scores[, i])))) == FALSE) crg_cdr3_norm_scores[, i] <- as.numeric(crg_cdr3_norm_scores[, i]) } } } ### output - if(normalization == FALSE){ + if (normalization == FALSE) { output <- list(de_novo_sequences = de_novo, sample_sequences_scores = data.frame(seqs = all_crg_cdr3_seqs[connected_inds], scores = crg_cdr3_scores), cdr3_length_probability = crg_len_prob, PWM_Scoring = crg_pwm_scoring, PWM_Prediction = crg_pwm_predicting_list) } else { @@ -555,39 +550,36 @@ deNovoTCRs <- function(convergence_group_tag, ### save fname <- paste0(result_folder, convergence_group_tag, "_de_novo.txt") - if(save_results == TRUE) utils::write.table(x = de_novo,file = fname,quote = FALSE,sep = "\t",row.names = FALSE, col.names = TRUE) - if(save_results == TRUE) message("Output: results are stored in ", fname) + if (save_results == TRUE) utils::write.table(x = de_novo, file = fname, quote = FALSE, sep = "\t", row.names = FALSE, col.names = TRUE) + if (save_results == TRUE) message("Output: results are stored in ", fname) ### Print a graph with num_tops best scoring de novo sequences - if(make_figure == TRUE){ - if(normalization == FALSE){ - graphics::plot(x = seq_len(num_tops), y = de_novo$score*100, xlab = paste("Top", num_tops, "predicted TCRs"), ylab = "TCR probability based on PWM in %", type = "p", log = "xy", col = "grey", pch = 19) - graphics::points(x = seq_len(10), y = de_novo$score[seq_len(10)]*100, col = "red", pch = 19) + if (make_figure == TRUE) { + if (normalization == FALSE) { + graphics::plot(x = seq_len(num_tops), y = de_novo$score * 100, xlab = paste("Top", num_tops, "predicted TCRs"), ylab = "TCR probability based on PWM in %", type = "p", log = "xy", col = "grey", pch = 19) + graphics::points(x = seq_len(10), y = de_novo$score[seq_len(10)] * 100, col = "red", pch = 19) # which cluster sequences are present in de novo created sequences? common_ids <- which(de_novo_seqs %in% crg_cdr3_seqs) - graphics::points(x = common_ids, y = de_novo_seqs_scores[common_ids]*100, col = "yellow", pch = 20) - } else{ - graphics::plot(x = seq_len(num_tops), y = de_novo$norm_score *100, xlab = paste("Top", num_tops, "predicted TCRs"), ylab = "Normalized TCR probability based on PWM in %", + graphics::points(x = common_ids, y = de_novo_seqs_scores[common_ids] * 100, col = "yellow", pch = 20) + } else { + graphics::plot(x = seq_len(num_tops), y = de_novo$norm_score * 100, xlab = paste("Top", num_tops, "predicted TCRs"), ylab = "Normalized TCR probability based on PWM in %", type = "p", log = "xy", col = "grey", pch = 19) - graphics::points(x = seq_len(10), y = de_novo$norm_score[seq_len(10)]*100, col = "red", pch = 19) + graphics::points(x = seq_len(10), y = de_novo$norm_score[seq_len(10)] * 100, col = "red", pch = 19) # which cluster sequences are present in de novo created sequences? common_ids <- which(de_novo_seqs %in% crg_cdr3_seqs) - graphics::points(x = common_ids, y = de_novo_norm_scores[common_ids]*100, col = "yellow", pch = 20) + graphics::points(x = common_ids, y = de_novo_norm_scores[common_ids] * 100, col = "yellow", pch = 20) } - graphics::legend("bottomleft", legend=c(paste0("Top ", num_tops," de novo scores"), "Top 10 de novo scores", "Convergence group members\nin de novo sequences"), - col=c("grey", "red", "yellow"), pch = c(19,19,20),title="Legend", cex = 0.75) + graphics::legend("bottomleft", legend = c(paste0("Top ", num_tops, " de novo scores"), "Top 10 de novo scores", "Convergence group members\nin de novo sequences"), + col = c("grey", "red", "yellow"), pch = c(19, 19, 20), title = "Legend", cex = 0.75) } t2 <- Sys.time() - dt <- (t2-t1) + dt <- (t2 - t1) message("Total time = ", dt, " ", units(dt)) - .stop_parallel() - - ### Closing time! return(output) } diff --git a/R/getRandomSubsample.R b/R/getRandomSubsample.R index b2b810e..c45db80 100644 --- a/R/getRandomSubsample.R +++ b/R/getRandomSubsample.R @@ -52,86 +52,121 @@ getRandomSubsample <- function(cdr3_len_stratify = FALSE, motif_region_vgenes_list, ref_motif_vgenes_id_list, ref_lengths_vgenes_list, - lengths_vgenes_list){ + lengths_vgenes_list) { random_subsample <- c() ### Return an unbiased reference subsample - if(cdr3_len_stratify == FALSE && vgene_stratify == FALSE){ - random_subsample <- refseqs_motif_region[sample(x = seq_along(refseqs_motif_region),size = length(motif_region),replace = FALSE)] + if (!cdr3_len_stratify && !vgene_stratify) { + random_subsample <- refseqs_motif_region[ + sample( + x = seq_along(refseqs_motif_region), + size = length(motif_region), + replace = FALSE + ) + ] } - ### Return a reference subsample with biased CDR3b length distribution according to the sample - if(cdr3_len_stratify == TRUE && vgene_stratify == FALSE){ - - ## if there are more seqs with specific cdr3 length in sample than in reference database, surplus number of seqs will be taken randomly from remaining seqs with different cdr3 length + ### Return a reference subsample with biased CDR3b length distribution + if (cdr3_len_stratify && !vgene_stratify) { random_length <- 0 subsample_parts <- vector("list", length(motif_lengths_list)) idx <- 0L - for(cdr3_length in names(motif_lengths_list)){ + for (cdr3_length in names(motif_lengths_list)) { idx <- idx + 1L - if(length(ref_motif_lengths_id_list[[cdr3_length]]) < motif_lengths_list[[cdr3_length]]){ - random_length <- random_length + motif_lengths_list[[cdr3_length]] - length(ref_motif_lengths_id_list[[cdr3_length]]) - subsample_parts[[idx]] <- ref_motif_lengths_id_list[[cdr3_length]] - }else { - subsample_parts[[idx]] <- sample(x = ref_motif_lengths_id_list[[cdr3_length]],size = motif_lengths_list[[cdr3_length]],replace = FALSE) + ref_ids <- ref_motif_lengths_id_list[[cdr3_length]] + needed <- motif_lengths_list[[cdr3_length]] + if (length(ref_ids) < needed) { + random_length <- random_length + needed - length(ref_ids) + subsample_parts[[idx]] <- ref_ids + } else { + subsample_parts[[idx]] <- sample( + x = ref_ids, size = needed, replace = FALSE + ) } } random_subsample <- unlist(subsample_parts) - if(random_length > 0){ - random_subsample <- c(random_subsample, sample(x = (seq_along(refseqs_motif_region))[-random_subsample],size = random_length,replace = FALSE)) + if (random_length > 0) { + random_subsample <- c( + random_subsample, + sample( + x = seq_along(refseqs_motif_region)[-random_subsample], + size = random_length, + replace = FALSE + ) + ) } random_subsample <- refseqs_motif_region[random_subsample] } - ### Return a reference subsample with biased V gene distribution according to the sample - if(vgene_stratify == TRUE && cdr3_len_stratify == FALSE){ - - ## if there are more seqs with specific v gene in sample than in reference database, surplus number of seqs will be taken randomly from remaining seqs with different vgenes + ### Return a reference subsample with biased V gene distribution + if (vgene_stratify && !cdr3_len_stratify) { random_length <- 0 subsample_parts <- vector("list", length(motif_region_vgenes_list)) idx <- 0L - for(act_vgene in names(motif_region_vgenes_list)){ + for (act_vgene in names(motif_region_vgenes_list)) { idx <- idx + 1L - if(length(ref_motif_vgenes_id_list[[act_vgene]]) < motif_region_vgenes_list[[act_vgene]]){ - random_length <- random_length + motif_region_vgenes_list[[act_vgene]] - length(ref_motif_vgenes_id_list[[act_vgene]]) - subsample_parts[[idx]] <- ref_motif_vgenes_id_list[[act_vgene]] - }else { - subsample_parts[[idx]] <- sample(x = ref_motif_vgenes_id_list[[act_vgene]],size = motif_region_vgenes_list[[act_vgene]],replace = FALSE) + ref_ids <- ref_motif_vgenes_id_list[[act_vgene]] + needed <- motif_region_vgenes_list[[act_vgene]] + if (length(ref_ids) < needed) { + random_length <- random_length + needed - length(ref_ids) + subsample_parts[[idx]] <- ref_ids + } else { + subsample_parts[[idx]] <- sample( + x = ref_ids, size = needed, replace = FALSE + ) } } random_subsample <- unlist(subsample_parts) - if(random_length > 0){ - random_subsample <- c(random_subsample, sample(x = (seq_along(refseqs_motif_region))[-random_subsample],size = random_length,replace = FALSE)) + if (random_length > 0) { + random_subsample <- c( + random_subsample, + sample( + x = seq_along(refseqs_motif_region)[-random_subsample], + size = random_length, + replace = FALSE + ) + ) } random_subsample <- refseqs_motif_region[random_subsample] } - ### Return a reference subsample with biased V gene and CDR3b length distribution according to the sample - if(vgene_stratify == TRUE && cdr3_len_stratify == TRUE){ - - ## if there are more seqs with specific v gene or CDR3b length in sample than in reference database, surplus number of seqs will be taken randomly from remaining seqs + ### Return a reference subsample with biased V gene and CDR3b length distribution + if (vgene_stratify && cdr3_len_stratify) { random_length <- 0 - subsample_parts <- vector("list", length(motif_lengths_list) * length(motif_region_vgenes_list)) + subsample_parts <- vector( + "list", + length(motif_lengths_list) * length(motif_region_vgenes_list) + ) idx <- 0L - for(cdr3_length in names(motif_lengths_list)){ - for(act_vgene in names(motif_region_vgenes_list)){ + for (cdr3_length in names(motif_lengths_list)) { + for (act_vgene in names(motif_region_vgenes_list)) { idx <- idx + 1L - if(length(ref_lengths_vgenes_list[[cdr3_length]][[act_vgene]]) < lengths_vgenes_list[[cdr3_length]][[act_vgene]]){ - random_length <- random_length + lengths_vgenes_list[[cdr3_length]][[act_vgene]] - length(ref_lengths_vgenes_list[[cdr3_length]][[act_vgene]]) - subsample_parts[[idx]] <- ref_lengths_vgenes_list[[cdr3_length]][[act_vgene]] - }else { - subsample_parts[[idx]] <- sample(x = ref_lengths_vgenes_list[[cdr3_length]][[act_vgene]],size = lengths_vgenes_list[[cdr3_length]][[act_vgene]],replace = FALSE) + ref_ids <- ref_lengths_vgenes_list[[cdr3_length]][[act_vgene]] + needed <- lengths_vgenes_list[[cdr3_length]][[act_vgene]] + if (length(ref_ids) < needed) { + random_length <- random_length + needed - length(ref_ids) + subsample_parts[[idx]] <- ref_ids + } else { + subsample_parts[[idx]] <- sample( + x = ref_ids, size = needed, replace = FALSE + ) } } } random_subsample <- unlist(subsample_parts) - if(random_length > 0){ - random_subsample <- c(random_subsample, sample(x = (seq_along(refseqs_motif_region))[-random_subsample],size = random_length,replace = FALSE)) + if (random_length > 0) { + random_subsample <- c( + random_subsample, + sample( + x = seq_along(refseqs_motif_region)[-random_subsample], + size = random_length, + replace = FALSE + ) + ) } random_subsample <- refseqs_motif_region[random_subsample] } - ## Closing time! return(random_subsample) } From 66ed04c15300fa699d410a055e94affaa33b59fe Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 07:53:56 -0500 Subject: [PATCH 07/10] Documentation update Based on changes from https://github.com/Bioconductor/Contributions/issues/4143 --- DESCRIPTION | 5 ++--- NAMESPACE | 1 - NEWS.md | 18 ++++++++++++++++++ R/local-fisher.R | 2 +- R/utils-parallel.R | 9 +++++++++ man/dot-cluster_gliph1.Rd | 5 ++++- man/dot-cluster_gliph2.Rd | 5 ++++- man/dot-global_cutoff.Rd | 8 ++++---- man/dot-global_cutoff_stringdist.Rd | 6 +++--- man/dot-global_fisher.Rd | 5 +++-- man/dot-local_fisher.Rd | 5 +++-- man/dot-local_rrs.Rd | 5 +++-- man/dot-setup_parallel.Rd | 23 +++++++++++++---------- man/dot-stop_parallel.Rd | 15 --------------- tests/testthat/test-utils-parallel.R | 12 ++++-------- 15 files changed, 71 insertions(+), 53 deletions(-) delete mode 100644 man/dot-stop_parallel.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 27f165e..02a98bf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: immGLIPH Title: Grouping of Lymphocyte Interactions by Paratope Hotspots -Version: 0.99.2 +Version: 0.99.3 Authors@R: c( person("Nick", "Borcherding", role = c("aut", "cre"), email = "ncborch@gmail.com") @@ -26,8 +26,7 @@ Depends: Imports: stringdist, igraph, - foreach, - doParallel, + BiocParallel, parallel, stringr, stats, diff --git a/NAMESPACE b/NAMESPACE index 3479bfb..767757e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,5 @@ export(getRandomSubsample) export(loadGLIPH) export(plotNetwork) export(runGLIPH) -import(foreach) import(grDevices) import(viridis) diff --git a/NEWS.md b/NEWS.md index 9da9e29..d86530c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,21 @@ +# immGLIPH 0.99.3 + +* Replaced `foreach`/`doParallel` with `BiocParallel` for parallelization + across all functions per Bioconductor recommendations. +* Replaced `devtools::install_github()` references with + `BiocManager::install()` in vignette and error messages. +* Updated vignette to use `SingleCellExperiment` instead of `Seurat` for + the single-cell workflow example. +* Fixed `combineTCR()` example in vignette (removed obsolete `cells` + argument). +* Made more vignette code chunks evaluable (`clusterScoring()`, + `deNovoTCRs()`, `plotNetwork()` examples). +* Noted that `scRepertoire` and `immApex` are Bioconductor packages. +* Replaced iterative for-loop list growing with vectorized alternatives + (`lapply()`, `vapply()`, `Reduce()`). +* Standardized code spacing around operators and after commas per + Bioconductor coding style. + # immGLIPH 0.99.2 * Fixing roxygen documentation issue creating warnings. diff --git a/R/local-fisher.R b/R/local-fisher.R index 196fc75..d790068 100644 --- a/R/local-fisher.R +++ b/R/local-fisher.R @@ -27,7 +27,7 @@ #' @param motif_distance_cutoff Numeric. Maximum positional distance for motifs #' to be grouped together. Not used directly in this function but kept for #' interface consistency. -#' @param BPPARAM A \linkS4class{BiocParallelParam} object specifying the +#' @param BPPARAM A \code{\link[BiocParallel]{BiocParallelParam}} object specifying the #' parallel backend. Defaults to \code{BiocParallel::SerialParam()}. #' @param verbose Logical. If \code{TRUE}, print status messages via #' \code{message()}. diff --git a/R/utils-parallel.R b/R/utils-parallel.R index 180e2ce..9fc0e11 100644 --- a/R/utils-parallel.R +++ b/R/utils-parallel.R @@ -7,6 +7,9 @@ #' when only one core is requested, a #' \code{\link[BiocParallel]{SerialParam}} is used instead. #' +#' The core count is clamped to 2 when the \env{_R_CHECK_LIMIT_CORES_} +#' environment variable is set (as during \command{R CMD check}). +#' #' @param n_cores Number of cores. \code{NULL} auto-detects. #' @return A \code{BiocParallelParam} object. #' @keywords internal @@ -16,6 +19,12 @@ } n_cores <- max(1L, min(n_cores, parallel::detectCores() - 1L)) + ## Respect R CMD check core limit + chk <- tolower(Sys.getenv("_R_CHECK_LIMIT_CORES_", "")) + if (nzchar(chk) && chk != "false") { + n_cores <- min(n_cores, 2L) + } + if (n_cores <= 1L || .Platform$OS.type == "windows") { BiocParallel::SerialParam() } else { diff --git a/man/dot-cluster_gliph1.Rd b/man/dot-cluster_gliph1.Rd index 14187e1..4bd2490 100644 --- a/man/dot-cluster_gliph1.Rd +++ b/man/dot-cluster_gliph1.Rd @@ -14,7 +14,8 @@ global_vgene, public_tcrs, cluster_min_size, - verbose + verbose, + BPPARAM ) } \arguments{ @@ -40,6 +41,8 @@ global neighbours (used to add singletons).} \item{cluster_min_size}{Integer. Minimum cluster size to retain.} \item{verbose}{Logical. Print progress messages.} + +\item{BPPARAM}{A \code{BiocParallelParam} object for parallel evaluation.} } \value{ A list with: diff --git a/man/dot-cluster_gliph2.Rd b/man/dot-cluster_gliph2.Rd index 4ddc9c8..21211b0 100644 --- a/man/dot-cluster_gliph2.Rd +++ b/man/dot-cluster_gliph2.Rd @@ -17,7 +17,8 @@ motif_distance_cutoff, cluster_min_size, boost_local_significance, - verbose + verbose, + BPPARAM ) } \arguments{ @@ -55,6 +56,8 @@ local motifs.} using germline N-nucleotide information.} \item{verbose}{Logical. Print progress messages.} + +\item{BPPARAM}{A \code{BiocParallelParam} object for parallel evaluation.} } \value{ A list with: diff --git a/man/dot-global_cutoff.Rd b/man/dot-global_cutoff.Rd index 0076c83..d56872f 100644 --- a/man/dot-global_cutoff.Rd +++ b/man/dot-global_cutoff.Rd @@ -10,7 +10,7 @@ sequences, gccutoff, global_vgene, - no_cores, + BPPARAM, verbose ) } @@ -31,9 +31,9 @@ considered globally similar.} \item{global_vgene}{logical. If \code{TRUE}, global connections are restricted to sequence pairs that share a V gene.} -\item{no_cores}{numeric. Number of cores registered with the parallel -backend (used only for documentation; the function relies on a -pre-registered \code{foreach} backend).} +\item{BPPARAM}{A \code{\link[BiocParallel]{BiocParallelParam}} object +controlling parallel evaluation (e.g. +\code{BiocParallel::MulticoreParam()}).} \item{verbose}{logical. If \code{TRUE}, progress messages are printed.} } diff --git a/man/dot-global_cutoff_stringdist.Rd b/man/dot-global_cutoff_stringdist.Rd index ab505fa..9db54cb 100644 --- a/man/dot-global_cutoff_stringdist.Rd +++ b/man/dot-global_cutoff_stringdist.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/global-cutoff.R \name{.global_cutoff_stringdist} \alias{.global_cutoff_stringdist} -\title{stringdist + foreach fallback for global cutoff} +\title{stringdist + BiocParallel fallback for global cutoff} \usage{ .global_cutoff_stringdist( seqs, @@ -10,7 +10,7 @@ sequences, gccutoff, global_vgene, - no_cores, + BPPARAM, verbose ) } @@ -18,6 +18,6 @@ A list with edge data and excluded sequence IDs. } \description{ -stringdist + foreach fallback for global cutoff +stringdist + BiocParallel fallback for global cutoff } \keyword{internal} diff --git a/man/dot-global_fisher.Rd b/man/dot-global_fisher.Rd index 5bb330a..fafee1e 100644 --- a/man/dot-global_fisher.Rd +++ b/man/dot-global_fisher.Rd @@ -14,7 +14,7 @@ boundary_size, global_vgene, all_aa_interchangeable, - no_cores, + BPPARAM, verbose ) } @@ -43,7 +43,8 @@ a V-gene.} \item{all_aa_interchangeable}{Logical. If \code{FALSE}, only pairs whose variable-position amino acids have BLOSUM62 >= 0 are kept.} -\item{no_cores}{Integer. Number of registered parallel cores.} +\item{BPPARAM}{A \code{\link[BiocParallel]{BiocParallelParam}} object +controlling parallel evaluation.} \item{verbose}{Logical. Print progress messages.} } diff --git a/man/dot-local_fisher.Rd b/man/dot-local_fisher.Rd index 77a7248..abea02c 100644 --- a/man/dot-local_fisher.Rd +++ b/man/dot-local_fisher.Rd @@ -16,7 +16,7 @@ lcminove, discontinuous_motifs, motif_distance_cutoff, - no_cores, + BPPARAM, verbose ) } @@ -55,7 +55,8 @@ in the search.} to be grouped together. Not used directly in this function but kept for interface consistency.} -\item{no_cores}{Numeric. Number of cores to use for parallel motif finding.} +\item{BPPARAM}{A \code{\link[BiocParallel]{BiocParallelParam}} object specifying the +parallel backend. Defaults to \code{BiocParallel::SerialParam()}.} \item{verbose}{Logical. If \code{TRUE}, print status messages via \code{message()}.} diff --git a/man/dot-local_rrs.Rd b/man/dot-local_rrs.Rd index b4dec86..a762a84 100644 --- a/man/dot-local_rrs.Rd +++ b/man/dot-local_rrs.Rd @@ -17,7 +17,7 @@ discontinuous_motifs, cdr3_len_stratify, vgene_stratify, - no_cores, + BPPARAM, verbose, motif_lengths_list, ref_motif_lengths_id_list, @@ -62,7 +62,8 @@ CDR3 length distribution.} \item{vgene_stratify}{Logical. Whether to stratify random subsamples by V-gene usage distribution.} -\item{no_cores}{Integer. Number of cores for parallel execution.} +\item{BPPARAM}{A \code{\link[BiocParallel]{BiocParallelParam}} object +controlling parallel execution (default: \code{BiocParallel::bpparam()}).} \item{verbose}{Logical. If \code{TRUE}, emit progress messages.} diff --git a/man/dot-setup_parallel.Rd b/man/dot-setup_parallel.Rd index f4afe91..eed677c 100644 --- a/man/dot-setup_parallel.Rd +++ b/man/dot-setup_parallel.Rd @@ -2,23 +2,26 @@ % Please edit documentation in R/utils-parallel.R \name{.setup_parallel} \alias{.setup_parallel} -\title{Setup parallel backend} +\title{Create a BiocParallel backend parameter object} \usage{ .setup_parallel(n_cores) } \arguments{ -\item{n_cores}{Number of cores. NULL auto-detects.} +\item{n_cores}{Number of cores. \code{NULL} auto-detects.} } \value{ -The actual number of cores being used +A \code{BiocParallelParam} object. } \description{ -On Unix-like systems (macOS, Linux) \code{doParallel} uses forked -processes that inherit the loaded package namespace. On Windows, PSOCK -clusters are used instead; these require loading the package on each -worker via \code{library()}, which fails during \code{R CMD check} -because the package is not yet installed in the standard library path. -To avoid this, we fall back to sequential execution (\code{registerDoSEQ}) -when only one core is requested or when running on Windows. +Returns a \code{\link[BiocParallel]{BiocParallelParam}} suitable for the +current platform and requested number of cores. On Unix-like systems +(macOS, Linux) with more than one core, a +\code{\link[BiocParallel]{MulticoreParam}} is returned. On Windows or +when only one core is requested, a +\code{\link[BiocParallel]{SerialParam}} is used instead. +} +\details{ +The core count is clamped to 2 when the \env{_R_CHECK_LIMIT_CORES_} +environment variable is set (as during \command{R CMD check}). } \keyword{internal} diff --git a/man/dot-stop_parallel.Rd b/man/dot-stop_parallel.Rd deleted file mode 100644 index 8c01239..0000000 --- a/man/dot-stop_parallel.Rd +++ /dev/null @@ -1,15 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils-parallel.R -\name{.stop_parallel} -\alias{.stop_parallel} -\title{Stop parallel backend} -\usage{ -.stop_parallel() -} -\value{ -NULL (invisibly). Called for side effect. -} -\description{ -Stop parallel backend -} -\keyword{internal} diff --git a/tests/testthat/test-utils-parallel.R b/tests/testthat/test-utils-parallel.R index dd2f968..23aea87 100644 --- a/tests/testthat/test-utils-parallel.R +++ b/tests/testthat/test-utils-parallel.R @@ -1,31 +1,27 @@ # Tests for .setup_parallel() -# ---- .setup_parallel with n_cores = 1 returns SerialParam -------------------- - test_that(".setup_parallel with 1 core returns a SerialParam", { result <- immGLIPH:::.setup_parallel(1) expect_s4_class(result, "SerialParam") }) -# ---- .setup_parallel with NULL returns a valid param ------------------------- - test_that(".setup_parallel with NULL returns a valid BiocParallelParam", { result <- immGLIPH:::.setup_parallel(NULL) expect_true(is(result, "BiocParallelParam")) }) -# ---- .setup_parallel with multiple cores on non-Windows ---------------------- - test_that(".setup_parallel returns MulticoreParam on non-Windows with multiple cores", { skip_on_os("windows") skip_if(parallel::detectCores() < 3, "Need at least 3 cores to test MulticoreParam") + # Respect _R_CHECK_LIMIT_CORES_ — only expect MulticoreParam when allowed + chk <- tolower(Sys.getenv("_R_CHECK_LIMIT_CORES_", "")) + skip_if(nzchar(chk) && chk != "false", + "_R_CHECK_LIMIT_CORES_ is set; MulticoreParam may be clamped") result <- immGLIPH:::.setup_parallel(2) expect_s4_class(result, "MulticoreParam") }) -# ---- .setup_parallel clamps to valid range ----------------------------------- - test_that(".setup_parallel clamps excessive core count", { result <- immGLIPH:::.setup_parallel(9999) expect_true(is(result, "BiocParallelParam")) From c36272c71883259ab3b3d659ab0765194c831ad6 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Wed, 8 Apr 2026 08:08:49 -0500 Subject: [PATCH 08/10] cleaning vignette --- vignettes/immGLIPH.Rmd | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/vignettes/immGLIPH.Rmd b/vignettes/immGLIPH.Rmd index 762311f..fc5d89a 100644 --- a/vignettes/immGLIPH.Rmd +++ b/vignettes/immGLIPH.Rmd @@ -16,10 +16,11 @@ vignette: > --- ```{r setup, include=FALSE} +library(BiocStyle) knitr::opts_chunk$set( echo = TRUE, message = FALSE, - + tidy = FALSE, warning = FALSE, fig.width = 7, fig.height = 5 @@ -68,6 +69,8 @@ BiocManager::install("BiocFileCache") ref <- getGLIPHreference() ``` +### Loading Package + ```{r} library(immGLIPH) ``` @@ -126,13 +129,12 @@ scRepertoire to prepare your data and pass the output directly to immGLIPH. library(scRepertoire) # After processing with cellranger/etc, combine contigs -combined <- combineTCR( - contig_list, - samples = c("P1", "P2") -) +combined <- combineTCR(contig_list[1:2], + samples = c("P1", "P2")) # Pass scRepertoire output directly to runGLIPH -results <- runGLIPH(combined, method = "gliph2") +results <- runGLIPH(combined, + method = "gliph2") ``` For **SingleCellExperiment** objects that already contain TCR metadata (e.g., @@ -145,7 +147,9 @@ library(SingleCellExperiment) data("gliph_sce") # SingleCellExperiment object with TCR info in colData -results <- runGLIPH(gliph_sce, method = "gliph2", chains = "TRB") +results <- runGLIPH(gliph_sce, + method = "gliph2", + chains = "TRB") ``` # The `runGLIPH()` Function @@ -379,18 +383,19 @@ data("gliph_input_data") sample_seqs <- as.character(gliph_input_data$CDR3b[seq_len(100)]) # Find all 3-mers appearing at least 5 times -motifs <- findMotifs(seqs = sample_seqs, q = 3, kmer_mindepth = 5) +motifs <- findMotifs(seqs = sample_seqs, + q = 3, + kmer_mindepth = 5) head(motifs[order(motifs$V1, decreasing = TRUE), ]) ``` Including discontinuous motifs (e.g., `C.S` where `.` is any amino acid): ```{r} -disc_motifs <- findMotifs( - seqs = sample_seqs, - q = 2, - kmer_mindepth = 5, - discontinuous = TRUE +disc_motifs <- findMotifs(seqs = sample_seqs, + q = 2, + kmer_mindepth = 5, + discontinuous = TRUE ) # Show discontinuous motifs (those containing a dot) disc_only <- disc_motifs[grep("\\.", disc_motifs$motif), ] @@ -439,8 +444,7 @@ if (length(res_gliph1$cluster_list) > 0) { refdb_beta = ref_df, gliph_version = 2, sim_depth = 100, - n_cores = 1 - ) + n_cores = 1) head(rescored) } ``` @@ -565,7 +569,7 @@ computationally intensive steps: call, replacing the parallel loop over `stringdist::stringdist()`. If immApex is not installed, immGLIPH falls back to the original pure-R -implementations transparently---no code changes are needed. +implementations transparently, no code changes are needed. ```{r eval=FALSE} # Install immApex for performance acceleration From 1a24f9f9b989fad1a792065670038a1b3bbb6e3d Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Sun, 3 May 2026 04:26:59 -0500 Subject: [PATCH 09/10] Fix bug in clusterScoring --- R/clusterScoring.R | 2 +- tests/testthat/test-clusterScoring.R | 50 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/R/clusterScoring.R b/R/clusterScoring.R index d4a57d9..092552c 100644 --- a/R/clusterScoring.R +++ b/R/clusterScoring.R @@ -428,7 +428,7 @@ clusterScoring <- function(cluster_list, sample_score <- sum(as.numeric(act_seq_infos$counts)) / num_members test_scores <- vapply(seq_len(sim_depth), function(i) { random_subsample <- sample( - x = cdr3_sequences$counts, size = num_members, replace = FALSE + x = cdr3_sequences$counts, size = num_members, replace = TRUE ) sum(as.numeric(random_subsample)) / num_members }, numeric(1)) diff --git a/tests/testthat/test-clusterScoring.R b/tests/testthat/test-clusterScoring.R index 3559f11..58be87a 100644 --- a/tests/testthat/test-clusterScoring.R +++ b/tests/testthat/test-clusterScoring.R @@ -344,6 +344,56 @@ test_that("clusterScoring with multiple clusters", { expect_equal(nrow(result), 2) }) +test_that("clusterScoring handles cluster larger than reference pool (counts)", { + # Regression test: previously errored with + # "invalid first argument" from sample.int when num_members exceeded + # length(cdr3_sequences$counts) because the null draw used + # replace = FALSE. Bootstrap (replace = TRUE) is the correct + # semantics for the clonal-expansion-enrichment null distribution. + skip_on_cran() + + ref_df <- data.frame( + CDR3b = c("CASSLAPGATNEKLFF", "CASSLDRGEVFF", "CASSYLAGGRNTLYF", + "CASSLTGGEETQYF", "CASSLGGRETQYF"), + TRBV = c("TRBV5-1", "TRBV6-2", "TRBV5-1", "TRBV7-2", "TRBV5-1"), + stringsAsFactors = FALSE + ) + + seqs <- data.frame( + CDR3b = c("CASSLAPGATNEKLFF", "CASSLDRGEVFF", "CASSYLAGGRNTLYF", + "CASSLTGGEETQYF", "CASSLGGRETQYF"), + TRBV = c("TRBV5-1", "TRBV6-2", "TRBV5-1", "TRBV7-2", "TRBV5-1"), + counts = c(5, 3, 2, 1, 1), + stringsAsFactors = FALSE + ) + + # Cluster has 10 members (via duplicated CDR3s), exceeding the 5-row + # reference pool — exercises the resampling branch where + # num_members > length(cdr3_sequences$counts). + cl <- list( + "CRG-1" = data.frame( + CDR3b = rep(c("CASSLAPGATNEKLFF", "CASSLDRGEVFF"), times = 5), + TRBV = rep(c("TRBV5-1", "TRBV6-2"), times = 5), + counts = rep(c(5, 3), times = 5), + stringsAsFactors = FALSE + ) + ) + + expect_no_error( + result <- clusterScoring( + cluster_list = cl, + cdr3_sequences = seqs, + refdb_beta = ref_df, + gliph_version = 1, + sim_depth = 10, + n_cores = 1 + ) + ) + expect_s3_class(result, "data.frame") + expect_true("clonal.expansion.score" %in% colnames(result)) + expect_equal(nrow(result), 1) +}) + test_that("clusterScoring with custom v_usage_freq", { skip_on_cran() From f8af5e9560ed8fb86282649c07ef99ab7e74bc40 Mon Sep 17 00:00:00 2001 From: theHumanBorch Date: Sun, 3 May 2026 04:27:53 -0500 Subject: [PATCH 10/10] Adding GLIPH1/2 compare Per bioconductor review: https://github.com/Bioconductor/Contributions/issues/4143 --- NEWS.md | 15 +++++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ man/figures/concordance_summary.png | Bin 0 -> 86504 bytes 3 files changed, 43 insertions(+) create mode 100644 man/figures/concordance_summary.png diff --git a/NEWS.md b/NEWS.md index d86530c..0200bc0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,18 @@ +# immGLIPH 0.99.4 + +* Added a Validation section to the README with concordance metrics + against the published GLIPH and GLIPH2 cluster vectors from + Glanville et al. (2017) and Huang et al. (2020). With paper-matched + parameters, immGLIPH reproduces the published cluster vectors at + ARI 0.985 (Glanville) and 0.863 (Huang) on the intersection of + shared CDR3s. Full benchmark code lives at + [BorchLab/immGLIPH-benchmark](https://github.com/BorchLab/immGLIPH-benchmark). +* Fixed `clusterScoring()` failure in the clonal-expansion-enrichment + test when a cluster contains more members than the reference pool + has rows. The null draw now uses `replace = TRUE` (bootstrap), + matching the V-gene null and the statistically appropriate choice + for resampling. Surfaced on the Huang 2020 benchmark. + # immGLIPH 0.99.3 * Replaced `foreach`/`doParallel` with `BiocParallel` for parallelization diff --git a/README.md b/README.md index 510a717..4a0d4d7 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,34 @@ See the package vignette for the full tutorial: vignette("immGLIPH") ``` +## Validation + +immGLIPH reproduces the published GLIPH and GLIPH2 cluster vectors on +each paper's own dataset, when run with paper-matched parameters and +(where applicable) the same post-hoc filtering criteria. + +| Dataset | immGLIPH configuration | n | ARI | NMI | Pairwise F1 | Precision | Recall | +|:--------|:-----------------------|--:|----:|----:|------------:|----------:|-------:| +| Glanville 2017 | `gliph1` + paper params | 144 | **0.985** | 0.994 | **0.985** | 1.000 | 0.971 | +| Huang 2020 | `gliph2` + paper params + filter | 171 | **0.863** | 0.968 | **0.867** | 0.931 | 0.812 | + +*Comparison universe: CDR3s present in both the immGLIPH input and the +published reference cluster output. Higher = stronger agreement with the +original tool's output.* + +![](man/figures/concordance_summary.png) + +For Glanville, every pair immGLIPH co-clustered was also co-clustered by +the original GLIPH (precision = 1.000), and 97% of the original GLIPH +co-clusterings were recovered (recall = 0.971). For Huang, precision is +0.931 and recall 0.812 against the curated 354-group GLIPH2 set. + +Full pipeline (data prep, paper-matched parameter settings, post-hoc +filter implementation, metric definitions, and a Quarto report) lives at +the companion benchmark repository: + +**[BorchLab/immGLIPH-benchmark](https://github.com/BorchLab/immGLIPH-benchmark)** + ## Bug Reports/New Features #### If you run into any issues or bugs please submit a [GitHub issue](https://github.com/BorchLab/immGLIPH/issues) with details of the issue. diff --git a/man/figures/concordance_summary.png b/man/figures/concordance_summary.png new file mode 100644 index 0000000000000000000000000000000000000000..4cc7d87dd719f6b98a71980c81af90ac2152d6d4 GIT binary patch literal 86504 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 literal 0 HcmV?d00001