☰
从文本到[N,T]:Embedding输入向量构建与实战避坑
2026/10/26 6:50:21 网站建设 项目流程

1. 文本进模型之前的这道坎:为什么一定要变成 [N,T]

刚接触自然语言处理的人,十个里有八个会在同一个地方卡住:手里拿着一堆句子,兴冲冲地往模型里塞,结果第一行代码就报错,提示类型不匹配、维度不对、或者索引越界。原因说穿了很简单——神经网络从头到尾只认数字,而且只认形状规整的数字矩阵。文本是变长的字符序列,模型要的是固定形状的张量,中间这层翻译工作,就是把文本变成 embedding 输入向量 [N,T]这件事的全部内容。

这个标题里藏了三个信息。embedding说的是把离散的符号映射成连续的稠密向量,让语义相近的东西在向量空间里也靠得近;输入向量说的是这一步的产物是喂给下游网络的第一层表示;[N,T]说的是形状,N 是批大小(batch size),T 是序列长度(time steps),这两个维度决定了后面所有层的计算图长什么样。很多教程只讲"用某个现成工具一行代码搞定",但实际做项目时,词表怎么建、长度怎么对齐、padding 的 id 该不该参与梯度、维度定多少不炸显存,这些细节才是真正决定项目能不能跑起来的东西。

这篇内容适合三类人看:刚入门、知道 attention 公式但没亲手搭过输入管线的;做工程落地、被线上推理的形状问题折磨过的;以及带别人做项目、需要把这块讲清楚的。我会从设计思路讲到具体代码,参数怎么算、坑在哪里、报错怎么定位,尽量一次讲透。文中不依赖任何特定平台,你用哪套框架都能对照着改。

2. 整体设计思路:一条三段式的流水线

2.1 从一句话到一张表,中间发生了什么

先把整条链路在脑子里跑一遍。输入是一句"今天天气不错",输出是一个形状为[N,T,D]的浮点张量,其中 D 是 embedding 维度。中间要经过三个不可跳过的环节:切分、映射、对齐。

切分是把句子拆成模型词表里的最小单位,可能是一个字、一个词、或者一个子词片段;映射是把这个单位换成词表里的整数编号,得到一个变长的整数列表;对齐是把一批长短不一的整数列表补齐或截断成统一的长度 T,再堆叠成[N,T]的整数矩阵。这三步做完,才轮到 embedding 层做查表,把每个整数换成一行 D 维向量,最终得到[N,T,D]。

为什么要拆成三段而不是一步到位?因为这三段的变化频率完全不同。切分规则和词表一旦确定,通常整个训练周期都不动;映射是纯查表,逻辑最稳定;而对齐策略经常要根据数据长度分布调,比如发现 95% 的样本都在 60 个 token 以内,就可以把 T 从 512 降到 64,显存直接省一大截。把它们分开写,调优的时候只动一段,不会牵一发动全身。

我在实际项目里见过反面例子:有人把切分、映射、padding 全塞进一个函数,后来换了个领域的数据,词表要重建,结果整个函数全得推倒重写。这种耦合带来的返工成本,比一开始多写三十行代码高得多。

2.2 N 和 T 这两个字母,决定了后面的一切

N 是并行度,T 是信息密度。N 越大,一次处理的样本越多,GPU 利用率越高,但显存占用线性增长;T 越长,单条样本保留的上下文越完整,但计算量按平方增长——注意力机制的复杂度是 O(T²),T 从 128 翻到 256,注意力那部分的计算量就是四倍。

这两个值不是随便定的。N 受显存限制,通常从 16、32 开始试,跑一次看显存占用,再往上加。T 受数据分布限制,得先统计语料里长度的分位数,取 95 分位或 99 分位作为截断阈值,剩下的长尾样本直接截掉或者分段处理。取 99 分位意味着 1% 的样本会丢失尾巴信息,如果你的任务是长文档摘要,这个损失可能不可接受;如果是短文本分类,取 95 分位基本没影响。

还有一个容易被忽略的点:N 和 T 的乘积决定了这一个 batch 里所有序列的总 token 数,很多算子(比如 LayerNorm 的变体、序列池化)是对这个总数敏感的。定的 T 太大而实际句子普遍很短,等于让模型花大量算力在 padding 位上做无用功,这时候用动态 padding(每个 batch 按该 batch 最长样本定 T)能把训练速度提上来不少。

2.3 为什么不用 one-hot,非要 embedding

最朴素的映射方式是 one-hot:词表 3 万,每个词就是一个 3 万维的向量,只有一位是 1。这种表示的问题有两个。一是维度灾难,词表翻倍向量长度就翻倍,而且极度稀疏,矩阵乘法效率极低;二是语义零信息,任意两个词的 one-hot 向量内积都是 0,模型没法从表示本身知道"开心"和"高兴"有关系,只能靠大量数据硬学。

embedding 的做法是给每个词分配一个低维稠密向量,比如 256 维,这 256 个数在训练中一起更新。好处是语义相近的词会在优化过程中被推到相近的位置,而且维度可控、稠密高效。从数学上看,nn.Embedding就是一个形状为[V,D]的矩阵,前向传播就是按索引取行——它不是魔法,就是一次索引查找。

理解了这一点,很多问题就好解释了:为什么padding_idx=0有效?因为它在反向传播时跳过第 0 行的梯度更新,让填充位永远保持零向量;为什么词表要预留特殊符号?因为它们是这个矩阵里的固定行;为什么 embedding 维度不能太小?因为[V,D]的容量要能承载语义区分的需求,D 太小所有词挤在一起,区分度不够。

3. 第一步:分词与词表构建的取舍

3.1 三种切分粒度,选错了后面全是坑

中文和英文的处理思路差别很大,但本质都是决定切分粒度。常见三种选择,各有明确的适用边界。

粒度做法优点缺点适用场景
字/字符级一个汉字或一个字母算一个单位词表极小(几千),绝无未登录词序列变长,单字语义弱数据量小、领域特殊、拼写噪声大
词级按分词工具切成词序列短,语义单元完整词表大,未登录词多,分词错误会传导领域固定、有成熟分词词典
子词级按统计规律切常见片段兼顾词表大小与覆盖率实现复杂,可读性差通用场景、多语言、大规模预训练

字符级最省心,词表小到几千,永远不会遇到"这个词没见过"的问题,代价是同样一句话的序列长度翻两三倍,注意力开销随之上涨。词级最符合人的直觉,但中文分词本身就带歧义,"南京市长江大桥"这种经典例子,切错一次后面的表示全歪,而且新词、人名、网络用语层出不穷,未登录词永远处理不完。子词级是当下的主流折中,高频词整体保留,低频词拆成常见片段,词表控制在几万,覆盖率接近 100%。

我个人的建议:数据量少于十万条、领域又比较垂直的时候,直接上字符级,省掉分词器调参的精力,把时间花在模型结构上更值。数据量上来了、要做通用能力,再考虑子词方案。

3.2 词表大小和频率阈值怎么定

词表大小 V 直接决定 embedding 层的参数量:参数量 = V × D。V=30000、D=256 时是 768 万参数,fp32 下约 30MB,还能接受;V 涨到 20 万,同样 D 就是 5000 万参数、200MB,一个小模型的参数量可能全被 embedding 吃掉了。

实践中常用频率阈值来裁剪:统计全语料的词频,出现次数低于min_freq的直接丢掉,映射成统一的未登录标记。min_freq取 2 到 5 比较常见。取 1 意味着所有只出现一次的词都进词表,这些词在训练中只被更新一次,学不到任何有意义的表示,纯粹占位置;取太高又会把一批中等频率的词误伤。

这里有个实操细节:裁剪要在划分训练集之后做,只用训练集统计词频。拿全量数据建词表看着覆盖率更高,实际上是让模型提前见到了验证集和测试集的词汇分布,评估结果会虚高。这个泄漏很隐蔽,因为它不影响训练过程的任何指标,只在最终评估时给你一个偏乐观的数字。

词表构建的骨架代码大概是这样:

from collections import Counter def build_vocab(train_lines, min_freq=2, max_size=30000): counter = Counter() for line in train_lines: counter.update(tokenize(line)) # 按频率降序,频率相同按字典序保证可复现 items = sorted(counter.items(), key=lambda x: (-x[1], x[0])) vocab = {"<pad>": 0, "<unk>": 1, "<bos>": 2, "<eos>": 3} for tok, freq in items: if freq < min_freq: continue if len(vocab) >= max_size: break vocab[tok] = len(vocab) return vocab

注意排序里加了字典序作为次级排序键。不加的话,频率相同的词在不同机器上的顺序可能不同,词表 id 就变了,同一个 checkpoint 换台机器加载,所有 id 全部错位,输出全是乱码。这种问题排查起来极其痛苦,加一行排序就能避免。

3.3 特殊符号必须一开始就预留好

<pad>、<unk>、<bos>、<eos>这四个是基础配置,id 最好固定成 0、1、2、3,别让它们参与频率排序。理由很实际:padding 位的 id 会被下游的 mask 逻辑引用,如果它的 id 随词表变化,所有硬编码 0 的地方都得跟着改,容易出错。

<unk>的处理尤其要想清楚。它承担了所有未登录词的表示,如果训练语料里未登录词占比很高(比如 15% 以上),说明词表建得不合理,要么min_freq太高,要么切分粒度太粗,这时候该回头调整,而不是硬扛。我见过一个项目,测试集上效果怎么都上不去,最后发现测试集是另一个领域的数据,未登录词占比接近 30%,模型有一小半输入全是同一个向量,等于信息已经丢了一大半。

特殊符号的预留数量要留点余量。后面如果要加<mask>(掩码语言建模用)、<sep>(句子对任务用),得有位置放。预留 10 个左右的槽位是常见做法,多占十行参数量可以忽略,省得后面改词表重新训练。

4. 第二步:ID 映射与批量对齐

4.1 从变长列表到规整矩形

单条样本映射完是一个变长的整数列表,比如[45, 102, 7, 33]。要变成[N,T],得先确定这个 batch 的 T 是多少,再逐条补齐或截断。

补齐用<pad>的 id(通常是 0),截断要讲究策略:保留头部还是保留尾部。大部分场景保留头部,因为关键信息通常在开头;但如果任务是判断文本的情感倾向,结论往往在最后一句,那就得保留尾部。还有一种折中,头尾各留一半,中间截掉,适合长文档。

def encode_batch(texts, vocab, max_len=128, pad_id=0, unk_id=1, trunc="head"): batch = [] for text in texts: ids = [vocab.get(tok, unk_id) for tok in tokenize(text)] if len(ids) > max_len: if trunc == "head": ids = ids[:max_len] elif trunc == "tail": ids = ids[-max_len:] else: half = max_len // 2 ids = ids[:half] + ids[-(max_len - half):] ids = ids + [pad_id] * (max_len - len(ids)) batch.append(ids) return batch # 形状 [N, T]

这段代码有两个点值得说。一是vocab.get(tok, unk_id)的兜底,没有它,遇到未登录词直接 KeyError,而线上环境永远会有你没见过的输入。二是 padding 的长度必须补到max_len,不能补到当前 batch 最长,除非你确定下游能处理动态长度——静态形状对导出的计算图更友好,动态形状在多卡训练时会触发重新编译,反而更慢。

4.2 padding 位不是摆设,mask 必须跟着走

padding 补完,形状是对了,但信息是脏的:那些补出来的 0 在注意力计算里会参与加权,让模型误以为句子里有一堆真实存在的词。所以只要有 padding,就必须有 mask。

标准的做法是生成一个[N,T]的布尔矩阵,真实 token 位置为 1,padding 位置为 0,然后把它传给注意力的 mask 参数或者手动做加法掩码。如果模型里有多层注意力,这个 mask 要逐层传下去,不能只在第一层用。

import torch def make_padding_mask(input_ids, pad_id=0): # [N, T] -> [N, 1, 1, T],方便广播到注意力分数上 mask = (input_ids != pad_id).unsqueeze(1).unsqueeze(1) return mask

还有一个隐蔽的坑:序列池化的时候要用 mask 加权平均,不能直接 mean。直接对[N,T,D]在 T 维求平均,padding 的零向量虽然不影响分子,但分母算的是 T 而不是真实长度,短句的表示会被稀释。正确做法是(x * mask.unsqueeze(-1)).sum(1) / mask.sum(1).clamp(min=1)。这个细节不注意,分类任务在长短句混杂的数据集上会莫名其妙地掉点。

另外,padding_idx参数一定要在 embedding 层里指定。它有两个作用:初始时把第 pad_id 行置零,并且反向传播时跳过这一行的梯度。不指定的话,padding 行会随着训练被更新成非零值,mask 逻辑再严密也挡不住它已经在表示里掺了东西。

4.3 先看长度分布,再定 T

T 定多少,不该拍脑袋。拿训练集跑一遍长度统计,看分位数,这是最靠谱的办法。

import numpy as np lens = np.array([len(tokenize(t)) for t in train_lines]) for p in [50, 90, 95, 99, 99.9]: print(f"P{p}: {np.percentile(lens, p):.0f}") print("mean:", lens.mean(), "max:", lens.max())

如果 P95 是 48、P99 是 112、最大是 870,那 T 取 128 是个合理的平衡点,覆盖 99% 以上的样本,代价可控。剩下那不到 1% 的超长样本,可以选择分块后分别编码再融合,或者直接截断。

这里有个经验:训练时的 T 和推理时的 T 可以不一样。训练为了吞吐,T 压到 128;推理时如果显存宽裕、任务对完整上下文敏感,可以放到 512。但要注意位置编码如果用的是可学习参数,训练时没见过的位置就没有对应的编码向量,效果会打折。用可学习位置编码的话,训练和推理的 T 上限必须一致。

4.4 动态 padding 与分桶,两个提速手段

静态 padding 到固定 T,简单但浪费。假设 T=128,实际样本平均只有 31 个 token,意味着 76% 的计算都在处理填充位。两个优化方向:

动态 padding:每个 batch 内取该 batch 的最大长度作为 T,而不是全局固定的 T。配合torch.nn.utils.rnn.pad_sequence之类的工具,实现不难。代价是每个 batch 的形状不同,可能触发算子重编译,在编译型框架上要评估收益。

长度分桶:把长度相近的样本分到一个 batch 里。这样动态 padding 之后每个 batch 的 T 都接近真实长度,浪费最小。实现上就是在构建 dataloader 时按长度排序,然后按顺序切 batch,再在 batch 内打乱。注意不要在全局打乱,否则分桶就失效了。

我用分桶在一个中文分类任务上测过,同样的步数,训练时间从 42 分钟降到 26 分钟,接近 40% 的提升,效果指标没有变化。这个收益是白捡的,值得花半小时改一下 dataloader。

5. 第三步:查表、维度与位置信息

5.1 embedding 层的本质是一次索引

拿到[N,T]的整数矩阵,就可以进 embedding 层了。用框架的现成实现最省事:

import torch.nn as nn V, D = 30000, 256 embed = nn.Embedding(num_embeddings=V, embedding_dim=D, padding_idx=0) x = torch.tensor(batch_ids, dtype=torch.long) # [N, T] out = embed(x) # [N, T, D]

就三行。但要知道这三行背后发生了什么:embed.weight是一个[V,D]的可训练矩阵,前向时按x里的每个整数去取对应的行。x的形状是[N,T],输出的每一维就在末尾加一个 D,变成[N,T,D]。所以如果x是三维的[B,N,T],输出就是[B,N,T,D],形状规则是索引张量的形状 + 一个 D 维。

这也解释了最常见的那个报错:Expected tensor for argument #1 'indices' to have scalar type Long。embedding 层只接受整型索引,你传了浮点数它就拒绝。数据从文件读进来默认可能是 float,转成long就行。

还有一个:index out of range。索引超过 V-1 就报这个。通常是词表构建后又被改动过,或者用了别的模型的词表来编码但没对齐 id。查的时候打印一下max(batch_ids)和V对比,一眼就能定位。

5.2 维度 D 怎么选,参数量和显存怎么估

D 的选择是个平衡。太小表示能力不足,几万个词挤在几十维空间里区分不开;太大参数暴涨,而且训练数据不够时容易过拟合。

DV=30000 时参数量说明
64192 万小模型、数据量少时的保守选择
128384 万轻量任务的常用档位
256768 万通用场景的稳妥选择
5121536 万配合更大模型使用,需要足够数据
7682304 万大模型标配,embedding 占比不能太大

估算显存更实用的方式是看激活值。[N,T,D]的张量,N=32、T=128、D=256 时是 104 万个浮点数,fp32 下 4MB,fp16 下 2MB。看着不大,但一层网络里往往同时存在好几个这样的中间张量(查询、键、值、注意力输出、残差、归一化中间量),实际占用要乘上好几倍。粗估的话,用N × T × D × 4 字节 × 层数系数来算量级。

有个原则值得记住:embedding 层的参数量不要超过总参数量的三成。超过了说明词表相对于模型容量太大了,要么砍词表,要么降 D。因为 embedding 层的每个参数只能被对应词的出现样本更新,频率低的词更新次数极少,参数虽然多但大部分是"僵"的,占容量不出力。

5.3 只有词向量不够,位置信息得单独补

embedding 查完表得到[N,T,D],但这里有个致命问题:这个表示对词序完全不敏感。打乱句子里的词,得到的向量集合是一样的。注意力机制本身也是置换不变的,不补位置信息,模型就分不清"狗咬人"和"人咬狗"。

位置编码分两类。可学习的:再建一个形状为[T_max, D]的参数矩阵,按位置索引取出来加(或拼)到词向量上,实现简单,T_max是训练时见过的最大长度。固定公式的:用不同频率的正余弦函数生成,不需要训练,理论上支持任意长度,但表达能力不如可学习版本灵活。

import math def sinusoidal_pos_encoding(max_len, d_model): pe = torch.zeros(max_len, d_model) pos = torch.arange(0, max_len).unsqueeze(1).float() div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(pos * div) pe[:, 1::2] = torch.cos(pos * div) return pe # [T_max, D]

拼接还是相加?拼接得到[N,T,2D],维度翻倍,后续层要适配;相加保持[N,T,D],是主流做法。相加的前提是 D 相同,所以位置编码的维度必须和词向量对齐。

有个容易忽略的细节:padding 位的位置编码要不要保留。保留下来的话,一个长 padding 的句子在位置 100 处也有位置信号,加上 mask 之后影响不大;但如果用序列池化,这部分会掺进来。稳妥的做法是把位置编码也乘上 padding mask,让它和词向量保持一致。

到这一步,[N,T,D]才算真正可用了。后面接一层池化变[N,D],或者直接送进上下文编码层,都是常规操作。整条链路上,只要把“切分 → 映射 → 对齐 → 查表 → 补位置”这五步的顺序和各自的职责分清,形状问题基本不会再找上门。

6. 常见问题与排查速查表

6.1 报错信息对应的问题定位

调试这类管线,最怕的是报错信息抽象。下面这张表是我从实际项目里攒的,按报错关键词查比一行行读代码快得多。

报错关键词大概率原因处理方式
indices to have scalar type Long输入 dtype 是 floatx = x.long()或构造时指定 dtype
index out of range in selfid 超出词表范围打印max(id)与V对比,检查词表版本
expected 4D input / shape mismatchmask 维度不对,广播失败检查 mask 是否[N,1,1,T]
CUDA out of memoryN、T、D 组合过大降 N 或 T,开梯度检查点
Loss 一直是常数不降全是 padding 或全是 unk打印一批真实输入看看内容
输出全一样padding_idx 未设,或词表 id 全错位检查 embedding 初始化与 id 范围

重点说两个。第一个index out of range,八成是词表不一致:训练时 V=30000,推理时加载了另一个版本的映射表,最大 id 到了 32000,越界。这类问题在模型导出、跨环境部署时特别常见,解决办法是把词表和模型存成一个整体,别分开管。

第二个“loss 不动”,我遇到过两次。一次是序列化时把整批文本都变成空串了,全部 padding,模型只能输出先验分布;一次是词表 key 用了字符串而输入是列表,全部命中 unk。两次都不报错,纯粹是数据问题。所以训练前一定要打印两三个 batch 的原始文本和 id 序列,肉眼确认一遍。

6.2 几个只有踩过才知道的经验

词表要带版本号。词表文件命名带上 hash 或日期,模型权重里记录对应的词表版本。换词表而不换模型,输出必然是噪声,而且这种错不会报错,只会让效果莫名其妙地崩掉。

截断策略要和任务对齐。做文本分类,关键信息可能在任意位置,头尾各留一半比单纯截头部更稳;做因果语言建模,截尾部保留头部才有意义。别用同一个策略套所有任务。

小心长度统计和编码时用的分词器不一致。统计长度用的是 A 分词器,编码用的是 B,两边切出来的 token 数量差一截,T 就定错了。这种问题在多人协作时很容易出现,一个人改了一个人不知道。

小数据集上,embedding 层要不要冻结值得试试。数据量小的时候,随机初始化的 embedding 很难训好,加载预训练向量然后冻结,往往比从零训效果更好,收敛也更快。冻结的时候记得把requires_grad关掉,否则优化器里还留着一大堆参数状态,显存白占。

别在__getitem__里做重活。有人把分词、截断、padding 全写进 dataloader 的单样本处理函数,多 worker 起来之后每个样本都重新分词,CPU 直接打满。正确做法是离线预处理:一次性把全量文本编码成 id 列表存下来,训练时只做 padding 和堆叠。内存不够就存成二进制数组,按需读取,比在线分词快一个数量级。

推理时 padding 的方式要和训练一致。训练用左 padding、推理用右 padding,位置编码全错位,输出自然对不上。这类不一致在自定义推理服务里尤其常见,因为服务端代码往往是另一个人写的。最保险的办法是把编码逻辑封装成一个函数,训练和推理都调它,从源头上避免分叉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询