Purpose

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:

  1. Extract and audit Xenium cell-centroid coordinates across all fields of view (FOVs).
  2. Confirm the relationship between FOVs and specimen identifiers.
  3. Quantify spatial accessibility between malignant cells and NK cells, CD8 T cells, Tregs, macrophages, MDSCs, and stromal cells.
  4. Quantify reciprocal NK/CD8 infiltration into malignant regions.
  5. Construct local malignant-cell neighborhoods from the composition of nearby cells.
  6. Discover recurrent spatial neighborhood classes without access to treatment or outcome information.
  7. Relate previously frozen malignant and immune functional states to these spatial neighborhoods.
  8. Generate specimen-level spatial features for subsequent clinical association testing.
  9. Freeze the spatial architecture before clinical unblinding.

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.

1. Setup

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.

2. Load the frozen Notebook 2 object

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

3. Recreate blinded specimen QC tiers

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.

4. Extract Xenium cell-centroid coordinates

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

5. Join spatial coordinates to frozen metadata

# ============================================================
# 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."
  )
}

6. Audit FOV-to-sample mapping

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

7. Audit coordinate scale and tissue density

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.

8. Define cell classes for neighborhood analysis

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

9. Helper function for nearest distance to a target population

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

10. Construct local neighborhoods around malignant cells

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"
  )
)

11. Quantify tumor accessibility to immune and stromal populations

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.

12. Define reciprocal NK and CD8 tumor infiltration

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. Summarize NK/CD8 spatial access by specimen

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

14. Prepare malignant-cell neighborhood composition for clustering

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
  )

15. Create a specimen-balanced training set

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
)

16. Discover blinded spatial neighborhood classes

Six neighborhood classes are defined a priori for this blinded discovery analysis. They remain neutrally labeled N1N6 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 every malignant cell to a neighborhood class

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

18. Describe the biological composition of each neighborhood

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. Quantify neighborhood abundance by specimen

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

20. Relate frozen malignant programs to spatial neighborhoods

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. Map neighborhood classes back into the Seurat object

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

22. Generate spatial maps for each FOV

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
  )
}

23. Generate blinded NK/CD8 spatial maps

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
  )
}

24. Generate a single blinded sample-level spatial feature table

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

# ============================================================
# 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 the frozen spatial object

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

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

Conclusion

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.