第4章 节点特征工程:原子特征、化学键特征、基因表达特征、药物性质特征的编码与标准化

节点特征工程这个活儿,说白了就是决定图神经网络能“看到”什么信息。你想想看,如果喂给模型的特征是错的或者噪声大,后面GNN再厉害也白搭。在我做药物批次效应检测项目时,这个问题被放大了很多倍——不同批次的实验条件不一样,特征不统一,模型直接“傻眼”。

这一章我们就专门讲特征怎么编码、怎么标准化。我会把原子、化学键、基因表达、药物性质这四类特征全部捋一遍。

4.1 原子特征编码

原子是图里的节点。每个节点到底带哪些属性?我个人习惯分成三类:

  • 离散类别型——元素种类、杂化类型、环归属
  • 数值连续型——原子质量、电负性、部分电荷
  • 位点索引型——原子在分子中的序号、是否属于特定官能团

离散类别型特征,我用One-hot编码。比如元素种类,常见的有C、N、O、S、F,那就搞一个5维的One-hot向量。但要注意,有些数据集中会出现稀有元素,比如Se或P。我建议把稀有元素归到“其他”类别里,不然特征维度会失控。

关键原则:所有批次的原子特征维度必须一致。如果批次A有Se原子而批次B没有,那“其他”类就是你的安全垫。

import numpy as np

# 元素种类映射
ELEM_MAP = {'C':0, 'N':1, 'O':2, 'S':3, 'F':4, 'OTHER':5}
def encode_element(elem):
    idx = ELEM_MAP.get(elem, ELEM_MAP['OTHER'])
    vec = np.zeros(6, dtype=np.float32)
    vec[idx] = 1.0
    return vec

# 连续型特征标准化
def normalize_atom_mass(mass, mean=12.01, std=6.0):
    return (mass - mean) / std

4.2 化学键特征编码

化学键是图的边。这里有一个很容易被忽略的事:键的类型在批次之间可能存在差异。比如一个化合物在批次A被测为单键,批次B因为pH不同变成了双键——这种情况在药物研发里并不少见。

我也踩过这个坑。我建议大家编码化学键特征时,不光编码键的类型,还要加上键的置信度或者权重。

键类型 One-hot维度 附加特征
单键 [1,0,0,0] 键长、是否可旋转
双键 [0,1,0,0] 键长、是否共轭
三键 [0,0,1,0] 键长、是否芳香族
芳香键 [0,0,0,1] 环大小、离域电子数

我的经验:化学键特征千万不能只用One-hot。加上键长和可旋转性之后,GNN对分子构象变化的鲁棒性明显提升。特别是在批次效应检测中,构象变化经常是批次差异的来源之一。

4.3 基因表达特征融合

基因表达数据是药物批次效应检测里最特殊的特征。它不是分子本身的属性,而是分子与生物系统相互作用的产物。也就是说,同一个药物在不同细胞系或者不同批次里,基因表达谱可能完全不同。

怎么把几十个甚至上万个基因的表达值融合到节点特征里?总不能每个原子都拼接一堆基因表达吧?

我常用的做法是这样:

  1. 先降维——用PCA或自编码器把高维表达数据压缩到16或32维。
  2. 然后拼接一个全局向量——将降维后的表达向量拼接到每个原子的特征末尾。
  3. 最后做逐原子归一化——保证每个原子受到的“基因信号”尺度一致。
from sklearn.decomposition import PCA

# 假设 gene_matrix 是 (num_samples, num_genes) 的表达矩阵
pca = PCA(n_components=16)
gene_latent = pca.fit_transform(gene_matrix)

# 拼接到每个原子特征上
def combine_features(atom_feat, gene_latent_vec):
    # 广播:每个原子都加上相同的基因特征
    atom_feat_aug = np.hstack([atom_feat, 
                               np.tile(gene_latent_vec, 
                                       (atom_feat.shape[0], 1))])
    return atom_feat_aug

注意:不同批次之间的表达谱差异可能很大。我建议在训练前对每个批次单独做一次标准化,然后再用批次的对照样本做校正。否则GNN可能会学到错误的关联。

4.4 药物性质特征标准化

药物性质特征包括logP、分子量、氢键供体/受体数、可旋转键数等。这些特征通常是全局的——即一个分子只有一个值。所以它们不能直接作为节点特征,得做一下处理。

我一般是这样操作的:

  • 把全局性质特征复制到所有原子节点上。
  • 或者,把全局性质拼到“虚拟节点”上,然后让GNN自己学怎么传播。

第二种方式更灵活,我在实际项目里也验证过,效果更好。但批次效应检测时,一定要注意:药物性质本身在批次之间可能会有测量误差。比如同一个化合物,不同实验室测出来的logP可能差0.3个单位。

标准化方法我推荐用Z-score,按批次来做:

# 按批次做Z-score标准化
def batch_normalize(properties, batch_ids):
    unique_batches = np.unique(batch_ids)
    normalized = np.zeros_like(properties, dtype=np.float32)
    for batch in unique_batches:
        mask = batch_ids == batch
        batch_data = properties[mask]
        mean = np.mean(batch_data, axis=0)
        std = np.std(batch_data, axis=0) + 1e-8
        normalized[mask] = (batch_data - mean) / std
    return normalized

避坑指南:千万不要把所有批次的数据一起做标准化。我曾经这么干过,结果模型完全不收敛。因为不同批次的均值和方差都不一样,混在一起标准化后,真正的批次差异反而被抹掉了。最后,GNN检测不到的批次效应,等于白做。

4.5 特征拼接与最终特征向量

所有特征都编好码、标准化之后,最后一步就是把它们拼成一个完整的特征向量。我通常会按这个顺序拼接:

  1. [原子类别One-hot] —— 比如6维
  2. [原子数值特征] —— 比如质量、电荷、范德华半径,3维
  3. [基因表达latent] —— 16维
  4. [药物全局性质] —— 比如logP、MW、HBD、HBA,4维

最后得到的是一个6+3+16+4=29维的节点特征向量。这个向量就是GNN每个节点看到的输入。边特征的处理方法类似,只不过维度会少一些,通常8到12维就够了。

小技巧:特征维度不要超过64。维度过高会导致GNN过拟合,而且训练速度会变慢。如果特征本身就很多,先降维再拼接。

这就是节点特征工程的核心流程。说白了,你就是在做一件事:把异构的、多源的数据统一成一组数字,然后让GNN去学这些数字背后的规律。

节点特征工程完整流程 原子信息 化学键信息 基因表达 药物性质 One-hot + 数值标准化 类别编码 + 键长特征 PCA / Autoencoder 降维 Z-score 按批次标准化 特征拼接 → 29维节点特征向量 [原子向量:9维] + [基因向量:16维] + [性质向量:4维] 输入 GNN 模型 关键:所有特征必须按批次独立标准化,且最终维度在所有批次中保持一致

特征工程做好了,后面GNN的训练和批次效应检测才能跑得顺。嗯,这里要注意:特征的质量比数量重要得多。多花时间清洗和标准化,远比你堆100维的噪声特征有效。