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 图是怎么构建的?
这一步我建议咱们走一遍实际流程,不然光记概念很容易忘。说白了就三步:
- 计算两两距离:用欧氏距离,遍历所有细胞对。
- 阈值判断:距离 < μ 的细胞对,给它们连边。
- 填邻接矩阵:把连边关系写进矩阵。
嗯,这里要注意。如果细胞数量上万,两两距离的计算量会很大。我曾经试过 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 的核心接口:
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 里怎么一步到位建图。下回分析空间邻域特征的时候,你会意识到:所有高阶特征都建立在这个「谁和谁挨着」的基础上,所以建图这一步,值得多花点心思。