This notebook validates and freezes broad cell identities in the blinded metastatic breast cancer ADC spatial transcriptomics cohort.
The objectives are to:
No ADC identity, pre/post-treatment status, treatment response, progression, survival, or other clinical outcome information is used in this notebook.
This notebook defines cell identity, not treatment-associated cell states. Functional states and spatial neighborhoods will be addressed in subsequent blinded notebooks.
You can also embed plots, for example:
# ============================================================
# 01. SETUP
# ============================================================
library(Seurat)
## Loading required package: SeuratObject
## Loading required package: sp
## 'SeuratObject' was built with package 'Matrix' 1.7.5 but the current
## version is 1.7.6; it is recomended that you reinstall 'SeuratObject' as
## the ABI for 'Matrix' may have changed
##
## Attaching package: 'SeuratObject'
## The following objects are masked from 'package:base':
##
## intersect, t
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(tidyr)
library(ggplot2)
library(patchwork)
set.seed(1234)
# ----------------------------
# Input files
# ----------------------------
XENIUM_FILE <- "/project/InternalMedicine/Chan_lab//shared/shared_spatial/integrated_ADC_objects/20260804_154828_integrated_ADC_Xenium_output.rds"
VISIUM_FILE <- "/project/InternalMedicine/Chan_lab/shared/shared_spatial/integrated_ADC_objects/20260804_integrated_ADC_Visium_object.rds"
# ----------------------------
# Output directory
# ----------------------------
OUTPUT_DIR <- "01_ADC_Audit_Freeze"
dir.create(
OUTPUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)
FIGURE_DIR <- file.path(OUTPUT_DIR, "figures")
TABLE_DIR <- file.path(OUTPUT_DIR, "tables")
OBJECT_DIR <- file.path(OUTPUT_DIR, "objects")
dir.create(FIGURE_DIR, showWarnings = FALSE)
dir.create(TABLE_DIR, showWarnings = FALSE)
dir.create(OBJECT_DIR, showWarnings = FALSE)
# ============================================================
# 02. LOAD OBJECTS
# ============================================================
xenium <- readRDS(XENIUM_FILE)
visium <- readRDS(VISIUM_FILE)
xenium
## An object of class Seurat
## 908 features across 458323 samples within 5 assays
## Active assay: SCT (400 features, 400 variable features)
## 3 layers present: counts, data, scale.data
## 4 other assays present: Xenium, BlankCodeword, ControlCodeword, ControlProbe
## 3 dimensional reductions calculated: pca, harmony, umap
## 17 spatial fields of view present: fov fov.2 fov.3 fov.4 fov.5 fov.6 fov.7 fov.8 fov.9 fov.10 fov.11 fov.12 fov.13 fov.14 fov.15 fov.16 fov.17
visium
## An object of class Seurat
## 55361 features across 25147 samples within 3 assays
## Active assay: SCT (18325 features, 3000 variable features)
## 3 layers present: counts, data, scale.data
## 2 other assays present: Spatial, sketch
## 3 dimensional reductions calculated: pca, harmony, umap
## 15 spatial fields of view present: slice1 slice1.2 slice1.3 slice1.4 slice1.5 slice1.6 slice1.7 slice1.8 slice1.9 slice1.10 slice1.11 slice1.12 slice1.13 slice1.14 slice1.15
DefaultAssay(xenium)
## [1] "SCT"
DefaultAssay(visium)
## [1] "SCT"
Note that the echo = FALSE parameter was added to the
code chunk to prevent printing of the R code that generated the
plot.
# ============================================================
# 03. OBJECT AUDIT
# ============================================================
object_audit <- data.frame(
modality = c("Xenium", "Visium"),
observations = c(ncol(xenium), ncol(visium)),
active_assay = c(DefaultAssay(xenium), DefaultAssay(visium)),
features_active_assay = c(
nrow(xenium[[DefaultAssay(xenium)]]),
nrow(visium[[DefaultAssay(visium)]])
),
spatial_fovs = c(
length(Images(xenium)),
length(Images(visium))
)
)
object_audit
## modality observations active_assay features_active_assay spatial_fovs
## 1 Xenium 458323 SCT 400 17
## 2 Visium 25147 SCT 18325 15
write.csv(
object_audit,
file.path(TABLE_DIR, "object_audit.csv"),
row.names = FALSE
)
#Save available metadata fields
xenium_metadata_columns <- data.frame(
column = colnames(xenium@meta.data)
)
visium_metadata_columns <- data.frame(
column = colnames(visium@meta.data)
)
write.csv(
xenium_metadata_columns,
file.path(TABLE_DIR, "xenium_metadata_columns.csv"),
row.names = FALSE
)
write.csv(
visium_metadata_columns,
file.path(TABLE_DIR, "visium_metadata_columns.csv"),
row.names = FALSE
)
Confirm sample identities. In these particular integrations, orig.ident is uninformative and sample contains the meaningful specimen identifiers.
# ============================================================
# 04. SAMPLE AUDIT
# ============================================================
xenium_sample_counts <- xenium@meta.data %>%
count(sample, name = "total_cells") %>%
arrange(desc(total_cells))
visium_sample_counts <- visium@meta.data %>%
count(sample, name = "total_spots") %>%
arrange(desc(total_spots))
xenium_sample_counts
## sample total_cells
## 1 ADC011 169979
## 2 ADC014 111543
## 3 ADC001 49737
## 4 ADC003 44418
## 5 ADC004 35021
## 6 ADC005 13350
## 7 ADC019 8430
## 8 ADC009 6546
## 9 ADC007 5937
## 10 ADC018 3682
## 11 ADC006 3441
## 12 ADC002 2278
## 13 ADC020 2137
## 14 ADC010 988
## 15 ADC021 435
## 16 ADC017 312
## 17 ADC008 89
visium_sample_counts
## sample total_spots
## 1 ADC011 4078
## 2 ADC014 3444
## 3 SU22_18324_ADC 2872
## 4 ADC009 2581
## 5 SU22_29416_ADC 2217
## 6 37834_ADC 1920
## 7 03119_ADC 1479
## 8 SU23_33192_ADC 1475
## 9 ADC008 1392
## 10 44484_ADC 1140
## 11 SU21_05811_ADC 708
## 12 SU22_27508_ADC 653
## 13 ADC018 556
## 14 SU21_18999_ADC 528
## 15 ADC010 104
write.csv(
xenium_sample_counts,
file.path(TABLE_DIR, "xenium_sample_counts.csv"),
row.names = FALSE
)
write.csv(
visium_sample_counts,
file.path(TABLE_DIR, "visium_sample_counts.csv"),
row.names = FALSE
)
#Document current modality overlap:
xenium_samples <- sort(unique(xenium$sample))
visium_samples <- sort(unique(visium$sample))
modality_overlap <- list(
shared = intersect(xenium_samples, visium_samples),
xenium_only = setdiff(xenium_samples, visium_samples),
visium_only = setdiff(visium_samples, xenium_samples)
)
modality_overlap
## $shared
## [1] "ADC008" "ADC009" "ADC010" "ADC011" "ADC014" "ADC018"
##
## $xenium_only
## [1] "ADC001" "ADC002" "ADC003" "ADC004" "ADC005" "ADC006" "ADC007" "ADC017"
## [9] "ADC019" "ADC020" "ADC021"
##
## $visium_only
## [1] "03119_ADC" "37834_ADC" "44484_ADC" "SU21_05811_ADC"
## [5] "SU21_18999_ADC" "SU22_18324_ADC" "SU22_27508_ADC" "SU22_29416_ADC"
## [9] "SU23_33192_ADC"
#Save table
sample_overlap_table <- tibble(
sample = sort(unique(c(xenium_samples, visium_samples)))
) %>%
mutate(
in_xenium = sample %in% xenium_samples,
in_visium = sample %in% visium_samples
)
write.csv(
sample_overlap_table,
file.path(TABLE_DIR, "xenium_visium_sample_overlap.csv"),
row.names = FALSE
)
The current naming suggests only a subset directly matches between modalities. A separate blinded specimen crosswalk should eventually tell us which Visium and Xenium samples correspond to the same biopsy.
#Audit inherited Xenium cell identities
# ============================================================
# 05. CELL-TYPE AUDIT
# ============================================================
celltype_counts <- xenium@meta.data %>%
count(predicted.celltype, name = "cells") %>%
mutate(
fraction = cells / sum(cells)
) %>%
arrange(desc(cells))
celltype_counts
## predicted.celltype cells fraction
## 1 Cancer Epithelial Cells 323290 0.7053759030
## 2 Fibroblasts 67029 0.1462483881
## 3 Endothelial Cells 21618 0.0471676089
## 4 Myoepithelial Cells 11395 0.0248623787
## 5 Epithelial Cells 11102 0.0242230916
## 6 Macrophages 6146 0.0134097569
## 7 Monocytes 4834 0.0105471469
## 8 Perivascular-like (PVL) Cells 4773 0.0104140530
## 9 Plasma Cells 4411 0.0096242170
## 10 B Cells 916 0.0019985905
## 11 MDSCs 709 0.0015469440
## 12 CD4+ T Cells 523 0.0011411166
## 13 Dendritic Cells 371 0.0008094728
## 14 Mast Cells 371 0.0008094728
## 15 Regulatory T Cells 307 0.0006698333
## 16 CD8+ T Cells 214 0.0004669196
## 17 NK Cells 185 0.0004036455
## 18 Neutrophils 129 0.0002814609
write.csv(
celltype_counts,
file.path(TABLE_DIR, "xenium_celltype_counts.csv"),
row.names = FALSE
)
#Calculate counts by specimen
celltype_by_sample <- xenium@meta.data %>%
count(sample, predicted.celltype, name = "cells") %>%
group_by(sample) %>%
mutate(
fraction_within_sample = cells / sum(cells)
) %>%
ungroup()
write.csv(
celltype_by_sample,
file.path(TABLE_DIR, "xenium_celltype_by_sample.csv"),
row.names = FALSE
)
#Determine how much each sample contributes to each cell type
celltype_sample_contribution <- xenium@meta.data %>%
count(sample, predicted.celltype, name = "cells") %>%
group_by(predicted.celltype) %>%
mutate(
fraction_of_celltype = cells / sum(cells)
) %>%
ungroup()
write.csv(
celltype_sample_contribution,
file.path(
TABLE_DIR,
"xenium_celltype_sample_contribution.csv"
),
row.names = FALSE
)
Cell identity UMAP
# ============================================================
# 06. GLOBAL UMAPS
# ============================================================
p_celltype <- DimPlot(
xenium,
reduction = "umap",
group.by = "predicted.celltype",
label = TRUE,
repel = TRUE,
raster = TRUE
) +
ggtitle("Blinded Xenium cohort — predicted cell type")
p_celltype
ggsave(
file.path(FIGURE_DIR, "UMAP_predicted_celltype.png"),
p_celltype,
width = 12,
height = 8,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "UMAP_predicted_celltype.pdf"),
p_celltype,
width = 12,
height = 8
)
## Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
## for 'Blinded Xenium cohort — predicted cell type' in 'mbcsToSbcs': -
## substituted for — (U+2014)
#Sample UMAP
p_sample <- DimPlot(
xenium,
reduction = "umap",
group.by = "sample",
raster = TRUE
) +
ggtitle("Blinded Xenium cohort — sample")
p_sample
ggsave(
file.path(FIGURE_DIR, "UMAP_sample.png"),
p_sample,
width = 12,
height = 8,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "UMAP_sample.pdf"),
p_sample,
width = 12,
height = 8
)
## Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
## for 'Blinded Xenium cohort — sample' in 'mbcsToSbcs': - substituted for —
## (U+2014)
#Validate marker availability in the Xenium panel
# ============================================================
# 07. MARKER AVAILABILITY
# ============================================================
DefaultAssay(xenium) <- "SCT"
validation_genes <- c(
# Cancer / epithelial
"EPCAM", "KRT8", "KRT19", "TACSTD2", "ERBB2",
# NK / cytotoxic
"NKG7", "KLRD1", "FCGR3A", "GNLY", "PRF1", "GZMB",
# T cells
"CD3D", "CD3E", "CD8A", "CD4", "FOXP3",
# Inhibitory receptors
"PDCD1", "LAG3", "TIGIT", "HAVCR2",
# Myeloid
"TYROBP", "FCER1G", "CD68", "CD163"
)
marker_availability <- data.frame(
gene = validation_genes,
available = validation_genes %in% rownames(xenium)
)
marker_availability
## gene available
## 1 EPCAM TRUE
## 2 KRT8 TRUE
## 3 KRT19 TRUE
## 4 TACSTD2 TRUE
## 5 ERBB2 TRUE
## 6 NKG7 TRUE
## 7 KLRD1 TRUE
## 8 FCGR3A TRUE
## 9 GNLY TRUE
## 10 PRF1 TRUE
## 11 GZMB TRUE
## 12 CD3D TRUE
## 13 CD3E TRUE
## 14 CD8A TRUE
## 15 CD4 TRUE
## 16 FOXP3 TRUE
## 17 PDCD1 TRUE
## 18 LAG3 TRUE
## 19 TIGIT TRUE
## 20 HAVCR2 TRUE
## 21 TYROBP TRUE
## 22 FCER1G TRUE
## 23 CD68 TRUE
## 24 CD163 TRUE
write.csv(
marker_availability,
file.path(TABLE_DIR, "marker_availability.csv"),
row.names = FALSE
)
validation_genes <- validation_genes[
validation_genes %in% rownames(xenium)
]
#Validate broad cell identities using canonical markers
The existing classifications should show coherent lineage-specific expression: - Cancer/epithelial → EPCAM, keratins, TACSTD2, ERBB2 - NK → NKG7, KLRD1, FCGR3A, GNLY, PRF1, GZMB - T cells → CD3D, CD3E - Tregs → CD4, FOXP3 - Myeloid → TYROBP, FCER1G, CD68, CD163
We have already observed this pattern interactively; this notebook formally records it.
# ============================================================
# 08. BROAD MARKER VALIDATION
# ============================================================
p_markers <- DotPlot(
xenium,
features = validation_genes,
group.by = "predicted.celltype"
) +
RotatedAxis() +
ggtitle("Canonical marker validation of inherited cell identities")
p_markers
ggsave(
file.path(FIGURE_DIR, "DotPlot_celltype_validation.png"),
p_markers,
width = 14,
height = 8,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "DotPlot_celltype_validation.pdf"),
p_markers,
width = 14,
height = 8
)
The existing NK population is biologically credible because it demonstrates: [ NKG7+, KLRD1+, FCGR3A+, GNLY+, PRF1+, GZMB+ ] while showing minimal CD3D, distinguishing it from conventional T-cell populations.
CD8 T cells appropriately share cytotoxic genes but remain CD3D/CD3E positive.
We therefore retain NK as a distinct broad identity.
# ============================================================
# 09. LYMPHOID VALIDATION
# ============================================================
Idents(xenium) <- "predicted.celltype"
lymphoid_identities <- c(
"NK Cells",
"CD8+ T Cells",
"CD4+ T Cells",
"Regulatory T Cells"
)
lymphoid_markers <- c(
"NKG7",
"KLRD1",
"FCGR3A",
"GNLY",
"PRF1",
"GZMB",
"CD3D",
"CD3E",
"CD8A",
"CD4",
"FOXP3",
"PDCD1",
"LAG3",
"TIGIT",
"HAVCR2"
)
lymphoid_markers <- lymphoid_markers[
lymphoid_markers %in% rownames(xenium)
]
#Lymphoid DotPlot
p_lymphoid_dot <- DotPlot(
xenium,
features = lymphoid_markers,
idents = lymphoid_identities
) +
RotatedAxis() +
ggtitle("Lymphoid marker validation")
## Warning: Scaling data with a low number of groups may produce misleading
## results
p_lymphoid_dot
#Lymphoid Violin Plot
p_lymphoid_vln <- VlnPlot(
xenium,
features = c(
"NKG7",
"KLRD1",
"FCGR3A",
"GNLY",
"PRF1",
"GZMB",
"CD3D",
"CD3E"
),
idents = lymphoid_identities,
pt.size = 0,
ncol = 4
)
p_lymphoid_vln
#Save
ggsave(
file.path(FIGURE_DIR, "DotPlot_lymphoid_validation.png"),
p_lymphoid_dot,
width = 12,
height = 6,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "DotPlot_lymphoid_validation.pdf"),
p_lymphoid_dot,
width = 12,
height = 6
)
ggsave(
file.path(FIGURE_DIR, "VlnPlot_lymphoid_validation.png"),
p_lymphoid_vln,
width = 14,
height = 8,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "VlnPlot_lymphoid_validation.pdf"),
p_lymphoid_vln,
width = 14,
height = 8
)
#Quantify the NK compartment
We already know there are only approximately 185 classified NK cells across the Xenium cohort, with a large fraction arising from a small number of specimens.
Therefore: - NK identity can be retained. - NK abundance and spatial localization can be studied. - Fine unsupervised NK subclustering should not be performed at this stage. - Absence of NK cells in very small specimens should not be interpreted as biological NK exclusion.
# ============================================================
# 10. NK AUDIT
# ============================================================
nk_summary <- xenium@meta.data %>%
group_by(sample) %>%
summarise(
total_cells = n(),
cancer_cells = sum(
predicted.celltype == "Cancer Epithelial Cells"
),
NK_cells = sum(
predicted.celltype == "NK Cells"
),
CD8_cells = sum(
predicted.celltype == "CD8+ T Cells"
),
Treg_cells = sum(
predicted.celltype == "Regulatory T Cells"
),
macrophages = sum(
predicted.celltype == "Macrophages"
),
MDSCs = sum(
predicted.celltype == "MDSCs"
),
.groups = "drop"
) %>%
mutate(
NK_fraction_all =
NK_cells / total_cells,
NK_per_1000_cancer =
ifelse(
cancer_cells > 0,
1000 * NK_cells / cancer_cells,
NA_real_
),
CD8_per_1000_cancer =
ifelse(
cancer_cells > 0,
1000 * CD8_cells / cancer_cells,
NA_real_
)
) %>%
arrange(desc(NK_fraction_all))
nk_summary
## # A tibble: 17 × 11
## sample total_cells cancer_cells NK_cells CD8_cells Treg_cells macrophages
## <chr> <int> <int> <int> <int> <int> <int>
## 1 ADC007 5937 3085 14 13 0 589
## 2 ADC003 44418 28136 76 32 16 1028
## 3 ADC018 3682 2535 3 2 0 93
## 4 ADC005 13350 9646 9 4 17 30
## 5 ADC002 2278 1733 1 1 0 31
## 6 ADC001 49737 40189 19 8 18 1157
## 7 ADC019 8430 7993 3 0 0 139
## 8 ADC006 3441 2323 1 5 0 2
## 9 ADC011 169979 121547 38 92 214 1357
## 10 ADC014 111543 78820 20 45 39 763
## 11 ADC009 6546 3356 1 1 0 33
## 12 ADC004 35021 20441 0 11 2 904
## 13 ADC008 89 85 0 0 0 1
## 14 ADC010 988 846 0 0 0 2
## 15 ADC017 312 225 0 0 0 0
## 16 ADC020 2137 1971 0 0 1 10
## 17 ADC021 435 359 0 0 0 7
## # ℹ 4 more variables: MDSCs <int>, NK_fraction_all <dbl>,
## # NK_per_1000_cancer <dbl>, CD8_per_1000_cancer <dbl>
write.csv(
nk_summary,
file.path(TABLE_DIR, "NK_audit_by_sample.csv"),
row.names = FALSE
)
The broad identities should largely map coherently to the unsupervised structure.
For example, our prior audit showed that cluster 0 is overwhelmingly cancer epithelial, while cluster 8 contains the major lymphoid populations.
This supports retaining the existing annotations instead of reclustering the full cohort solely for cell-type assignment.
# ============================================================
# 11. CLUSTER VS CELL-TYPE CONSISTENCY
# ============================================================
cluster_celltype <- xenium@meta.data %>%
count(
seurat_clusters,
predicted.celltype,
name = "cells"
) %>%
group_by(seurat_clusters) %>%
mutate(
proportion = cells / sum(cells)
) %>%
ungroup()
write.csv(
cluster_celltype,
file.path(TABLE_DIR, "cluster_celltype_composition.csv"),
row.names = FALSE
)
#Heatmap
p_cluster_heatmap <- cluster_celltype %>%
ggplot(
aes(
x = seurat_clusters,
y = predicted.celltype,
fill = proportion
)
) +
geom_tile() +
labs(
title = "Cell-type composition of unsupervised Xenium clusters",
x = "Seurat cluster",
y = "Predicted cell type",
fill = "Proportion"
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1)
)
p_cluster_heatmap
ggsave(
file.path(FIGURE_DIR, "Heatmap_cluster_celltype.png"),
p_cluster_heatmap,
width = 10,
height = 8,
dpi = 300
)
ggsave(
file.path(FIGURE_DIR, "Heatmap_cluster_celltype.pdf"),
p_cluster_heatmap,
width = 10,
height = 8
)
These thresholds are pragmatic rather than biological truth. Their purpose is to prevent us from retrospectively excluding inconvenient samples after we know which patients responded.
This should happen before unblinding.
For later analyses: - Primary: Can generally support composition and spatial-neighborhood analyses. - Limited: May support selected analyses but should be interpreted carefully. - Exploratory: Should not drive negative conclusions such as “NK cells are absent.”
# ============================================================
# 12. SAMPLE QC TIERS
# ============================================================
sample_qc <- xenium@meta.data %>%
count(sample, name = "total_cells") %>%
mutate(
qc_tier = case_when(
total_cells >= 5000 ~ "Primary",
total_cells >= 1000 ~ "Limited",
TRUE ~ "Exploratory"
)
) %>%
arrange(desc(total_cells))
sample_qc
## sample total_cells qc_tier
## 1 ADC011 169979 Primary
## 2 ADC014 111543 Primary
## 3 ADC001 49737 Primary
## 4 ADC003 44418 Primary
## 5 ADC004 35021 Primary
## 6 ADC005 13350 Primary
## 7 ADC019 8430 Primary
## 8 ADC009 6546 Primary
## 9 ADC007 5937 Primary
## 10 ADC018 3682 Limited
## 11 ADC006 3441 Limited
## 12 ADC002 2278 Limited
## 13 ADC020 2137 Limited
## 14 ADC010 988 Exploratory
## 15 ADC021 435 Exploratory
## 16 ADC017 312 Exploratory
## 17 ADC008 89 Exploratory
write.csv(
sample_qc,
file.path(TABLE_DIR, "xenium_sample_QC_tiers.csv"),
row.names = FALSE
)
#Freeze broad cell identities
Create new metadata columns rather than altering inherited prediction.
# ============================================================
# 13. CELL IDENTITY FREEZE
# ============================================================
xenium$cell_type_final <- xenium$predicted.celltype
#Create broader lineage hierarchy:
xenium$broad_lineage <- case_when(
xenium$cell_type_final ==
"Cancer Epithelial Cells" ~ "Cancer",
xenium$cell_type_final %in% c(
"Epithelial Cells",
"Myoepithelial Cells"
) ~ "Nonmalignant_Epithelial",
xenium$cell_type_final %in% c(
"NK Cells",
"CD4+ T Cells",
"CD8+ T Cells",
"Regulatory T Cells"
) ~ "T_NK",
xenium$cell_type_final %in% c(
"Macrophages",
"Monocytes",
"MDSCs",
"Dendritic Cells",
"Neutrophils"
) ~ "Myeloid",
xenium$cell_type_final %in% c(
"B Cells",
"Plasma Cells"
) ~ "B_Plasma",
xenium$cell_type_final %in% c(
"Fibroblasts",
"Perivascular-like (PVL) Cells"
) ~ "Stromal",
xenium$cell_type_final ==
"Endothelial Cells" ~ "Endothelial",
xenium$cell_type_final ==
"Mast Cells" ~ "Mast",
TRUE ~ "Other"
)
#Preserve cytotoxic-lymphocyte category for later analyses
xenium$cytotoxic_lymphocyte <- case_when(
xenium$cell_type_final == "NK Cells" ~ "NK",
xenium$cell_type_final == "CD8+ T Cells" ~ "CD8_T",
TRUE ~ "Other"
)
Audit and Save final counts
table(xenium$cell_type_final)
##
## B Cells Cancer Epithelial Cells
## 916 323290
## CD4+ T Cells CD8+ T Cells
## 523 214
## Dendritic Cells Endothelial Cells
## 371 21618
## Epithelial Cells Fibroblasts
## 11102 67029
## Macrophages Mast Cells
## 6146 371
## MDSCs Monocytes
## 709 4834
## Myoepithelial Cells Neutrophils
## 11395 129
## NK Cells Perivascular-like (PVL) Cells
## 185 4773
## Plasma Cells Regulatory T Cells
## 4411 307
table(xenium$broad_lineage)
##
## B_Plasma Cancer Endothelial
## 5327 323290 21618
## Mast Myeloid Nonmalignant_Epithelial
## 371 12189 22497
## Stromal T_NK
## 71802 1229
table(xenium$cytotoxic_lymphocyte)
##
## CD8_T NK Other
## 214 185 457924
final_identity_counts <- xenium@meta.data %>%
count(
cell_type_final,
broad_lineage,
name = "cells"
) %>%
arrange(desc(cells))
write.csv(
final_identity_counts,
file.path(TABLE_DIR, "final_cell_identity_counts.csv"),
row.names = FALSE
)
#Create a blinded annotation-freeze manifest It makes analytic provenance explicit.
# ============================================================
# 14. FREEZE MANIFEST
# ============================================================
freeze_manifest <- data.frame(
field = c(
"freeze_date",
"total_xenium_cells",
"xenium_samples",
"xenium_features",
"NK_cells",
"clinical_metadata_used",
"cell_type_source",
"NK_subclustering",
"status"
),
value = c(
as.character(Sys.Date()),
ncol(xenium),
length(unique(xenium$sample)),
nrow(xenium[["Xenium"]]),
sum(xenium$cell_type_final == "NK Cells"),
"No",
"Inherited predicted.celltype validated with canonical markers",
"Not performed due to sparse NK population",
"Broad cell identities frozen"
)
)
freeze_manifest
## field
## 1 freeze_date
## 2 total_xenium_cells
## 3 xenium_samples
## 4 xenium_features
## 5 NK_cells
## 6 clinical_metadata_used
## 7 cell_type_source
## 8 NK_subclustering
## 9 status
## value
## 1 2026-08-24
## 2 458323
## 3 17
## 4 400
## 5 185
## 6 No
## 7 Inherited predicted.celltype validated with canonical markers
## 8 Not performed due to sparse NK population
## 9 Broad cell identities frozen
write.csv(
freeze_manifest,
file.path(TABLE_DIR, "annotation_freeze_manifest.csv"),
row.names = FALSE
)
# ============================================================
# 15. SAVE FROZEN OBJECT
# ============================================================
saveRDS(
xenium,
file = file.path(
OBJECT_DIR,
"ADC_Xenium_Blinded_Cell_Identity_Frozen.rds"
)
)
# ============================================================
# 16. SESSION INFORMATION
# ============================================================
writeLines(
capture.output(sessionInfo()),
con = file.path(
OUTPUT_DIR,
"sessionInfo.txt"
)
)
## Warning: Your system is mis-configured: '/etc/localtime' is not a symlink
## Warning: It is strongly recommended to set envionment variable TZ to
## 'America/Chicago' (or equivalent)
Broad cell identities in the blinded ADC Xenium cohort were validated using canonical lineage-marker expression, integrated UMAP structure, and unsupervised cluster composition.
The inherited cell annotations were retained as the final broad cell identities. NK cells were confirmed as a transcriptionally distinct cytotoxic lymphocyte population characterized by expression of NKG7, KLRD1, FCGR3A, GNLY, PRF1, and GZMB with minimal CD3D expression.
NK cells were rare and unevenly distributed across specimens. Therefore, NK identity was retained, but fine NK-state subclustering was not performed. Future NK analyses will emphasize abundance, cytotoxic activity, FCGR3A expression, and spatial proximity to malignant cells.
Objective specimen-level QC tiers were defined before clinical unblinding. No ADC identity, treatment timing, response, or outcome information was used to define or validate cell identities.
The resulting frozen Xenium object will serve as the immutable starting point for subsequent blinded analyses of malignant states, immune functional states, and spatial tumor neighborhoods.