Purpose

This notebook validates and freezes broad cell identities in the blinded metastatic breast cancer ADC spatial transcriptomics cohort.

The objectives are to:

  1. Audit the integrated Xenium and Visium objects.
  2. Confirm sample composition and modality overlap.
  3. Validate inherited Xenium cell-type annotations using canonical markers.
  4. Confirm consistency between cell-type annotations and unsupervised clusters.
  5. Specifically validate NK-cell identity because NK cells are sparse and are relevant to subsequent ADC immune analyses.
  6. Define objective sample-level QC tiers before clinical unblinding.
  7. Freeze the broad Xenium cell identities for downstream analyses.

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.

Including Plots

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)

Load the integrated spatial objects.

# ============================================================
# 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.

Object audit

# ============================================================
# 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
)

Visualize the exisitng integrated atlas

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
)

Focused NK/T-cell validation

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
)

Compare inherited annotations with unsupervised clusters

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
)

Define blinded sample QC tiers

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
)

Save objects and session information

# ============================================================
# 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)

Conclusion

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.