4. 邻域图概念:空间邻域的定义、图论基础

聊到空间邻域特征,咱们得先坐下来把「图」这个东西讲清楚。说白了,空间邻域分析就是把组织切片上的细胞看作一个个的节点,然后根据它们之间的距离关系连上线。我刚开始接触这个领域的时候,总觉得这不就是画个连线图吗?结果真上手做才发现,选什么距离阈值、用什么连边方式,直接决定了你后面算出来的特征靠不靠谱。

4.1 什么是空间邻域?

先给个直觉感受。你想象一张 H&E 染色切片,上面密密麻麻都是细胞核。每个细胞周围都有一圈邻居——可能是肿瘤细胞、免疫细胞、基质细胞。空间邻域,就是把这种「谁挨着谁」的关系,用数学语言讲清楚。

我习惯把邻域定义成两步走:

  • 空间坐标:每个细胞有 (x, y) 位置,这是从图像分割那一步得来的。
  • 邻域半径 μ:只有距离小于 μ 的两个细胞,才被认为有「邻居关系」。

你想想看,这个 μ 值选大了,所有细胞都连在一起,跟没分析一样;选小了,稀稀拉拉几根线,信息量不够。我在项目中遇到过这个问题,选 50 像素和 100 像素出来的结果差很多,后来我一般会试三五个值再决定。

4.2 图论基础速览

空间邻域本质上就是一个图(Graph)。图论这个词听着高大上,其实核心就三样东西:

图的三个核心要素:
  • 节点(Node/Vertex):就是你的细胞。每个节点有唯一编号,也有属性(比如细胞类型、基因表达量)。
  • 边(Edge):表示两个节点之间有联系。在空间邻域里,边代表「这两个细胞靠得很近」。
  • 邻接矩阵(Adjacency Matrix):用一个方阵记录谁跟谁连着。

我个人建议你把这个矩阵想象成一个「邻居电话本」——行和列都是细胞编号,如果细胞 i 和细胞 j 是邻居,那第 (i, j) 个格子就填 1,否则填 0。

概念 空间邻域中的对应 举个栗子
节点 单个细胞 细胞 ID: 001, 002, ...
边 距离 ≤ μ 的两个细胞 细胞 001 与 002 距离 45px,小于 μ=50 → 存在边
邻接矩阵 N×N 的 0/1 矩阵 A[1][2] = 1,代表 1 和 2 是邻居
邻居集合 某个细胞的所有邻居 细胞 001 的邻居:{002, 005, 010}

4.3 图是怎么构建的?

这一步我建议咱们走一遍实际流程,不然光记概念很容易忘。说白了就三步:

  1. 计算两两距离:用欧氏距离,遍历所有细胞对。
  2. 阈值判断:距离 < μ 的细胞对,给它们连边。
  3. 填邻接矩阵:把连边关系写进矩阵。

嗯,这里要注意。如果细胞数量上万,两两距离的计算量会很大。我曾经试过 5 万个细胞做全连接距离矩阵,直接卡爆了内存。后来改用scipy.spatial.KDTree做近邻搜索,速度能快几十倍。

在 Squidpy 里,这些步骤已经被封装好了,咱们直接调用:

import squidpy as sq

# 假设 adata 已经包含细胞的空间坐标
# 坐标存储在 adata.obsm['spatial'] 中

# 使用 "radius" 方式构建邻域图,半径设为 100 像素
sq.gr.spatial_neighbors(adata, coord_type='generic', radius=100)

# 结果存储在 adata.obsp['spatial_connectivities']
print(adata.obsp['spatial_connectivities'].shape)  # (N, N) 稀疏矩阵
小技巧:

sq.gr.spatial_neighbors 默认返回稀疏矩阵(scipy.sparse.csr_matrix),不会占用太多内存。我一般设置 n_neighbors=6 做最邻近图,或者用 radius=50 做半径图,看具体数据密度来定。

4.4 邻接矩阵的两种表示

邻接矩阵看着简单,但实际存储的时候分两种形态。一张表说清楚:

类型 表示方式 占用空间 使用场景
稠密矩阵 N×N 全部存下来,每个格子 0 或 1 大(N=10000 就有 1 亿个元素) 小数据或教学演示
稀疏矩阵 只存非零元素的位置和值 小(邻居占比通常 < 1%) 实际项目,毫秒级运算

我习惯用稀疏矩阵。为什么呢?因为空间邻域图天然是稀疏的——一个细胞周围一般就几十个邻居,没必要用大矩阵把空白区域都填上。举个具体的,Squidpy 返回值就是稀疏矩阵,你可以这样看:

# 查看邻居信息
conn = adata.obsp['spatial_connectivities']

# 获取细胞 0 的邻居
neighbors_0 = conn[0].nonzero()[1]
print(f"细胞 0 的邻居数量: {len(neighbors_0)}")
print(f"邻居 ID: {neighbors_0}")

4.5 为什么邻域图这么重要?

邻域图决定了后续所有特征计算的「信息范围」。打个比方,你要算一个细胞周围的免疫细胞丰度,计算过程其实就是「找到该节点的所有邻居,统计邻居的细胞类型分布」。如果图没建对,后面算出来的特征全是错的。

我曾经踩过一个坑:用 radius 方式建图的时候,选了太大半径,导致一个肿瘤细胞的「邻居」里包含了远处几个完全无关的基质细胞。结果富集分析出来说「肿瘤细胞周围基质细胞很多」,实际上人家根本不在一个微环境里。嗯,从那以后我每次建完图,都会先可视化一下:

import scanpy as sc
import matplotlib.pyplot as plt

# 可视化前 100 个细胞的邻域图
sc.pl.spatial(adata, color='cell_type', edges=True, edges_width=0.5, spot_size=10)

可视化面前,你能直观看到连线是不是合理。如果发现一个细胞跟几十个细胞连成一片,那多半是参数设大了。

4.6 本讲知识结构总览

下面这张图把今天讲的串了一遍。从坐标空间到邻域定义,再到图结构和矩阵表示,最后进入 Squidpy 的核心接口:

邻域图知识结构 邻域图概念体系 1. 空间坐标 (x, y) 位置信息 2. 邻域定义 距离阈值 μ 3. 图结构 节点 → 边 → 图 4. 邻接矩阵 稠密 vs 稀疏 5. Squidpy 实现 sq.gr.spatial_neighbors() 6. 可视化验证 sc.pl.spatial(edges=True) 核心路径:坐标 → 定义 → 图 → 矩阵 → Squidpy → 验证 记住:图的质量决定特征质量,一定要先可视化再信任结果

4.7 动手试一下:从坐标到图

纸上谈兵多了,咱们用代码验证一下上面讲的逻辑。下面这个脚本可以自己跑,看看邻域图是怎么一步步生成的:

import numpy as np
import squidpy as sq
import scanpy as sc

# 1. 造点假数据:100 个细胞,随机分布在 500x500 的区域
np.random.seed(42)
n_cells = 100
coords = np.random.rand(n_cells, 2) * 500

# 2. 构造一个假的 AnnData 对象
adata = sc.AnnData(X=np.random.rand(n_cells, 10))
adata.obsm['spatial'] = coords
adata.obs['cell_type'] = np.random.choice(['Tumor', 'Immune', 'Stroma'], n_cells)

# 3. 构建邻域图:半径 50
sq.gr.spatial_neighbors(adata, coord_type='generic', radius=50)

# 4. 看看结果
conn = adata.obsp['spatial_connectivities']
print(f"邻接矩阵大小: {conn.shape}")
print(f"非零元素数量: {conn.nnz}")
print(f"平均每个细胞的邻居数: {conn.nnz / n_cells:.2f}")

# 5. 可视化前面 50 个细胞
sc.pl.spatial(adata, color='cell_type', edges=True, edges_width=0.3, spot_size=15)
注意:

我刚开始用 sq.gr.spatial_neighbors 时,没注意 coord_type 参数。如果是 Visium 数据要用 'visium',一般空间坐标用 'generic'。选错了,距离计算方式会出错,得到的图根本不对。这个坑我已经替大家踩过了,一定检查数据格式。

好了,邻域图的概念和构建就到这里。你已经知道了什么是节点、边、邻接矩阵,也知道了 Squidpy 里怎么一步到位建图。下回分析空间邻域特征的时候,你会意识到:所有高阶特征都建立在这个「谁和谁挨着」的基础上,所以建图这一步,值得多花点心思。