This notebook defines and freezes blinded spatial features in the metastatic breast cancer ADC Xenium cohort after broad cell identities and functional states were established in Notebooks 1 and 2.
The objectives are to:
No ADC identity, pre/post-treatment status, treatment response, metastatic outcome, progression, or survival information is used to define spatial features or neighborhood classes.
The patient/specimen will ultimately remain the biological unit for clinical inference. Individual cells provide high-resolution estimates of spatial architecture but are not treated as independent patients.
The 30-nearest-neighbor representation is used for unbiased neighborhood discovery because it does not require a predefined physical radius and is less sensitive to differences in cell density between specimens.
Physical-distance analyses will separately evaluate 25, 50, and 100 coordinate-unit thresholds. Xenium centroid coordinates are expected to be represented in micron-scale physical coordinates, but coordinate ranges and nearest-neighbor distances will be audited before biological interpretation.
# ============================================================
# 02. LOAD FROZEN FUNCTIONAL-STATE OBJECT
# ============================================================
xenium <- readRDS(XENIUM_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
stopifnot(
"cell_type_final" %in% colnames(xenium@meta.data),
"broad_lineage" %in% colnames(xenium@meta.data),
"Cytotoxicity_score" %in% colnames(xenium@meta.data),
"FCGR3A_expression" %in% colnames(xenium@meta.data)
)
table(xenium$sample)
##
## ADC001 ADC002 ADC003 ADC004 ADC005 ADC006 ADC007 ADC008 ADC009 ADC010 ADC011
## 49737 2278 44418 35021 13350 3441 5937 89 6546 988 169979
## ADC014 ADC017 ADC018 ADC019 ADC020 ADC021
## 111543 312 3682 8430 2137 435
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
Images(xenium)
## [1] "fov" "fov.2" "fov.3" "fov.4" "fov.5" "fov.6" "fov.7" "fov.8"
## [9] "fov.9" "fov.10" "fov.11" "fov.12" "fov.13" "fov.14" "fov.15" "fov.16"
## [17] "fov.17"
Cell identities and functional-state measurements loaded from Notebook 2 are treated as frozen and will not be modified.
Specimens vary substantially in cell number. The same blinded QC rule established previously is retained so that very small specimens do not drive negative spatial conclusions.
# ============================================================
# 03. SAMPLE QC
# ============================================================
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,
"sample_spatial_QC_tiers.csv"
),
row.names = FALSE
)
All specimens will be retained. However, absence of a rare population
such as NK cells in an Exploratory specimen should not be
interpreted as definitive biological exclusion.
Each FOV is extracted independently. Spatial distances will never be calculated across separate FOVs.
# ============================================================
# 04. EXTRACT CELL COORDINATES
# ============================================================
get_fov_coordinates <- function(
object,
fov_name
) {
fov_object <- object[[fov_name]]
coords <- tryCatch(
GetTissueCoordinates(
fov_object,
which = "centroids"
),
error = function(e) {
GetTissueCoordinates(
fov_object
)
}
)
coords <- as.data.frame(coords)
# ----------------------------------------------------------
# Determine cell identifier
# ----------------------------------------------------------
if ("cell" %in% colnames(coords)) {
coords$cell_id <- as.character(
coords$cell
)
} else {
coords$cell_id <- rownames(coords)
}
# ----------------------------------------------------------
# Determine coordinate columns
# ----------------------------------------------------------
x_candidates <- intersect(
c(
"x",
"x_centroid",
"imagecol",
"col"
),
colnames(coords)
)
y_candidates <- intersect(
c(
"y",
"y_centroid",
"imagerow",
"row"
),
colnames(coords)
)
if (
length(x_candidates) == 0 ||
length(y_candidates) == 0
) {
stop(
paste(
"Could not identify x/y coordinate columns for",
fov_name
)
)
}
x_col <- x_candidates[1]
y_col <- y_candidates[1]
coords %>%
transmute(
cell_id = cell_id,
x = as.numeric(.data[[x_col]]),
y = as.numeric(.data[[y_col]]),
fov = fov_name
)
}
Extract all FOVs.
fov_names <- Images(xenium)
coordinate_list <- lapply(
fov_names,
function(fov_name) {
message(
"Extracting coordinates from: ",
fov_name
)
get_fov_coordinates(
xenium,
fov_name
)
}
)
## Extracting coordinates from: fov
## Extracting coordinates from: fov.2
## Extracting coordinates from: fov.3
## Extracting coordinates from: fov.4
## Extracting coordinates from: fov.5
## Extracting coordinates from: fov.6
## Extracting coordinates from: fov.7
## Extracting coordinates from: fov.8
## Extracting coordinates from: fov.9
## Extracting coordinates from: fov.10
## Extracting coordinates from: fov.11
## Extracting coordinates from: fov.12
## Extracting coordinates from: fov.13
## Extracting coordinates from: fov.14
## Extracting coordinates from: fov.15
## Extracting coordinates from: fov.16
## Extracting coordinates from: fov.17
xenium_coordinates <- bind_rows(
coordinate_list
)
dim(xenium_coordinates)
## [1] 458323 4
head(xenium_coordinates)
## cell_id x y fov
## 1 ADC001_aaabdmmm-1 71.64692 1236.204 fov
## 2 ADC001_aaacbcng-1 89.06297 1233.190 fov
## 3 ADC001_aaaeacih-1 102.21131 1232.613 fov
## 4 ADC001_aaaedfdn-1 135.70370 1245.775 fov
## 5 ADC001_aaaenapg-1 145.62679 1256.313 fov
## 6 ADC001_aaafjoih-1 140.50882 1252.812 fov
# ============================================================
# 05. JOIN COORDINATES TO METADATA
# ============================================================
xenium_meta <- xenium@meta.data %>%
tibble::rownames_to_column(
"cell_id"
)
spatial_cells <- xenium_coordinates %>%
left_join(
xenium_meta,
by = "cell_id"
)
coordinate_match_audit <- data.frame(
total_xenium_cells = ncol(xenium),
coordinate_rows = nrow(xenium_coordinates),
matched_metadata_rows =
sum(!is.na(spatial_cells$sample)),
unmatched_rows =
sum(is.na(spatial_cells$sample))
)
coordinate_match_audit
## total_xenium_cells coordinate_rows matched_metadata_rows unmatched_rows
## 1 458323 458323 458323 0
write.csv(
coordinate_match_audit,
file.path(
TABLE_DIR,
"coordinate_match_audit.csv"
),
row.names = FALSE
)
Stop if substantial coordinate metadata are missing.
if (
coordinate_match_audit$unmatched_rows > 0
) {
warning(
"Some spatial coordinate rows did not match Xenium metadata. Review coordinate_match_audit.csv before interpreting spatial analyses."
)
}
# ============================================================
# 06. FOV-SAMPLE AUDIT
# ============================================================
fov_sample_map <- spatial_cells %>%
filter(
!is.na(sample)
) %>%
count(
fov,
sample,
name = "cells"
) %>%
group_by(fov) %>%
mutate(
fraction_of_fov =
cells / sum(cells)
) %>%
ungroup()
fov_sample_map
## # A tibble: 17 × 4
## fov sample cells fraction_of_fov
## <chr> <chr> <int> <dbl>
## 1 fov ADC001 49737 1
## 2 fov.10 ADC010 988 1
## 3 fov.11 ADC011 169979 1
## 4 fov.12 ADC014 111543 1
## 5 fov.13 ADC017 312 1
## 6 fov.14 ADC018 3682 1
## 7 fov.15 ADC019 8430 1
## 8 fov.16 ADC020 2137 1
## 9 fov.17 ADC021 435 1
## 10 fov.2 ADC002 2278 1
## 11 fov.3 ADC003 44418 1
## 12 fov.4 ADC004 35021 1
## 13 fov.5 ADC005 13350 1
## 14 fov.6 ADC006 3441 1
## 15 fov.7 ADC007 5937 1
## 16 fov.8 ADC008 89 1
## 17 fov.9 ADC009 6546 1
write.csv(
fov_sample_map,
file.path(
TABLE_DIR,
"FOV_sample_map.csv"
),
row.names = FALSE
)
Create a unique spatial-unit identifier. This prevents cells from separate tissue sections from becoming artificial neighbors.
spatial_cells <- spatial_cells %>%
mutate(
spatial_unit = paste(
fov,
sample,
sep = "__"
)
)
The coordinate scale is evaluated using the nearest physical neighbor within each spatial unit.
# ============================================================
# 07. COORDINATE SCALE AUDIT
# ============================================================
audit_coordinate_scale <- function(df) {
if (nrow(df) < 2) {
return(
data.frame(
cells = nrow(df),
x_min = min(df$x, na.rm = TRUE),
x_max = max(df$x, na.rm = TRUE),
y_min = min(df$y, na.rm = TRUE),
y_max = max(df$y, na.rm = TRUE),
median_nearest_neighbor = NA_real_
)
)
}
xy <- as.matrix(
df[, c("x", "y")]
)
nn <- RANN::nn2(
data = xy,
query = xy,
k = min(
2,
nrow(df)
)
)
nearest <- nn$nn.dists[, 2]
data.frame(
cells = nrow(df),
x_min = min(df$x),
x_max = max(df$x),
y_min = min(df$y),
y_max = max(df$y),
median_nearest_neighbor =
median(
nearest,
na.rm = TRUE
)
)
}
coordinate_scale_audit <- spatial_cells %>%
filter(
!is.na(sample)
) %>%
group_split(
spatial_unit
)
coordinate_scale_audit <- bind_rows(
lapply(
coordinate_scale_audit,
function(df) {
audit <- audit_coordinate_scale(df)
audit$sample <-
unique(df$sample)[1]
audit$fov <-
unique(df$fov)[1]
audit$spatial_unit <-
unique(df$spatial_unit)[1]
audit
}
)
)
coordinate_scale_audit
## cells x_min x_max y_min y_max median_nearest_neighbor
## 1 988 61.911312 1379.277 530.234314 1540.318 8.870728
## 2 169979 3.251215 6622.545 325.082886 9292.498 8.660903
## 3 111543 423.219330 5427.553 95.027039 8671.952 8.849198
## 4 312 2413.765625 5304.161 597.286011 2183.684 9.333263
## 5 3682 533.433960 7841.291 723.598755 3296.808 12.175036
## 6 8430 322.537109 7340.730 73.123306 3734.121 11.902270
## 7 2137 58.895367 9905.771 523.633240 2163.661 17.826293
## 8 435 557.099060 10672.029 88.593369 1816.690 22.207775
## 9 2278 298.738159 3027.083 4.225451 3402.996 10.885963
## 10 44418 7.319952 6044.013 209.111115 5784.003 11.891576
## 11 35021 433.599426 4836.089 4.064771 4164.108 10.080972
## 12 13350 9.573312 1530.586 312.419647 5779.138 10.049566
## 13 3441 34.685825 1755.455 423.382843 3517.325 9.686543
## 14 5937 297.623352 7394.881 461.406372 2305.501 9.878500
## 15 89 1546.093506 4988.771 1174.526489 2120.888 21.533176
## 16 6546 415.247009 6119.859 284.766785 5025.506 13.521462
## 17 49737 3.300496 5438.940 438.418579 5471.796 9.785036
## sample fov spatial_unit
## 1 ADC010 fov.10 fov.10__ADC010
## 2 ADC011 fov.11 fov.11__ADC011
## 3 ADC014 fov.12 fov.12__ADC014
## 4 ADC017 fov.13 fov.13__ADC017
## 5 ADC018 fov.14 fov.14__ADC018
## 6 ADC019 fov.15 fov.15__ADC019
## 7 ADC020 fov.16 fov.16__ADC020
## 8 ADC021 fov.17 fov.17__ADC021
## 9 ADC002 fov.2 fov.2__ADC002
## 10 ADC003 fov.3 fov.3__ADC003
## 11 ADC004 fov.4 fov.4__ADC004
## 12 ADC005 fov.5 fov.5__ADC005
## 13 ADC006 fov.6 fov.6__ADC006
## 14 ADC007 fov.7 fov.7__ADC007
## 15 ADC008 fov.8 fov.8__ADC008
## 16 ADC009 fov.9 fov.9__ADC009
## 17 ADC001 fov fov__ADC001
write.csv(
coordinate_scale_audit,
file.path(
TABLE_DIR,
"coordinate_scale_audit.csv"
),
row.names = FALSE
)
Review the coordinate ranges and median nearest-neighbor distances before interpreting 25-, 50-, and 100-unit thresholds as microns.
Detailed cell identities are preserved, but a compact spatial classification is created for neighborhood composition.
# ============================================================
# 08. SPATIAL CELL CLASSES
# ============================================================
spatial_cells <- spatial_cells %>%
mutate(
neighborhood_cellclass = case_when(
cell_type_final ==
"Cancer Epithelial Cells" ~
"Cancer",
cell_type_final ==
"NK Cells" ~
"NK",
cell_type_final ==
"CD8+ T Cells" ~
"CD8_T",
cell_type_final ==
"CD4+ T Cells" ~
"CD4_T",
cell_type_final ==
"Regulatory T Cells" ~
"Treg",
cell_type_final ==
"Macrophages" ~
"Macrophage",
cell_type_final ==
"MDSCs" ~
"MDSC",
cell_type_final %in% c(
"Monocytes",
"Dendritic Cells",
"Neutrophils"
) ~
"Other_Myeloid",
cell_type_final %in% c(
"Fibroblasts",
"Perivascular-like (PVL) Cells"
) ~
"Stromal",
cell_type_final ==
"Endothelial Cells" ~
"Endothelial",
cell_type_final %in% c(
"B Cells",
"Plasma Cells"
) ~
"B_Plasma",
TRUE ~
"Other"
)
)
NEIGHBOR_CLASSES <- c(
"Cancer",
"NK",
"CD8_T",
"CD4_T",
"Treg",
"Macrophage",
"MDSC",
"Other_Myeloid",
"Stromal",
"Endothelial",
"B_Plasma",
"Other"
)
table(
spatial_cells$neighborhood_cellclass
)
##
## B_Plasma Cancer CD4_T CD8_T Endothelial
## 5327 323290 523 214 21618
## Macrophage MDSC NK Other Other_Myeloid
## 6146 709 185 22868 5334
## Stromal Treg
## 71802 307
# ============================================================
# 09. NEAREST-DISTANCE FUNCTION
# ============================================================
nearest_distance_to_target <- function(
df,
query_indices,
target_classes
) {
target_indices <- which(
df$neighborhood_cellclass %in%
target_classes
)
if (
length(query_indices) == 0
) {
return(
numeric(0)
)
}
if (
length(target_indices) == 0
) {
return(
rep(
Inf,
length(query_indices)
)
)
}
query_xy <- as.matrix(
df[
query_indices,
c("x", "y")
]
)
target_xy <- as.matrix(
df[
target_indices,
c("x", "y")
]
)
result <- RANN::nn2(
data = target_xy,
query = query_xy,
k = 1
)
as.numeric(
result$nn.dists[, 1]
)
}
For each malignant cell, the composition of its 30 nearest neighboring cells is calculated.
This provides a density-adaptive representation of the local tumor ecosystem.
# ============================================================
# 10. MALIGNANT KNN NEIGHBORHOODS
# ============================================================
compute_tumor_neighborhoods <- function(
df,
k_neighbors = 30
) {
tumor_idx <- which(
df$neighborhood_cellclass ==
"Cancer"
)
if (
length(tumor_idx) == 0 ||
nrow(df) < 2
) {
return(NULL)
}
all_xy <- as.matrix(
df[, c("x", "y")]
)
query_xy <- all_xy[
tumor_idx,
,
drop = FALSE
]
k_query <- min(
k_neighbors + 1,
nrow(df)
)
nn <- RANN::nn2(
data = all_xy,
query = query_xy,
k = k_query
)
neighbor_idx <- nn$nn.idx
# First returned neighbor is the query cell itself.
if (
ncol(neighbor_idx) > 1
) {
neighbor_idx <-
neighbor_idx[, -1, drop = FALSE]
} else {
return(NULL)
}
neighbor_classes <- matrix(
df$neighborhood_cellclass[
neighbor_idx
],
nrow = nrow(neighbor_idx),
ncol = ncol(neighbor_idx)
)
output <- data.frame(
cell_id = df$cell_id[tumor_idx],
sample = df$sample[tumor_idx],
fov = df$fov[tumor_idx],
spatial_unit =
df$spatial_unit[tumor_idx],
x = df$x[tumor_idx],
y = df$y[tumor_idx],
neighbors_used =
ncol(neighbor_idx)
)
for (
class_name in NEIGHBOR_CLASSES
) {
output[[paste0(
"prop_",
class_name
)]] <- rowMeans(
neighbor_classes ==
class_name
)
}
# ----------------------------------------------------------
# Exact nearest distances from tumor cells
# ----------------------------------------------------------
output$dist_NK <-
nearest_distance_to_target(
df,
tumor_idx,
"NK"
)
output$dist_CD8_T <-
nearest_distance_to_target(
df,
tumor_idx,
"CD8_T"
)
output$dist_Treg <-
nearest_distance_to_target(
df,
tumor_idx,
"Treg"
)
output$dist_Macrophage <-
nearest_distance_to_target(
df,
tumor_idx,
"Macrophage"
)
output$dist_MDSC <-
nearest_distance_to_target(
df,
tumor_idx,
"MDSC"
)
output$dist_Stromal <-
nearest_distance_to_target(
df,
tumor_idx,
"Stromal"
)
output
}
Run the calculation separately for every spatial unit.
spatial_unit_list <- split(
spatial_cells,
spatial_cells$spatial_unit
)
tumor_neighborhood_list <- list()
for (
unit_name in names(spatial_unit_list)
) {
message(
"Processing tumor neighborhoods: ",
unit_name
)
tumor_neighborhood_list[[unit_name]] <- compute_tumor_neighborhoods(
spatial_unit_list[[unit_name]],
k_neighbors = K_NEIGHBORS
)
}
## Processing tumor neighborhoods: fov__ADC001
## Processing tumor neighborhoods: fov.10__ADC010
## Processing tumor neighborhoods: fov.11__ADC011
## Processing tumor neighborhoods: fov.12__ADC014
## Processing tumor neighborhoods: fov.13__ADC017
## Processing tumor neighborhoods: fov.14__ADC018
## Processing tumor neighborhoods: fov.15__ADC019
## Processing tumor neighborhoods: fov.16__ADC020
## Processing tumor neighborhoods: fov.17__ADC021
## Processing tumor neighborhoods: fov.2__ADC002
## Processing tumor neighborhoods: fov.3__ADC003
## Processing tumor neighborhoods: fov.4__ADC004
## Processing tumor neighborhoods: fov.5__ADC005
## Processing tumor neighborhoods: fov.6__ADC006
## Processing tumor neighborhoods: fov.7__ADC007
## Processing tumor neighborhoods: fov.8__ADC008
## Processing tumor neighborhoods: fov.9__ADC009
tumor_spatial <- bind_rows(
tumor_neighborhood_list
)
dim(tumor_spatial)
## [1] 323290 25
head(tumor_spatial)
## cell_id sample fov spatial_unit x y neighbors_used
## 1 ADC001_aaabdmmm-1 ADC001 fov fov__ADC001 71.64692 1236.204 30
## 2 ADC001_aaacbcng-1 ADC001 fov fov__ADC001 89.06297 1233.190 30
## 3 ADC001_aaaeacih-1 ADC001 fov fov__ADC001 102.21131 1232.613 30
## 4 ADC001_aaaedfdn-1 ADC001 fov fov__ADC001 135.70370 1245.775 30
## 5 ADC001_aaaenapg-1 ADC001 fov fov__ADC001 145.62679 1256.313 30
## 6 ADC001_aaafjoih-1 ADC001 fov fov__ADC001 140.50882 1252.812 30
## prop_Cancer prop_NK prop_CD8_T prop_CD4_T prop_Treg prop_Macrophage prop_MDSC
## 1 0.8666667 0 0 0 0 0 0
## 2 0.9333333 0 0 0 0 0 0
## 3 0.8666667 0 0 0 0 0 0
## 4 0.9000000 0 0 0 0 0 0
## 5 0.9000000 0 0 0 0 0 0
## 6 0.8666667 0 0 0 0 0 0
## prop_Other_Myeloid prop_Stromal prop_Endothelial prop_B_Plasma prop_Other
## 1 0 0.06666667 0.03333333 0 0.03333333
## 2 0 0.00000000 0.03333333 0 0.03333333
## 3 0 0.00000000 0.03333333 0 0.10000000
## 4 0 0.00000000 0.00000000 0 0.10000000
## 5 0 0.03333333 0.00000000 0 0.06666667
## 6 0 0.03333333 0.00000000 0 0.10000000
## dist_NK dist_CD8_T dist_Treg dist_Macrophage dist_MDSC dist_Stromal
## 1 318.8678 1676.677 508.1495 44.35582 500.4782 39.69668
## 2 301.2309 1661.966 499.4017 61.03633 491.4111 49.74803
## 3 288.0947 1650.202 494.7330 53.61990 486.4878 60.52663
## 4 256.7330 1614.259 498.4763 53.43448 489.5159 54.01523
## 5 249.0466 1600.890 506.5672 64.43186 497.3789 41.11841
## 6 253.3178 1606.991 504.2451 60.50324 495.1643 47.02704
Save the full malignant-cell spatial table as an R object rather than a very large CSV.
saveRDS(
tumor_spatial,
file.path(
RDS_DIR,
"malignant_cell_spatial_features.rds"
)
)
For each specimen, calculate the proportion of malignant cells with a target population within each spatial radius.
# ============================================================
# 11. SAMPLE-LEVEL SPATIAL ACCESSIBILITY
# ============================================================
distance_columns <- c(
"dist_NK",
"dist_CD8_T",
"dist_Treg",
"dist_Macrophage",
"dist_MDSC",
"dist_Stromal"
)
summarize_distance_feature <- function(
df,
distance_column
) {
distances <- df[[distance_column]]
finite_distances <- distances[
is.finite(distances)
]
output <- data.frame(
median_distance =
ifelse(
length(finite_distances) > 0,
median(finite_distances),
NA_real_
),
target_detected =
any(
is.finite(distances)
)
)
for (
radius in SPATIAL_RADII
) {
output[[paste0(
"fraction_within_",
radius)]] <- mean(
distances <= radius
)
}
output
}
sample_distance_summary <- list()
samples <- sort(
unique(
tumor_spatial$sample
)
)
for (
sample_name in samples
) {
df <- tumor_spatial %>%
filter(
sample == sample_name
)
for (
distance_column in distance_columns
) {
temp <- summarize_distance_feature(
df,
distance_column
)
temp$sample <- sample_name
temp$target <- sub(
"^dist_",
"",
distance_column
)
sample_distance_summary[[length(sample_distance_summary) + 1]] <- temp
}
}
sample_distance_summary <- bind_rows(
sample_distance_summary
)
sample_distance_summary <- sample_distance_summary %>%
left_join(
sample_qc,
by = "sample"
)
sample_distance_summary
## median_distance target_detected fraction_within_25 fraction_within_50
## 1 436.42086 TRUE 1.020180e-03 0.0071163751
## 2 831.29848 TRUE 4.727662e-04 0.0031600687
## 3 519.16258 TRUE 8.708851e-04 0.0054243699
## 4 65.09144 TRUE 7.479659e-02 0.3370325213
## 5 303.84380 TRUE 2.388713e-03 0.0155017542
## 6 36.17112 TRUE 2.514618e-01 0.7394809525
## 7 1394.89474 TRUE 0.000000e+00 0.0000000000
## 8 578.23132 TRUE 2.885170e-03 0.0115406809
## 9 NA FALSE 0.000000e+00 0.0000000000
## 10 137.08708 TRUE 1.038661e-02 0.0675129833
## 11 589.63283 TRUE 2.885170e-03 0.0132717830
## 12 52.97274 TRUE 1.627236e-01 0.4754760531
## 13 280.51938 TRUE 3.163207e-03 0.0181262440
## 14 373.17048 TRUE 1.599374e-03 0.0085655388
## 15 595.84623 TRUE 1.137333e-03 0.0048692067
## 16 94.86903 TRUE 2.743816e-02 0.1708487347
## 17 493.42727 TRUE 6.042081e-04 0.0041583736
## 18 47.27505 TRUE 1.325704e-01 0.5451734433
## 19 NA FALSE 0.000000e+00 0.0000000000
## 20 779.20168 TRUE 2.446064e-04 0.0018100876
## 21 1634.08911 TRUE 1.467639e-04 0.0006359767
## 22 120.17018 TRUE 3.517440e-02 0.1470573847
## 23 1316.67943 TRUE 4.892129e-05 0.0004402916
## 24 45.98975 TRUE 1.877599e-01 0.5533486620
## 25 514.31113 TRUE 5.287166e-03 0.0208376529
## 26 916.59874 TRUE 1.347709e-03 0.0038357869
## 27 435.04485 TRUE 4.354136e-03 0.0152394775
## 28 210.20648 TRUE 1.036699e-02 0.0410532863
## 29 240.96047 TRUE 6.116525e-03 0.0257101389
## 30 27.05520 TRUE 4.452623e-01 0.8788098694
## 31 1009.89206 TRUE 1.291433e-03 0.0111924236
## 32 321.01823 TRUE 6.026690e-03 0.0271201033
## 33 NA FALSE 0.000000e+00 0.0000000000
## 34 497.24938 TRUE 3.874300e-03 0.0232458028
## 35 226.26312 TRUE 1.162290e-02 0.0551011623
## 36 33.20885 TRUE 3.491175e-01 0.7292294447
## 37 217.16498 TRUE 7.779579e-03 0.0320907618
## 38 283.86537 TRUE 9.076175e-03 0.0304700162
## 39 NA FALSE 0.000000e+00 0.0000000000
## 40 50.53414 TRUE 1.646677e-01 0.4910858995
## 41 961.45460 TRUE 0.000000e+00 0.0000000000
## 42 41.51019 TRUE 1.957861e-01 0.6363047002
## 43 NA FALSE 0.000000e+00 0.0000000000
## 44 NA FALSE 0.000000e+00 0.0000000000
## 45 NA FALSE 0.000000e+00 0.0000000000
## 46 701.19903 TRUE 1.176471e-02 0.0823529412
## 47 NA FALSE 0.000000e+00 0.0000000000
## 48 696.77869 TRUE 0.000000e+00 0.0823529412
## 49 2428.94562 TRUE 0.000000e+00 0.0000000000
## 50 1721.82962 TRUE 0.000000e+00 0.0000000000
## 51 NA FALSE 0.000000e+00 0.0000000000
## 52 344.99785 TRUE 8.939213e-04 0.0125148987
## 53 1434.87726 TRUE 0.000000e+00 0.0005959476
## 54 32.59254 TRUE 3.146603e-01 0.7970798570
## 55 NA FALSE 0.000000e+00 0.0000000000
## 56 NA FALSE 0.000000e+00 0.0000000000
## 57 NA FALSE 0.000000e+00 0.0000000000
## 58 400.14991 TRUE 0.000000e+00 0.0000000000
## 59 NA FALSE 0.000000e+00 0.0000000000
## 60 55.61407 TRUE 1.382979e-01 0.4373522459
## 61 1112.19892 TRUE 7.404543e-04 0.0032004081
## 62 856.48627 TRUE 2.624499e-03 0.0128098596
## 63 905.23953 TRUE 3.216863e-03 0.0177462216
## 64 81.62807 TRUE 5.504044e-02 0.2393148329
## 65 290.62179 TRUE 9.741088e-03 0.0472245304
## 66 25.23606 TRUE 4.930192e-01 0.9264564325
## 67 899.20512 TRUE 1.459021e-03 0.0053159097
## 68 544.69530 TRUE 2.461304e-03 0.0105683837
## 69 685.12029 TRUE 1.839635e-03 0.0102638924
## 70 102.91637 TRUE 4.337732e-02 0.1798782035
## 71 404.41505 TRUE 8.360822e-03 0.0394950520
## 72 29.99039 TRUE 3.790662e-01 0.8168992641
## 73 NA FALSE 0.000000e+00 0.0000000000
## 74 NA FALSE 0.000000e+00 0.0000000000
## 75 NA FALSE 0.000000e+00 0.0000000000
## 76 NA FALSE 0.000000e+00 0.0000000000
## 77 194.18754 TRUE 4.444444e-03 0.0444444444
## 78 30.77697 TRUE 3.466667e-01 0.8444444444
## 79 959.67845 TRUE 0.000000e+00 0.0007889546
## 80 1320.36801 TRUE 0.000000e+00 0.0000000000
## 81 NA FALSE 0.000000e+00 0.0000000000
## 82 157.56775 TRUE 3.274162e-02 0.1258382643
## 83 756.09282 TRUE 0.000000e+00 0.0031558185
## 84 50.10486 TRUE 1.759369e-01 0.4978303748
## 85 1359.26300 TRUE 3.753284e-04 0.0032528462
## 86 NA FALSE 0.000000e+00 0.0000000000
## 87 NA FALSE 0.000000e+00 0.0000000000
## 88 111.74710 TRUE 3.478043e-02 0.1458776429
## 89 NA FALSE 0.000000e+00 0.0000000000
## 90 146.52744 TRUE 1.601401e-02 0.0774427624
## 91 NA FALSE 0.000000e+00 0.0000000000
## 92 NA FALSE 0.000000e+00 0.0000000000
## 93 5092.78838 TRUE 1.522070e-03 0.0015220700
## 94 755.41722 TRUE 1.014713e-02 0.0309487570
## 95 NA FALSE 0.000000e+00 0.0000000000
## 96 131.16135 TRUE 5.225774e-02 0.1582952816
## 97 NA FALSE 0.000000e+00 0.0000000000
## 98 NA FALSE 0.000000e+00 0.0000000000
## 99 NA FALSE 0.000000e+00 0.0000000000
## 100 829.16422 TRUE 8.356546e-03 0.0222841226
## 101 NA FALSE 0.000000e+00 0.0000000000
## 102 385.88315 TRUE 8.356546e-03 0.0334261838
## fraction_within_100 sample target total_cells qc_tier
## 1 0.0345865784 ADC001 NK 49737 Primary
## 2 0.0148299286 ADC001 CD8_T 49737 Primary
## 3 0.0241110752 ADC001 Treg 49737 Primary
## 4 0.7695389286 ADC001 Macrophage 49737 Primary
## 5 0.0807932519 ADC001 MDSC 49737 Primary
## 6 0.9814625893 ADC001 Stromal 49737 Primary
## 7 0.0057703405 ADC002 NK 2278 Limited
## 8 0.0502019619 ADC002 CD8_T 2278 Limited
## 9 0.0000000000 ADC002 Treg 2278 Limited
## 10 0.3317945759 ADC002 Macrophage 2278 Limited
## 11 0.0507789960 ADC002 MDSC 2278 Limited
## 12 0.7253317946 ADC002 Stromal 2278 Limited
## 13 0.0841626386 ADC003 NK 44418 Primary
## 14 0.0409439864 ADC003 CD8_T 44418 Primary
## 15 0.0212894512 ADC003 Treg 44418 Primary
## 16 0.5312055729 ADC003 Macrophage 44418 Primary
## 17 0.0236352005 ADC003 MDSC 44418 Primary
## 18 0.9378731874 ADC003 Stromal 44418 Primary
## 19 0.0000000000 ADC004 NK 35021 Primary
## 20 0.0096864146 ADC004 CD8_T 35021 Primary
## 21 0.0025439069 ADC004 Treg 35021 Primary
## 22 0.4076610733 ADC004 Macrophage 35021 Primary
## 23 0.0029841984 ADC004 MDSC 35021 Primary
## 24 0.9187906658 ADC004 Stromal 35021 Primary
## 25 0.0705992121 ADC005 NK 13350 Primary
## 26 0.0159651669 ADC005 CD8_T 13350 Primary
## 27 0.0580551524 ADC005 Treg 13350 Primary
## 28 0.1598590089 ADC005 Macrophage 13350 Primary
## 29 0.1118598383 ADC005 MDSC 13350 Primary
## 30 0.9896330085 ADC005 Stromal 13350 Primary
## 31 0.0396039604 ADC006 NK 3441 Limited
## 32 0.0981489453 ADC006 CD8_T 3441 Limited
## 33 0.0000000000 ADC006 Treg 3441 Limited
## 34 0.0576840293 ADC006 Macrophage 3441 Limited
## 35 0.2130865260 ADC006 MDSC 3441 Limited
## 36 0.9044339217 ADC006 Stromal 3441 Limited
## 37 0.1286871961 ADC007 NK 5937 Primary
## 38 0.0962722853 ADC007 CD8_T 5937 Primary
## 39 0.0000000000 ADC007 Treg 5937 Primary
## 40 0.8596434360 ADC007 Macrophage 5937 Primary
## 41 0.0003241491 ADC007 MDSC 5937 Primary
## 42 0.9513776337 ADC007 Stromal 5937 Primary
## 43 0.0000000000 ADC008 NK 89 Exploratory
## 44 0.0000000000 ADC008 CD8_T 89 Exploratory
## 45 0.0000000000 ADC008 Treg 89 Exploratory
## 46 0.1764705882 ADC008 Macrophage 89 Exploratory
## 47 0.0000000000 ADC008 MDSC 89 Exploratory
## 48 0.2235294118 ADC008 Stromal 89 Exploratory
## 49 0.0000000000 ADC009 NK 6546 Primary
## 50 0.0000000000 ADC009 CD8_T 6546 Primary
## 51 0.0000000000 ADC009 Treg 6546 Primary
## 52 0.0587008343 ADC009 Macrophage 6546 Primary
## 53 0.0026817640 ADC009 MDSC 6546 Primary
## 54 0.9833134684 ADC009 Stromal 6546 Primary
## 55 0.0000000000 ADC010 NK 988 Exploratory
## 56 0.0000000000 ADC010 CD8_T 988 Exploratory
## 57 0.0000000000 ADC010 Treg 988 Exploratory
## 58 0.0059101655 ADC010 Macrophage 988 Exploratory
## 59 0.0000000000 ADC010 MDSC 988 Exploratory
## 60 0.7966903073 ADC010 Stromal 988 Exploratory
## 61 0.0153520860 ADC011 NK 169979 Primary
## 62 0.0476605757 ADC011 CD8_T 169979 Primary
## 63 0.0569820728 ADC011 Treg 169979 Primary
## 64 0.6307930266 ADC011 Macrophage 169979 Primary
## 65 0.1425292274 ADC011 MDSC 169979 Primary
## 66 0.9963553193 ADC011 Stromal 169979 Primary
## 67 0.0223166709 ADC014 NK 111543 Primary
## 68 0.0412458767 ADC014 CD8_T 111543 Primary
## 69 0.0446840903 ADC014 Treg 111543 Primary
## 70 0.4839000254 ADC014 Macrophage 111543 Primary
## 71 0.1207688404 ADC014 MDSC 111543 Primary
## 72 0.9871098706 ADC014 Stromal 111543 Primary
## 73 0.0000000000 ADC017 NK 312 Exploratory
## 74 0.0000000000 ADC017 CD8_T 312 Exploratory
## 75 0.0000000000 ADC017 Treg 312 Exploratory
## 76 0.0000000000 ADC017 Macrophage 312 Exploratory
## 77 0.1866666667 ADC017 MDSC 312 Exploratory
## 78 0.9777777778 ADC017 Stromal 312 Exploratory
## 79 0.0063116371 ADC018 NK 3682 Limited
## 80 0.0000000000 ADC018 CD8_T 3682 Limited
## 81 0.0000000000 ADC018 Treg 3682 Limited
## 82 0.3313609467 ADC018 Macrophage 3682 Limited
## 83 0.0157790927 ADC018 MDSC 3682 Limited
## 84 0.8126232742 ADC018 Stromal 3682 Limited
## 85 0.0135118228 ADC019 NK 8430 Primary
## 86 0.0000000000 ADC019 CD8_T 8430 Primary
## 87 0.0000000000 ADC019 Treg 8430 Primary
## 88 0.4423870887 ADC019 Macrophage 8430 Primary
## 89 0.0000000000 ADC019 MDSC 8430 Primary
## 90 0.2986363068 ADC019 Stromal 8430 Primary
## 91 0.0000000000 ADC020 NK 2137 Limited
## 92 0.0000000000 ADC020 CD8_T 2137 Limited
## 93 0.0035514967 ADC020 Treg 2137 Limited
## 94 0.0659563673 ADC020 Macrophage 2137 Limited
## 95 0.0000000000 ADC020 MDSC 2137 Limited
## 96 0.3916793506 ADC020 Stromal 2137 Limited
## 97 0.0000000000 ADC021 NK 435 Exploratory
## 98 0.0000000000 ADC021 CD8_T 435 Exploratory
## 99 0.0000000000 ADC021 Treg 435 Exploratory
## 100 0.0640668524 ADC021 Macrophage 435 Exploratory
## 101 0.0000000000 ADC021 MDSC 435 Exploratory
## 102 0.1086350975 ADC021 Stromal 435 Exploratory
write.csv(
sample_distance_summary,
file.path(
TABLE_DIR,
"tumor_spatial_accessibility_by_sample.csv"
),
row.names = FALSE
)
This table will later allow questions such as:
\[ P( NK\ within\ 50\mu m \mid malignant\ cell ) \]
or
\[ P( CD8\ within\ 50\mu m \mid malignant\ cell ). \]
No response comparisons are performed yet.
The previous analysis asks whether malignant cells have nearby cytotoxic lymphocytes.
The reciprocal analysis asks whether NK or CD8 cells themselves are physically positioned near malignant cells.
# ============================================================
# 12. EFFECTOR-TO-TUMOR SPATIAL CONTEXT
# ============================================================
compute_effector_context <- function(
df,
k_neighbors = 30
) {
effector_idx <- which(
df$neighborhood_cellclass %in%
c(
"NK",
"CD8_T"
)
)
if (
length(effector_idx) == 0 ||
nrow(df) < 2
) {
return(NULL)
}
all_xy <- as.matrix(
df[, c("x", "y")]
)
query_xy <- all_xy[
effector_idx,
,
drop = FALSE
]
k_query <- min(
k_neighbors + 1,
nrow(df)
)
nn <- RANN::nn2(
data = all_xy,
query = query_xy,
k = k_query
)
neighbor_idx <-
nn$nn.idx[, -1, drop = FALSE]
neighbor_classes <- matrix(
df$neighborhood_cellclass[
neighbor_idx
],
nrow = nrow(neighbor_idx),
ncol = ncol(neighbor_idx)
)
output <- data.frame(
cell_id = df$cell_id[effector_idx],
sample = df$sample[effector_idx],
fov = df$fov[effector_idx],
spatial_unit =
df$spatial_unit[effector_idx],
effector_type =
df$neighborhood_cellclass[
effector_idx
],
x = df$x[effector_idx],
y = df$y[effector_idx]
)
output$dist_to_Cancer <-
nearest_distance_to_target(
df,
effector_idx,
"Cancer"
)
output$local_Cancer_fraction <-
rowMeans(
neighbor_classes ==
"Cancer"
)
output$local_NK_fraction <-
rowMeans(
neighbor_classes ==
"NK"
)
output$local_CD8_fraction <-
rowMeans(
neighbor_classes ==
"CD8_T"
)
output$local_Treg_fraction <-
rowMeans(
neighbor_classes ==
"Treg"
)
output$local_Macrophage_fraction <-
rowMeans(
neighbor_classes ==
"Macrophage"
)
output$local_MDSC_fraction <-
rowMeans(
neighbor_classes ==
"MDSC"
)
output$local_Stromal_fraction <-
rowMeans(
neighbor_classes ==
"Stromal"
)
output$local_suppressive_fraction <-
output$local_Treg_fraction +
output$local_Macrophage_fraction +
output$local_MDSC_fraction +
output$local_Stromal_fraction
output
}
effector_context_list <- list()
for (
unit_name in names(spatial_unit_list)
) {
message(
"Processing cytotoxic lymphocyte context: ",
unit_name
)
effector_context_list[[unit_name]] <- compute_effector_context(
spatial_unit_list[[unit_name]],
k_neighbors = K_NEIGHBORS
)
}
## Processing cytotoxic lymphocyte context: fov__ADC001
## Processing cytotoxic lymphocyte context: fov.10__ADC010
## Processing cytotoxic lymphocyte context: fov.11__ADC011
## Processing cytotoxic lymphocyte context: fov.12__ADC014
## Processing cytotoxic lymphocyte context: fov.13__ADC017
## Processing cytotoxic lymphocyte context: fov.14__ADC018
## Processing cytotoxic lymphocyte context: fov.15__ADC019
## Processing cytotoxic lymphocyte context: fov.16__ADC020
## Processing cytotoxic lymphocyte context: fov.17__ADC021
## Processing cytotoxic lymphocyte context: fov.2__ADC002
## Processing cytotoxic lymphocyte context: fov.3__ADC003
## Processing cytotoxic lymphocyte context: fov.4__ADC004
## Processing cytotoxic lymphocyte context: fov.5__ADC005
## Processing cytotoxic lymphocyte context: fov.6__ADC006
## Processing cytotoxic lymphocyte context: fov.7__ADC007
## Processing cytotoxic lymphocyte context: fov.8__ADC008
## Processing cytotoxic lymphocyte context: fov.9__ADC009
effector_spatial <- bind_rows(
effector_context_list
)
dim(effector_spatial)
## [1] 399 16
head(effector_spatial)
## cell_id sample fov spatial_unit effector_type x y
## 1 ADC001_addomjga-1 ADC001 fov fov__ADC001 NK 1304.660 1554.1779
## 2 ADC001_alihenai-1 ADC001 fov fov__ADC001 CD8_T 1607.140 1909.6335
## 3 ADC001_bchfnneb-1 ADC001 fov fov__ADC001 NK 1024.666 2745.2134
## 4 ADC001_bemnngem-1 ADC001 fov fov__ADC001 NK 1274.439 2447.4482
## 5 ADC001_bmhgpcpc-1 ADC001 fov fov__ADC001 NK 1105.994 616.2038
## 6 ADC001_bmjaipmi-1 ADC001 fov fov__ADC001 NK 1155.527 861.8052
## dist_to_Cancer local_Cancer_fraction local_NK_fraction local_CD8_fraction
## 1 12.418671 0.7000000 0 0
## 2 8.921081 0.5000000 0 0
## 3 15.613757 0.8000000 0 0
## 4 11.008256 0.8000000 0 0
## 5 35.567025 0.5333333 0 0
## 6 12.816539 0.8000000 0 0
## local_Treg_fraction local_Macrophage_fraction local_MDSC_fraction
## 1 0 0.03333333 0.00000000
## 2 0 0.06666667 0.00000000
## 3 0 0.10000000 0.00000000
## 4 0 0.00000000 0.00000000
## 5 0 0.10000000 0.06666667
## 6 0 0.00000000 0.00000000
## local_Stromal_fraction local_suppressive_fraction
## 1 0.16666667 0.2000000
## 2 0.10000000 0.1666667
## 3 0.03333333 0.1333333
## 4 0.13333333 0.1333333
## 5 0.16666667 0.3333333
## 6 0.13333333 0.1333333
Bring in the frozen functional-state measurements.
effector_function <- xenium@meta.data %>%
tibble::rownames_to_column(
"cell_id"
) %>%
select(
cell_id,
Cytotoxicity_score,
FCGR3A_expression
)
effector_spatial <- effector_spatial %>%
left_join(
effector_function,
by = "cell_id"
)
Save the full table.
saveRDS(
effector_spatial,
file.path(
RDS_DIR,
"NK_CD8_spatial_context.rds"
)
)
# ============================================================
# 13. EFFECTOR SPATIAL SUMMARY
# ============================================================
effector_sample_summary <- effector_spatial %>%
group_by(
sample,
effector_type
) %>%
summarise(
cells = n(),
median_tumor_distance =
median(
dist_to_Cancer[
is.finite(dist_to_Cancer)
],
na.rm = TRUE
),
fraction_within_25 =
mean(
dist_to_Cancer <= 25
),
fraction_within_50 =
mean(
dist_to_Cancer <= 50
),
fraction_within_100 =
mean(
dist_to_Cancer <= 100
),
median_local_cancer =
median(
local_Cancer_fraction,
na.rm = TRUE
),
median_local_suppression =
median(
local_suppressive_fraction,
na.rm = TRUE
),
median_cytotoxicity =
median(
Cytotoxicity_score,
na.rm = TRUE
),
median_FCGR3A =
median(
FCGR3A_expression,
na.rm = TRUE
),
fraction_FCGR3A_positive =
mean(
FCGR3A_expression > 0,
na.rm = TRUE
),
.groups = "drop"
) %>%
left_join(
sample_qc,
by = "sample"
)
effector_sample_summary
## # A tibble: 22 × 14
## sample effector_type cells median_tumor_distance fraction_within_25
## <chr> <chr> <int> <dbl> <dbl>
## 1 ADC001 CD8_T 8 17.5 0.625
## 2 ADC001 NK 19 21.6 0.737
## 3 ADC002 CD8_T 1 14.8 1
## 4 ADC002 NK 1 61.6 0
## 5 ADC003 CD8_T 32 18.9 0.625
## 6 ADC003 NK 76 22.2 0.605
## 7 ADC004 CD8_T 11 40.8 0.273
## 8 ADC005 CD8_T 4 14.8 1
## 9 ADC005 NK 9 12.5 1
## 10 ADC006 CD8_T 5 16.1 0.8
## # ℹ 12 more rows
## # ℹ 9 more variables: fraction_within_50 <dbl>, fraction_within_100 <dbl>,
## # median_local_cancer <dbl>, median_local_suppression <dbl>,
## # median_cytotoxicity <dbl>, median_FCGR3A <dbl>,
## # fraction_FCGR3A_positive <dbl>, total_cells <int>, qc_tier <chr>
Save.
write.csv(
effector_sample_summary,
file.path(
TABLE_DIR,
"NK_CD8_spatial_context_by_sample.csv"
),
row.names = FALSE
)
This produces the blinded measurements most directly relevant to the future NK/ADCC hypothesis:
\[ NK\ abundance \]
\[ NK\rightarrow tumor\ distance \]
\[ NK\ cytotoxicity \]
\[ FCGR3A/CD16 \]
and
\[ local\ suppressive\ context. \]
Neighborhood classes will be learned only from local cell composition.
Frozen malignant gene-expression scores will not be used to construct the neighborhood classes.
# ============================================================
# 14. NEIGHBORHOOD FEATURE MATRIX
# ============================================================
neighborhood_feature_columns <- paste0(
"prop_",
NEIGHBOR_CLASSES
)
neighborhood_feature_columns
## [1] "prop_Cancer" "prop_NK" "prop_CD8_T"
## [4] "prop_CD4_T" "prop_Treg" "prop_Macrophage"
## [7] "prop_MDSC" "prop_Other_Myeloid" "prop_Stromal"
## [10] "prop_Endothelial" "prop_B_Plasma" "prop_Other"
Use a Hellinger transformation of neighborhood proportions:
\[ h_{ik} = \sqrt{p_{ik}} \]
This preserves compositional structure without allowing highly abundant compartments to completely dominate Euclidean clustering.
neighborhood_matrix <- as.matrix(
tumor_spatial[
,
neighborhood_feature_columns,
drop = FALSE
]
)
neighborhood_matrix_hellinger <-
sqrt(
neighborhood_matrix
)
Large specimens such as ADC011 and ADC014 should not determine the neighborhood definitions simply because they contain many more cells.
Therefore, a maximum equal number of malignant cells is sampled from each specimen for neighborhood-model training.
# ============================================================
# 15. BALANCED TRAINING SET
# ============================================================
training_indices <- unlist(
lapply(
split(
seq_len(
nrow(tumor_spatial)
),
tumor_spatial$sample
),
function(indices) {
sample(
indices,
size = min(
length(indices),
MAX_TRAINING_CELLS_PER_SAMPLE
),
replace = FALSE
)
}
)
)
length(training_indices)
## [1] 37077
training_matrix <-
neighborhood_matrix_hellinger[
training_indices,
,
drop = FALSE
]
training_sample_counts <- tumor_spatial[
training_indices,
,
drop = FALSE
] %>%
count(
sample,
name = "training_cells"
)
training_sample_counts
## sample training_cells
## 1 ADC001 3000
## 2 ADC002 1733
## 3 ADC003 3000
## 4 ADC004 3000
## 5 ADC005 3000
## 6 ADC006 2323
## 7 ADC007 3000
## 8 ADC008 85
## 9 ADC009 3000
## 10 ADC010 846
## 11 ADC011 3000
## 12 ADC014 3000
## 13 ADC017 225
## 14 ADC018 2535
## 15 ADC019 3000
## 16 ADC020 1971
## 17 ADC021 359
write.csv(
training_sample_counts,
file.path(
TABLE_DIR,
"neighborhood_training_cells_by_sample.csv"
),
row.names = FALSE
)
Six neighborhood classes are defined a priori for this blinded
discovery analysis. They remain neutrally labeled
N1–N6 until their biological composition is
examined.
# ============================================================
# 16. K-MEANS NEIGHBORHOOD DISCOVERY
# ============================================================
set.seed(1234)
neighborhood_kmeans <- kmeans(
training_matrix,
centers = N_NEIGHBORHOOD_CLUSTERS,
nstart = 30,
iter.max = 100
)
table(
neighborhood_kmeans$cluster
)
##
## 1 2 3 4 5 6
## 8056 8650 3689 3929 4512 8241
Save the model centers.
neighborhood_centers <-
as.data.frame(
neighborhood_kmeans$centers
)
neighborhood_centers$cluster <-
paste0(
"N",
seq_len(
nrow(neighborhood_centers)
)
)
write.csv(
neighborhood_centers,
file.path(
TABLE_DIR,
"neighborhood_cluster_centers.csv"
),
row.names = FALSE
)
# ============================================================
# 17. ASSIGN ALL MALIGNANT CELLS
# ============================================================
assign_to_centers <- function(
matrix_data,
centers,
chunk_size = 50000
) {
cluster_assignment <- integer(
nrow(matrix_data)
)
starts <- seq(
1,
nrow(matrix_data),
by = chunk_size
)
for (
start_row in starts
) {
end_row <- min(
start_row +
chunk_size -
1,
nrow(matrix_data)
)
chunk <- matrix_data[
start_row:end_row,
,
drop = FALSE
]
distances <- sapply(
seq_len(
nrow(centers)
),
function(k) {
rowSums(
(
chunk -
matrix(
centers[k, ],
nrow = nrow(chunk),
ncol = ncol(chunk),
byrow = TRUE
)
)^2
)
}
)
cluster_assignment[
start_row:end_row
] <- max.col(
-distances,
ties.method = "first"
)
}
cluster_assignment
}
all_cluster_numbers <- assign_to_centers(
matrix_data =
neighborhood_matrix_hellinger,
centers =
neighborhood_kmeans$centers
)
tumor_spatial$tumor_neighborhood <-
paste0(
"N",
all_cluster_numbers
)
table(
tumor_spatial$tumor_neighborhood
)
##
## N1 N2 N3 N4 N5 N6
## 69337 99272 25375 32446 31404 65456
The neighborhood labels remain neutral, but their average cellular composition is calculated while still blinded.
# ============================================================
# 18. NEIGHBORHOOD COMPOSITION
# ============================================================
neighborhood_composition <- tumor_spatial %>%
group_by(
tumor_neighborhood
) %>%
summarise(
cells = n(),
across(
all_of(
neighborhood_feature_columns
),
~mean(
.x,
na.rm = TRUE
)
),
.groups = "drop"
)
neighborhood_composition
## # A tibble: 6 × 14
## tumor_neighborhood cells prop_Cancer prop_NK prop_CD8_T prop_CD4_T prop_Treg
## <chr> <int> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 N1 69337 0.813 0.000294 0.000340 0.000994 0.000542
## 2 N2 99272 0.876 0.000106 0.000168 0.000324 0.000192
## 3 N3 25375 0.581 0.000881 0.000666 0.000832 0.000578
## 4 N4 32446 0.535 0.000213 0.000240 0.000434 0.000325
## 5 N5 31404 0.809 0.000274 0.000205 0.000426 0.000260
## 6 N6 65456 0.969 0.000193 0.000188 0.000385 0.000127
## # ℹ 7 more variables: prop_Macrophage <dbl>, prop_MDSC <dbl>,
## # prop_Other_Myeloid <dbl>, prop_Stromal <dbl>, prop_Endothelial <dbl>,
## # prop_B_Plasma <dbl>, prop_Other <dbl>
write.csv(
neighborhood_composition,
file.path(
TABLE_DIR,
"neighborhood_composition.csv"
),
row.names = FALSE
)
Create a heatmap.
neighborhood_heatmap_data <-
neighborhood_composition %>%
select(
-cells
) %>%
pivot_longer(
-tumor_neighborhood,
names_to = "cell_class",
values_to = "mean_proportion"
) %>%
mutate(
cell_class = sub(
"^prop_",
"",
cell_class
)
)
p_neighborhood_heatmap <- ggplot(
neighborhood_heatmap_data,
aes(
x = cell_class,
y = tumor_neighborhood,
fill = mean_proportion
)
) +
geom_tile() +
labs(
title =
"Blinded cellular composition of tumor neighborhoods",
x = NULL,
y = "Neighborhood",
fill = "Mean\nproportion"
) +
theme_minimal() +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
)
)
p_neighborhood_heatmap
ggsave(
file.path(
FIGURE_DIR,
"Heatmap_tumor_neighborhood_composition.png"
),
p_neighborhood_heatmap,
width = 11,
height = 6,
dpi = 300
)
ggsave(
file.path(
FIGURE_DIR,
"Heatmap_tumor_neighborhood_composition.pdf"
),
p_neighborhood_heatmap,
width = 11,
height = 6
)
# ============================================================
# 19. NEIGHBORHOOD ABUNDANCE BY SAMPLE
# ============================================================
sample_neighborhood_abundance <- tumor_spatial %>%
count(
sample,
tumor_neighborhood,
name = "malignant_cells"
) %>%
group_by(sample) %>%
mutate(
neighborhood_fraction =
malignant_cells /
sum(malignant_cells)
) %>%
ungroup() %>%
left_join(
sample_qc,
by = "sample"
)
sample_neighborhood_abundance
## # A tibble: 96 × 6
## sample tumor_neighborhood malignant_cells neighborhood_fraction total_cells
## <chr> <chr> <int> <dbl> <int>
## 1 ADC001 N1 14125 0.351 49737
## 2 ADC001 N2 9767 0.243 49737
## 3 ADC001 N3 4065 0.101 49737
## 4 ADC001 N4 1221 0.0304 49737
## 5 ADC001 N5 4041 0.101 49737
## 6 ADC001 N6 6970 0.173 49737
## 7 ADC002 N1 540 0.312 2278
## 8 ADC002 N2 476 0.275 2278
## 9 ADC002 N3 55 0.0317 2278
## 10 ADC002 N4 176 0.102 2278
## # ℹ 86 more rows
## # ℹ 1 more variable: qc_tier <chr>
write.csv(
sample_neighborhood_abundance,
file.path(
TABLE_DIR,
"tumor_neighborhood_abundance_by_sample.csv"
),
row.names = FALSE
)
Visualize the blinded specimen-level architecture.
p_sample_neighborhoods <- ggplot(
sample_neighborhood_abundance,
aes(
x = sample,
y = neighborhood_fraction,
fill = tumor_neighborhood
)
) +
geom_col() +
labs(
title =
"Blinded tumor neighborhood composition by specimen",
x = "Specimen",
y = "Fraction of malignant cells",
fill = "Neighborhood"
) +
theme_minimal() +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
)
)
p_sample_neighborhoods
ggsave(
file.path(
FIGURE_DIR,
"Barplot_neighborhood_composition_by_sample.png"
),
p_sample_neighborhoods,
width = 12,
height = 7,
dpi = 300
)
Neighborhoods were constructed without gene-expression scores. We can therefore now ask whether the previously frozen malignant functional programs occupy different spatial ecosystems.
# ============================================================
# 20. MALIGNANT PROGRAMS BY NEIGHBORHOOD
# ============================================================
frozen_malignant_features <- xenium@meta.data %>%
tibble::rownames_to_column(
"cell_id"
) %>%
select(
cell_id,
any_of(
c(
"Proliferation_score",
"EMT_Plasticity_score",
"Interferon_Response_score"
)
)
)
tumor_spatial <- tumor_spatial %>%
left_join(
frozen_malignant_features,
by = "cell_id"
)
malignant_program_columns <- intersect(
c(
"Proliferation_score",
"EMT_Plasticity_score",
"Interferon_Response_score"
),
colnames(tumor_spatial)
)
neighborhood_function_summary <- tumor_spatial %>%
group_by(
tumor_neighborhood
) %>%
summarise(
cells = n(),
across(
all_of(
malignant_program_columns
),
list(
median = ~median(
.x,
na.rm = TRUE
),
mean = ~mean(
.x,
na.rm = TRUE
)
)
),
.groups = "drop"
)
neighborhood_function_summary
## # A tibble: 6 × 8
## tumor_neighborhood cells Proliferation_score_median Proliferation_score_mean
## <chr> <int> <dbl> <dbl>
## 1 N1 69337 -0.575 0.0232
## 2 N2 99272 0.0268 0.0565
## 3 N3 25375 0.217 0.422
## 4 N4 32446 -0.575 -0.0202
## 5 N5 31404 0.271 0.493
## 6 N6 65456 -0.575 0.0198
## # ℹ 4 more variables: EMT_Plasticity_score_median <dbl>,
## # EMT_Plasticity_score_mean <dbl>, Interferon_Response_score_median <dbl>,
## # Interferon_Response_score_mean <dbl>
write.csv(
neighborhood_function_summary,
file.path(
TABLE_DIR,
"malignant_programs_by_neighborhood.csv"
),
row.names = FALSE
)
No neighborhood is interpreted as resistant or sensitive at this stage.
# ============================================================
# 21. ADD NEIGHBORHOODS TO XENIUM METADATA
# ============================================================
xenium$tumor_neighborhood <- NA_character_
xenium@meta.data[
tumor_spatial$cell_id,
"tumor_neighborhood"
] <- tumor_spatial$tumor_neighborhood
table(
xenium$tumor_neighborhood,
useNA = "ifany"
)
##
## N1 N2 N3 N4 N5 N6 <NA>
## 69337 99272 25375 32446 31404 65456 135033
These figures provide a visual QC check that neighborhood assignments form plausible spatial structures rather than scattered technical artifacts.
# ============================================================
# 22. SPATIAL NEIGHBORHOOD MAPS
# ============================================================
for (
unit_name in unique(
tumor_spatial$spatial_unit
)
) {
plot_df <- tumor_spatial %>%
filter(
spatial_unit == unit_name
)
if (
nrow(plot_df) >
MAX_PLOT_CELLS
) {
set.seed(1234)
plot_df <- plot_df %>%
slice_sample(
n = MAX_PLOT_CELLS
)
}
sample_name <-
unique(
plot_df$sample
)[1]
fov_name <-
unique(
plot_df$fov
)[1]
p <- ggplot(
plot_df,
aes(
x = x,
y = y,
color = tumor_neighborhood
)
) +
geom_point(
size = 0.35,
alpha = 0.7
) +
coord_fixed() +
labs(
title = paste(
sample_name,
"— malignant spatial neighborhoods"
),
subtitle = fov_name,
x = "x",
y = "y",
color = "Neighborhood"
) +
theme_minimal()
print(p)
safe_name <- gsub(
"[^A-Za-z0-9_-]",
"_",
unit_name
)
ggsave(
file.path(
FIGURE_DIR,
paste0(
"Spatial_neighborhood_",
safe_name,
".png"
)
),
p,
width = 8,
height = 8,
dpi = 300
)
}
These maps visualize cytotoxic lymphocyte localization relative to malignant cells.
# ============================================================
# 23. CYTOTOXIC LYMPHOCYTE MAPS
# ============================================================
for (
unit_name in unique(
spatial_cells$spatial_unit
)
) {
df <- spatial_cells %>%
filter(
spatial_unit == unit_name
)
if (
nrow(df) == 0
) {
next
}
# Background malignant cells
background <- df %>%
filter(
neighborhood_cellclass ==
"Cancer"
)
if (
nrow(background) >
MAX_PLOT_CELLS
) {
set.seed(1234)
background <- background %>%
slice_sample(
n = MAX_PLOT_CELLS
)
}
effectors <- df %>%
filter(
neighborhood_cellclass %in%
c(
"NK",
"CD8_T"
)
)
sample_name <-
unique(
df$sample
)[1]
fov_name <-
unique(
df$fov
)[1]
p <- ggplot() +
geom_point(
data = background,
aes(
x = x,
y = y
),
size = 0.2,
alpha = 0.2
) +
geom_point(
data = effectors,
aes(
x = x,
y = y,
color = neighborhood_cellclass
),
size = 1.3
) +
coord_fixed() +
labs(
title = paste(
sample_name,
"— cytotoxic lymphocyte localization"
),
subtitle = fov_name,
x = "x",
y = "y",
color = "Cell type"
) +
theme_minimal()
print(p)
safe_name <- gsub(
"[^A-Za-z0-9_-]",
"_",
unit_name
)
ggsave(
file.path(
FIGURE_DIR,
paste0(
"Spatial_NK_CD8_",
safe_name,
".png"
)
),
p,
width = 8,
height = 8,
dpi = 300
)
}
The following table collects the principal spatial variables that will eventually be joined to clinical metadata after unblinding.
# ============================================================
# 24. SAMPLE-LEVEL SPATIAL FEATURE TABLE
# ============================================================
distance_wide <- sample_distance_summary %>%
select(
sample,
target,
median_distance,
target_detected,
starts_with(
"fraction_within_"
)
) %>%
pivot_wider(
names_from = target,
values_from = c(
median_distance,
target_detected,
starts_with(
"fraction_within_"
)
)
)
Convert neighborhood proportions to wide format.
neighborhood_wide <- sample_neighborhood_abundance %>%
select(
sample,
tumor_neighborhood,
neighborhood_fraction
) %>%
pivot_wider(
names_from =
tumor_neighborhood,
values_from =
neighborhood_fraction,
names_prefix =
"fraction_neighborhood_",
values_fill = 0
)
Join.
sample_spatial_features <- sample_qc %>%
left_join(
distance_wide,
by = "sample"
) %>%
left_join(
neighborhood_wide,
by = "sample"
)
sample_spatial_features
## sample total_cells qc_tier median_distance_NK median_distance_CD8_T
## 1 ADC011 169979 Primary 1112.1989 856.4863
## 2 ADC014 111543 Primary 899.2051 544.6953
## 3 ADC001 49737 Primary 436.4209 831.2985
## 4 ADC003 44418 Primary 280.5194 373.1705
## 5 ADC004 35021 Primary NA 779.2017
## 6 ADC005 13350 Primary 514.3111 916.5987
## 7 ADC019 8430 Primary 1359.2630 NA
## 8 ADC009 6546 Primary 2428.9456 1721.8296
## 9 ADC007 5937 Primary 217.1650 283.8654
## 10 ADC018 3682 Limited 959.6784 1320.3680
## 11 ADC006 3441 Limited 1009.8921 321.0182
## 12 ADC002 2278 Limited 1394.8947 578.2313
## 13 ADC020 2137 Limited NA NA
## 14 ADC010 988 Exploratory NA NA
## 15 ADC021 435 Exploratory NA NA
## 16 ADC017 312 Exploratory NA NA
## 17 ADC008 89 Exploratory NA NA
## median_distance_Treg median_distance_Macrophage median_distance_MDSC
## 1 905.2395 81.62807 290.6218
## 2 685.1203 102.91637 404.4151
## 3 519.1626 65.09144 303.8438
## 4 595.8462 94.86903 493.4273
## 5 1634.0891 120.17018 1316.6794
## 6 435.0448 210.20648 240.9605
## 7 NA 111.74710 NA
## 8 NA 344.99785 1434.8773
## 9 NA 50.53414 961.4546
## 10 NA 157.56775 756.0928
## 11 NA 497.24938 226.2631
## 12 NA 137.08708 589.6328
## 13 5092.7884 755.41722 NA
## 14 NA 400.14991 NA
## 15 NA 829.16422 NA
## 16 NA NA 194.1875
## 17 NA 701.19903 NA
## median_distance_Stromal target_detected_NK target_detected_CD8_T
## 1 25.23606 TRUE TRUE
## 2 29.99039 TRUE TRUE
## 3 36.17112 TRUE TRUE
## 4 47.27505 TRUE TRUE
## 5 45.98975 FALSE TRUE
## 6 27.05520 TRUE TRUE
## 7 146.52744 TRUE FALSE
## 8 32.59254 TRUE TRUE
## 9 41.51019 TRUE TRUE
## 10 50.10486 TRUE TRUE
## 11 33.20885 TRUE TRUE
## 12 52.97274 TRUE TRUE
## 13 131.16135 FALSE FALSE
## 14 55.61407 FALSE FALSE
## 15 385.88315 FALSE FALSE
## 16 30.77697 FALSE FALSE
## 17 696.77869 FALSE FALSE
## target_detected_Treg target_detected_Macrophage target_detected_MDSC
## 1 TRUE TRUE TRUE
## 2 TRUE TRUE TRUE
## 3 TRUE TRUE TRUE
## 4 TRUE TRUE TRUE
## 5 TRUE TRUE TRUE
## 6 TRUE TRUE TRUE
## 7 FALSE TRUE FALSE
## 8 FALSE TRUE TRUE
## 9 FALSE TRUE TRUE
## 10 FALSE TRUE TRUE
## 11 FALSE TRUE TRUE
## 12 FALSE TRUE TRUE
## 13 TRUE TRUE FALSE
## 14 FALSE TRUE FALSE
## 15 FALSE TRUE FALSE
## 16 FALSE FALSE TRUE
## 17 FALSE TRUE FALSE
## target_detected_Stromal fraction_within_25_NK fraction_within_25_CD8_T
## 1 TRUE 0.0007404543 0.0026244992
## 2 TRUE 0.0014590206 0.0024613042
## 3 TRUE 0.0010201797 0.0004727662
## 4 TRUE 0.0031632073 0.0015993745
## 5 TRUE 0.0000000000 0.0002446064
## 6 TRUE 0.0052871657 0.0013477089
## 7 TRUE 0.0003753284 0.0000000000
## 8 TRUE 0.0000000000 0.0000000000
## 9 TRUE 0.0077795786 0.0090761750
## 10 TRUE 0.0000000000 0.0000000000
## 11 TRUE 0.0012914335 0.0060266896
## 12 TRUE 0.0000000000 0.0028851702
## 13 TRUE 0.0000000000 0.0000000000
## 14 TRUE 0.0000000000 0.0000000000
## 15 TRUE 0.0000000000 0.0000000000
## 16 TRUE 0.0000000000 0.0000000000
## 17 TRUE 0.0000000000 0.0000000000
## fraction_within_25_Treg fraction_within_25_Macrophage
## 1 0.0032168626 0.0550404370
## 2 0.0018396346 0.0433773154
## 3 0.0008708851 0.0747965861
## 4 0.0011373330 0.0274381575
## 5 0.0001467639 0.0351744044
## 6 0.0043541364 0.0103669915
## 7 0.0000000000 0.0347804329
## 8 0.0000000000 0.0008939213
## 9 0.0000000000 0.1646677472
## 10 0.0000000000 0.0327416174
## 11 0.0000000000 0.0038743005
## 12 0.0000000000 0.0103866128
## 13 0.0015220700 0.0101471334
## 14 0.0000000000 0.0000000000
## 15 0.0000000000 0.0083565460
## 16 0.0000000000 0.0000000000
## 17 0.0000000000 0.0117647059
## fraction_within_25_MDSC fraction_within_25_Stromal fraction_within_50_NK
## 1 9.741088e-03 0.493019161 0.0032004081
## 2 8.360822e-03 0.379066227 0.0053159097
## 3 2.388713e-03 0.251461843 0.0071163751
## 4 6.042081e-04 0.132570372 0.0181262440
## 5 4.892129e-05 0.187759894 0.0000000000
## 6 6.116525e-03 0.445262285 0.0208376529
## 7 0.000000e+00 0.016014012 0.0032528462
## 8 0.000000e+00 0.314660310 0.0000000000
## 9 0.000000e+00 0.195786062 0.0320907618
## 10 0.000000e+00 0.175936884 0.0007889546
## 11 1.162290e-02 0.349117520 0.0111924236
## 12 2.885170e-03 0.162723601 0.0000000000
## 13 0.000000e+00 0.052257737 0.0000000000
## 14 0.000000e+00 0.138297872 0.0000000000
## 15 0.000000e+00 0.008356546 0.0000000000
## 16 4.444444e-03 0.346666667 0.0000000000
## 17 0.000000e+00 0.000000000 0.0000000000
## fraction_within_50_CD8_T fraction_within_50_Treg
## 1 0.012809860 0.0177462216
## 2 0.010568384 0.0102638924
## 3 0.003160069 0.0054243699
## 4 0.008565539 0.0048692067
## 5 0.001810088 0.0006359767
## 6 0.003835787 0.0152394775
## 7 0.000000000 0.0000000000
## 8 0.000000000 0.0000000000
## 9 0.030470016 0.0000000000
## 10 0.000000000 0.0000000000
## 11 0.027120103 0.0000000000
## 12 0.011540681 0.0000000000
## 13 0.000000000 0.0015220700
## 14 0.000000000 0.0000000000
## 15 0.000000000 0.0000000000
## 16 0.000000000 0.0000000000
## 17 0.000000000 0.0000000000
## fraction_within_50_Macrophage fraction_within_50_MDSC
## 1 0.23931483 0.0472245304
## 2 0.17987820 0.0394950520
## 3 0.33703252 0.0155017542
## 4 0.17084873 0.0041583736
## 5 0.14705738 0.0004402916
## 6 0.04105329 0.0257101389
## 7 0.14587764 0.0000000000
## 8 0.01251490 0.0005959476
## 9 0.49108590 0.0000000000
## 10 0.12583826 0.0031558185
## 11 0.02324580 0.0551011623
## 12 0.06751298 0.0132717830
## 13 0.03094876 0.0000000000
## 14 0.00000000 0.0000000000
## 15 0.02228412 0.0000000000
## 16 0.00000000 0.0444444444
## 17 0.08235294 0.0000000000
## fraction_within_50_Stromal fraction_within_100_NK fraction_within_100_CD8_T
## 1 0.92645643 0.015352086 0.047660576
## 2 0.81689926 0.022316671 0.041245877
## 3 0.73948095 0.034586578 0.014829929
## 4 0.54517344 0.084162639 0.040943986
## 5 0.55334866 0.000000000 0.009686415
## 6 0.87880987 0.070599212 0.015965167
## 7 0.07744276 0.013511823 0.000000000
## 8 0.79707986 0.000000000 0.000000000
## 9 0.63630470 0.128687196 0.096272285
## 10 0.49783037 0.006311637 0.000000000
## 11 0.72922944 0.039603960 0.098148945
## 12 0.47547605 0.005770340 0.050201962
## 13 0.15829528 0.000000000 0.000000000
## 14 0.43735225 0.000000000 0.000000000
## 15 0.03342618 0.000000000 0.000000000
## 16 0.84444444 0.000000000 0.000000000
## 17 0.08235294 0.000000000 0.000000000
## fraction_within_100_Treg fraction_within_100_Macrophage
## 1 0.056982073 0.630793027
## 2 0.044684090 0.483900025
## 3 0.024111075 0.769538929
## 4 0.021289451 0.531205573
## 5 0.002543907 0.407661073
## 6 0.058055152 0.159859009
## 7 0.000000000 0.442387089
## 8 0.000000000 0.058700834
## 9 0.000000000 0.859643436
## 10 0.000000000 0.331360947
## 11 0.000000000 0.057684029
## 12 0.000000000 0.331794576
## 13 0.003551497 0.065956367
## 14 0.000000000 0.005910165
## 15 0.000000000 0.064066852
## 16 0.000000000 0.000000000
## 17 0.000000000 0.176470588
## fraction_within_100_MDSC fraction_within_100_Stromal
## 1 0.1425292274 0.9963553
## 2 0.1207688404 0.9871099
## 3 0.0807932519 0.9814626
## 4 0.0236352005 0.9378732
## 5 0.0029841984 0.9187907
## 6 0.1118598383 0.9896330
## 7 0.0000000000 0.2986363
## 8 0.0026817640 0.9833135
## 9 0.0003241491 0.9513776
## 10 0.0157790927 0.8126233
## 11 0.2130865260 0.9044339
## 12 0.0507789960 0.7253318
## 13 0.0000000000 0.3916794
## 14 0.0000000000 0.7966903
## 15 0.0000000000 0.1086351
## 16 0.1866666667 0.9777778
## 17 0.0000000000 0.2235294
## fraction_neighborhood_N1 fraction_neighborhood_N2 fraction_neighborhood_N3
## 1 0.20926062 0.42931541 0.024155265
## 2 0.20554428 0.31272520 0.041207815
## 3 0.35146433 0.24302670 0.101147080
## 4 0.14621837 0.12372050 0.340489053
## 5 0.02529230 0.08096473 0.173474879
## 6 0.47677794 0.24196558 0.055878084
## 7 0.07406481 0.15438509 0.001626423
## 8 0.36084625 0.08820024 0.017282479
## 9 0.09011345 0.16337115 0.221069692
## 10 0.17475345 0.38816568 0.025246548
## 11 0.42315971 0.08136031 0.210073181
## 12 0.31159838 0.27466821 0.031736872
## 13 0.06849315 0.48756976 0.007102993
## 14 0.02836879 0.42316785 0.065011820
## 15 0.16991643 0.30640669 0.000000000
## 16 0.35111111 0.20444444 0.142222222
## 17 0.00000000 0.54117647 0.000000000
## fraction_neighborhood_N4 fraction_neighborhood_N5 fraction_neighborhood_N6
## 1 0.1404970917 0.0208067661 0.175964853
## 2 0.1079040853 0.0594138544 0.273204770
## 3 0.0303814477 0.1005499017 0.173430541
## 4 0.0426499858 0.2147426784 0.132179414
## 5 0.0314074654 0.5959591018 0.092901521
## 6 0.1154882853 0.0320340037 0.077856106
## 7 0.0002502189 0.0275240836 0.742149381
## 8 0.5277115614 0.0002979738 0.005661502
## 9 0.0385737439 0.2622366288 0.224635332
## 10 0.1143984221 0.0465483235 0.250887574
## 11 0.1166594920 0.1050365906 0.063710719
## 12 0.1015579919 0.0103866128 0.270051933
## 13 0.0020294267 0.0294266870 0.405377981
## 14 0.0118203310 0.1418439716 0.329787234
## 15 0.1086350975 0.0000000000 0.415041783
## 16 0.0222222222 0.1333333333 0.146666667
## 17 0.0000000000 0.0000000000 0.458823529
write.csv(
sample_spatial_features,
file.path(
TABLE_DIR,
"blinded_sample_spatial_features.csv"
),
row.names = FALSE
)
# ============================================================
# 25. SPATIAL FREEZE MANIFEST
# ============================================================
spatial_freeze_manifest <- data.frame(
field = c(
"freeze_date",
"input_object",
"clinical_metadata_used",
"spatial_units",
"neighborhood_definition",
"K_nearest_neighbors",
"physical_radii",
"neighborhood_clusters",
"training_balance",
"NK_spatial_features",
"CD8_spatial_features",
"suppressive_features",
"malignant_programs_used_to_define_clusters",
"status"
),
value = c(
as.character(
Sys.Date()
),
basename(
XENIUM_FILE
),
"No",
as.character(
length(
unique(
spatial_cells$spatial_unit
)
)
),
paste(
"Hellinger-transformed composition",
"of local neighboring cells"
),
as.character(
K_NEIGHBORS
),
paste(
SPATIAL_RADII,
collapse = "; "
),
as.character(
N_NEIGHBORHOOD_CLUSTERS
),
paste(
"Maximum",
MAX_TRAINING_CELLS_PER_SAMPLE,
"malignant cells per specimen"
),
paste(
"Tumor-to-NK distance;",
"NK-to-tumor distance;",
"25/50/100 radius accessibility;",
"cytotoxicity;",
"FCGR3A;",
"local suppressive context"
),
paste(
"Tumor-to-CD8 distance;",
"CD8-to-tumor distance;",
"25/50/100 radius accessibility;",
"cytotoxicity;",
"local suppressive context"
),
paste(
"Treg, macrophage, MDSC,",
"and stromal neighborhood composition"
),
"No",
"Blinded spatial neighborhoods frozen"
)
)
spatial_freeze_manifest
## field
## 1 freeze_date
## 2 input_object
## 3 clinical_metadata_used
## 4 spatial_units
## 5 neighborhood_definition
## 6 K_nearest_neighbors
## 7 physical_radii
## 8 neighborhood_clusters
## 9 training_balance
## 10 NK_spatial_features
## 11 CD8_spatial_features
## 12 suppressive_features
## 13 malignant_programs_used_to_define_clusters
## 14 status
## value
## 1 2026-08-26
## 2 ADC_Xenium_Blinded_Functional_States_Frozen.rds
## 3 No
## 4 17
## 5 Hellinger-transformed composition of local neighboring cells
## 6 30
## 7 25; 50; 100
## 8 6
## 9 Maximum 3000 malignant cells per specimen
## 10 Tumor-to-NK distance; NK-to-tumor distance; 25/50/100 radius accessibility; cytotoxicity; FCGR3A; local suppressive context
## 11 Tumor-to-CD8 distance; CD8-to-tumor distance; 25/50/100 radius accessibility; cytotoxicity; local suppressive context
## 12 Treg, macrophage, MDSC, and stromal neighborhood composition
## 13 No
## 14 Blinded spatial neighborhoods frozen
write.csv(
spatial_freeze_manifest,
file.path(
TABLE_DIR,
"spatial_freeze_manifest.csv"
),
row.names = FALSE
)
# ============================================================
# 26. SAVE FROZEN OBJECT
# ============================================================
saveRDS(
xenium,
file = file.path(
OBJECT_DIR,
"ADC_Xenium_Blinded_Spatial_States_Frozen.rds"
)
)
Also save the spatial-analysis tables as R objects.
saveRDS(
spatial_cells,
file.path(
RDS_DIR,
"all_cells_with_coordinates.rds"
)
)
saveRDS(
tumor_spatial,
file.path(
RDS_DIR,
"malignant_cell_spatial_features.rds"
)
)
saveRDS(
effector_spatial,
file.path(
RDS_DIR,
"NK_CD8_spatial_context.rds"
)
)
# ============================================================
# 27. 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)
This notebook defined blinded spatial architecture in the metastatic breast cancer ADC Xenium cohort after cell identities and functional states were frozen in Notebooks 1 and 2.
Cell-centroid coordinates were analyzed independently within each tissue FOV so that spatial distances could not cross unrelated tissue sections.
Spatial relationships between malignant cells and NK cells, CD8 T cells, Tregs, macrophages, MDSCs, and stromal cells were quantified using both exact nearest-neighbor distances and local cellular composition. NK and CD8 T-cell infiltration were additionally evaluated from the reciprocal perspective of cytotoxic lymphocyte proximity to malignant cells.
Local tumor ecosystems were defined from the cellular composition of the 30 nearest neighbors surrounding each malignant cell. A specimen-balanced training set was used to prevent specimens with very large cell numbers from disproportionately determining the spatial neighborhood model. Six neighborhood classes were identified while blinded and retained using neutral labels N1–N6.
Previously frozen malignant transcriptional programs were not used to construct spatial neighborhood classes. Instead, their relationship to the independently derived spatial neighborhoods was evaluated after neighborhood assignment.
NK-specific spatial features included malignant-cell accessibility to NK cells, NK proximity to tumor cells, NK cytotoxicity, FCGR3A/CD16 expression, and the suppressive cellular context surrounding NK cells. Parallel CD8 T-cell measurements were retained to distinguish potential NK-specific biology from broader cytotoxic-lymphocyte exclusion.
No ADC identity, pre/post-treatment status, response, progression, survival, or other clinical outcome information was used to construct or select spatial features.
The resulting frozen object and specimen-level spatial feature table represent the final blinded feature-definition stage. After review of spatial QC and receipt of the clinical metadata, subsequent notebooks can unblind the cohort and test associations between these prespecified biological features and ADC exposure, clinical benefit, metastatic site, SG versus T-DXd treatment, and the three available longitudinal pre/post pairs.