4、基因组序列数据的张量表示:one-hot编码、embedding、序列长度对齐
好,咱们直接切入正题。上一章我们把原始FASTA数据读进来了,但GPU可不认识ATCG字符。它只吃数字,准确来讲是张量。所以这一章的核心任务就一个:怎么把一堆字符串,变成GPU能算的东西。
这个过程说白了就三步:先做索引映射,再考虑要不要做向量化,最后把长短不一的序列“拍齐”。每一步都有坑,我踩过不少,今天一并讲清楚。
4.1 从字符到整数:先放下one-hot
很多人一上来就搞one-hot编码。其实不用。GPU处理密集计算时,整数索引的张量比高维one-hot张量高效十倍不止。我习惯先做一步简单的映射:
BASE2ID = {'A': 0, 'C': 1, 'G': 2, 'T': 3, 'N': 4}
def seq_to_ids(seq):
return [BASE2ID.get(b, 4) for b in seq.upper()]
ids = seq_to_ids("ATCGN")
print(ids) # 输出 [0, 3, 1, 2, 4]
你看,就这样。一个字符变一个整数。但这只是过渡,因为很多模型(尤其是CNN)需要的是 one-hot 输入。
one-hot编码的正确姿势
当你真的需要one-hot时,别手写for循环。那太慢了。用 torch.nn.functional.one_hot 或 torch.scatter 搞定。我个人推荐后者,因为它更灵活:
import torch
def ids_to_onehot(batch_ids, num_classes=5):
batch_size, seq_len = batch_ids.shape
onehot = torch.zeros(batch_size, seq_len, num_classes, device=batch_ids.device)
onehot.scatter_(2, batch_ids.unsqueeze(-1), 1.0)
return onehot
ids_tensor = torch.tensor([[0, 3, 1, 2, 4]])
onehot = ids_to_onehot(ids_tensor)
print(onehot.shape) # torch.Size([1, 5, 5])
避坑提醒:我曾经在 scatter_ 时用错了维度,结果生成了全零张量。嗯,batch维度别搞混,scatter_(2, ...) 里的2就是最后一个维度(类别维)。
4.2 为什么我们需要embedding
one-hot虽然直观,但有两个硬伤:
- 维度爆炸:5个碱基就需要5维。要是加上了修饰碱基(比如m6A标记),类别一多,张量立刻膨胀。
- 没语义:one-hot向量之间都是正交的,A和C的距离跟A和G的距离一样,这不合理。真实的DNA序列里,相邻碱基之间是有上下文的。
Embedding就是来解决这些问题的。它本质上是一个可学习的查找表。每个碱基(或k-mer)都被映射到一个低维、稠密的向量上。
PyTorch实现示例
vocab_size = 5 # A C G T N
embed_dim = 64 # 嵌入维度
embedding_layer = torch.nn.Embedding(
num_embeddings=vocab_size,
embedding_dim=embed_dim,
padding_idx=4 # 把N的索引设为填充位
)
# 输入:整数索引 (batch, seq_len)
x = torch.randint(0, 5, (2, 100))
embedded = embedding_layer(x)
print(embedded.shape) # torch.Size([2, 100, 64])
你想想看,一个原本5维的one-hot,变成64维可学习的向量,信息密度大了多少?
在Transformer架构的基因组模型里,通常还会把位置编码叠加到embedding上。这点我后面那章讲注意力机制时会细说。
4.3 序列长度对齐——不齐就没法批量算
GPU最怕的就是“长短不一”。它必须用一个固定形状的张量来并行计算。所以,我们得把批里的序列拼成统一的长度。
常用的策略是尾部填充。拿最长的序列当标准,短的就在后面补占位符(比如 N 或 <PAD>)。
来,看代码:
def pad_sequences(seqs, max_len=None, pad_value=4):
if max_len is None:
max_len = max(len(s) for s in seqs)
padded = []
for s in seqs:
ids = [BASE2ID.get(b, 4) for b in s.upper()]
if len(ids) < max_len:
ids += [pad_value] * (max_len - len(ids))
else:
ids = ids[:max_len]
padded.append(ids)
return torch.tensor(padded, dtype=torch.long)
batch_seqs = ["ATCG", "GCTA", "A" * 200] # 长短不一
padded_tensor = pad_sequences(batch_seqs)
print(padded_tensor.shape) # torch.Size([3, 200])
padding_idx=4(N)作为填充,同时在Embedding层设 padding_idx,保证填充位置的梯度不会回传。
长度统计的工程建议
很多初学者一上来就设 max_len=10000,结果显存瞬间爆炸。我建议大家先统计一下数据集的序列长度分布:
| 百分位 | 长度(bp) | 建议 |
|---|---|---|
| 50% (中位数) | 1200 | 大部分序列在这附近 |
| 90% | 4800 | 可以截断到90% |
| 95% | 7500 | 截断会丢掉少量信息 |
| 99% | 12000 | 不推荐,显存开销太大 |
我个人习惯:选90%分位长度作为 max_len,剩下的长序列截断。这样能覆盖绝大部分数据,又不会把GPU显存撑爆。
4.4 本章知识体系总览
从上图能看明白吧?整个流程是个两叉树。整数索引之后,你可以选one-hot(简单粗暴但浪费显存),也可以选embedding(主流做法)。最后无论选哪条路,都得过长度对齐这一关。
我个人在实际项目中几乎只用 整数索引 + Embedding 组合。One-hot我只在debug或者验证某个概念时用。毕竟,embedding能学到的模式远多于固定编码,而且显存效率高得多。
做完这三步,你的序列数据就已经准备好喂给GPU了。