From 471b8e83508c7113d25b4a97ffa541eb8459d3b3 Mon Sep 17 00:00:00 2001 From: bbimber Date: Thu, 27 Aug 2026 18:09:14 -0700 Subject: [PATCH 1/5] Add function to RecoverUnassignedCells --- NAMESPACE | 1 + R/CellTypist.R | 52 ++++++++++++++++++++++++++++++++ man/RecoverUnassignedCells.Rd | 34 +++++++++++++++++++++ tests/testthat/test-celltypist.R | 11 +++++++ 4 files changed, 98 insertions(+) create mode 100644 man/RecoverUnassignedCells.Rd diff --git a/NAMESPACE b/NAMESPACE index e8a3045..8f41110 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -25,6 +25,7 @@ export(PlotUcellCorrelation) export(PredictCellTypeProbability) export(PredictTcellActivation) export(PredictTcellActivationUsingCustomModel) +export(RecoverUnassignedCells) export(RegisterGeneSet) export(RunCellTypist) export(RunScGate) diff --git a/R/CellTypist.R b/R/CellTypist.R index 6a02a76..d020161 100644 --- a/R/CellTypist.R +++ b/R/CellTypist.R @@ -670,4 +670,56 @@ FilterDisallowedClasses <- function(seuratObj, sourceField = 'RIRA_Immune_v2.maj print(table(seuratObj@meta.data[[sourceField]], seuratObj@meta.data[[outputFieldName]])) return(seuratObj) +} + +#' @title RecoverUnassignedCells +#' +#' @description This step recovers unassigned cells by inspecting each cluster and assigning Unassigned cells to the match the cluster majority +#' @param seuratObj The seurat object +#' @param classField The name of the field holding the cell type call +#' @param groupField The field on which to group, such as the cluster ID +#' @param targetField The field that will store the result +#' @param unassignedValue The string value that denotes Unassigned cells +#' @param minClusterProp If at least this proportion of the group is one class, unassigned cells will be assigned as this class +#' @return The updated seurat object +#' @export +RecoverUnassignedCells <- function(seuratObj, classField = 'RIRA_Immune_v2.cellclass', groupField = 'ClusterNames_0.2', targetField = 'RIRA_Immune_v2.cellclass.recovered', unassignedValue = 'Unassigned', minClusterProp = 0.6) { + if (!classField %in% (seuratObj@meta.data)) { + stop(paste0('Missing field: ', classField, groupField)) + } + + seuratObj[[targetField]] <- seuratObj[[classField]] + + x <- seuratObj@meta.data %>% + dplyr::group_by(dplyr::across(dplyr::all_of(groupField))) %>% + dplyr::mutate(TotalCellsForGroup = n()) %>% + dplyr::group_by(dplyr::across(dplyr::all_of(groupField, classField))) %>% + dplyr::summarize(TotalCells = dplyr::n()) %>% + as.data.frame() %>% + dplyr::mutate(Prop = TotalCells / TotalCellsForGroup) + + for (clusterName in unique(x[[groupField]])) { + y <- x %>% + dplyr::filter(!!rlang::sym(groupField) == clusterName) %>% + dplyr::filter(!!rlang::sym(classField) != unassignedValue) %>% + dplyr::filter(Prop >= minClusterProp) %>% + dplyr::arrange(-Prop) + + if (nrow(y) == 0) { + next + } + + toUpdate <- x %>% + dplyr::filter(!!rlang::sym(groupField) == clusterName) %>% + dplyr::filter(!!rlang::sym(classField) == unassignedValue) + + if (nrow(toUpdate) == 0) { + next + } + + maxValue <- y[[classField]][1] + print(paste0('Reassigning ', nrow(toUpdate)), ' cells from ', unassignedValue, ' to ', maxValue) + + seuratObj@meta.data[[targetField]][seuratObj@meta.data[[groupField]] == clusterName & seuratObj@meta.data[[classField]] == unassignedValue] <- maxValue + } } \ No newline at end of file diff --git a/man/RecoverUnassignedCells.Rd b/man/RecoverUnassignedCells.Rd new file mode 100644 index 0000000..f3fc7b5 --- /dev/null +++ b/man/RecoverUnassignedCells.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/CellTypist.R +\name{RecoverUnassignedCells} +\alias{RecoverUnassignedCells} +\title{RecoverUnassignedCells} +\usage{ +RecoverUnassignedCells( + seuratObj, + classField = "RIRA_Immune_v2.cellclass", + groupField = "ClusterNames_0.2", + targetField = "RIRA_Immune_v2.cellclass.recovered", + unassignedValue = "Unassigned", + minClusterProp = 0.6 +) +} +\arguments{ +\item{seuratObj}{The seurat object} + +\item{classField}{The name of the field holding the cell type call} + +\item{groupField}{The field on which to group, such as the cluster ID} + +\item{targetField}{The field that will store the result} + +\item{unassignedValue}{The string value that denotes Unassigned cells} + +\item{minClusterProp}{If at least this proportion of the group is one class, unassigned cells will be assigned as this class} +} +\value{ +The updated seurat object +} +\description{ +This step recovers unassigned cells by inspecting each cluster and assigning Unassigned cells to the match the cluster majority +} diff --git a/tests/testthat/test-celltypist.R b/tests/testthat/test-celltypist.R index daff18c..1cd9295 100644 --- a/tests/testthat/test-celltypist.R +++ b/tests/testthat/test-celltypist.R @@ -148,4 +148,15 @@ test_that("FilterDisallowedClasses works as expected", { expect_equal(21, sum(seuratObj$DisallowedUCellCombinations == 'Erythrocyte.RM_UCell', na.rm = T), tolerance = 1) expect_equal(55, sum(seuratObj$DisallowedUCellCombinations == 'NK.RM_UCell', na.rm = T), tolerance = 3) expect_equal(57, sum(seuratObj$DisallowedUCellCombinations == 'Platelet.RM_UCell', na.rm = T), tolerance = 1) + + seuratObj <- RecoverUnassignedCells(seuratObj) + print(table(seuratObj$RIRA_TNK_v2.cellclass.recovered)) + + # These should be unchanged from above: + expect_equal(256, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Bcell', na.rm = T), tolerance = 1) + expect_equal(577, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Myeloid', na.rm = T)) + expect_equal(1340, sum(seuratObj$RIRA_Immune_v2.cellclass == 'T_NK', na.rm = T)) + + # TODO: new classes + }) From 9443c9b62445332620ce31cdc87037fff6952b46 Mon Sep 17 00:00:00 2001 From: bbimber Date: Thu, 27 Aug 2026 20:16:47 -0700 Subject: [PATCH 2/5] Update tests --- R/CellTypist.R | 40 +++++++++++++++++++++++--------- man/RecoverUnassignedCells.Rd | 4 ++-- tests/testthat/test-celltypist.R | 10 +++++--- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/R/CellTypist.R b/R/CellTypist.R index d020161..3d2dd0b 100644 --- a/R/CellTypist.R +++ b/R/CellTypist.R @@ -679,29 +679,37 @@ FilterDisallowedClasses <- function(seuratObj, sourceField = 'RIRA_Immune_v2.maj #' @param classField The name of the field holding the cell type call #' @param groupField The field on which to group, such as the cluster ID #' @param targetField The field that will store the result -#' @param unassignedValue The string value that denotes Unassigned cells +#' @param unassignedValues A list of string values that denote Unassigned/Unknown cells #' @param minClusterProp If at least this proportion of the group is one class, unassigned cells will be assigned as this class #' @return The updated seurat object #' @export -RecoverUnassignedCells <- function(seuratObj, classField = 'RIRA_Immune_v2.cellclass', groupField = 'ClusterNames_0.2', targetField = 'RIRA_Immune_v2.cellclass.recovered', unassignedValue = 'Unassigned', minClusterProp = 0.6) { - if (!classField %in% (seuratObj@meta.data)) { - stop(paste0('Missing field: ', classField, groupField)) +RecoverUnassignedCells <- function(seuratObj, classField = 'RIRA_Immune_v2.cellclass', groupField = 'ClusterNames_0.2', targetField = 'RIRA_Immune_v2.cellclass.recovered', unassignedValues = c('Unassigned', 'Unknown'), minClusterProp = 0.6) { + if (!classField %in% names(seuratObj@meta.data)) { + stop(paste0('Missing field: ', classField)) } seuratObj[[targetField]] <- seuratObj[[classField]] x <- seuratObj@meta.data %>% dplyr::group_by(dplyr::across(dplyr::all_of(groupField))) %>% - dplyr::mutate(TotalCellsForGroup = n()) %>% - dplyr::group_by(dplyr::across(dplyr::all_of(groupField, classField))) %>% + dplyr::mutate(TotalCellsForGroup = dplyr::n()) %>% + dplyr::group_by(dplyr::across(dplyr::all_of(c(groupField, classField, 'TotalCellsForGroup')))) %>% dplyr::summarize(TotalCells = dplyr::n()) %>% as.data.frame() %>% dplyr::mutate(Prop = TotalCells / TotalCellsForGroup) + print('Before:') + print(table(seuratObj@meta.data[[classField]], seuratObj@meta.data[[targetField]])) + for (clusterName in unique(x[[groupField]])) { + if (is.na(clusterName)) { + next + } + print(paste0('Inspecting: ', clusterName)) + y <- x %>% dplyr::filter(!!rlang::sym(groupField) == clusterName) %>% - dplyr::filter(!!rlang::sym(classField) != unassignedValue) %>% + dplyr::filter(!(!!rlang::sym(classField) %in% unassignedValues)) %>% dplyr::filter(Prop >= minClusterProp) %>% dplyr::arrange(-Prop) @@ -711,15 +719,25 @@ RecoverUnassignedCells <- function(seuratObj, classField = 'RIRA_Immune_v2.cellc toUpdate <- x %>% dplyr::filter(!!rlang::sym(groupField) == clusterName) %>% - dplyr::filter(!!rlang::sym(classField) == unassignedValue) + dplyr::filter(!(!!rlang::sym(classField) %in% unassignedValues)) if (nrow(toUpdate) == 0) { next } - maxValue <- y[[classField]][1] - print(paste0('Reassigning ', nrow(toUpdate)), ' cells from ', unassignedValue, ' to ', maxValue) + maxValue <- as.character(y[[classField]][1]) + sel <- (!is.na(seuratObj@meta.data[[groupField]]) & seuratObj@meta.data[[groupField]] == clusterName) & (!is.na(seuratObj@meta.data[[classField]]) & seuratObj@meta.data[[classField]] %in% unassignedValues) + if (any(is.na(sel))) { + stop('NA values in the cell selector') + } - seuratObj@meta.data[[targetField]][seuratObj@meta.data[[groupField]] == clusterName & seuratObj@meta.data[[classField]] == unassignedValue] <- maxValue + print(paste0('Reassigning ', sum(sel), ' cells from [', paste0(unassignedValues, collapse = ','), '] to ', maxValue)) + + seuratObj@meta.data[[targetField]][sel] <- maxValue } + + print('After:') + print(table(seuratObj@meta.data[[classField]], seuratObj@meta.data[[targetField]])) + + return(seuratObj) } \ No newline at end of file diff --git a/man/RecoverUnassignedCells.Rd b/man/RecoverUnassignedCells.Rd index f3fc7b5..d9d4a51 100644 --- a/man/RecoverUnassignedCells.Rd +++ b/man/RecoverUnassignedCells.Rd @@ -9,7 +9,7 @@ RecoverUnassignedCells( classField = "RIRA_Immune_v2.cellclass", groupField = "ClusterNames_0.2", targetField = "RIRA_Immune_v2.cellclass.recovered", - unassignedValue = "Unassigned", + unassignedValues = c("Unassigned", "Unknown"), minClusterProp = 0.6 ) } @@ -22,7 +22,7 @@ RecoverUnassignedCells( \item{targetField}{The field that will store the result} -\item{unassignedValue}{The string value that denotes Unassigned cells} +\item{unassignedValues}{A list of string values that denote Unassigned/Unknown cells} \item{minClusterProp}{If at least this proportion of the group is one class, unassigned cells will be assigned as this class} } diff --git a/tests/testthat/test-celltypist.R b/tests/testthat/test-celltypist.R index 1cd9295..66f4ea3 100644 --- a/tests/testthat/test-celltypist.R +++ b/tests/testthat/test-celltypist.R @@ -149,14 +149,18 @@ test_that("FilterDisallowedClasses works as expected", { expect_equal(55, sum(seuratObj$DisallowedUCellCombinations == 'NK.RM_UCell', na.rm = T), tolerance = 3) expect_equal(57, sum(seuratObj$DisallowedUCellCombinations == 'Platelet.RM_UCell', na.rm = T), tolerance = 1) - seuratObj <- RecoverUnassignedCells(seuratObj) + # Create fake clustering: + print(table(seuratObj$RIRA_Immune_v2.cellclass, seuratObj$scGateConsensus)) + seuratObj <- RecoverUnassignedCells(seuratObj, groupField = 'scGateConsensus') print(table(seuratObj$RIRA_TNK_v2.cellclass.recovered)) # These should be unchanged from above: - expect_equal(256, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Bcell', na.rm = T), tolerance = 1) + expect_equal(258, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Bcell', na.rm = T)) expect_equal(577, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Myeloid', na.rm = T)) expect_equal(1340, sum(seuratObj$RIRA_Immune_v2.cellclass == 'T_NK', na.rm = T)) - # TODO: new classes + expect_equal(336, sum(seuratObj$RIRA_Immune_v2.cellclass.recovered == 'Bcell', na.rm = T)) + expect_equal(665, sum(seuratObj$RIRA_Immune_v2.cellclass.recovered == 'Myeloid', na.rm = T)) + expect_equal(1615, sum(seuratObj$RIRA_Immune_v2.cellclass.recovered == 'T_NK', na.rm = T)) }) From 92a51d845f47d1adb70c7e3779fbf7de59b6faf5 Mon Sep 17 00:00:00 2001 From: bbimber Date: Thu, 27 Aug 2026 20:21:13 -0700 Subject: [PATCH 3/5] Create RIRA_Immune_v2.cellclass.threeclass field --- R/CellTypist.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/R/CellTypist.R b/R/CellTypist.R index 3d2dd0b..b553fab 100644 --- a/R/CellTypist.R +++ b/R/CellTypist.R @@ -739,5 +739,13 @@ RecoverUnassignedCells <- function(seuratObj, classField = 'RIRA_Immune_v2.cellc print('After:') print(table(seuratObj@meta.data[[classField]], seuratObj@meta.data[[targetField]])) + # One more simple field: + seuratObj@meta.data[['RIRA_Immune_v2.cellclass.threeclass']] <- dplyr::case_when( + is.na(seuratObj@meta.data[[targetField]]) ~ 'NotTorB', + seuratObj@meta.data[[targetField]] == 'T_NK' ~ 'T_NK', + seuratObj@meta.data[[targetField]] == 'Bcell' ~ 'Bcell', + .default = 'NotTorB' + ) + return(seuratObj) } \ No newline at end of file From 350106ff53ac92624fc541212af31a29796274cf Mon Sep 17 00:00:00 2001 From: bbimber Date: Fri, 28 Aug 2026 05:17:20 -0700 Subject: [PATCH 4/5] Fix test --- tests/testthat/test-celltypist.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-celltypist.R b/tests/testthat/test-celltypist.R index 66f4ea3..651a2ca 100644 --- a/tests/testthat/test-celltypist.R +++ b/tests/testthat/test-celltypist.R @@ -152,7 +152,7 @@ test_that("FilterDisallowedClasses works as expected", { # Create fake clustering: print(table(seuratObj$RIRA_Immune_v2.cellclass, seuratObj$scGateConsensus)) seuratObj <- RecoverUnassignedCells(seuratObj, groupField = 'scGateConsensus') - print(table(seuratObj$RIRA_TNK_v2.cellclass.recovered)) + print(table(seuratObj$RIRA_Immune_v2.cellclass.recovered)) # These should be unchanged from above: expect_equal(258, sum(seuratObj$RIRA_Immune_v2.cellclass == 'Bcell', na.rm = T)) From a609be7a6dc3fe4a34a80a452c73ade2d39ef476 Mon Sep 17 00:00:00 2001 From: bbimber Date: Fri, 28 Aug 2026 06:28:06 -0700 Subject: [PATCH 5/5] Code cleanup --- R/CellTypist.R | 2 +- R/Classification.R | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/R/CellTypist.R b/R/CellTypist.R index b553fab..5a943f6 100644 --- a/R/CellTypist.R +++ b/R/CellTypist.R @@ -1,7 +1,7 @@ #' @include Utils.R utils::globalVariables( - names = c('majority_voting', 'Fraction', 'PropPerCluster', 'over_clustering', 'predicted_labels', 'totalPerCluster', 'totalPerLabel', 'propPerLabel', 'sortOrder', 'Category', 'reason', 'cellbarcode'), + names = c('majority_voting', 'Fraction', 'PropPerCluster', 'over_clustering', 'predicted_labels', 'totalPerCluster', 'totalPerLabel', 'propPerLabel', 'sortOrder', 'Category', 'reason', 'cellbarcode', 'Prop', 'TotalCells', 'TotalCellsForGroup'), package = 'RIRA', add = TRUE ) diff --git a/R/Classification.R b/R/Classification.R index d318dff..9df9d94 100644 --- a/R/Classification.R +++ b/R/Classification.R @@ -647,12 +647,7 @@ PredictTcellActivationUsingCustomModel <- function(seuratObj, modelName, modelFi modelObj <- readRDS(modelFile) if (!.CanPredict(modelObj)) { - #user provides some other kind of model object, but it can't predict using stats::predict - if (length(modelList) == 1) { - stop(paste0("Provided model does not have a detectable predict method. Please provide a valid model or file path to an RDS file containing a trained model.")) - } else { - stop(paste0("Model '", modelName, "' does not have a detectable predict method. Please provide valid models or file paths to RDS files containing trained models.")) - } + stop(paste0("Model '", modelName, "' does not have a detectable predict method. Please provide valid models or file paths to RDS files containing trained models.")) } # Deetermine expected number of components from model coefficients