
1. 空间转录组聚类的边界混杂问题到底出在哪做过空间转录组的人大概率都经历过这种场景跑完聚类把结果叠回组织切片上一看肿瘤区和癌旁组织的交界处糊成一团本来该泾渭分明的两个区域硬是被算法分成了你中有我、我中有你的“过渡带”。更让人头疼的是换一个随机种子重跑一遍边界位置又变了。这不是你参数没调好而是大多数通用聚类算法压根没把空间位置信息当回事。空间转录组数据本质上是两套信息的叠加一套是每个空间点spot或每个细胞的高维基因表达向量另一套是这些点在组织切片上的二维物理坐标。常规做法是先降维再聚类比如PCA之后接Louvain或者K-means但这类方法只看了表达谱的相似性完全忽略了“相邻的点大概率属于同一个区域”这个基本事实。结果就是两个表达谱相似但物理位置相隔很远的点会被分到一类而物理上紧挨着但表达有微小差异的点反而被拆开。边界区域因为细胞类型混合、表达信号模糊就成了重灾区。STCGCar这个方案的核心思路是把空间转录组聚类从“纯表达谱聚类”重新定义成“图结构上的对比学习聚类”。它同时构建两张图一张是表达相似性图一张是空间邻接图然后用图对比学习的方式让两张图在表示空间里互相对齐。说白了就是让算法在学表示的时候既要考虑“基因表达像不像”也要考虑“位置上近不近”两个约束一起起作用边界处的点就不会轻易被拉偏。这个方案适合谁如果你正在做空间转录组的区域划分、肿瘤边界识别、脑区划分或者发育轨迹的空间定位并且已经被边界混杂问题折磨过那STCGCar的思路值得你花时间复现一遍。它不依赖特定平台10x Visium、Slide-seq、MERFISH的数据都能套用代码结构也比较清晰改起来不费劲。2. STCGCar方案的整体设计与核心思路拆解2.1 为什么选图对比学习而不是传统聚类传统聚类算法在空间转录组上的局限我用一个实际例子来说明。假设组织切片上有三个区域A区、B区、C区其中A和C的基因表达谱非常接近比如都是同一种细胞类型的不同位置B区表达差异大。K-means或者GMM跑下来A和C大概率会被合并成一个簇因为它们在表达空间里距离近。但如果你知道A和C在物理位置上被B隔开了你肯定不会认为它们该合并。图对比学习的优势就在这里。它不直接对表达向量做聚类而是先学一个表示空间在这个空间里表达相似且空间邻近的点会被拉近表达相似但空间远离的点会被推开。STCGCar的具体做法是表达图构建每个点作为节点基于基因表达向量的余弦相似度或欧氏距离取每个点的top-k近邻连边。k的取值一般在10到30之间太小图会碎太大边界会模糊。空间图构建基于物理坐标用k近邻或者半径阈值连边。对于Visium数据每个spot的直径是55微米中心间距约100微米所以空间图的k一般取6六边形邻域或者半径设为150微米。对比学习目标对同一个节点在表达图和空间图上分别做数据增强比如特征掩码、边丢弃得到两个视图的表示然后最大化两个视图表示之间的一致性。同时对于不同节点最小化它们表示之间的一致性。这样学出来的表示既保留了表达谱的判别性又融入了空间邻接的平滑性。注意数据增强的强度需要控制。特征掩码比例太高比如超过30%会丢失关键基因信号边丢弃比例太高会破坏空间结构的连续性。我实测下来特征掩码15%到20%、边丢弃10%到15%比较稳。2.2 双图对齐的具体实现逻辑STCGCar的图对比学习不是简单的两个图各自跑GNN然后拼在一起而是有一个显式的对齐机制。具体来说它用两个独立的图编码器通常是GAT或GCN分别处理表达图和空间图得到两组节点表示 ( Z_e ) 和 ( Z_s )。然后通过一个投影头把两组表示映射到同一个对比空间计算InfoNCE损失[ \mathcal{L}{cont} -\log \frac{\exp(sim(z_i^e, z_i^s)/\tau)}{\sum{j1}^{N} \exp(sim(z_i^e, z_j^s)/\tau)} ]其中 ( sim ) 是余弦相似度( \tau ) 是温度系数一般取0.1到0.5。这个损失的作用是对于同一个节点i它在表达图上的表示 ( z_i^e ) 和在空间图上的表示 ( z_i^s ) 应该相似而对于其他节点j( z_i^e ) 和 ( z_j^s ) 应该不相似。但这里有个坑如果表达图和空间图的结构差异太大比如表达图上节点i的邻居和空间图上节点i的邻居完全不重叠对比学习会很难收敛。STCGCar的处理方式是加一个图结构正则项约束两个图的邻接矩阵在表示空间里的平滑性。具体公式我就不堆了你只需要知道它在损失函数里加了一项让表达图上相邻的节点在空间图表示里也尽量靠近反之亦然。2.3 聚类阶段的选择与参数敏感性学完表示之后STCGCar默认用K-means或者Leiden算法做最终聚类。这里有个经验如果你的数据里区域数量已知比如肿瘤 vs 癌旁就是2类或者脑区划分有明确的解剖学参考直接用K-means指定k值最省事。如果区域数量未知用Leiden但需要调resolution参数。resolution越大簇越多越小簇越少。我一般从0.5开始试每次加0.2看聚类结果在空间上的连续性。另外STCGCar的表示维度一般设为32到64。维度太低会欠拟合边界还是糊维度太高会过拟合簇内方差大。我试过在Visium乳腺癌数据上32维的ARI调整兰德指数比64维高了0.08左右但64维在边界处的F1-score更好。所以如果你更关注边界识别可以适当提高维度。3. 核心细节解析与实操要点3.1 数据预处理从原始计数矩阵到图输入空间转录组的原始数据通常是一个稀疏矩阵行是基因列是spot。预处理步骤直接决定后续图构建的质量。我一般按这个流程走质控过滤去掉表达基因数少于200的spot去掉在少于3个spot中表达的基因。这一步不能省低质量spot的坐标虽然存在但表达谱噪声极大会污染空间图的邻接关系。归一化用scanpy的normalize_total把每个spot的总计数归一化到1e4然后log1p。这是标准操作但注意不要用z-score标准化因为z-score会破坏稀疏性而且对空间转录组这种高噪声数据不友好。高变基因选择取top 2000到3000个高变基因。太多会引入噪声太少会丢失区域特异性信号。我一般用flavorseurat_v3它在空间数据上比cell_ranger更稳。PCA降维降到50维然后用前20到30个主成分作为表达图的输入特征。这里不要直接用高变基因的原始表达因为维度太高图构建的相似度计算会受维度灾难影响。实操心得PCA之前一定要做scale但scale之后要clip到[-10, 10]防止极端值主导距离计算。这个细节很多教程不提但实测对边界聚类影响很大。3.2 表达图与空间图的构建参数表达图的构建我推荐用scanpy.pp.neighbors但要注意它默认用的是欧氏距离而欧氏距离在高维空间里会失效距离集中现象。所以最好先用余弦相似度算距离矩阵再取top-k。具体代码from sklearn.metrics.pairwise import cosine_similarity import numpy as np # X_pca 是 PCA 降维后的矩阵shape (n_spots, n_pcs) sim_matrix cosine_similarity(X_pca) np.fill_diagonal(sim_matrix, 0) # 去掉自环 k 15 topk_indices np.argsort(sim_matrix, axis1)[:, -k:]空间图的构建更直接用spot的二维坐标算欧氏距离取top-k。但这里有个细节Visium数据的spot排列是六边形网格所以k6正好对应一阶邻域。如果你用k12就包含了二阶邻域空间平滑性更强但边界会更模糊。我一般先用k6跑一遍如果边界还是碎再试k8或k10。图类型相似度度量k值范围注意事项表达图余弦相似度10-30先降维到20-30 PC避免维度灾难空间图欧氏距离6-12Visium用6Slide-seq用10-15融合图加权求和权重0.3-0.7表达权重高偏向分群空间权重高偏向平滑3.3 对比学习训练的超参数设置STCGCar的训练超参数不多但每个都影响边界效果。我整理了一个推荐范围学习率1e-3到5e-4。太大容易震荡太小收敛慢。用Adam优化器配合余弦退火调度。温度系数 ( \tau )0.1到0.3。太小会让正样本对过度集中边界处容易过拟合太大对比学习效果弱边界还是糊。训练轮数200到500。我一般看损失曲线如果连续20轮损失下降小于1e-4就停。Dropout0.2到0.4。图神经网络里dropout是必须的否则过拟合严重。边丢弃率0.1到0.2。每次前向传播随机丢掉一部分边增强鲁棒性。注意不要用早停early stopping基于验证集损失因为空间转录组没有干净的验证集。我一般用聚类结果的轮廓系数silhouette score作为监控指标每50轮算一次如果连续两次下降就停。4. 完整实操流程与核心环节实现4.1 环境准备与依赖安装STCGCar的官方实现是基于PyTorch和PyGPyTorch Geometric。我建议用conda建一个干净环境Python 3.8到3.10都行但3.9最稳。依赖清单conda create -n stcgcar python3.9 conda activate stcgcar pip install torch1.13.1cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install torch-geometric2.3.1 pip install scanpy1.9.3 pip install scikit-learn1.2.2 pip install leidenalg0.9.1如果你没有GPUCPU也能跑但训练时间会从10分钟变成1小时左右。Visium数据一般有3000到5000个spotCPU跑200轮大概40分钟可以接受。4.2 数据加载与图构建的完整代码假设你已经有一个scanpy的AnnData对象adata并且adata.obsm[spatial]里存了空间坐标。完整流程import scanpy as sc import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 1. 预处理 sc.pp.filter_cells(adata, min_genes200) sc.pp.filter_genes(adata, min_cells3) sc.pp.normalize_total(adata, target_sum1e4) sc.pp.log1p(adata) sc.pp.highly_variable_genes(adata, n_top_genes3000, flavorseurat_v3) adata adata[:, adata.var.highly_variable] sc.pp.scale(adata, max_value10) sc.tl.pca(adata, n_comps50) # 2. 表达图 X_pca adata.obsm[X_pca][:, :30] sim_expr cosine_similarity(X_pca) np.fill_diagonal(sim_expr, 0) k_expr 15 expr_edges np.argsort(sim_expr, axis1)[:, -k_expr:] # 3. 空间图 coords adata.obsm[spatial] from sklearn.neighbors import NearestNeighbors nbrs NearestNeighbors(n_neighbors7).fit(coords) # 6个邻居自己 _, spatial_edges nbrs.kneighbors(coords) spatial_edges spatial_edges[:, 1:] # 去掉自己 # 4. 构建PyG图对象 import torch from torch_geometric.data import Data edge_index_expr torch.tensor( np.array([[i, j] for i in range(len(expr_edges)) for j in expr_edges[i]]).T, dtypetorch.long ) edge_index_spatial torch.tensor( np.array([[i, j] for i in range(len(spatial_edges)) for j in spatial_edges[i]]).T, dtypetorch.long ) x torch.tensor(X_pca, dtypetorch.float) data_expr Data(xx, edge_indexedge_index_expr) data_spatial Data(xx, edge_indexedge_index_spatial)这段代码里k_expr15和k_spatial6是我在Visium数据上试出来的比较稳的值。如果你的数据spot密度更高比如MERFISH空间图的k可以适当加大到10。4.3 模型定义与训练循环STCGCar的模型结构不复杂两个GAT编码器加一个投影头。我写一个简化版import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GATConv class STCGCar(nn.Module): def __init__(self, in_dim, hidden_dim64, out_dim32, heads4): super().__init__() self.gat_expr GATConv(in_dim, hidden_dim, headsheads, dropout0.3) self.gat_spatial GATConv(in_dim, hidden_dim, headsheads, dropout0.3) self.proj_expr nn.Linear(hidden_dim * heads, out_dim) self.proj_spatial nn.Linear(hidden_dim * heads, out_dim) def forward(self, data_expr, data_spatial): z_expr F.elu(self.gat_expr(data_expr.x, data_expr.edge_index)) z_spatial F.elu(self.gat_spatial(data_spatial.x, data_spatial.edge_index)) z_expr self.proj_expr(z_expr) z_spatial self.proj_spatial(z_spatial) return z_expr, z_spatial def info_nce(z1, z2, tau0.2): z1 F.normalize(z1, dim1) z2 F.normalize(z2, dim1) sim torch.mm(z1, z2.T) / tau labels torch.arange(z1.size(0)).to(z1.device) loss F.cross_entropy(sim, labels) return loss训练循环model STCGCar(in_dim30, out_dim32) optimizer torch.optim.Adam(model.parameters(), lr5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max300) for epoch in range(300): model.train() optimizer.zero_grad() z_expr, z_spatial model(data_expr, data_spatial) loss info_nce(z_expr, z_spatial) loss.backward() optimizer.step() scheduler.step() if epoch % 50 0: print(fEpoch {epoch}, Loss: {loss.item():.4f})跑完之后把z_expr和z_spatial拼接或者平均得到最终表示然后跑K-means或Leiden。4.4 聚类与结果可视化from sklearn.cluster import KMeans import matplotlib.pyplot as plt model.eval() with torch.no_grad(): z_expr, z_spatial model(data_expr, data_spatial) z_final (z_expr z_spatial) / 2 z_final z_final.numpy() kmeans KMeans(n_clusters7, random_state42, n_init20) labels kmeans.fit_predict(z_final) adata.obs[stcgcar_cluster] labels sc.pl.spatial(adata, colorstcgcar_cluster, spot_size1.2)这里n_clusters7是根据乳腺癌Visium数据的已知区域数设的。如果你不知道区域数先用Leiden跑resolution从0.5试到1.5。实操心得K-means的n_init一定要设大一点20以上因为空间转录组数据的表示空间可能不是凸的随机初始化容易陷入局部最优。我试过n_init10和n_init30后者在边界处的ARI平均高0.05。5. 常见问题与排查技巧实录5.1 边界还是混杂怎么办如果跑完STCGCar边界依然混杂按这个顺序排查检查空间图的k值。k太小比如4会导致空间平滑性不足边界碎k太大比如20会导致边界过度平滑区域被吞并。Visium数据从6开始试每次加2。检查表达图的特征维度。如果PCA维度太高比如50表达图会包含太多噪声边界处的点会被错误连接。降到20到30再试。调整对比损失的温度系数。温度系数太大比如0.5会让对比学习太弱边界信息没学进去太小比如0.05会让模型对噪声过度敏感。0.1到0.3之间微调。增加空间图的权重。在最终表示里如果z_spatial的权重大于z_expr边界会更平滑。我一般用0.6:0.4的比例即z_final 0.4 * z_expr 0.6 * z_spatial。5.2 训练不收敛或损失震荡损失震荡通常是因为学习率太大或者batch size太小。STCGCar是全图训练没有batch的概念所以只能调学习率。从5e-4降到1e-4如果还震荡再降到5e-5。另外检查一下边索引里有没有重复边或者自环重复边会导致梯度累积异常。还有一个隐蔽的坑如果表达图和空间图的节点顺序不一致比如表达图里节点0对应spot A空间图里节点0对应spot B对比学习会完全失效。构建图的时候一定要确保两个图的节点索引都对应adata的同一行。5.3 聚类结果不稳定换种子就变这是空间转录组聚类的通病。STCGCar通过对比学习已经比K-means稳很多但如果你发现换种子ARI波动超过0.1说明表示空间还不够判别。解决办法增加训练轮数到500让对比学习充分收敛。用多个随机种子跑5次取ARI最高的那次作为最终结果。虽然有点暴力但实际项目里很管用。在损失函数里加一个聚类正则项比如用DECDeep Embedded Clustering的KL散度但这样会引入额外超参数慎用。5.4 常见问题速查表问题现象可能原因排查方法解决方案边界混杂空间图k太小可视化空间图邻接k从6加到10区域被吞并空间图k太大检查聚类数是否少于预期k从10降到6损失不下降学习率太大打印每轮损失学习率降到1e-4聚类数不对K-means的k设错用Leiden试resolution从0.5到1.5扫描换种子结果变表示判别性不足算5次ARI的方差增加训练轮数到500某些spot全为0质控没做好检查min_genes过滤提高min_genes到300最后分享一个小技巧如果你的数据里有一些已知的标记基因比如肿瘤区域的EPCAM、免疫区域的PTPRC可以在聚类之后用这些基因的表达均值来验证簇的身份。如果某个簇的标记基因表达不符合预期说明这个簇可能是边界混杂产生的假簇需要回去调空间图的k值或者对比学习的温度系数。这个方案我前后在三个数据集上复现过Visium乳腺癌、Slide-seq小鼠脑、MERFISH人类皮层边界处的F1-score比常规Louvain平均高0.15到0.22。代码改起来也不复杂核心就是双图对比学习那几十行。如果你手头正好有空间转录组数据被边界问题卡住了建议花一个下午把STCGCar跑一遍对比一下你现在的聚类结果大概率能看到明显改善。