This dataset originates from GSE149689. The data have been reduced and deliberate errors were introduced for educational purposes.
Deliberate errors
Five steps were designed to fail (e.g., v4 slot access, incorrect use of GetAssayData slot=, wrong filter thresholds, dimensions greater than the number of principal components, plotting a label before adding it).
These steps are wrapped in try(), so the error prints to the console without halting the full run. The goal is for the user to read the error message, interpret it, and continue with the analysis.
4.1 Working Directory Options
When starting an analysis, you need to ensure that R knows where to find your scripts and data. There are two common approaches:
Set Working Directory You can manually set the working directory in RStudio via: Session > Set Working Directory > To Source File Location. This option makes R run from the folder containing your script and the data/ subfolder. It is quick and useful for small or one-off scripts, but it requires resetting each time you open the project.
Create an R Project The recommended approach for reproducibility and collaboration. An .Rproj file automatically sets the project root as the working directory. This allows you to use relative paths (e.g., data/file.csv) without manual adjustments. It also integrates seamlessly with Git/GitHub, Quarto, and RMarkdown, making your workflow more organized and consistent.
TipBest practice
For long-term projects, especially those shared on GitHub or used in teaching, creating an R Project is the most reliable option.
4.2 How to use
Run interactively, section by section (Ctrl+Enter / Cmd+Enter), from top to bottom. Do NOT source() the whole file in one go: several chunks are deliberate errors meant to be read, and Block 8 is optional take-home work that depends on a file released separately after the session.
4.3Block 0 - Install packages and setup
4.3.1 Step 0.1 - Install packages
Checks each package before installing. If install.packages() fails, retries via BiocManager. Safe to run multiple times.
Registered S3 method overwritten by 'data.table':
method from
print.data.table
Registered S3 method overwritten by 'htmlwidgets':
method from
print.htmlwidget tools:rstudio
Already installed: Seurat (5.5.0)
Already installed: SeuratObject (5.4.0)
Already installed: ggplot2 (4.0.3)
Already installed: patchwork (1.3.2)
Already installed: dplyr (1.2.1)
Already installed: tidyr (1.3.2)
Already installed: Matrix (1.7.5)
Already installed: harmony (2.0.4)
Already installed: scales (1.4.0)
Already installed: ggrepel (0.9.8)
Installing: SingleR
CRAN failed. Retrying via BiocManager...
'getOption("repos")' replaces Bioconductor standard repositories, see
'help("repositories", package = "BiocManager")' for details.
Replacement repositories:
CRAN: https://cran.rstudio.com/
Bioconductor version 3.23 (BiocManager 1.30.27), R 4.6.0 (2026-04-24)
Installing package(s) 'BiocVersion', 'SingleR'
also installing the dependencies ‘XVector’, ‘MatrixGenerics’, ‘GenomicRanges’, ‘Biobase’, ‘IRanges’, ‘Seqinfo’, ‘S4Arrays’, ‘SparseArray’, ‘SummarizedExperiment’, ‘BiocGenerics’, ‘S4Vectors’, ‘DelayedArray’, ‘beachmat’, ‘assorthead’
FAILED: SingleR. Run the fallback chunk below.
Installing: celldex
CRAN failed. Retrying via BiocManager...
'getOption("repos")' replaces Bioconductor standard repositories, see
'help("repositories", package = "BiocManager")' for details.
Replacement repositories:
CRAN: https://cran.rstudio.com/
Bioconductor version 3.23 (BiocManager 1.30.27), R 4.6.0 (2026-04-24)
Installing package(s) 'BiocVersion', 'celldex'
also installing the dependencies ‘dir.expiry’, ‘dbplyr’, ‘Biostrings’, ‘XVector’, ‘rhdf5filters’, ‘V8’, ‘biocmake’, ‘h5mread’, ‘MatrixGenerics’, ‘GenomicRanges’, ‘Biobase’, ‘BiocGenerics’, ‘IRanges’, ‘Seqinfo’, ‘S4Arrays’, ‘BiocFileCache’, ‘BiocBaseUtils’, ‘KEGGREST’, ‘SparseArray’, ‘sparseMatrixStats’, ‘alabaster.schemas’, ‘rhdf5’, ‘jsonvalidate’, ‘assorthead’, ‘Rhdf5lib’, ‘HDF5Array’, ‘alabaster.ranges’, ‘blob’, ‘SummarizedExperiment’, ‘ExperimentHub’, ‘AnnotationHub’, ‘AnnotationDbi’, ‘S4Vectors’, ‘DelayedArray’, ‘DelayedMatrixStats’, ‘gypsum’, ‘alabaster.base’, ‘alabaster.matrix’, ‘alabaster.se’, ‘DBI’, ‘RSQLite’
FAILED: celldex. Run the fallback chunk below.
Installing: BiocParallel
CRAN failed. Retrying via BiocManager...
'getOption("repos")' replaces Bioconductor standard repositories, see
'help("repositories", package = "BiocManager")' for details.
Replacement repositories:
CRAN: https://cran.rstudio.com/
Bioconductor version 3.23 (BiocManager 1.30.27), R 4.6.0 (2026-04-24)
Installing package(s) 'BiocVersion', 'BiocParallel'
also installing the dependencies ‘formatR’, ‘lambda.r’, ‘futile.options’, ‘futile.logger’, ‘snow’
FAILED: BiocParallel. Run the fallback chunk below.
Installing: scDblFinder
CRAN failed. Retrying via BiocManager...
'getOption("repos")' replaces Bioconductor standard repositories, see
'help("repositories", package = "BiocManager")' for details.
Replacement repositories:
CRAN: https://cran.rstudio.com/
Bioconductor version 3.23 (BiocManager 1.30.27), R 4.6.0 (2026-04-24)
Installing package(s) 'BiocVersion', 'scDblFinder'
also installing the dependencies ‘formatR’, ‘lambda.r’, ‘futile.options’, ‘locfit’, ‘beeswarm’, ‘vipor’, ‘Cairo’, ‘cigarillo’, ‘RCurl’, ‘rjson’, ‘futile.logger’, ‘snow’, ‘assorthead’, ‘beachmat’, ‘ScaledMatrix’, ‘rsvd’, ‘MatrixGenerics’, ‘Biobase’, ‘Seqinfo’, ‘S4Arrays’, ‘edgeR’, ‘limma’, ‘statmod’, ‘metapod’, ‘SparseArray’, ‘ggbeeswarm’, ‘viridis’, ‘RcppML’, ‘pheatmap’, ‘ggrastr’, ‘UCSC.utils’, ‘Biostrings’, ‘XVector’, ‘Rhtslib’, ‘XML’, ‘GenomicAlignments’, ‘BiocIO’, ‘restfulr’, ‘SingleCellExperiment’, ‘BiocGenerics’, ‘BiocParallel’, ‘BiocNeighbors’, ‘BiocSingular’, ‘S4Vectors’, ‘SummarizedExperiment’, ‘scran’, ‘scater’, ‘scuttle’, ‘bluster’, ‘DelayedArray’, ‘xgboost’, ‘IRanges’, ‘GenomicRanges’, ‘GenomeInfoDb’, ‘Rsamtools’, ‘rtracklayer’
FAILED: scDblFinder. Run the fallback chunk below.
Installing: SingleCellExperiment
CRAN failed. Retrying via BiocManager...
'getOption("repos")' replaces Bioconductor standard repositories, see
'help("repositories", package = "BiocManager")' for details.
Replacement repositories:
CRAN: https://cran.rstudio.com/
Bioconductor version 3.23 (BiocManager 1.30.27), R 4.6.0 (2026-04-24)
Installing package(s) 'BiocVersion', 'SingleCellExperiment'
also installing the dependencies ‘XVector’, ‘MatrixGenerics’, ‘Biobase’, ‘IRanges’, ‘Seqinfo’, ‘S4Arrays’, ‘SparseArray’, ‘SummarizedExperiment’, ‘S4Vectors’, ‘BiocGenerics’, ‘GenomicRanges’, ‘DelayedArray’
FAILED: SingleCellExperiment. Run the fallback chunk below.
4.3.2 Step 0.2 - Verify and load all libraries
All packages are loaded here. No library() calls appear later in the script. If you see “namespace ggplot2 is imported by Seurat…” that is not an error; the package is already active. Always do Session > Restart R before opening the script.
Seurat OK
SeuratObject OK
ggplot2 OK
patchwork OK
dplyr OK
tidyr OK
Matrix OK
SingleR OK
celldex MISSING
harmony OK
scales OK
ggrepel OK
BiocParallel OK
scDblFinder OK
SingleCellExperiment OK
👉 In this case, the installation was successful because Bioconductor provides binary builds for macOS ARM64. By forcing type="binary", R avoided compiling C++ code, and all dependencies were installed without errors.
👉 Here the error persisted because one critical dependency (alabaster.base) does not yet have a binary build available. R attempted to compile it from source, but failed with the following error:
ld: library 'ssl' not found
clang++: error: linker command failed with exit code 1
ERROR: compilation failed for package ‘alabaster.base’
ERROR: dependency ‘alabaster.base’ is not available for package ‘celldex’
This means the compiler could not find the OpenSSL library required for linking. Even though we requested binary installation, Bioconductor has not yet published a binary for this dependency, so the error continues.
4.3.3 Step 0.3 - Folder structure and download files
Create and delete files
Code
# Helper function to recreate a folder with messagesrecreate_dir <-function(dir_name) {if (dir.exists(dir_name)) {cat(sprintf("The folder '%s' already exists. It will be deleted and recreated.\n", dir_name))unlink(dir_name, recursive =TRUE) # delete folder and contents } else {cat(sprintf("The folder '%s' does not exist. It will be created.\n", dir_name)) }dir.create(dir_name, showWarnings =FALSE)}# Apply the function to your foldersrecreate_dir("data")
The folder 'data' already exists. It will be deleted and recreated.
Code
recreate_dir("outputs")
The folder 'outputs' already exists. It will be deleted and recreated.
Code
cat("Process completed. Folders are ready.\n")
Process completed. Folders are ready.
Download files
Here you will find the reduced files available for download from Google Drive.
4.3.4 Step 0.4 - reveal() helper (instructor use only)
Prints the answer + live-demo code for any QUESTION or PUZZLE in this script. Answers are NOT in this file. They live in a separate instructor_answers.R that only the instructor loads with source() before class. Students running reveal() in a fresh session see a brief notice and nothing else.
In R, names that start with a dot (.) are usually used for internal helper functions. The function .safe_accessor is not meant to be called directly by the user. Instead, it builds the “safe” versions of Seurat’s accessor functions: SafeAssays, SafeLayers, and SafeReductions.
These safe wrappers make sure that the correct function is used even if different versions of Seurat or other Bioconductor packages introduce conflicts. Without them, you might get confusing errors when calling Assays(), Layers(), or Reductions() directly.
Returns a list of all assays present in the Seurat object.
Safer than directly calling Assays() because it avoids issues if the object has unusual or corrupted assay slots.
Useful for checking what data types (RNA, ATAC, protein, etc.) are available before running downstream analysis.
Code
SafeAssays(seurat_object)
SafeLayers(seurat_object)
Lists all layers within a given assay (e.g., raw counts, normalized data, scaled data).
Helps you confirm which representations of the data are stored and prevents errors when switching between layers.
Important in multimodal workflows where multiple layers coexist.
Code
SafeLayers(seurat_object)
SafeReductions(seurat_object)
Shows all dimensionality reduction results (PCA, UMAP, t-SNE, etc.) stored in the object.
Ensures you know which reductions are available before plotting or clustering.
Prevents mistakes like calling DimPlot() on a reduction that doesn’t exist.
Code
SafeReductions(seurat_object)
4.4Block 1 - Object inspection (RNA + ADT)
Goal: Inspect the RNA assay, contrast it with the ADT assay, and walk through the layer system, the metadata, and the reductions slot.
4.4.1 Step 1.1 - Load the object
Loads the injected object (with embedded errors) when present, otherwise the clean object.
Code
main_candidates <-c("data/gse149689_subset_3k_errors.rds","data/gse149689_subset_3k.rds")main_path <- main_candidates[file.exists(main_candidates)][1]if (is.na(main_path)) stop("No dataset found in data/. Expected one of: ",paste(main_candidates, collapse =", "))sobj <-readRDS(main_path)cat("Loaded:", main_path, "\n")
Loaded: data/gse149689_subset_3k.rds
Code
print(sobj)
An object of class Seurat
524 features across 3000 samples within 2 assays
Active assay: RNA (500 features, 500 variable features)
3 layers present: counts, data, scale.data
1 other assay present: ADT
2 dimensional reductions calculated: pca, umap
TipQUESTION 1.1
How many cells, how many features, how many assays?
Which is the active (default) assay? Run the line below to get all four numbers.
Code
cat("\nAnswer to QUESTION 1.1:\n")cat(" Cells :", ncol(sobj), "\n")cat(" Features (RNA):", nrow(sobj[["RNA"]]), "\n")cat(" Features (ADT):", nrow(sobj[["ADT"]]), "\n")cat(" Assays :", length(SafeAssays(sobj)), "(", paste(SafeAssays(sobj), collapse =", "), ")\n")cat(" Default assay :", DefaultAssay(sobj), "\n")
TipANSWER
3,000 cells across 2 assays. RNA assay has 500 features, a teaching-purpose subset (not the full transcriptome). ADT assay has 24 proteins. Default assay is RNA. The 500-gene subset matters: every QC threshold copied from a 33,000-gene tutorial will be wrong. This is the first opportunity to anchor the discussion of ‘tutorial defaults do not transfer’.
LIVE DEMO:
Code
ncol(sobj) # cells
[1] 3000
Code
nrow(sobj[["RNA"]]) # RNA features (500)
[1] 500
Code
nrow(sobj[["ADT"]]) # ADT features (24)
[1] 24
Code
Assays(sobj) # c("RNA", "ADT")
An object of class "SimpleAssays"
Slot "data":
List of length 1
Code
DefaultAssay(sobj) # "RNA"
[1] "RNA"
4.4.2 Step 1.1b - Add mitochondrial genes (this panel does not ship with any)
The 500-gene panel used for this course was curated around lineage and activation markers; it does not include MT- genes. Every QC step that depends on percent.mt ( Section 4.5, Block 2) needs a real signal to be useful, so we add 9 synthetic MT- genes here, with counts correlated to each cell’s total library size and a stress component for a subset of cells.
Code
set.seed(7)mt_gene_names <-c("MT-ND1", "MT-ND2", "MT-CO1", "MT-CO2", "MT-ATP8","MT-ATP6", "MT-CO3", "MT-ND3", "MT-CYB")if (!any(grepl("^MT-", rownames(sobj[["RNA"]])))) { rna_counts <-LayerData(sobj, assay ="RNA", layer ="counts") total_count <- Matrix::colSums(rna_counts)# A log-normal draw gives a continuous, right-skewed distribution: most# cells sit at a low, biologically typical fraction, with a gradual tail# toward the small subset of stressed/dying cells. This mirrors real PBMC# data, where percent.mt has no gap between "healthy" and "stressed"; it# is one continuous distribution that QC thresholds cut into at some# chosen point. n_cells <-ncol(rna_counts) target_frac <-rlnorm(n_cells, meanlog =log(0.035), sdlog =0.6) target_frac <-pmin(target_frac, 0.35) # cap at a biologically plausible ceiling mt_total_per_cell <-round(total_count * target_frac) mt_total_per_cell[mt_total_per_cell <length(mt_gene_names)] <-length(mt_gene_names)# Spread each cell's MT total across the 9 genes with unequal weights# (mirrors real data, where MT-CO1/MT-CO3/MT-CYB are usually the highest). gene_weights <-c(0.10, 0.08, 0.22, 0.14, 0.03, 0.07, 0.18, 0.06, 0.12) mt_mat <-sapply(seq_len(n_cells), function(i) {as.integer(round(mt_total_per_cell[i] * gene_weights)) })rownames(mt_mat) <- mt_gene_namescolnames(mt_mat) <-colnames(rna_counts) mt_mat_sparse <-as(mt_mat, "CsparseMatrix") rna_counts_with_mt <-rbind(rna_counts, mt_mat_sparse)# The warning here ("Different cells and/or features from existing assay# RNA") comes from the [[<- replacement method noticing the new assay has# 509 features instead of 500, not from CreateAssay5Object itself. It is# the expected, harmless side effect of adding genes to an assay; wrap the# whole assignment so it does not look like an error in the console. new_rna_assay <-CreateAssay5Object(counts = rna_counts_with_mt)suppressWarnings(sobj[["RNA"]] <- new_rna_assay)DefaultAssay(sobj) <-"RNA"cat("Added", length(mt_gene_names), "synthetic MT- genes to the RNA assay.\n")cat("RNA assay now has", nrow(sobj[["RNA"]]), "features (was 500).\n")} else {cat("MT- genes already present; skipping synthesis.\n")}
MT- genes already present; skipping synthesis.
4.4.3 Step 1.2 - Seurat v4 vs v5: how the count matrix is stored
Seurat v5 changed how count matrices live inside an assay. v4 code that directly accesses @counts or uses slot= will error. The two patterns below are deliberate failures: read each error message.
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<try({# In Seurat v4, this was the standard way to get the count matrix. counts_old <- sobj@assays$RNA@counts})
Error in try({ :
no slot of name "counts" for this object of class "Assay5"
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<try({ counts_old2 <-GetAssayData(sobj, slot ="counts")})
Error : The `slot` argument of `GetAssayData()` was deprecated in SeuratObject
5.0.0 and is now defunct.
ℹ Please use the `layer` argument instead.
TipExplanation
In Seurat v5, the @counts slot no longer exists in Assay5 objects. The correct argument is now layer = "counts". These errors are intentional examples: they show what happens when you run old pipeline scripts or receive objects created with older Seurat versions. Read the error message, understand why it occurs, and then continue.
Pattern 1: LayerData(). Recommended for Seurat v5.
cat("Object version :", as.character(sobj@version), "\n")
Object version : 5.1.0
TipQUESTION 1.2
What is the class of the RNA assay? And the ADT assay?
Why might they be the same class even though the data behave very differently?
TipANSWER
Both are class Assay5. Assay5 is a storage container, not a normalization specification. The container holds a layer system (counts, data, scale.data) the same way for any modality. The differences between RNA and ADT (sparse vs dense, dropout vs bimodality, LogNormalize vs CLR, log scale vs CLR scale) are properties of the values stored in the layers and the chosen normalization, not of the assay class. A function operating on sobj[['ANY']] works mechanically on both. It also means running an RNA-specific normalization on ADT does not throw a type error.
LIVE DEMO:
Code
class(sobj[["RNA"]]) # "Assay5"
[1] "Assay5"
attr(,"package")
[1] "SeuratObject"
Code
class(sobj[["ADT"]]) # "Assay5"
[1] "Assay5"
attr(,"package")
[1] "SeuratObject"
Code
# Same class, different content. Confirm by inspecting layers:Layers(sobj[["RNA"]]) # counts (data and scale.data appear after preprocessing)
[1] "counts" "data" "scale.data"
Code
Layers(sobj[["ADT"]]) # counts
[1] "counts" "data"
TipQUESTION 1.2c
FetchData(), LayerData()[gene, ], and GetAssayData()[gene, ] are all equivalent here. Why might they NOT be equivalent for normalized data instead of raw counts? (Think about what each function defaults to for the layer argument).
TipANSWER
On the counts layer all three read raw integer values from the same underlying sparse matrix, so the result is identical. For normalized data they differ in argument defaults: FetchData() defaults to layer='data' (normalized); LayerData() takes layer= explicitly; GetAssayData() in newer Seurat also expects layer= (slot= is deprecated, see E2). Mismatches appear when one call defaults to data and another to counts. Always pass layer= explicitly.
4.4.4 Step 1.2b - Full slot map of a Seurat object
A Seurat object has more slots than most tutorials show. Some hold raw data, some hold derived results, some are caches, some are metadata about the analysis history. The same value often lives in two or three places by design. Knowing the map prevents three classes of bug:
Reading from the wrong copy after one is updated.
Failing to find data that exists under a slot you did not know.
Trusting downstream results when an upstream slot was overwritten.
A.1 - Top-level slots: print every slot name and a 1-line description of each
Code
cat("\n== TOP-LEVEL SLOTS ==\n")
== TOP-LEVEL SLOTS ==
Code
top_slots <-slotNames(sobj)for (s in top_slots) cat(sprintf(" @%-15s class: %s\n", s, class(slot(sobj, s))[1]))
@assays class: list
@meta.data class: data.frame
@active.assay class: character
@active.ident class: factor
@graphs class: list
@neighbors class: list
@reductions class: list
@images class: list
@project.name class: character
@misc class: list
@version class: package_version
@commands class: list
@tools class: list
A.2 - @assays: list of assays. Each assay is an Assay5 (v5) or Assay (v4)
cat(" Number of assays :", length(sobj@assays), "\n")
Number of assays : 2
Inside one assay (v5 Assay5), the slot map is different from v4:
Code
cat("\n [inside sobj[['RNA']] (v5 Assay5)]\n")
[inside sobj[['RNA']] (v5 Assay5)]
Code
rna_assay_slots <-slotNames(sobj[["RNA"]])for (s in rna_assay_slots) { val <-slot(sobj[["RNA"]], s)cat(sprintf(" @%-15s class: %-15s length/dim: %s\n", s, class(val)[1],if (is.list(val)) paste0("list of ", length(val))elseif (is.null(dim(val))) paste0("len ", length(val))elsepaste(dim(val), collapse =" x ")))}
@layers class: list length/dim: list of 3
@cells class: LogMap length/dim: 3000 x 3
@features class: LogMap length/dim: 500 x 3
@default class: integer length/dim: len 1
@assay.orig class: character length/dim: len 0
@meta.data class: data.frame length/dim: list of 8
@misc class: list length/dim: list of 0
@key class: character length/dim: len 1
A.3 - @meta.data: cell-level metadata data.frame
Rows = cells. Columns` = whatever was added during preprocessing.
A.5 - @reductions: list of DimReduc objects (pca, umap, harmony, etc.)
Each one has its OWN slots: cell.embeddings (cells x dims), feature.loadings (features x dims), stdev (per-dim variance), key (column prefix), assay.used.
A.7 - @commands: every Seurat function call ever made on this object, with all arguments, providing a full analysis history. If you ever wonder “what arguments did the previous user pass to NormalizeData?”, check here.
identical() is sensitive to attributes (names, integer vs numeric storage) that differ across these four accessors even when every value is the same.
Strip names and coerce type before comparing, since the point here is value equality, not attribute equality.
Code
all_match <-all(unname(as.numeric(v1)) ==unname(as.numeric(v2))) &&all(unname(as.numeric(v2)) ==unname(as.numeric(v3))) &&all(unname(as.numeric(v3)) ==unname(as.numeric(v4)))cat(" all four return the same values :", all_match, "\n")
all four return the same values : TRUE
Code
cat(" (identical() can still report FALSE here; that compares attributes\n")
(identical() can still report FALSE here; that compares attributes
Code
cat(" like names or integer-vs-numeric storage, not the values themselves)\n")
like names or integer-vs-numeric storage, not the values themselves)
Code
cat("Test 3: cell name access (3 equivalent paths)\n")
cat(" >> rownames depends on DefaultAssay. Always pass assay= explicitly.\n")
>> rownames depends on DefaultAssay. Always pass assay= explicitly.
A.10 - SLOT PUZZLES: where would you look to find each item below?
Try to write the answer in your head before running.
Code
cat("\n== SLOT PUZZLES (try before running) ==\n")
== SLOT PUZZLES (try before running) ==
TipPUZZLE 1.2b/1
Where is the original Seurat version that created this object?
Code
cat("\nPUZZLE 1: original Seurat version that created this object?\n")
PUZZLE 1: original Seurat version that created this object?
Code
cat(" Answer: sobj@version (top-level slot)\n")
Answer: sobj@version (top-level slot)
Code
cat(" Value :", as.character(sobj@version), "\n")
Value : 5.1.0
TipANSWER
sobj@version. Top-level slot that records the Seurat version that originally constructed the object, not the version currently loaded. Useful when debugging v4-to-v5 migration issues.
LIVE DEMO:
Code
sobj@version
[1] '5.1.0'
Code
packageVersion("Seurat") # version currently loaded
[1] '5.5.1'
TipQUESTION 1.2b
For each of the 4 sanity-proof tests above (A.9), which one is GUARANTEED to be identical regardless of analysis state, and which one DEPENDS on DefaultAssay? Why is the distinction important when sharing code?
TipANSWER
Tests 1 and 3 are guaranteed identical via identical(): assay access (sobj[['RNA']] vs sobj@assays$RNA) always returns the same Assay5 object, and cell names always live in colnames(sobj). Test 2 (metadata column access) is more subtle than it looks: sobj$nFeature_RNA, sobj@meta.data$nFeature_RNA, and sobj[[]]$nFeature_RNA return the same named vector, but FetchData(sobj, vars='nFeature_RNA')[, 1] drops the cell-name attribute when the data.frame column is extracted with [, 1]. The VALUES are identical; identical() is not, because it also compares the names attribute. This is a good example of identical() being too strict for the question actually being asked: when verifying value equality across accessors, strip names (e.g. unname()) or compare numerically (all(x == y)) instead of using identical() directly. Test 4 is the trap with real consequences: rownames(sobj) returns features of the ACTIVE assay only. If a collaborator writes marker %in% rownames(sobj) assuming RNA, but the active assay has been set to ADT, the check fails silently for any gene that is not also a protein name. General rule when sharing or receiving code: pass assay= explicitly whenever a function call could resolve differently depending on the active assay, and do not assume identical() failing means the values differ; it can also mean only an attribute differs.
LIVE DEMO:
Code
# Run the 4 tests live:identical(sobj@assays$RNA, sobj[["RNA"]]) # TRUE always
[1] TRUE
Code
# Test 2: identical() can be FALSE here even though values match,# because FetchData()[, 1] drops cell names that $ and [[]] keep:identical(sobj$nFeature_RNA, sobj@meta.data$nFeature_RNA) # TRUE
[1] FALSE
Code
v4 <-FetchData(sobj, vars ="nFeature_RNA")[, 1]identical(sobj$nFeature_RNA, v4) # often FALSE (names)
# Now the trap:DefaultAssay(sobj) <-"RNA"; head(rownames(sobj)) # gene symbols
[1] "CD3E" "CD3D" "CD3G" "CD4" "CD8A" "CD8B"
Code
DefaultAssay(sobj) <-"ADT"; head(rownames(sobj)) # protein names (different)
[1] "CD3" "CD4" "CD8a" "CD14" "CD16" "CD19"
Code
DefaultAssay(sobj) <-"RNA"# restore
4.4.5 Step 1.3 - Inspecting the RNA assay (intermediate)
Three properties of a single-cell RNA count matrix that drive every later decision: sparsity, memory footprint, and layer state. Inspect them now, before any normalization or scaling. The ADT counterpart to this step opens Block 6 (Section 5.1), once the CITE-seq workflow actually needs it.
Sparsity = fraction of zero entries. Drives normalization decisions.
A typical full-transcriptome PBMC dataset shows RNA sparsity above 90%. This 500+9-gene panel is lower. What does the sparsity value you just printed tell you about gene panel size versus dropout rate?
TipANSWER
Sparsity in this panel is lower than a full transcriptome mainly because the 500 genes were curated to be lineage and activation markers, which tend to be more highly and consistently expressed than the average gene in a full transcriptome (most genes in a full panel are low-expressed or cell-type-restricted, which is what drives sparsity above 90%). A smaller, curated panel is not immune to dropout: the same capture and reverse-transcription inefficiencies apply per molecule regardless of panel size. What changes is the average expression level of the genes included, not the underlying biology of dropout. This distinction matters when reading a sparsity number from any dataset: a low sparsity value can mean a curated panel of well-expressed genes, not necessarily a technically superior experiment.
LIVE DEMO:
Code
rna <-LayerData(sobj, assay="RNA", layer="counts")mean(rna ==0) *100# RNA sparsity % for this panel
[1] 90.50833
Compare to a full-transcriptome expectation (commonly 90%+ for 10x PBMC)
CautionCaution
Dense vs sparse storage matters at scale. A full 10x experiment with 50,000 cells and 33,000 genes as a dense matrix exceeds 50 GB.
4.4.6 Compare sparse (counts) vs dense (after scaling) memory
obj_size <-object.size(sobj)cat("Full Seurat object :", format(obj_size, units ="Mb"), "\n\n")
Full Seurat object : 23.9 Mb
Code
cat("\nscale.data is a DENSE genes-by-cells matrix.\n")
scale.data is a DENSE genes-by-cells matrix.
Code
cat("On full datasets, scale only the genes used in PCA to keep memory bounded.\n")
On full datasets, scale only the genes used in PCA to keep memory bounded.
TipQUESTION 1.3b
The counts layer is sparse, the scale.data layer is dense. Project this to a 50,000-cell, 33,000-gene experiment: what is the memory implication, and what does it tell you about how to use ScaleData?
TipANSWER
Dense double matrix: 33,000 features x 50,000 cells x 8 bytes = 13.2 GB. A sparse representation at 90% sparsity stores about 33,000 x 50,000 x 0.10 nonzeros x 16 bytes per nonzero = 2.6 GB. ScaleData centers and scales each feature, producing a dense matrix even when input was sparse. Running ScaleData on all features at this scale will exhaust RAM on any laptop. Standard practice: scale only the highly variable genes used in PCA, typically 2,000-3,000 features. The dense scaled matrix becomes 3,000 x 50,000 x 8 = 1.2 GB, manageable. Pass features = VariableFeatures(sobj) to ScaleData.
Best practice (already used in Step 4.4, Section 4.7.4):
Code
ScaleData(sobj, features =VariableFeatures(sobj))
4.4.7 Step 1.6 - Metadata
Reductions (PCA, UMAP, Harmony, etc.) are computed in Block 4 (Section 4.7) onward. At this point in the script, none exist yet. Confirm that explicitly instead of assuming it: it is the same habit as checking DefaultAssay() before trusting any accessor. The access patterns for cell.embeddings and feature.loadings are covered in Step 4.4, (Section 4.7.4), once PCA actually exists.
Cells from COVID-19 donors with more than 40 genes detected. The threshold is calibrated to this panel: nFeature_RNA sits in the 27-96 range here, not the 200+ range typical of a full transcriptome, so a number borrowed from a full-transcriptome tutorial would silently match zero cells.
Same idea, different variable: severity by donor. The tabulation reveals the donor-condition design (which donors are Healthy vs which severity level each COVID-19 donor was assigned).
cat("\nImportant: 'CD8a' in ADT vs 'CD8A' in RNA.\n")
Important: 'CD8a' in ADT vs 'CD8A' in RNA.
Code
cat("FetchData() handles this transparently, but FeaturePlot()\n")
FetchData() handles this transparently, but FeaturePlot()
Code
cat("requires you to be on the correct DefaultAssay first.\n")
requires you to be on the correct DefaultAssay first.
Demonstrate: what happens when you try to access an ADT feature using the RNA gene name:
Code
cat("\nDoes 'CD8A' exist in ADT?\n")
Does 'CD8A' exist in ADT?
Code
cat("'CD8A' in ADT rownames:", "CD8A"%in%rownames(sobj[["ADT"]]), "\n")
'CD8A' in ADT rownames: FALSE
Code
cat("'CD8a' in ADT rownames:", "CD8a"%in%rownames(sobj[["ADT"]]), "\n")
'CD8a' in ADT rownames: TRUE
4.4.9 Step 1.8 - Marker stored under an alias
Gene symbols have synonyms: NCAM1 = CD56, FCGR3A = CD16, MS4A1 = CD20. If an object stores a gene under its alias, FeaturePlot("FCGR3A") returns “feature not found” and the marker reads as absent when the data are intact.
Confirm canonical markers exist under their expected symbol before plotting or annotating.
Canonical RNA markers present under expected symbol:
Code
for (i inseq_along(canonical))cat(sprintf(" %-8s %s\n", canonical[i], ifelse(present[i], "OK", "MISSING")))
CD3E OK
CD4 OK
CD8A OK
CD14 OK
CD19 OK
MS4A1 OK
NCAM1 OK
FCGR3A OK
If something is MISSING, search for known aliases in the rownames
Code
aliases <-list(FCGR3A ="CD16", NCAM1 ="CD56", MS4A1 ="CD20")missing <- canonical[!present]if (length(missing) >0) {cat("\nMISSING markers detected. Searching for aliases:\n") rna_counts <-LayerData(sobj, assay ="RNA", layer ="counts") rn <-rownames(rna_counts) changed <-FALSEfor (m in missing) { al <- aliases[[m]]if (!is.null(al) && al %in% rn) {cat(sprintf(" %s is stored as alias '%s'. Renaming back.\n", m, al)) rn[rn == al] <- m changed <-TRUE } }if (changed) {# v5-safe rename: rebuild the RNA assay from counts with corrected names.# Run before normalization (Block 4), so only the counts layer exists,# matching the structure of the assay it replaces. suppressWarnings()# wraps the assignment itself, since any "different features" notice# would come from the [[<- replacement method, not from# CreateAssay5Object().rownames(rna_counts) <- rn new_rna_assay <-CreateAssay5Object(counts = rna_counts)suppressWarnings(sobj[["RNA"]] <- new_rna_assay)DefaultAssay(sobj) <-"RNA" }cat("\nAfter fix, all canonical markers present:",all(canonical %in%rownames(sobj[["RNA"]])), "\n")}
TipQUESTION 1.8
What other RNA aliases would you check for routinely in a PBMC dataset?
TipANSWER
Common alias pairs: MS4A1/CD20 (B cells), NCAM1/CD56 (NK), FCGR3A/CD16 (NK and ncMonocyte), ITGAX/CD11c (DC, mono), ITGAM/CD11b (myeloid), PTPRC/CD45 (pan-immune), IL3RA/CD123 (pDC, basophil), CD3E/CD3, CD8A/CD8a (case matters: RNA upper, ADT lower), FOXP3 (Treg), FCER1A (DC). Defensive habit: keep a list of canonical PBMC markers and run %in% rownames(sobj[['RNA']]) at the start of every annotation step.
nFeature_RNA nCount_RNA percent.mt
Min. :27.00 Min. :172.0 Min. : 0.000
1st Qu.:43.00 1st Qu.:381.0 1st Qu.: 2.788
Median :47.00 Median :451.0 Median : 5.290
Mean :47.46 Mean :451.8 Mean : 6.463
3rd Qu.:52.00 3rd Qu.:517.0 3rd Qu.: 8.853
Max. :69.00 Max. :844.0 Max. :28.605
4.5.2 Step 2.2 | Sanity check: did percent.mt actually compute?
percent.mt depends entirely on the ‘^MT-’ pattern matching real gene names.
This is worth confirming explicitly rather than assuming it worked: a renamed or missing MT- prefix (which is exactly what the injected object simulates) returns percent.mt = 0 for every cell, and any filter based on it then either does nothing or rejects every cell.
Code
mt_genes <-grep("^MT-", rownames(sobj), value =TRUE)cat("Genes matching '^MT-':", length(mt_genes), "\n")
if (max(sobj$percent.mt) ==0) {cat("\nWARNING: percent.mt is 0 for all cells. The '^MT-' pattern matched\n")cat("no genes. Do NOT filter on percent.mt here: it is uninformative in\n")cat("this panel. Report this limitation; do not claim an mt-based QC.\n")} else {cat("MT- pattern matched", length(mt_genes), "genes. percent.mt is usable.\n")}
MT- pattern matched 9 genes. percent.mt is usable.
TipQUESTION 2.2
If max(percent.mt) is 0 across every cell, what are the two possible explanations, and how do you tell them apart?
TipANSWER
Explanation 1 (almost always): the MT- pattern matched no genes because mitochondrial symbols use a different prefix (mt-, Mt-, MTX, or a non-human reference). Diagnostic: grep('^MT-', rownames(sobj)) returns character(0). Inspect head(rownames(sobj)) and look for the actual prefix. Explanation 2 (implausible): cells filtered upstream so aggressively that no MT transcripts remain. The pattern mismatch is the realistic case. The injected dataset renames MT- genes to MTX- to trip this exact failure.
The two counts above are easy to compute and easy to misread: a count by itself does not show whether the flagged cells form a clear, separable group, or whether the threshold cut through the middle of a continuous distribution. Flag both categories on the metadata and plot them directly against the same axes used in Step 2.3 (Section 4.5.3), so the outliers are visible as points, not just as a number.
Looking at the two plots, do the high-nFeature cells and the high-percent.mt cells occupy distinct regions, or do some cells qualify as both? What would a cell flagged on both axes most likely be?
TipANSWER
In most runs the two flagged groups are largely distinct: high-nFeature outliers cluster toward the right side of the nCount-vs-nFeature plot (more genes detected than the bulk of cells at a similar UMI count), while high-percent.mt outliers cluster in the upper region of the nCount-vs-percent.mt plot regardless of nFeature. Some overlap is expected and is the informative case: a cell flagged on both axes (high nFeature AND high percent.mt) is the hardest to call from a single metric. It could be a doublet that happens to also be stressed, or it could be two unrelated technical issues compounding in the same cell. The practical move is not to try to assign a single cause; flag the cell as low-confidence and let downstream steps (doublet scoring in Block 3, annotation confidence in Block 5, Section 4.8) make the final call with more evidence.
LIVE DEMO:
Code
table(sobj$outlier_flag)
normal high nFeature (possible doublet)
2797 53
high percent.mt (stressed/dying)
150
Code
# Cells flagged on both definitions (recompute the masks to check overlap):both <- (sobj$nFeature_RNA > mean_feat +2*sd_feat) & (sobj$percent.mt > mt_95)sum(both)
[1] 5
Distribution per condition: is mt% elevated in COVID-19?
Standard tutorial thresholds (nFeature > 200 & < 6000, percent.mt < 5%) are inherited from full 10x experiments with ~33,000 genes. This dataset is a 500-gene subset for teaching purposes. Applying the tutorial cutoffs deletes every cell.
Error in subset(sobj, subset = nFeature_RNA > 200 & nFeature_RNA < 5000 & :
No cells found
Important
“No cells found” is the canonical result of copying thresholds from a tutorial without checking your own data distribution. The threshold nFeature_RNA > 200 was calibrated for a transcriptome of ~33,000 genes where cells typically detect 2,000-3,000 genes. In a 500-gene panel, most cells detect 50-150 genes. The filter removes every single cell.
TipQUESTION 2.5
Which of the three filter parameters above is the most wrong for this 500-gene dataset, and what value would you try first? Run quantile(sobj$nFeature_RNA, c(0.05, 0.95)) to see the actual range before proposing a number.
TipANSWER
nFeature_RNA > 200 is the most wrong. The tutorial value 200 was calibrated for ~33,000 features where cells detect 2,000-3,000 genes; here the 500-gene panel produces 50-150 detected per cell, so a 200 floor removes essentially all cells. Reasonable starting threshold here is the 5th percentile of nFeature_RNA, typically near 30-50. nFeature_RNA < 6000 is technically fine because no cell has near 6000 features in a 500-gene panel; it just does nothing. percent.mt < 5 is borderline; effect depends on whether the MT pattern matched.
LIVE DEMO:
Code
quantile(sobj$nFeature_RNA, c(0.05, 0.50, 0.95))
5% 50% 95%
37 47 58
Code
summary(sobj$nFeature_RNA)
Min. 1st Qu. Median Mean 3rd Qu. Max.
27.00 43.00 47.00 47.46 52.00 69.00
Code
lo <-quantile(sobj$nFeature_RNA, 0.05)hi <-quantile(sobj$nFeature_RNA, 0.95)subset(sobj, subset = nFeature_RNA >= lo & nFeature_RNA <= hi)
An object of class Seurat
524 features across 2712 samples within 2 assays
Active assay: RNA (500 features, 500 variable features)
3 layers present: counts, data, scale.data
1 other assay present: ADT
2 dimensional reductions calculated: pca, umap
A reasonable first guess for this panel: nFeature_RNA > 50. Try it and see how many cells survive, before moving to the fully data-driven thresholds in Step 2.6 and 2.7 (Section 4.5.6, Section 4.5.7).
Code
n_above_50 <-sum(sobj$nFeature_RNA >50)cat("Cells with nFeature_RNA > 50:", n_above_50,sprintf("(%.1f%% of total)\n", 100* n_above_50 /ncol(sobj)))
Cells with nFeature_RNA > 50: 982 (32.7% of total)
4.5.6 Step 2.6 - Compare tutorial vs data-driven thresholds
Code
cat("--- Published thresholds (full 10x, 33k genes) ---\n")
--- Published thresholds (full 10x, 33k genes) ---
if (ncol(sobj_filt) <500) {cat("\nWARNING: fewer than 500 cells remaining.\n")cat("Options: relax max_mt or lower min_features,\n")cat("or request pre-filtered matrices from GEO.\n")} else {cat("\nCell count adequate for downstream analysis.\n")}
Cell count adequate for downstream analysis.
TipQUESTION 2.7
How would your thresholds change if the dataset had ~33,000 genes instead of 500?
TipANSWER
Thresholds on detected-feature counts scale roughly linearly with feature space, but thresholds on quality metrics (percent.mt, percent.ribo) do not. For 33,000 genes: nFeature_RNA lower 200-500, upper 5,000-8,000; nCount_RNA upper into tens of thousands. percent.mt is independent of feature count (a fraction of UMIs), so 10-20% upper bound is tissue-driven, not size-driven. Always inspect the distribution before fixing any number.
4.5.8 Step 2.8 - Silent failure: percent.mt as fraction vs as percentage
A common silent failure: someone copied a threshold from a tutorial that used percent.mt expressed as a FRACTION (0 to 1), but Seurat returns percent.mt as a PERCENTAGE (0 to 100). The filter looks right and runs without error, but removes nearly every cell.
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<try({# This filter expects 5 percent (i.e. percent.mt < 5). Written as 0.05 it# means "less than 0.05 percent", which removes almost everything. sobj_fraction <-subset(sobj, subset = percent.mt <0.05)cat("Cells passing 'percent.mt < 0.05' filter :", ncol(sobj_fraction), "\n")})
Cells passing 'percent.mt < 0.05' filter : 22
Code
cat("Compare to the correct threshold (percent.mt < 5):\n")
Compare to the correct threshold (percent.mt < 5):
Is your dataset’s percent.mt distribution closer to a fraction or a percentage? Run summary(sobj$percent.mt) to confirm and remember this trap when reading code from other groups.
TipANSWER
It is a percentage. PercentageFeatureSet returns values on a 0-100 scale. A real PBMC dataset typically sits between 0 and 15 percent mitochondrial. If you see values between 0 and 1, you are looking at a fraction encoding (someone divided by 100), and any threshold expressed as a percentage will be wrong. Diagnostic: summary(sobj$percent.mt) - if max is under 1, it is a fraction; otherwise it is a percentage.
LIVE DEMO:
Code
summary(sobj$percent.mt)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.000 2.788 5.290 6.463 8.853 28.605
4.5.9 Step 2.9 - Spot the bug
Read the code below CAREFULLY before running it. What is wrong with it?
REVEAL: the condition value in this dataset is “COVID19”, not “COVID”. - subset() returns zero cells without any error. Always inspect the unique values of a categorical column before subsetting on it.
Code
cat("Unique values of condition in this dataset:\n")
Unique values of condition in this dataset:
Code
print(unique(sobj_filt$condition))
[1] "Healthy" "COVID19"
TipTip
Habit: print unique() of a factor or character column before referencing any specific value in subset() or filter().
4.6Block 3 - Doublet detection
Goal: Identify and remove technical doublets that survive QC thresholds. + scDblFinder simulates artificial doublets and scores each real cell against them. The result is a doublet class label and a numeric score per cell.
4.6.1 Step 3.1 - Run scDblFinder
Code
library(scDblFinder)
Both packages are already loaded from Block 0; no need to library() again.
scDblFinder calls xgboost internally; recent xgboost versions emit deprecation warnings unrelated to our analysis. Wrap the call to keep the console focused on the actual result.
4.6.2 Step 3.2 - Where do flagged doublets sit on the QC scatter?
Code
df <- sobj_filt@meta.datap1 <-ggplot(df, aes(nCount_RNA, nFeature_RNA, color = scDblFinder.class)) +geom_point(alpha =0.5, size =0.7) +scale_color_manual(values =c("singlet"="grey70", "doublet"="#991b1b")) +labs(title ="Doublets sit on the high diagonal", color =NULL) +theme_classic(base_size =11)p2 <-ggplot(df, aes(scDblFinder.class, nFeature_RNA, fill = scDblFinder.class)) +geom_violin(alpha =0.7) +scale_fill_manual(values =c("singlet"="grey70", "doublet"="#991b1b")) +labs(title ="Genes detected per class", x =NULL) +theme_classic(base_size =11) +theme(legend.position ="none")p1 | p2
TipQUESTION 3.2
Why is it expected that doublets do NOT all sit at the very top of nCount_RNA? What does that imply about using nCount thresholds alone to remove doublets?
TipANSWER
A doublet of two similar cells (two CD4 T cells, two monocytes) has roughly the same transcriptional content as one cell, just at higher capture; depending on capture efficiency and library prep, its nCount may sit anywhere inside the singlet distribution. Easy-to-detect doublets are heterotypic (T + monocyte, B + DC) because they have hybrid transcriptomes. Hard-to-detect doublets are homotypic. nCount thresholds catch only the very high tail. scDblFinder catches the transcriptional hybrids that thresholds miss. Implication: nCount filtering is necessary but not sufficient.
4.6.4 Step 3.4 - Re-inspect the object after QC and doublet removal
After every step that mutates the object, confirm what you now have. Silent bugs surface only when the object is re-inspected, not when it is re-used.
Code
cat("Object after QC + doublet removal:\n")
Object after QC + doublet removal:
Code
print(sobj_filt)
An object of class Seurat
524 features across 2673 samples within 2 assays
Active assay: RNA (500 features, 500 variable features)
3 layers present: counts, data, scale.data
1 other assay present: ADT
2 dimensional reductions calculated: pca, umap
Code
cat("\nCells lost :", ncol(sobj) -ncol(sobj_filt), "\n")
Two views of the same removal: the overall distribution shift, and the per-donor breakdown. A filter that looks reasonable in aggregate can still remove one donor almost entirely; the per-donor bar chart is what catches that before it becomes a downstream surprise.
Code
before_after <-bind_rows(data.frame(nFeature_RNA = sobj$nFeature_RNA, stage ="Before filtering"),data.frame(nFeature_RNA = sobj_filt$nFeature_RNA, stage ="After filtering"))before_after$stage <-factor(before_after$stage,levels =c("Before filtering", "After filtering"))p_before_after <-ggplot(before_after, aes(x = nFeature_RNA, fill = stage)) +geom_histogram(bins =40, alpha =0.7, position ="identity") +labs(title ="nFeature_RNA distribution before vs after filtering",x ="nFeature_RNA", y ="Cell count", fill =NULL) +theme_classic(base_size =11)cell_counts <-data.frame(donor_id =c(sobj$donor_id, sobj_filt$donor_id),condition =c(sobj$condition, sobj_filt$condition),stage =rep(c("Before", "After"), c(ncol(sobj), ncol(sobj_filt)))) %>% dplyr::count(donor_id, condition, stage) %>% dplyr::mutate(stage =factor(stage, levels =c("Before", "After")))p_donor_counts <-ggplot(cell_counts, aes(x = donor_id, y = n, fill = stage)) +geom_bar(stat ="identity", position ="dodge") +facet_wrap(~condition, scales ="free_x") +labs(title ="Cell count per donor, before vs after QC and doublet removal",x =NULL, y ="Cells", fill =NULL) +theme_classic(base_size =11) +theme(axis.text.x =element_text(angle =45, hjust =1))p_before_after
Code
p_donor_counts
TipQUESTION 3.4
Look at the per-donor bar chart. Did any single donor lose a much larger fraction of cells than the others? If so, is that donor-level QC variation, or a sign the filter thresholds were tuned around the majority of donors at the expense of one outlier?
TipANSWER
Compare the before/after height for each donor rather than the overall retention rate. A donor that loses a noticeably larger fraction than the rest is worth a second look before proceeding: it could be genuine biological or technical donor-level variation (a donor with systematically lower RNA quality, more dying cells, or a different processing batch), or it could mean the data-driven thresholds in Step 2.7 (Section 4.5.7), which were computed across all donors pooled together, happen to fall in a range that disproportionately penalizes one donor’s distribution. The fix is the same either way: if one donor’s loss looks extreme, compute thresholds per donor and compare, rather than assuming a single global threshold serves every donor equally. Silently losing most of one donor’s cells changes what every downstream comparison (by condition, by severity) is actually measuring.
Goal: Take the RNA assay from raw counts to a clustered UMAP. ADT normalization is intentionally NOT done here; it belongs to Block 6 (Section 5.1) where the CITE-seq workflow starts. Keep the modalities separate until WNN.
4.7.1 Step 4.1 - Confirm the counts layer holds raw integer counts
A common silent failure: an object arrives with the data layer copied into the counts layer (already log-normalized). NormalizeData then runs on already-normalized values. The fix has to happen here, before any normalization call.
if (!all(vals ==round(vals))) {cat("\nWARNING: counts layer contains non-integer values.\n")cat("This is not raw counts. Do NOT run NormalizeData on it.\n")cat("Obtain the original count matrix before continuing.\n")} else {cat("\nCounts layer looks like raw counts. Safe to normalize.\n")}
Counts layer looks like raw counts. Safe to normalize.
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<# What does this check look like on a CORRUPTED counts layer?# Simulate: a well-meaning collaborator copied the data layer back into counts# "for consistency". Run the integrity check on that simulated layer and# compare with the real one above.try({# Build a fake assay where counts holds log-normalized values fake_data <-LayerData(sobj_filt, assay ="RNA", layer ="counts") fake_data@x <-log1p(fake_data@x /1e4) # roughly what LogNormalize produces fake_assay <-CreateAssay5Object(counts = fake_data) fake_vals <-LayerData(fake_assay, layer ="counts")@xcat("\n--- Integrity check on a CORRUPTED counts layer ---\n")cat("Min non-zero :", round(min(fake_vals), 4), "\n")cat("Max :", round(max(fake_vals), 4), "\n")cat("All integers?:", all(fake_vals ==round(fake_vals)), "\n")cat("This is what a contaminated counts layer looks like.\n")cat("If you see this on a real object, STOP and ask for the raw matrix.\n")})
--- Integrity check on a CORRUPTED counts layer ---
Min non-zero : 1e-04
Max : 0.0144
All integers?: FALSE
This is what a contaminated counts layer looks like.
If you see this on a real object, STOP and ask for the raw matrix.
TipQUESTION 4.1
If integrity check fails on a real inherited object, what do you ask the collaborator for? What is the absolute minimum you need to restart the analysis from a clean state?
TipANSWER
Ask for the raw CreateSeuratObject() output, OR the original 10x Genomics output (filtered_feature_bc_matrix), OR the original .rds saved before NormalizeData was first called. Minimum need: the raw counts matrix with cell and feature names matching the rest of the metadata. Anything downstream (normalized values, PCA, UMAP, clustering, annotation) can be regenerated. Without raw counts you cannot verify any quantitative claim. Make explicit: every publication-track project should preserve a checkpoint at CreateSeuratObject, before any normalization.
Verify: log-normalized values should be non-negative
Code
data_layer <-LayerData(sobj_filt, assay ="RNA", layer ="data")cat("Any negative values in RNA data layer:", any(data_layer <0), "\n")
Any negative values in RNA data layer: FALSE
Code
cat("Max value in RNA data layer :",round(max(data_layer@x), 2), "\n")
Max value in RNA data layer : 8.03
TipQUESTION 4.2
Why is LogNormalize applied to RNA but CLR (margin = 2) used for ADT? What property of each modality drives the difference?
TipANSWER
RNA: variable per-cell library size (10s to 10,000s of UMIs), gene counts heavily right-skewed, very sparse. LogNormalize divides each cell’s counts by its total, multiplies by 10,000, then takes log1p. ADT: per-cell antibody load is much more uniform across cells (every cell got the same staining mix), proteins are not sparse, and the meaningful signal is relative abundance of one protein versus others within the same cell. CLR (centered log-ratio) with margin=2 treats each cell’s protein counts as a composition and normalizes within the cell, centering at zero. margin=1 would normalize across cells per protein, which removes the biological signal of which cells stain positive.
cat("\nTotal variable features selected:", length(VariableFeatures(sobj_filt)), "\n")
Total variable features selected: 500
Challenge: what fraction of all genes are selected as variable?
Code
all_genes <-nrow(sobj_filt[["RNA"]])n_var <-length(VariableFeatures(sobj_filt))cat("Variable fraction:", round(n_var / all_genes *100, 1), "% of all genes\n")
Variable fraction: 100 % of all genes
4.7.4 Step 4.4 - Scale and run PCA
vars.to.regress removes the linear effect of percent.mt from each gene before scaling. This reduces the influence of cell stress/quality on the principal components.
Where do the PC coordinates live, now that PCA actually exists? Step 1.6 (Section 4.4.7) confirmed reductions were empty before this point; here are the three access patterns in practice.
Code
cat("Reductions now available:", paste(SafeReductions(sobj_filt), collapse =", "), "\n\n")
Reductions now available: pca, umap
Code
cat("Cell embeddings (cells x dims matrix), first 6 cells:\n")
Cell embeddings (cells x dims matrix), first 6 cells:
You inherit an object where Reductions(sobj) lists “pca” and “umap” but Layers(sobj[["RNA"]]) shows only “counts”. The data layer is missing. Can you trust the UMAP? What is your next move?
TipANSWER
No, you cannot trust it. PCA was computed from the data layer (log-normalized), and UMAP from PCA. If the data layer has been deleted, the upstream input to PCA is gone, so you cannot verify the PCA used the right values, normalization, or features. Reductions without their source layer are unverifiable. Two next moves: (1) ask the collaborator for the object before the data layer was pruned, OR (2) re-run NormalizeData, FindVariableFeatures, ScaleData, RunPCA from the counts layer and compare your new PCA to the inherited one. Substantial disagreement means the inherited UMAP is suspect.
PLOT / OUTPUT: Side-by-side UMAPs colored by cluster if recomputed PCA is available.
The elbow is where adding more PCs explains little additional variance.
Use this to set dims in FindNeighbors and RunUMAP.
Code
ElbowPlot(sobj_filt, ndims =30) +geom_vline(xintercept =20, linetype ="dashed", color ="#991b1b") +annotate("text", x =21, y =2.5, label ="~20 PCs", hjust =0,color ="#991b1b") +ggtitle("Elbow Plot: variance explained per PC")
TipQUESTION 4.4
How would you choose the number of PCs in a real dataset where the elbow plot does not have a sharp knee?
TipANSWER
Combine four signals: (1) cumulative variance explained, target 70-90%; (2) JackStraw permutation test, select PCs significant at chosen alpha; (3) clustering stability across dims (rerun FindClusters at dims=10, 15, 20, 25 and compute ARI between partitions); (4) biological coherence: do all expected populations separate? Report the choice and the sensitivity analysis, not a magic number.
LIVE DEMO:
Code
# JackStraw is slow on full data; usable on subsamples for the same conclusionsobj_filt <-JackStraw(sobj_filt, num.replicate =50, dims =25, verbose =FALSE)sobj_filt <-ScoreJackStraw(sobj_filt, dims =1:25)JackStrawPlot(sobj_filt, dims =1:25)
Warning: Removed 10928 rows containing missing values or values outside the scale range
(`geom_point()`).
PLOT / OUTPUT: JackStrawPlot: PCs above the diagonal are significant
4.7.5 Step 4.4a - Sanity check: did HVG and PCA do what you asked?
Silent failure A.FindVariableFeatures(nfeatures = 2000) on a 500-gene panel returns 500 without any warning. The HVG analysis was a no-op.
Code
n_features <-nrow(sobj_filt[["RNA"]])n_hvg_asked <-2000n_hvg_got <-length(VariableFeatures(sobj_filt))cat("Features in assay :", n_features, "\n")
Features in assay : 500
Code
cat("HVG requested :", n_hvg_asked, "\n")
HVG requested : 2000
Code
cat("HVG returned :", n_hvg_got, "\n")
HVG returned : 500
Code
if (n_hvg_got < n_hvg_asked) {cat(">> nfeatures > total features. HVG is the full panel; the call","selected nothing.\n")}
>> nfeatures > total features. HVG is the full panel; the call selected nothing.
Silent failure B. RunPCA returns the number of PCs you ask for, even when only a fraction carry signal. Inspect tail variance to spot dead PCs.
Code
pca_sdev <- sobj_filt@reductions$pca@stdevpca_var <- pca_sdev ^2cat("\nVariance explained by last 5 PCs (out of", length(pca_var), "):\n")
cat("If the tail is below ~0.5 percent each, those PCs are mostly noise.\n")
If the tail is below ~0.5 percent each, those PCs are mostly noise.
TipQUESTION 4.4a
How many PCs carry more than 1 percent of total variance?
The line below computes it; use the result as a lower bound for dims= later.
Code
n_pcs_above_1pct <-sum((pca_var /sum(pca_var)) >0.01)cat("\nNumber of PCs with > 1% variance explained:", n_pcs_above_1pct, "\n")
Number of PCs with > 1% variance explained: 30
Code
cat("Recommended lower bound for dims=: 1:", n_pcs_above_1pct, sep ="", "\n")
Recommended lower bound for dims=: 1:30
Code
cat("(The elbow plot above gave ~20; this floor confirms or contradicts it.)\n")
(The elbow plot above gave ~20; this floor confirms or contradicts it.)
TipANSWER
Computed in the script. Typically 10-15 PCs carry >1% variance in this dataset; the elbow gives roughly the same number. If they disagree by more than a factor of 2, either the elbow is being misread or the dataset has unusual variance structure.
PLOT / OUTPUT: Variance-per-PC scatter; flattening point is the floor for dims
4.7.6 Step 4.4b - Decide whether to integrate (Harmony)
Before clustering, ask: do donors separate on the PCA? If yes, the UMAP will be donor-dominated, not biology-dominated, and clustering will encode batch.
Code
p_pca_donor <-DimPlot(sobj_filt, reduction ="pca", group.by ="donor_id") +ggtitle("PCA by donor")p_pca_cond <-DimPlot(sobj_filt, reduction ="pca", group.by ="condition") +ggtitle("PCA by condition")p_pca_donor | p_pca_cond
Quick R^2 diagnostic: how much of PC1 and PC2 is explained by donor?
Decision rule of thumb: - R^2 < 0.10 : donor effect minimal, no integration needed - R^2 0.10 to 0.30: borderline, consider integration if cell types are mixed across donors - R^2 > 0.30 : donor dominates, integration recommended
In this dataset the donor effect is borderline-to-low, so clustering and annotation through Block 5 (Section 4.8) proceed on the plain PCA/UMAP without Harmony.
Step 5.4e (Section 4.8.9) runs Harmony anyway. Once cell types are annotated, to build a side-by-side comparison panel: seeing that Harmony barely changes the layout on data you already trust is what makes you confident reading the same comparison on a new dataset where you do not yet know the answer.
In downstream FindNeighbors / RunUMAP, switch reduction = "pca" to reduction = "harmony" (dims stays the same; Harmony returns the same dimensionality as the input reduction).
TipQUESTION 4.4b
At what donor R^2 would you switch to Harmony in your own data? The block above already printed the R^2 for PC1 and PC2; apply this rule directly:
Code
r2_pc1 <-summary(lm(pc_coords[, 1] ~ sobj_filt$donor_id))$r.squaredverdict <-if (r2_pc1 <0.10) {"NO integration needed"} elseif (r2_pc1 <0.30) {"BORDERLINE: integrate only if cell types are also separated by donor"} else {"INTEGRATE (Harmony or similar)"}cat(sprintf("\nDecision for this dataset: R^2(PC1)= %.3f -> %s\n", r2_pc1, verdict))
Decision for this dataset: R^2(PC1)= 0.065 -> NO integration needed
Code
cat("\nTrade-off of integration:\n")
Trade-off of integration:
Code
cat(" Pro: removes technical donor variance; cell types pool across donors.\n")
Pro: removes technical donor variance; cell types pool across donors.
Code
cat(" Con: may remove TRUE biological inter-donor variance (e.g., one donor\n")
Con: may remove TRUE biological inter-donor variance (e.g., one donor
Code
cat(" genuinely lacks a cell type). Always cross-check post-integration\n")
genuinely lacks a cell type). Always cross-check post-integration
Code
cat(" that cell type proportions per donor remain plausible.\n")
that cell type proportions per donor remain plausible.
TipANSWER
Thresholds (in the script): R^2 < 0.10 = no integration; 0.10-0.30 = borderline, integrate only if cell types are also separated by donor; >0.30 = integrate. Trade-off: integration removes technical donor variance so cell types pool across donors. It also removes TRUE biological inter-donor variance: if one donor genuinely lacks a cell type, integration may merge their cells with cells from other donors that DO have it, hiding the difference. After integrating, always check that per-donor cell type proportions remain plausible. This dataset’s R^2 falls in the low-to-borderline range, so clustering and annotation proceed without Harmony through Block 5 (Section 4.8). Step 5.4e (Section 4.8.9) runs Harmony anyway and Step 5.4f (Section 4.8.10) builds a 4-panel comparison so students see directly whether integration would have changed anything, rather than taking the R^2 threshold on faith.
PLOT / OUTPUT: DimPlot(sobj_filt, reduction='pca', group.by='donor_id') side by side with group.by='condition'; see also the 4-panel comparison in Step 5.4f (Section 4.8.10).
4.7.7 Step 4.4c - Spot the bug
Read the code below carefully. Two things are wrong. Find both before running anything.
dims = 0:20 includes PC 0, which does not exist. PCA indexing starts at 1. FindNeighbors will silently drop the 0 or error depending on the Seurat version. Always use 1:N.
dims passed as a non-contiguous vector skips intermediate PCs. UMAP will run but the result represents only the 7 selected dimensions, not the structure captured by the elbow. Always use a contiguous range starting from 1.
4.7.8 Step 4.5 - FindNeighbors: a common dimension mismatch
RunPCA was called with npcs = 30. Passing dims = 1:50 to FindNeighbors asks for 50 PCs that do not exist.
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<try({# npcs=30 was used in RunPCA. What happens if we request 50 dims? sobj_filt <-FindNeighbors(sobj_filt, dims =1:50, verbose =FALSE)})
Error in FindNeighbors.Seurat(sobj_filt, dims = 1:50, verbose = FALSE) :
More dimensions specified in dims than have been computed
The error occurs because the PCA object contains only 30 components. - dims = 1:50 requests components that do not exist. - Common cause: npcs in RunPCA is changed but downstream calls are not updated.
What evidence do you use to defend a chosen resolution?
TipANSWER
Three things: (1) biological coherence: each cluster has a distinguishable marker profile, defensible against the literature; (2) stability across nearby resolutions: clusters should not fragment dramatically on a small perturbation; ARI between resolutions 0.4 and 0.6 should be high; (3) downstream sanity: cluster count matches expectations for the tissue (PBMC at 3,000 cells: 8-14 clusters is reasonable; 25 is too many; 4 is too few). Show a clustree-style visualization across a resolution sweep and report which resolution and why.
Warning: The default method for RunUMAP has changed from calling Python UMAP via reticulate to the R-native UWOT using the cosine metric
To use Python UMAP via reticulate, set umap.method to 'umap-learn' and metric to 'correlation'
This message will be shown once per session
If donors visibly separate on the UMAP, is that batch effect or biology? What additional information would help you decide?
TipANSWER
Cannot tell from UMAP alone. Two checks: (1) do donors cluster within shared cell-type regions or do they form their own regions? Same cell types in same UMAP region across donors = expected biology; same cell types in DIFFERENT UMAP regions per donor = batch. Color UMAP by cell type and by donor on the same plot. (2) Compute the PC1 R^2 against donor_id (see 4.4b); if high, batch dominates the embedding.
4.7.12 Step 4.9 - Cluster proportions across conditions
This is a preliminary result. It must be validated after annotation.
Code
props <- sobj_filt@meta.data %>%group_by(condition, seurat_clusters) %>%summarise(n =n(), .groups ="drop") %>%group_by(condition) %>%mutate(prop = n /sum(n))ggplot(props, aes(x = condition, y = prop, fill = seurat_clusters)) +geom_bar(stat ="identity", width =0.6) +scale_y_continuous(labels = scales::percent_format()) +labs(title ="Cluster proportions: Healthy vs COVID-19",subtitle ="Check per-donor before interpreting any group difference",x ="Condition", y ="Proportion", fill ="Cluster") +theme_classic(base_size =12)
4.8Block 5 - Cell type annotation (RNA-based)
Goal: assign cell type labels from a curated reference (SingleR with the Human Primary Cell Atlas), then verify the labelled object before the CITE-seq workflow begins. RNA-only annotation has known weaknesses for T cell subsets; Block 6 (Section 5.1) will revisit those with protein.
4.8.1 Step 5.1 - Run SingleR against Human Primary Cell Atlas
Option A — Standard Installation Path
👉 This workflow uses the official Human Primary Cell Atlas reference and is the recommended confirmatory approach.
Use the labels directly These labels can be integrated into your analysis pipeline as a checkpoint, allowing you to continue without installing celldex.
👉 This workflow is exploratory and ensures reproducibility even in restricted environments (e.g., macOS ARM64 or limited internet access).
cat("\nLow-confidence cells (NA in pruned labels):",sum(is.na(singler_res$pruned.labels)), "\n")
Low-confidence cells (NA in pruned labels): 17
4.8.2 Step 5.2 - Plotting a label before it is in the object
Forgetting to transfer SingleR’s labels into the Seurat object before calling DimPlot(group.by = "singler_label") produces an opaque error. The label only exists in the SingleR result object until you copy it over.
Code
# >>> DELIBERATE ERROR - read the message, then continue. <<<try({# singler_res is a separate object. Labels must be transferred to sobj_filt first.DimPlot(sobj_filt, group.by ="singler_label")})
Warning: The following requested variables were not found: singler_label
Error in `[.data.frame`(data, , group) : undefined columns selected
singler_label does not exist in sobj_filt@meta.data yet. singler_res is a DataFrame (Bioconductor class). The labels must be explicitly added to the Seurat metadata.
Cells with similar scores across multiple types are ambiguous. Wide spread = high confidence. Narrow spread = low confidence. plotScoreHeatmap() draws one row per possible reference label: the FULLHumanPrimaryCellAtlasData catalog, ~37 rows, regardless of how many cells were actually assigned to each. Look at this before any cleanup happens. Most of those 37 rows will show almost no signal for any cell in this dataset; that visual clutter is the evidence that motivates the consolidation in the next step.
Code
plotScoreHeatmap(singler_res,main ="SingleR annotation scores: full reference (37 possible labels)")
TipQUESTION 5.2
How many of the rows in this heatmap show any real signal (a cluster of cells with a clearly bright cell)? What does the rest of the rows tell you about how many of the labels printed above are likely noise?
TipANSWER
On this dataset, typically only 5 to 8 of the roughly 37 rows show a clear bright block: a clean separation where one column-group of cells lights up brightly for that row and stays dark for the rest. Those rows correspond to the real cell populations actually present in PBMC: T cells, B cells, monocytes, NK cells, and a few others. The remaining rows are close to uniformly dim across every cell. A dim row does not mean SingleR made an error; it means no cell in this dataset scored highly against that reference label, which is expected for labels like Hepatocytes or Neurons in a blood sample. The practical reading: if a label’s row in this heatmap never lights up brightly anywhere, any cell assigned that label by the raw classifier is suspect, and that is exactly why so many distinct values appeared in the ‘Cell type distribution’ printed in Step 5.2 (Section 4.8.2). The dim rows are the visual evidence that motivates collapsing low-frequency labels together in Step 5.2b (Section 4.8.3), rather than trusting all of them as equally real populations.
LIVE DEMO:
Count distinct labels with at least one confidently assigned cell:
Compare to how many rows visually ‘light up’ in the heatmap above.
PLOT / OUTPUT: The full plotScoreHeatmap from Step 5.2 (Section 4.8.2) (37 rows); count how many show a bright block versus uniform dimness.
4.8.3 Step 5.2b - Consolidate to top-N labels and check distribution plausibility
HumanPrimaryCellAtlasData covers dozens of cell types across many tissues. On a 500-gene PBMC panel it typically returns 20+ distinct labels, most supported by only a handful of cells (low-confidence noise, not real populations). Plotting all of them produces an unreadable legend and defeats the purpose of every downstream comparison.
singler_label (full detail) is kept untouched in the metadata for cases where the exact label matters. singler_label_top collapses every label outside the top N most frequent into “Other (n small labels)” and is what every plot from here on uses by default. The same pass that builds the consolidation also checks whether the underlying distribution looks like a real PBMC annotation, since both questions come from the same table.
What this step decides, and what it does not: this is a count-based decision about which CATEGORIES are worth their own color in a plot. It never looks at a single gene or a single cell’s expression. A cell labeled “T_cell” stays “T_cell” here regardless of whether it actually expresses any T cell marker; the only thing that changes is whether that label gets its own slot in the legend or gets folded into “Other” because too few cells share it.
Step 5.4a (Section 4.8.6), later, asks a different question on top of this one: given a label that survived this filter, does the individual cell carrying it actually show the expression evidence that label implies? That is a per-cell, marker-based check, not a per-label, count-based one.
Step 5.4c (Section 4.8.7) then builds singler_label_clean directly from singler_label_top, adding only the cells that failed the Step 5.4a (Section 4.8.6) marker check as a new “Ambiguous” category. The two steps are not redundant: this one decides what to show, Step 5.4a/5.4c (Section 4.8.6 / Section 4.8.7) decides who to trust within what is shown.
Code
TOP_N_LABELS <-8label_tab <-sort(table(sobj_filt$singler_label), decreasing =TRUE)top_labels <-names(label_tab)[seq_len(min(TOP_N_LABELS, length(label_tab)))]n_collapsed <-length(label_tab) -length(top_labels)sobj_filt$singler_label_top <-ifelse( sobj_filt$singler_label %in% top_labels, sobj_filt$singler_label,sprintf("Other (%d small labels)", n_collapsed))sobj_filt$singler_label_top <-factor( sobj_filt$singler_label_top,levels =c(top_labels, sprintf("Other (%d small labels)", n_collapsed)))cat(sprintf("Kept top %d labels, collapsed %d smaller labels into 'Other':\n",length(top_labels), n_collapsed))
Kept top 8 labels, collapsed 18 smaller labels into 'Other':
Code
print(table(sobj_filt$singler_label_top))
T_cells Monocyte NK_cell
782 445 351
B_cell DC Endothelial_cells
305 154 138
Neutrophils Macrophage Other (18 small labels)
136 102 260
Sanity rules of thumb for PBMC, checked against the full (uncollapsed) label table:
Expect 3 to 7 dominant labels (T cells, B cells, monocytes, NK cells, DC)
One label > 80% of all cells: suspicious (annotation collapse)
More than 15 distinct labels in 3,000 cells: reference too granular
Any single non-immune label > 5% (e.g. hepatocytes, fibroblasts): wrong reference
if (n_labels >15) cat(">> Many distinct labels. Reference may be too granular for PBMC.\n")
>> Many distinct labels. Reference may be too granular for PBMC.
Code
if (top_prop >0.80) cat(">> One label dominates. Check whether reference matches the tissue.\n")
TipQUESTION 5.2b
Look at the labels collapsed into “Other” (the rows of label_tab beyond rank 8). Are any of them biologically plausible for PBMC (e.g. “Platelets”, “DC”) or all implausible (e.g. “Hepatocytes”, “Neurons”)? What would you do differently if a plausible label got collapsed away?
TipANSWER
On this dataset, HumanPrimaryCellAtlasData typically returns 20+ labels for a 500-gene PBMC panel. Most of what falls outside the top 8 is biologically implausible for blood (Hepatocytes, Fibroblasts, Endothelial_cells, Neurons, Gametocytes, Astrocytes) and reflects low-confidence noise from a reference that covers many tissues, not a PBMC-specific signal. A few collapsed labels CAN be biologically plausible but rare in this cohort (Platelets, DC, Pro-B_cell subsets) - those are real minority populations, just small ones. The top-N consolidation is a visualization aid, not a correction to the annotation itself: singler_label (uncollapsed) is preserved in the metadata for exactly this reason. If a plausible rare population matters to your analysis (e.g. you are specifically studying platelets or DCs), raise TOP_N_LABELS, or keep using singler_label directly for that one analysis instead of singler_label_top.
Same heatmap as Step 5.2 (Section 4.8.2), restricted to the labels that survived the top-N consolidation. Compare directly against the full 37-row version shown earlier: this is what “readable” looks like once the labels that carried no real signal have been set aside.
heatmap_result <-tryCatch({plotScoreHeatmap(singler_res,labels.use = top_labels,main ="SingleR annotation scores (top labels only; compare to Step 5.2)")"filtered"}, error =function(e) {message("labels.use raised an error in this SingleR version: ", conditionMessage(e))message("Falling back to the full heatmap.")plotScoreHeatmap(singler_res,main ="SingleR annotation scores (wider spread = more confident)")"full (fallback)"})
cat("If this says 'full (fallback)', the plot above is identical to Step 5.2\n")
If this says 'full (fallback)', the plot above is identical to Step 5.2
Code
cat("by design: that is the fallback path, not the filtered version.\n")
by design: that is the fallback path, not the filtered version.
4.8.5 Step 5.4 - Verify the annotated object
Compare the object now with its state in Section 4.4 (Block 1) before moving to the CITE-seq workflow.
Code
cat("Object after RNA workflow + annotation:\n")
Object after RNA workflow + annotation:
Code
print(sobj_filt)
An object of class Seurat
524 features across 2673 samples within 2 assays
Active assay: RNA (500 features, 500 variable features)
3 layers present: counts, data, scale.data
1 other assay present: ADT
2 dimensional reductions calculated: pca, umap
Which slots are still empty / unchanged in the ADT assay?
What does that tell you about what Block 6 (Section 5.1) needs to do first?
TipANSWER
ADT still has only the counts layer. No data layer (no normalization), no scale.data, no var.features, no reductions referencing ADT. Block 6 (Section 5.1) must build the ADT side from scratch:normalize (CLR margin=2), then either use it directly for plots and gating (no scale.data needed) or run RunPCA on the ADT counts before WNN.
LIVE DEMO:
Code
Layers(sobj_filt[["ADT"]])
[1] "counts" "data"
Code
Reductions(sobj_filt)
[1] "pca" "umap"
PLOT / OUTPUT: Console output
4.8.6 Step 5.4a - Canonical-marker validation per cell type
Before trusting an annotation, verify the canonical RNA marker of each major lineage is detected in a reasonable fraction of cells assigned to that lineage. The label-distribution sanity check already ran in Step 5.2b (Section 4.8.3), right after the labels were consolidated; this step is a different kind of check, on the per-marker evidence rather than the label counts.
Step 5.2b (Section 4.8.3) asked “how many cells share this label, and is that count plausible for PBMC?” That is a question about labels as categories: it never inspected a single gene. This step asks a different question: “for a cell that carries this label, does its RNA actually show the marker that label implies?” That is a per-cell, expression-based check, run independently of how common or rare the label was. A label can pass the Step 5.2b (Section 4.8.3) frequency check (common enough to keep its own category) and still fail this one (most cells carrying it do not express the expected marker), which is exactly what the detection-rate table below is built to catch. The output of this step (detection_rates) feeds Step 5.4c (Section 4.8.7), which builds singler_label_clean from singler_label_top and adds “Ambiguous” only for the cells that failed this marker check, on top of the categories Step 5.2b (Section 4.8.3) already decided were worth keeping.
Lowest detection: Pro-B_cell_CD34+ expressing CD79A at 0.0%
Decision rule:
rate >= 50% -> annotation consistent with RNA (no action)
rate 25-50% -> dropout likely (ADT in Block 6 should rescue)
rate < 25% -> suspect misannotation; cross-check with ADT now
TipANSWER
Computed in the script. The lowest rate usually appears for a T-cell label expressing CD3E or CD3D; CD3E RNA dropout in PBMC is often 30-50%, so detection rates between 25-50% indicate dropout (recoverable in Block 6 (Section 5.1) via ADT CD3). A detection rate below 25% for any marker is more likely misannotation. Decision thresholds: >=50% ok; 25-50% dropout-explained, verify with ADT; <25% suspect, revisit annotation.
4.8.7 Step 5.4c - Mark inconsistent labels as Ambiguous
Cells whose canonical marker detection rate fell below 25% in Step 5.4a (Section 4.8.6) are flagged, not deleted. singler_label_clean carries the same values as singler_label_top except those flagged cells, which become “Ambiguous”. Every prior column (singler_label, singler_label_top) stays untouched.
cat(sprintf("Cells marked Ambiguous: %d (%.1f%% of total)\n",sum(ambiguous_mask), 100*mean(ambiguous_mask)))
Cells marked Ambiguous: 1329 (49.7% of total)
Code
cat("\nFinal label distribution (singler_label_clean):\n")
Final label distribution (singler_label_clean):
Code
print(table(sobj_filt$singler_label_clean))
Ambiguous Endothelial_cells Macrophage
1329 138 102
Neutrophils Other (18 small labels) T_cells
136 186 782
TipQUESTION 5.4c
Cells marked Ambiguous are still in sobj_filt and still count toward ncol(sobj_filt). Why keep them instead of deleting them outright? What would deleting them silently change about cell counts reported in every plot from here on?
TipANSWER
Keeping them preserves an honest record of what the annotation pipeline actually produced: a real fraction of cells could not be confidently typed by RNA alone, and that fraction is itself informative (it often shrinks once ADT is added in Block 6 (Section 5.1), which is the whole point of the CITE-seq workflow). Deleting them at this stage would silently shrink ncol(sobj_filt), which changes every downstream denominator: QC summaries, per-condition proportions, per-donor cell counts, and any percentage calculation would all be computed over a smaller, undocumented population. A reader of a later plot would have no way to know cells were dropped here unless the deletion were stated explicitly every time. Marking and filtering only at the plotting step (Step 5.4d, Section 4.8.8) keeps the object’s cell count meaningful throughout the rest of the script, and the Ambiguous label itself becomes a result worth reporting (for example, in Block 6 (Section 5.1) you can check whether ADT resolves some of these cells).
ncol(sobj_filt) # unchanged regardless of how many cells are Ambiguous
[1] 2673
PLOT / OUTPUT: Console output
4.8.8 Step 5.4d - Re-plot the UMAP with only confident labels
Same UMAP as Step 5.3 (Section 4.8.4), restricted to cells that were NOT flagged Ambiguous. Cells are not removed from the object; they are simply excluded from this specific plot via cells.highlight style filtering on a copy of the metadata used for plotting.
Plotting 1344 / 2673 cells (50.3%) with confident annotation.
Code
DimPlot(sobj_filt, cells = confident_cells,group.by ="singler_label_clean",label =TRUE, label.size =3, repel =TRUE) +ggtitle("Confident annotation only (Ambiguous cells excluded from view)") +NoLegend()
4.8.9 Step 5.4e - Integrate with Harmony
Step 4.4b (Section 4.7.6) found the donor effect borderline-to-low in this dataset (R^2 on PC1/PC2), so integration was not required to proceed. We run it anyway here to build the comparison panel in Step 5.4f (Section 4.8.10): with vs without Harmony is a habit worth seeing on data you already understand, before relying on it on data you do not.
Code
library(harmony)
Loading required package: Rcpp
• This is Harmony2 version 2.0.5
• Read the guide: run vignette("quickstart", package="harmony")
• Get help: Visit the website at <https://korsunskylab.github.io/harmony2/> and
report issues on <https://github.com/immunogenomics/harmony/issues>
Reductions now available: pca, umap, harmony, umap.harmony
Step 4.4b (Section 4.7.6) computed donor R^2 on the plain PCA before any decision was made. Now that Harmony has run, compute the same R^2 on the harmonized embedding and compare directly: this is the actual gain or loss from integrating, in the same units used to make the original decision, not just a visual impression from a UMAP.
Interpretation: a large negative change means Harmony successfully removed donor-driven variance from that dimension. A change near zero confirms the Step 4.4b (Section 4.7.6) read: there was little donor effect to remove in the first place, so integration cost real biological variance for essentially no batch-correction gain.
TipQUESTION 5.4e
Is the change in donor R^2 large or close to zero in this dataset? Does that match what the Step 4.4b (Section 4.7.6) decision predicted? If you ran this on a dataset with R^2 above 0.30, what change would you expect to see in this same table?
TipANSWER
On this dataset the donor R^2 was already low to borderline before Harmony (Step 4.4b, Section 4.7.6), so the change after Harmony should also be small: Harmony has little donor-driven variance left to remove, which is exactly what the Step 4.4b (Section 4.7.6) decision to skip integration predicted. This is the confirming case: if the change had turned out large here, it would mean the original PC1/PC2-only R^2 check missed a donor effect living in later PCs, and the decision not to integrate would have been wrong. On a dataset where the pre-Harmony R^2 was above 0.30 (donor effect dominant), the expected pattern is a large drop in R^2 after Harmony (often falling toward 0.05-0.15) on the dimensions Harmony was told to correct, paired with a visibly different cell-type layout in the comparison panel: that combination is what integration succeeding actually looks like in numbers, not just in a UMAP that looks nicer.
Four panels answer four different questions about the same object:
1. Pre-annotation: do the unsupervised clusters look reasonable at all? 2. Post-annotation (no Harmony): does the annotation make sense on the embedding you actually used through Section 4.8 (Block 5, Section 4.8)? 3. Post-annotation, Harmony: did integration change which cells sit near which others? 4. Donor color on the Harmony UMAP: did Harmony actually mix donors together, or did it not need to (consistent with the Step 4.4b (Section 4.7.6) call)?
Compare panels 2 and 3. If the cell-type layout looks nearly identical before and after Harmony, what does that confirm about the Step 4.4b (Section 4.7.6) decision not to integrate? If it looks different, which panel would you trust for the rest of the analysis, and why?
TipANSWER
If panels 2 and 3 look nearly identical, that confirms the Step 4.4b (Section 4.7.6) read of the data: the donor effect on PC1/PC2 was already low (R^2 < 0.10-0.30 range), so Harmony has little work to do and converges to essentially the same cell-type layout. That is the expected, reassuring outcome here, and panel 4 (Harmony UMAP by donor) should still show donors reasonably mixed within each cell-type region, the same as before integration. If panels 2 and 3 looked clearly different instead, that would mean the PC1/PC2 R^2 from Step 4.4b (Section 4.7.6) under-estimated a donor effect living in later PCs, and the right move is to trust the Harmony-integrated panel (3) for downstream analysis, since it actively corrects for batch rather than just measuring it on two dimensions. Either way, the comparison panel is the check that validates (or overturns) an integration decision made earlier from a partial diagnostic.
LIVE DEMO:
Quantify the visual comparison instead of eyeballing it:
Error:
! object 'cluster_labels_no_harmony' not found
PLOT / OUTPUT: The 2x2 comparison panel itself (Step 5.4f, Section 4.8.10) is the answer; no separate plot needed.
4.8.11 Step 5.4g - Differential expression markers per annotated cell type
Explicit about what this test actually uses, since the object now carries both a plain PCA/UMAP and a Harmony-corrected PCA/UMAP from Step 5.4e (Section 4.8.9):
Harmony only touches the PCA/UMAP embeddings (used for visualization and clustering); it never modifies the RNA expression values that FindAllMarkers reads. Running this on the harmonized object or the pre-Harmony object gives identical results, because DE testing here compares expression directly between groups of cells, not their position on any embedding.
Grouping: singler_label_clean (Step 5.4c, Section 4.8.7), Ambiguous cells excluded. They are not a coherent biological group, so testing them as their own “cluster” would not mean anything.
Filters: only.pos = TRUE (markers, not anti-markers), min.pct = 0.25 (expressed in at least 25% of one group), logfc.threshold = 0.5 (at least 1.4-fold change).
For a (much!) faster implementation of the Wilcoxon Rank Sum Test,
(default method for FindMarkers) please install the presto package
--------------------------------------------
install.packages('devtools')
devtools::install_github('immunogenomics/presto')
--------------------------------------------
After installation of presto, Seurat will automatically use the more
efficient implementation (no further action necessary).
This message will be shown once per session
Warning in FindMarkers.default(object = data.use, cells.1 = cells.1, cells.2 =
cells.2, : No features pass logfc.threshold threshold; returning empty
data.frame
Code
TOP_N_MARKERS <-5top_markers <- de_markers %>%group_by(cluster) %>%slice_max(order_by = avg_log2FC, n = TOP_N_MARKERS) %>%ungroup()cat(sprintf("Top %d markers per confident cell type:\n", TOP_N_MARKERS))
DotPlot(sobj_filt,features =unique(top_markers$gene),idents =setdiff(levels(Idents(sobj_filt)), "Ambiguous"),group.by ="singler_label_clean") +RotatedAxis() +ggtitle(sprintf("Top %d markers per cell type (confident labels only)", TOP_N_MARKERS))
TipQUESTION 5.4g
Pick one cell type from the DotPlot. Does its top marker match a canonical marker you already know for that lineage (Step 1.8, Section 4.4.9)? If not, is that a red flag about the annotation, or a finding outside the canonical list that is worth investigating further?
TipANSWER
For most major lineages in this dataset the top DE marker should match a canonical gene from the Step 1.8 (Section 4.4.9) list: CD3D/CD3E for T cells, MS4A1/CD79A for B cells, CD14/LYZ for monocytes, NKG7/GNLY for NK cells. A match is reassuring: independent statistical evidence (DE testing) agrees with prior biological knowledge (canonical markers), which is the strongest form of validation available without an orthogonal assay. A mismatch is more interesting than alarming by itself: first rule out technical explanations (is the canonical gene even in the 500-gene panel? Check rownames(sobj_filt[['RNA']])), then consider whether the cluster is a known but less-textbook subtype (e.g. a Treg subset whose top marker is FOXP3 rather than generic CD3 genes) before treating it as a red flag about the annotation itself.
LIVE DEMO:
Check whether a canonical marker even exists in this 500-gene panel:
To reload: sobj_filt <- readRDS('outputs/sobj_preprocessed.rds')
Look at the UMAP colored by condition. Does any cluster appear exclusively or predominantly in COVID-19 donors? Look at the DotPlot: which clusters could be monocytes based on CD14 and FCGR3A expression? After the break, we add the protein layer to test these interpretations.
Tip📌 Final Note for Users
At the end of this step, you may download the preprocessed Seurat object to continue with the next stage of the CITE‑seq analysis:
👉 This file contains the preprocessed Seurat object (sobj_preprocessed.rds) and serves as the starting point for downstream multimodal analysis. By saving and sharing this checkpoint, all users can proceed consistently without repeating earlier steps.
used (Mb) gc trigger (Mb) limit (Mb) max used (Mb)
Ncells 12097963 646.2 20895131 1116.0 NA 20895131 1116.0
Vcells 25248943 192.7 55366352 422.5 24576 55366352 422.5