
我们直接说个场景你跑通了模型代码数据也准备好了结果第一轮训练就报错错误信息像是“Expected tensor with 3 dimensions, but got 2”。这种问题十有八九不是模型的问题而是你喂给模型的文本“长得不对”。模型吃的是数字不是句子更不是一串字符串。从原始文本到模型能消化的张量中间这一整段路就是今天要聊的文本预处理与张量表示。这篇内容适合所有刚接触NLP或者已经在写训练脚本但老在数据环节翻车的朋友。我会从为什么不能直接把文本丢给模型开始一路讲清楚清洗、分词、编码、序列填充、掩码这些环节到底各自解决什么问题最后再给出一个完整的小流程示例。你会发现预处理这件事做好之后不仅模型训练更稳推理时的坑也会少掉一大半。1. 为什么文本必须先“翻译”成数字1.1 模型的输入本质是数值矩阵不管是BERT、GPT还是最简单的TextCNN模型的数学本质都是矩阵运算。你把一句“我喜欢猫”直接塞进网络层它无法理解“我”和“喜”的笔画结构也无法比较“猫”和“狗”谁离“宠物”更近。所有现代深度学习框架PyTorch、TensorFlow都要求输入是数值型张量通常形状是[batch_size, sequence_length, feature_dim]这样的结构。这就意味着我们要做两件事第一把文本拆成模型可以理解的最小单元字或词第二给这些单元建立一套从“离散符号”到“连续数值”的映射规则。这套规则可以是简单的序号映射也可以是复杂的词向量矩阵。无论哪一层前置工作都逃不开“文本预处理”这一步。1.2 预处理≠清洗数据这么简单很多朋友一听到“预处理”第一反应就是去掉标点、把英文转小写、去掉HTML标签。这些确实是对的但它只是预处理最表面的那一层。完整的文本预处理链路应该包括文本清洗去噪声、去特殊符号、处理编码问题。文本标准化繁体转简体、英文小写化、数字归一化。分词与词形还原把连续文本切成独立单元英文还要考虑时态、复数等变形。停用词处理决定哪些词对当前任务是无意义的。编码映射将词序列转为ID序列。序列对齐对序列做截断或填充使其变成等长的数值矩阵。前面几步的核心目标是“减少信息噪声”后面几步的核心目标是“让模型吃得进去”。这两个目标经常是矛盾的比如你为了减少噪声去掉了所有标点但有些任务如情感分析中“很好”里的重复标点其实是强特征。所以预处理并没有一套“天下通用”的死模板它和你的任务强相关。我会在后面详细拆解每个环节怎么做选择。2. 文本清洗与标准化的实操细节2.1 清洗顺序有讲究我先放一个我常用的清洗顺序按这个顺序做能避免很多连锁问题编码统一将输入转为UTF-8处理非法字符。全半角转换全角字母、数字转为半角全角标点按需处理。去除HTML标签/URL/邮箱等用正则匹配删除或替换为占位符。去除控制字符如\u0000-\u001F范围内的不可见字符。去空白合并多个连续空格为单个去掉首尾空格。统一小写英文场景。这个顺序里我特别想强调“编码统一”这一步。以前在处理爬虫数据时经常遇到奇怪的编码混合有的行是GBK解码后的文本有的行是UTF-8结果一到模型输入那里就报UnicodeDecodeError。你不在源头统一编码后面无论怎么做分词都会出幺蛾子。另外什么时候“不”做清洗也值得说。如果你的任务是代码生成或分析日志信息那URL、甚至特殊符号本身就有语义价值此时清洗策略要放宽。最好的做法是把清洗规则做成可配置的模块按数据集调整而不是写死在代码里。2.2 标准化时不要“一刀切”标准化是另一个容易踩坑的点。举个最常见的例子英文小写化对情感分析、垃圾邮件识别等任务大小写信息可能没太大用但对命名实体识别来说“Apple”和“apple”的语义完全不同。所以标准化的粒度取决于任务。数字归一化把“12345”替换为NUM占位符可以减少词汇表规模但代价是模型无法区分“第1名”和“第100名”的数值大小关系。如果任务需要理解数值的相对大小建议保留原数字或做分桶处理。我自己处理时会分两套逻辑一套是“通用清洗”应对所有数据都跑的基础规则另一套是“任务级标准化”专门由任务需求决定。这样模型迭代时换任务不会牵一发动全身。从函数角度看清洗和标准化这两步往往能合并成几个正则操作。但关键是在写正则之前先去样本里看50条数据确认噪声的真实长相而不是凭经验猜。预处理的任何一步都是在和你的具体语料“对话”别让它变成一个机械的流程动作。3. 分词与词汇表构建决定模型的上限3.1 中文分词为什么没有“标准答案”对英文来说分词天然存在空格分开单词。但中文不一样“我热爱自然语言处理”这句话既可以切成“我 / 热爱 / 自然语言处理”也可以切成“我 / 热 / 爱 / 自然 / 语言 / 处理”。具体用哪种切法取决于下游任务和分词工具本身。主流的处理分词方式有三种基于词典的前向最大匹配简单快速但无法处理未登录词。基于统计的分词如jieba的HMM模式利用隐马尔可夫模型识别新词泛化性更强。基于预训练模型的子词切分如BERT使用的WordPiece按信息熵切分出子词单元不是严格意义上的“词”。对于普通业务场景jieba已经能解决80%的问题但如果你的语料垂直度高比如医疗、法律那更要重视领域词典的导入和对未登录词的兜底策略。还有一点很多人容易忽略分词结果会直接影响词汇表的大小。词汇表过大会导致模型参数量爆炸过小则会导致Token被切得过碎丢失语义。所以很多实际项目已经不再用传统分词而是直接采用子词切分BPE、WordPiece。子词切分的好处是能平衡词汇表大小和OOV词表外词覆盖率比如“自然语言处理”如果不在词表里至少可以切成“自然 / 语言 / 处理”或“natural / ##ization”这类可组合的片段。3.2 词汇表构建的三个避坑建议建词汇表时我三个建议送给大家都是实际经验换来的一定要有“未知词”和“填充符”。[UNK]用于映射词表外的词[PAD]用于序列对齐。没有它们推理时模型遇到生词直接崩溃。词汇表最少保留多少词任务简单且数据量小时几千个词就够但做大规模预训练或翻译任务3万到6万个子词是常见配置。不要迷信“词表越大越好”大词表会占用显存增加Embedding层计算量收敛速度也会变慢。低频词处理出现频次小于5的词要么并入[UNK]要么保留在词表但是要增加dropout。低频词本身噪声大保留过多会拖累整体训练效果。如果你使用的是HuggingFace的Tokenizer那么构建词汇表时还能享受自带的分词器和特殊token管理基本开箱即用。但从原理上理解上面这几件事它会决定你排查问题的思路——很多人遇到“模型生成了一堆[UNK]”就以为模型坏了其实根因是词表太小或分词粒度不对。4. 从Token到ID张量表示的第一步4.1 建立词到序号的映射当分词完成后每个词或子词还是字符串我们必须给它一个唯一的数字ID。常见的做法是构建一个{word: id}字典。比如{我: 1, 喜欢: 2, 猫: 3, [PAD]: 0, [UNK]: 4}这看起来很简单但在实现时有几个细节值得注意ID分配顺序通常按词频降序分配频率越高的词ID越小这有助于Embedding层的学习稳定性。ID从0还是从1开始在PyTorch里我们习惯把0留给[PAD]那么真实词从1开始在TensorFlow里也类似。关键是注意Embedding层的padding_idx参数要与[PAD]对应上否则填充位也会参与梯度更新。词表必须和分词器绑定很多人只保存模型权重不保存Tokenizer结果换环境后ID映射完全错乱。这是低级但极高频的错误。4.2 什么是一维张量/二维张量零基础可视化理解在说张量表示之前用最白话的方式理解一下“张量”。你可以把0维张量想成一个单独的数标量把1维张量想成一行数向量把2维张量想成一个表格矩阵3维就是一组表格叠起来多个通道再往上可以理解成表格的“批次”概念。对我们文本任务来说[2, 3, 1, 5]就是一个长度为4的1D整数张量一个句子转成的ID序列。[[2, 3, 1], [4, 0, 0]]就是一个2D张量两个句子填充第一行长度3第二行也补成了3。再加一个batch维度变成[[[2, 3, 1], [4, 0, 0]]]就是3D通常模型输入还需要加一个channel维度所以最终常见的是3D或4D张量。这一块真的不用想复杂。你只需要记住张量的核心属性就是shape以及对每个维度含义的清醒认知。模型报维度错误时90%是shape没对齐而不是模型代码本身写错了。4.3 词嵌入从离散ID到稠密向量ID序列进入模型后第一层通常就是Embedding层。Embedding层本质上是一个可学习的查找表[词表大小, 嵌入维度]的矩阵。传入ID后按行取出对应的稠密向量。这样每个词就从一个离散的整数ID变成了一个连续的、能携带语义信息的向量。举个例子“我爱猫”分词得到[我, 爱, 猫]ID序列为[2, 5, 8]。Embedding矩阵形状假设是[10000, 256]那么查表后得到[3, 256]的张量这就是一整个句子的“张量表示”。很多初学者会把“张量表示”和“Embedding”混为一谈。严格来说张量是数据载体Embedding张量是其中一种最常见的表示方式除此之外还有one-hot编码、TF-IDF构造出的稀疏向量在进入模型早期也需要被处理成张量。不管哪种最终目的都是让字符串变成模型能高效计算的数字结构。5. 序列填充与掩码让一批数据等长且不“作弊”5.1 为什么要Padding深度学习训练通常是批量进行的也就是一次喂N个句子。但句子天然有长有短而多数模型内部要求同一batch的张量形状一致。所以我们需要把短句子填充到一个统一长度这就是Padding填充符使用[PAD]对应的ID通常是0。举个例子句子A我 / 爱 / 猫 → [2, 5, 8]句子B我 / 爱 / 自然 / 语言 / 处理 → [2, 5, 12, 15, 20]如果不处理这两个序列没法堆成一个矩阵。Padding后batch统一长度设为5句子A[2, 5, 8, 0, 0]句子B[2, 5, 12, 15, 20]这样就是一个形状为[2, 5]的2D张量。Padding策略上有一点要说明如果做的是分类模型一般统一长度用“批次内最大长度”就行如果做的是生成模型为了加速还可能要按长度分桶bucket避免短句子和长句子混在一个batch导致大量无效计算。5.2 掩码的本质告诉模型哪些位置别当真Padding很简单但光Padding不配掩码模型就会把[PAD]的位置也当成有效输入去计算注意力分数。拿Transformer架构说自注意力机制会计算每个token和所有token的相关性。如果[PAD]位置也参与了计算模型就会被干扰。所以在Attention阶段我们需要传入一个Attention Mask通常形状和输入张量一样有效位置为1无效位置为0也可以用布尔张量True/False。掩码有两种常见场景Padding Mask把[PAD]的位置遮住让注意力只集中在真实token上。Causal Mask因果掩码主要用于自回归生成任务如GPT确保每个位置只能看到当前位置及之前的token看不到“未来”的信息。很多框架里HuggingFace的BertModel、GPT2等掩码都是作为参数直接传进去的但如果你想自己实现Transformer掩码这一步是避不开的。漏了掩码最常见的现象就是训练loss异常低但生成结果乱七八——因为模型学的是“偷看未来的答案”。5.3 截断策略序列太长也是问题。BERT通常限制最长序列为512个token超过部分必须截断。截断有两种策略头部截断/尾部截断去掉最前面/最后面的部分。头尾结合截断保留开头和结尾只截中间部分对BERT做长文本分类时很常用因为开头和结尾的token通常包含更多关键语义。具体截多长取决于显存上限和任务需求。在业务场景里我一般会先统计语料长度分布比如第95百分位把截断长度设定在这个分位数附近这样既覆盖大部分样本又不至于太长导致显存爆炸。6. 一个完整的文本到张量小流程示例到这里我们把每个环节都过了一遍。下面我用一段非常简洁的PyTorch风格代码把从原始字符串到模型输入张量的整个过程串起来。这段代码不依赖太多第三方库目的只是让你在30秒内看懂整个链路。import re from collections import Counter # 原始语料 corpus [ I love cats!, I love natural language processing., Cats are cute. ] def clean_text(text): text text.lower() text re.sub(r[^a-z\s], , text) # 去掉标点和数字 text re.sub(r\s, , text).strip() return text # 1. 清洗与分词 cleaned [clean_text(s).split() for s in corpus] # 2. 构建词表 vocab Counter(w for tokens in cleaned for w in tokens) vocab {[PAD]: 0, [UNK]: 1, cls: 2} for w, freq in vocab.most_common(): pass # 演示逻辑实际按频率添加 # 这里为了清晰直接用全量的小词表 word2id {[PAD]: 0, [UNK]: 1} for tokens in cleaned: for w in tokens: if w not in word2id: word2id[w] len(word2id) # 3. Token转ID ids [[word2id.get(w, word2id[[UNK]]) for w in tokens] for tokens in cleaned] # 4. Padding max_len max(len(seq) for seq in ids) input_ids [seq [word2id[[PAD]]] * (max_len - len(seq)) for seq in ids] # 5. Attention Mask attention_mask [[1 if i 0 else 0 for i in seq] for seq in input_ids] import torch input_tensor torch.tensor(input_ids, dtypetorch.long) mask_tensor torch.tensor(attention_mask, dtypetorch.long) print(input_tensor.shape) # [3, max_len] print(input_tensor)这段代码里有一个很关键的细节Attention Mask我用的是“只要不是[PAD]就为1”等价写法是seq ! 0。在实际项目中你不需要手写这套逻辑直接调用Tokenizer就能同时返回input_ids和attention_mask。但自己手写一遍的意义在于——当模型莫名其妙变差或维度报错时你能快速定位是数据的问题还是模型的问题。7. 常见问题排查为什么模型总是报错7.1 维度不匹配这类报错最常见错误信息通常类似“Expected 2D tensor, but got 1D”。排查思路很简单打印输入张量shape确认有几个维度。确认是否少了batch维。很多模型要求输入至少是2D[batch, seq_len]不能直接喂1D序列。确认Embedding层的输出维度和后续层是否匹配。Embedding层输出通常是[batch, seq_len, hidden_dim]想进入全连接层一般要先做Pooling或取[CLS]向量。7.2 模型效果差、全是[UNK]这种情况通常是词汇表覆盖度不够。不要在模型层面调参去检查分词是否太粗糙某些长尾词被切碎了但没进入词表。是否有领域词比如医疗、法律术语在通用词表里根本没有要么导入领域自定义词典要么用更大的预训练词表。预训练模型和Tokenizer不匹配有些人加载BERT权重却配了一个自己训练的Tokenizer这会导致几乎所有词都映射到[UNK]。检查一下tokenizer.vocab_size和模型Embedding参数第一维是否一致。7.3 显存占用过高或OOM序列长度和batch size太大是最常见的原因。有两个处理方向减小batch size保持序列长度不变。对序列做截断或者使用梯度累积gradient accumulation模拟更大batch。如果要做长文本建模直接拉长序列对显存开销是平方级增长的注意力矩阵更合理的方案是用窗口注意力、LongFormer这类稀疏注意力架构而不是无脑调大max_len。8. 最后再分享一个实用技巧文本预处理看起来是一堆琐碎规则的堆砌但它真有一个“最小可运行”的验证标准拿到一条真实样本跑通text - token - id - tensor全流程然后用一个极小的模型比如单层Linear3步训练不掉点。如果这一步都稳不住那后面所有花里胡哨的模型结构都白搭。我在实际项目里踩过最深的一个坑是社区预训练模型的“隐藏要求”——它需要你同时使用它同源的Tokenizer和词表不能混搭。所以不管你是用HuggingFace还是自己训练的BPE务必把Tokenizer、词表、模型权重视为一个三件套一起保存、一起加载。换任何一个组件整个张量表示的语义空间就变了模型输出自然也变得不可解释。还有一个小技巧做数据处理脚本时把“预处理规则”和“数据处理代码”分开。规则是JSON配置代码是通用执行器。这样每换一个数据集不用改代码只需要调整配置项。这不仅让团队协作更清晰也让你的实验能够被完整复现——这也是做NLP项目时最值得早点养成的工程习惯。