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 输入。

我的小习惯:先保留整数索引版本,用作后续mask生成。这样做能省掉一次重新编码的开销。

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虽然直观,但有两个硬伤:

  1. 维度爆炸:5个碱基就需要5维。要是加上了修饰碱基(比如m6A标记),类别一多,张量立刻膨胀。
  2. 没语义:one-hot向量之间都是正交的,A和C的距离跟A和G的距离一样,这不合理。真实的DNA序列里,相邻碱基之间是有上下文的。

Embedding就是来解决这些问题的。它本质上是一个可学习的查找表。每个碱基(或k-mer)都被映射到一个低维、稠密的向量上。

关键认知:Embedding不是数据预处理,它是模型的一部分。反向传播时它的权重会更新,学到对下游任务(比如基因注释)最有利的表示。

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])
我曾经掉过的坑:填充值用0,结果0正好是'A'的索引。模型把填充部分当作真实的A去学习。所以填充位必须在词表之外或者用单独索引。我用 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 本章知识体系总览

基因组序列张量表示流程 原始FASTA序列(ATCG...) 整数索引映射(0,1,2,3,4) One-hot 编码(5维) Embedding 查找(d维) 序列长度对齐(Padding/Truncate) 固定形状张量 (B, L, C) 或 (B, L) 注意:实际训练常用 "整数索引+Embedding层" 组合

从上图能看明白吧?整个流程是个两叉树。整数索引之后,你可以选one-hot(简单粗暴但浪费显存),也可以选embedding(主流做法)。最后无论选哪条路,都得过长度对齐这一关。

我个人在实际项目中几乎只用 整数索引 + Embedding 组合。One-hot我只在debug或者验证某个概念时用。毕竟,embedding能学到的模式远多于固定编码,而且显存效率高得多。

一句话总结本章:先做整数映射,再选编码方式(推荐embedding),最后对齐长度。
做完这三步,你的序列数据就已经准备好喂给GPU了。