A.1Bloque 8 - Proyecto final (opcional, después de clase): un objeto desconocido y averiado
Objetivo: Aplicar los hábitos de diagnóstico de los bloques anteriores a un único objeto que contiene cuatro problemas independientes. Para cada escenario, ejecuta el código de diagnóstico, escribe tu hipótesis como un comentario en el bloque de abajo, y luego ejecuta la corrección para confirmarla.
Este es el proyecto final: el resto del curso te enseñó a detectar fallos específicos de uno en uno. Aquí todos llegan en el mismo objeto, sin un orden particular, y sin etiquetas.
Este bloque va después del Cierre a propósito. La sesión en vivo termina en el Bloque 7; esto es trabajo para hacer en casa para quien quiera más práctica antes de la siguiente sesión, no algo para apresurarse en el salón. Ejecútalo en tu propio tiempo, idealmente uno o dos días después del curso, una vez que los hábitos de diagnóstico de los Bloques 1-6 hayan tenido oportunidad de asentarse.
El instructor publicará sobj_broken.rds por separado. Si el archivo no está presente, este bloque se omite (have_broken = FALSE) y el resto del script sigue ejecutándose.
Intenta primero con el objeto inyectado (necesario para el Escenario 4), y luego con el base. Si ninguno existe, el Bloque 8 se omite de forma controlada (have_broken = FALSE) para que el resto del informe siga renderizándose.
Código
broken_candidates <-c("checkpoints/sobj_broken_errors.rds", "checkpoints/sobj_broken.rds")broken_path <- broken_candidates[file.exists(broken_candidates)][1]have_broken <-!is.na(broken_path)if (have_broken) { sobj_broken <-readRDS(broken_path)cat("Loaded broken object from:", broken_path, "\n")print(sobj_broken)head(sobj_broken@meta.data, 3)} else {cat("Broken object not found in data/. Block 8 will be skipped.\n")cat("To enable Block 8, place one of these files (paths relative to this .R script):\n")cat(" ", paste(broken_candidates, collapse =" or "), "\n")cat("Scenario 4 (ADT batch / isotype) requires the *_errors.rds from\n")cat("inject_course_errors.R.\n")}
Loaded broken object from: checkpoints/sobj_broken_errors.rds
An object of class Seurat
526 features across 3000 samples within 2 assays
Active assay: ADT (26 features, 0 variable features)
1 layer present: counts
1 other assay present: RNA
2 dimensional reductions calculated: pca, umap
Contexto: Recibiste este objeto de un colaborador. Ejecutas el flujo de trabajo estándar de preprocesamiento de RNA. Todo se ejecuta sin errores, pero los resultados son completamente incorrectos: FindVariableFeatures devuelve menos características de las esperadas, el PCA explica casi toda la varianza en PC1, y el UMAP es una única mancha (blob).
Código
if (have_broken) {cat("Default assay :", DefaultAssay(sobj_broken), "\n")cat("Features active assay:", nrow(sobj_broken), "\n")cat("All assays :", paste(SafeAssays(sobj_broken), collapse =", "), "\n")}
Default assay : ADT
Features active assay: 26
All assays : RNA, ADT
Código
if (have_broken) {# What does FindVariableFeatures return on this object? test_hvg <-FindVariableFeatures(sobj_broken, nfeatures =2000, verbose =FALSE)cat("Variable features found:", length(VariableFeatures(test_hvg)), "\n")cat("Feature names:\n")print(head(VariableFeatures(test_hvg), 15))}
if (have_broken) {# Compare RNA and ADT feature countscat("Features in RNA assay:", nrow(sobj_broken[["RNA"]]), "\n")cat("Features in ADT assay:", nrow(sobj_broken[["ADT"]]), "\n")}
Features in RNA assay: 500
Features in ADT assay: 26
📝 Escribe tu hipótesis como un comentario abajo, luego ejecuta la corrección para confirmarla. ¿Cuál es el DefaultAssay? ¿Por qué FindVariableFeatures devuelve tan pocas características? ¿Qué pasaría si ejecutaras RunPCA y RunUMAP en este objeto sin corregirlo? ¿Cómo detectarías este problema en un objeto Seurat publicado que descargaste?
Código
if (have_broken) {cat("Before fix:", DefaultAssay(sobj_broken), "\n")# The DefaultAssay is set to "ADT" (24 proteins).# All RNA functions are running silently on 24 protein features instead# of 500 RNA genes. FindVariableFeatures returns 24 features because that# is all that exists in the active assay.DefaultAssay(sobj_broken) <-"RNA"cat("After fix :", DefaultAssay(sobj_broken), "\n")cat("Features :", nrow(sobj_broken), "\n") test_fixed <-FindVariableFeatures(sobj_broken, nfeatures =2000, verbose =FALSE)cat("Variable features now:", length(VariableFeatures(test_fixed)), "\n")}
Before fix: ADT
After fix : RNA
Features : 500
Variable features now: 500
Lecciones aprendidas:
DefaultAssay() es la primera línea que se debe ejecutar en cualquier objeto recibido.
Este error es completamente silencioso. Sin advertencia. Sin error. Solo resultados incorrectos.
FindVariableFeatures, ScaleData, RunPCA, FindMarkers operan todos sobre el assay activo. Un PCA ejecutado sobre 24 características de ADT no es un PCA transcriptómico.
Siempre verifica DefaultAssay() después de cualquier cambio de assay para confirmar que se restableció.
A.1.2 Escenario 2 - Inconsistencias en los metadatos
Contexto: Este objeto se ensambló a partir de muestras procesadas en múltiples sitios. Agrupar por condition y donor_id produce resultados inesperados. Faltan algunas muestras en los gráficos y ciertos donantes aparecen duplicados.
Código
if (have_broken) {cat("Metadata columns:\n")print(colnames(sobj_broken@meta.data))cat("\nNAs per column:\n")print(colSums(is.na(sobj_broken@meta.data)))}
if (have_broken) {# What does a DimPlot by condition look like?DimPlot(sobj_broken, group.by ="condition") +ggtitle("Condition plot: how many groups appear?")}
📝 Escribe tu hipótesis como un comentario abajo, luego ejecuta la corrección para confirmarla. ¿Cuántos problemas distintos encontraste? ¿Cuál sería la consecuencia biológica si cada uno quedara sin corregir? Por ejemplo: si las etiquetas de condición son inconsistentes, ¿qué le sucede a una comparación entre Sano y COVID-19?
Código
if (have_broken) { sobj_meta <- sobj_broken# PROBLEM 1: donor_id has three different formats for the same donors# e.g. "Donor01", "donor1", "DONOR01" all mean the same donorcat("=== FIX 1: donor_id ===\n") sobj_meta$donor_id <-toupper(gsub("[^A-Z0-9a-z]", "", sobj_meta$donor_id))print(table(sobj_meta$donor_id))# PROBLEM 2: condition has 4 variants of 2 values# "COVID19", "covid19", "Covid-19", "HC", NAcat("\n=== FIX 2: condition ===\n") sobj_meta$condition_clean <- dplyr::case_when(grepl("covid", sobj_meta$condition, ignore.case =TRUE) ~"COVID-19",grepl("healthy|HC|control", sobj_meta$condition,ignore.case =TRUE) ~"Healthy",TRUE~NA_character_ )print(table(sobj_meta$condition_clean, useNA ="always"))# PROBLEM 3: 200 cells have NA in cell_typecat("\n=== FIX 3: cell_type NAs ===\n")cat("NAs before:", sum(is.na(sobj_meta$cell_type)), "\n") sobj_meta$cell_type[is.na(sobj_meta$cell_type)] <-"Unknown"cat("NAs after :", sum(is.na(sobj_meta$cell_type)), "\n")}
table(col, useNA = "always") y str(meta.data) antes de cada análisis.
Los datos de múltiples sitios casi siempre tienen inconsistencias de formato.
NA en condición: esa célula queda excluida de cualquier comparación entre Sano y COVID-19. Con 30 NAs, estás descartando silenciosamente el 1% de las células de todas las comparaciones de grupo.
Las mayúsculas y minúsculas importan: "COVID19" == "covid19" es FALSE en R.
A.1.3 Escenario 3 - Fallo de normalización y efecto de lote
Contexto: El UMAP muestra una separación clara por donante en lugar de por tipo celular. Los datos de ADT también parecen distorsionados. Identifica ambos problemas y corrígelos.
Código
if (have_broken) {DimPlot(sobj_broken, group.by ="orig.ident") +ggtitle("Donors separate in UMAP: batch effect or biology?")}
Código
if (have_broken) {DefaultAssay(sobj_broken) <-"ADT" adt_data <-LayerData(sobj_broken, layer ="data")cat("Row means (per protein); near 0 means margin=1 was used (wrong):\n")print(round(rowMeans(adt_data), 4))cat("\nColumn means (per cell, first 10); near 0 means margin=2 (correct):\n")print(round(colMeans(adt_data)[1:10], 4))DefaultAssay(sobj_broken) <-"RNA"}
Warning: Layer 'data' is empty
Row means (per protein); near 0 means margin=1 was used (wrong):
numeric(0)
Column means (per cell, first 10); near 0 means margin=2 (correct):
[1] NA NA NA NA NA NA NA NA NA NA
Código
if (have_broken) { rna_data <-LayerData(sobj_broken, assay ="RNA", layer ="data")cat("Any negative values in RNA data layer?",any(rna_data <0), "\n")cat("(TRUE would indicate SCTransform residuals stored as 'data')\n")cat("\nRange of non-zero values in RNA data:\n")print(summary(rna_data@x))}
Any negative values in RNA data layer? FALSE
(TRUE would indicate SCTransform residuals stored as 'data')
Range of non-zero values in RNA data:
Min. 1st Qu. Median Mean 3rd Qu. Max.
2.553 3.392 4.530 4.620 5.715 8.034
📝 Escribe tu hipótesis como un comentario abajo, luego ejecuta la corrección para confirmarla. ¿Cómo determinaste qué margin se usó? ¿Cuál es la consecuencia del efecto de lote para cualquier comparación a nivel de condición? Si publicaras el UMAP original como una figura, ¿qué afirmación científica quedaría invalidada?
RNA re-normalized from counts.
ADT re-normalized (CLR, margin=2).
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
Medias de fila ~0 en los datos de ADT es la firma diagnóstica de margin=1. Incorrecto.
Medias de columna ~0 en los datos de ADT es la firma de margin=2. Correcto.
La capa de conteos (counts) es la verdad fundamental (ground truth). Vuelve a normalizar a partir de ella; nunca la sobrescribas.
La separación por donante en el UMAP = efecto de lote. Ejecuta DimPlot(group.by="orig.ident") antes de interpretar cualquier UMAP biológicamente.
Harmony corrige los efectos de lote a nivel de donante mientras preserva la variación biológica. Requiere al menos 3 donantes por grupo para ser confiable.
A.1.4 Escenario 4 | Lote de tinción de ADT y fondo de isotipo (CITE-seq, 25 min)
Contexto: Los datos de proteína parecen técnicamente correctos: se normalizan, se grafican, el gating funciona. Pero el PCA de ADT separa las células por lote de procesamiento, no por tipo celular, y un donante muestra una señal inflada en todas las proteínas. Hay dos problemas distintos de CITE-seq presentes: un efecto de lote de tinción y un alto fondo inespecífico. Ninguno es un problema de RNA y ninguno produce un error.
Código
if (have_broken) {if (!"adt_batch"%in%colnames(sobj_broken@meta.data)) {cat("This broken object has no 'adt_batch' column.\n")cat("Scenario 4 needs the injected object (data/sobj_broken_errors.rds\n")cat("from inject_course_errors.R). Skipping Scenario 4 diagnostics.\n") } else {DefaultAssay(sobj_broken) <-"ADT" adt_feats <-rownames(sobj_broken[["ADT"]]) sobj_broken <-ScaleData(sobj_broken, features = adt_feats, verbose =FALSE) sobj_broken <-RunPCA(sobj_broken, features = adt_feats,npcs =15, reduction.name ="pca.adt.broken",reduction.key ="pcaADTb_", verbose =FALSE)print(DimPlot(sobj_broken, reduction ="pca.adt.broken", group.by ="adt_batch") +ggtitle("ADT PCA colored by staining batch: should NOT separate") )DefaultAssay(sobj_broken) <-"RNA" }}
Warning: No layers found matching search pattern provided
Error in `ScaleData()`:
! No layer matching pattern 'data' found. Please run NormalizeData and retry
Código
if (have_broken) {# Isotype antibodies bind nothing specific. High isotype = high background# (sticky/dying cells, over-staining). Per-cell total ADT correlated with# isotype signal is the fingerprint.DefaultAssay(sobj_broken) <-"ADT" adt_counts <-LayerData(sobj_broken, layer ="counts") isotypes <-grep("[Ii]sotype|IgG", rownames(adt_counts), value =TRUE)cat("Isotype control channels found:", paste(isotypes, collapse =", "), "\n")if (length(isotypes) >0) { iso_total <- Matrix::colSums(adt_counts[isotypes, , drop =FALSE]) adt_total <- Matrix::colSums(adt_counts)cat("Spearman(total ADT, isotype signal):",round(cor(adt_total, iso_total, method ="spearman"), 3), "\n")# donor_id is inconsistent in this object; use a clean donor number dn <- sobj_broken$donor_numif (is.null(dn)) dn <-suppressWarnings(as.integer(gsub("\\D", "",as.character(sobj_broken$donor_id))))cat("Median isotype signal by donor number:\n")print(round(tapply(iso_total, dn, median), 2)) }DefaultAssay(sobj_broken) <-"RNA"}
Isotype control channels found: IgG1-isotype, IgG2a-isotype
Spearman(total ADT, isotype signal): 0.215
Median isotype signal by donor number:
1 2 3 4 5 6 7
16.5 0.0 0.0 0.0 0.0 0.0 0.0
📝 Escribe tu hipótesis como un comentario abajo, luego ejecuta la corrección para confirmarla. ¿Qué donante tiene el fondo más alto? ¿Eliminaría CLR por sí solo un efecto de lote de tinción? Si aplicaras el gating de células CD4+ con un único umbral fijo a través de ambos lotes, ¿qué le sucedería a los conteos por lote?
Código
if (have_broken) {if (!"adt_batch"%in%colnames(sobj_broken@meta.data)) {cat("No 'adt_batch' column; Scenario 4 solution requires the injected object.\n") } else {# STEP 1: Flag and optionally remove high-background cells using isotypes.DefaultAssay(sobj_broken) <-"ADT" adt_counts <-LayerData(sobj_broken, layer ="counts") isotypes <-grep("[Ii]sotype|IgG", rownames(adt_counts), value =TRUE) sobj_clean <- sobj_brokenif (length(isotypes) >0) { iso_total <- Matrix::colSums(adt_counts[isotypes, , drop =FALSE]) cut_hi <-quantile(iso_total, 0.95) keep <- iso_total <= cut_hicat("High-background cells removed (top 5% isotype):", sum(!keep), "\n") sobj_clean <-subset(sobj_clean, cells =colnames(sobj_clean)[keep]) }# STEP 2: Re-normalize ADT (CLR margin=2) from counts. sobj_clean <-NormalizeData(sobj_clean, normalization.method ="CLR",margin =2, verbose =FALSE)# STEP 3: Correct the ADT staining batch with Harmony in protein space. adt_feats <-rownames(sobj_clean[["ADT"]]) sobj_clean <-ScaleData(sobj_clean, features = adt_feats, verbose =FALSE) sobj_clean <-RunPCA(sobj_clean, features = adt_feats, npcs =15,reduction.name ="pca.adt", reduction.key ="pcaADT_",verbose =FALSE) sobj_clean <-RunHarmony(sobj_clean, group.by.vars ="adt_batch",reduction ="pca.adt",reduction.save ="harmony.adt", verbose =FALSE)cat("ADT batch corrected in protein space (harmony.adt).\n")DefaultAssay(sobj_clean) <-"RNA" }}
Warning in svd.function(A = t(x = object), nv = npcs, ...): You're computing
too large a percentage of total singular values, use a standard svd instead.
ADT batch corrected in protein space (harmony.adt).
Lecciones aprendidas:
Una capa de ADT que se ve limpia aún puede estar dominada por estructura técnica. Siempre ejecuta un PCA de ADT y coloréalo por lote antes de confiar en el espacio de proteínas.
Los controles de isotipo son la verdad fundamental (ground truth) para el fondo. Si el ADT total sigue la señal de isotipo, las células con valores altos son fondo, no biología.
CLR por célula no elimina un efecto de tinción entre lotes. Se necesita una corrección de lote (Harmony sobre el PCA de ADT), separada del lote de RNA.
Un único umbral de gating a través de los lotes clasifica erróneamente las células. Aplica el gating por lote o corrige el lote primero.