
1. 我见过太多模型死在数据入口不是死在模型结构先讲个真事。前阵子帮人排查一个文本分类模型的线上效果暴跌问题模型结构没动、训练参数没动、数据量还比之前多了两倍结果F1值从0.82掉到0.71。查来查去最后定位到是一条新的数据源里的标点符号长得不太一样——某些英文引号被系统自动替换成了Unicode全角引号分词器没认出来整个句子被切成了一坨。模型输入侧的统计分布直接漂移线下验证再好看也没用。这个案例不是个例。太多人把精力花在模型结构、调参、换损失函数上却忽略了一个最基本的事实模型能学到什么完全取决于你喂进去的数据长什么样。文本预处理和质量控制就是模型的入口入口堵了后面一切都是空中楼阁。所以我特别想把文本喂给模型前要做啥这条链路从头到尾捋一遍。这篇文章围绕的核心就三件事文本预处理把乱七八糟的原始字符串收拾成干净、规范、可解析的文本、张量表示把离散的符号变成模型能计算的稠密向量或索引、以及这两者之间那条容易被忽视的隐性沟渠——分词策略、词表构建、Padding和Mask。这些内容你在任何一篇Transformer教程里都能看到零星碎片但很少有人把它们串成一条完整的生产线来讲。这篇内容适合谁看刚入坑NLP、跑通BERT但不太明白底层数据流的学生被线上数据搞得焦头烂额的算法工程师甚至是不小心接手了脏数据、需要快速补课的后端开发。读完你至少能回答三个问题为什么同样的模型不同人跑效果差这么多我该按什么顺序处理文本才能少踩坑张量的形状到底是怎么从一句话变出来的接下来我会按照我自己处理文本数据时的真实操作顺序往下走每一步都讲清楚为什么这么做不做会怎样有哪些隐藏细节。2. 文本清洗别让脏字符在入口处偷走模型效果2.1 先分清删除和保留的边界文本清洗是所有预处理的第一步也是最容易用力过猛的一步。很多人一上来就正则套正则把文本里的符号全删光结果模型表现反而更差——因为干净的文本不一定是有用的文本。我一般把清洗动作分成两类必须删的和视情况保留的。必须删的包括HTML标签爬虫数据里最常见的那种div、p残留、控制字符\u0000到\u001F这一段的不可见字符、零宽空格\u200B、\u200C复制粘贴时极易混入、以及URL链接。URL这种东西在大多数NLP任务里没有语义价值留着只会增加词表的噪声。比如一条微博文本里带了个https://t.cn/Ai3xK9分词器会把整个域名切成奇怪的碎片白白增加词表大小还什么都学不到。视情况保留的包括数字、货币符号、百分号、表情符号、Emoji和特殊标点。做情感分析时表情符号是极强的信号今天真是爽翻了和今天真是爽翻了完全两个意思你要是把Emoji全删了模型就少了一半的信息量。做金融领域的实体识别时$、%、#这些符号又非常重要因为它们经常紧贴着金额、比例、股票代码出现。所以清洗不是删得越干净越好而是删掉确定无用的保留可能有用的。2.2 连续空格、换行和不可见字符的坑文本数据最常见的脏东西其实是空白符家族的近亲繁殖全角空格、半角空格、不间断空格\u00A0HTML里nbsp;转义来的、制表符、换行符、回车符混在一起。肉眼看起来都是空白但在程序眼里它们是完全不同的字符。我的习惯是用一个统一的空白字符归一化函数把这些全部压缩成单个空格import re def normalize_whitespace(text: str) - str: # \s 匹配所有空白字符包括 \t\n\r\f\v 和 Unicode 空格 text re.sub(r[\s\u00A0\u200B\u200C\u200D\uFEFF], , text) return text.strip()注意最后那个\uFEFF这是BOM字节顺序标记头很多从Windows记事本或Excel导出的文件开头都会藏一个。不清掉的话它会被当成一个单独的token喂给分词器然后出现在你的词表里特别隐蔽。还有一个特别容易踩的坑不可见字符。阿拉伯语、希伯来语文本里那些零宽连接符/零宽非连接符\u200D、\u200C还有部分从PDF复制的文本里的软连字符\u00AD看起来什么都没有实际上占字符位置。很多人在统计文本长度时发现数字对不上最后排查半天才发现是这些幽灵字符搞的鬼。2.3 清洗的时机能早就早别拖到分词之后这是个血泪教训。我在早期做项目时习惯先分词再清洗——直接把原始文本丢给分词器想着分词之后再处理杂音。结果分词器把URL里的www当成一个词把和后面的逗号粘在一起词表里全是奇怪的碎片。正确顺序是先清洗再规范化最后才分词。清洗放在最前面因为它会影响分词的切分边界。一个干净的字符串分词器才能正确识别词边界带着噪声的字符串分词器的统计规律会被带偏。特别是那些基于统计学出来的分词模型如BPE分词器训练语料里的噪声模式会被它学到跑线上推理时它反而会期待这些噪声的出现——这就像你训练了一个检测骗子的模型却给它的训练数据里混入了大量错误标注它自然学歪了。3. 文本规范化把看起来差不多变成完全一致3.1 Unicode规范化同一个字的两副面孔前面说的线上效果暴跌案例根源就是Unicode规范化没做。在Unicode体系里同一个字符可能存在两种编码方式。最典型的例子是é既可以直接编码成U00E9单个码点也可以编码成e 组合音调符号U0065U0301两个码点。从人类视觉上看它俩一模一样但在程序眼里一个是长度为1的字符串另一个是长度为2的字符串。处理办法是Unicode规范化。Python的unicodedata模块提供了四种形式NFC、NFD、NFKC、NFKD。我对中文场景的推荐是NFKC因为它能做三件事把全角字母数字统一成半角→A、把兼容字符拆解或合并比如①→1、统一各种格式的变体。中文里常见的全角标点也会在这个阶段被转成半角标点比如会变成,,。这对后续分词和词表构建非常友好。import unicodedata def unicode_normalize(text: str) - str: return unicodedata.normalize(NFKC, text)有个需要特别留意的点NFKC会把一些标点合并成不那么直观的形态。比如有些罗马数字符号会直接转成拉丁字母组合如果做的是历史文献或特定领域的文本处理这种转换反而是有害的。所以规范化策略也要根据任务来定不是无脑套NFKC。3.2 大小写处理一个小写化背后的信息损失英文文本大小写处理也是经常打架的点。转小写在大多数分类任务里能降低词表规模、提高泛化性但也有明显的负面效应专有名词和缩写被抹平了信息量。US和us是不同的意思Apple和apple在不同语境下含义完全不同。我的建议是分任务来权衡做情感分类、主题分类这种粗粒度任务直接强制小写省词表省显存效果通常更好。做命名实体识别、机器翻译保留大小写信息或者至少做一个首字母大写感知的标记。做代码、化学式、生物序列这类特殊文本大小写本身就是语义的一部分绝对不能统一小写。如果你用的是一个预训练的大模型比如BERT还要额外注意它的分词器和词表本来就是区分大小写的你强行把所有文本转成小写反而会把一些原本在词表里的token如Apple踢到未登录词里面去。这就是好心办坏事的典型。3.3 中文语境下的规范化要多做一步繁体与简体如果你的语料里混杂了繁体中文和简体中文建议统一转换。中文开源社区的繁简转换工具很多比如OpenCC它还能处理一简对多繁的问题——比如头发和发财里的发在繁体里是两个不同的字髮和發无脑转换很容易出错。做这一步的时候请务必选带词典和上下文感知的转换库别用简单的字符映射表。繁体简体的统一本质上是和Unicode规范化同一层的工作让同一种语义的文本只有一种写法。语义相同但写法不同的文本进到模型里会被当成两个完全不同的样本等于无形中稀释了每个类别的样本量。4. 分词策略从一个字符串到一串最小语义单元4.1 为什么不能用纯字符切分到底对于中文来说最朴素的做法是直接把每个汉字当成一个token。好处是零依赖、实现快坏处也很明显模型难以学到词级别的语义——苹果被拆成苹和果模型需要自己用神经网络去学这两个字之间的组合关系这本质上就是把词级别的语义建模负担转嫁给了模型结构。英文里按空格切分同样问题很大。英文的词形变化太丰富run、runs、running、ran是同一个动作的不同形态按空格切分会把它们变成四个完全独立的词词汇表爆炸、数据稀疏、训练效率直线下降。所以现代NLP的主流做法是子词切分subword tokenization。它的核心理念是高频词完整保留低频词拆成常见的子片段。比如unhappiness可能被拆成un happiness或un happy ness这样即使遇到没见过的词只要它的局部片段在训练数据里出现过模型也能有个大概的表示。4.2 BPE、WordPiece、SentencePiece到底该怎么选这三个是现在最主流的子词切分算法它们的共同祖先都是数据压缩思想BPEByte Pair Encoding从字符级别出发反复统计高频相邻片段并合并成新的符号。优点是实现简单、透明、可控缺点是按频率来决定合并优先级某些低频但语义完整的词可能被拆得过碎。GPT系列用的就是BPE的变体。WordPieceGoogle提出用于BERT。它的合并标准不是出现频率而是语言模型似然度的提升量——优先合并那些能让语言模型更确定地预测下一个词的片段。效果通常比BPE更细腻但实现要复杂一点。SentencePiece严格来说它不是一个独立的分词算法而是一个预处理框架。它的特点是直接用字符流作为输入把空格也当成一种普通符号参与建模然后内部再套用BPE或Unigram算法。它天然支持多语言不需要预先用空格切词、处理未知语言时不至于退化到字符级散装状态。选谁我的经验是中文场景优先SentencePiece因为中文分词本身就没有天然空格边界SentencePiece可以直接在原始字符流上操作把分词这件事一并解决了英文场景用HuggingFace Tokenizers库里的BPE就够用如果你在微调一个已有的预训练模型BERT、RoBERTa等那就没有选择余地——必须沿用模型自带的分词器自己重新训练分词器反而会脱离预训练结构。4.3 词表大小30000还是50000怎么定词表大小直接影响两个东西模型的参数量嵌入层的大小和训练速度。嵌入层的参数量等于vocab_size × hidden_sizeBERT的hidden_size是768词表3万时嵌入层就有2300万参数这可不是小数目。词表定多大没有绝对公式但有几个经验参考字符级小规模任务词表几百到几千就够。子词级中文任务3万到5万是常用区间中文高频汉字只有三千多个但常用词和子词组合远不止这个数。多语言或领域特殊性强代码、生物医学的任务建议做到5万以上给特殊的领域词汇留空间。还有一个比较容易被忽略的点预留特殊token。[PAD]、[UNK]、[CLS]、[SEP]、[MASK]这些特殊符号都要占词表位置的。你在合并tokenizer时如果忽略了它们后面会遇到ID错位这种极其痛苦的bug。4.4 未登录词OOV问题尽管理论上子词解决了但仍会翻车理论上子词切分可以让任意词都有对应的切分结果不会出现完全无法表示的情况。但实践中仍会遇到问题场景极端生僻的字符比如古汉字、生僻异体字连BPE的训练语料里都没出现过这些字符就退化成纯字符级切分还可能切出[UNK]。乱码文本比如GBK编码误读成UTF-8后产生的那堆符号这些字符在很多分词器的词汇表里根本没有。数字和日期变体2024-03-15和2024年3月15日和Mar 15, 2024分词结果可能完全不同但它们表达的是同一个概念。针对OOV实践中比较有用的做法是在词表里固定保留所有单字节字符ASCII全量保证遇到任何奇怪的拉丁字符组合时至少能退化成字符级切分不产出[UNK]。如果[UNK]在你的数据里高频出现说明分词器训练语料和任务语料分布差异太大第一优先不是调模型而是重新训练或扩充分词器词表。5. 从词到ID词汇表的构建与映射5.1 词频排序与最小ID分配原则确定好分词策略和词表大小后接下来要构建从词到整数ID的映射表。这个过程看起来简单——给每个词一个数字编号而已——但里面有好多细节决定了下游数据流是否顺畅。基本流程是先在大规模语料上分词统计每个词的出现频次按频次降序排序从0开始逐一分配ID。有个默认约定要记住ID越小通常表示词频越高或越重要。特殊token[PAD]一般占用ID 0[UNK]占用ID 1[CLS]占用ID 2[SEP]占用ID 3[MASK]占用ID 4。这个顺序不是绝对规定但主流预训练模型基本都是类似排布你后续如果要加载别人训练好的模型权重必须严格对齐对方的特殊token顺序差一个就会导致gsd错乱。我自己写过一个糟糕的映射表一开始没把[UNK]放进去跑模型时发现所有没见过的词都映射成了0而0是[PAD]的ID。结果就是Attention的Mask把所有未登录词的位置全遮掉了模型相当于完全没看到这些信息训练还不出报错——因为[PAD]在损失计算里默认是不参与反向传播的。这种bug非常阴险单看日志根本发现不了。5.2 映射过程中的边界情况处理在写词到ID的映射函数时下面这几种边界情况是必须处理的分词器输出一个token不在词表里映射到[UNK]的ID。分词器输出空字符串某些分词器在极端情况下会返回空跳过。整数ID超出词表范围这类异常要在数据加载时就报错不要在模型训练时才暴露。同一个token在特殊token和普通词之间冲突比如你的语料里真的有pad这个词它和[PAD]不是同一个token记得名字上区分开。5.3 词表泄露一个必须提高警惕的错误这是一个严重度很高的错误在构造词表时把测试集的数据也用进去了。中文NLP任务的数据划分如果做得不仔细你分词、统计词频时把全量数据包括测试集都喂进去构建词表模型在训练阶段就能看到测试集里出现的词汇分布信息导致线上效果被严重高估。这个问题的隐蔽点在于它不会让训练集上的分数异常训练集该多少还是多少它悄无声息地抬高测试集分数你还会以为是模型泛化得好。正确的做法是所有词表构建、分词器训练、归一化统计如均值方差都必须只在训练集上完成验证集和测试集只能做最后的映射和预测。6. 从ID序列到张量维度的诞生、形状的艺术6.1 一句话是怎么变成二维矩阵的经过前面的步骤你现在有一条这样的数据一段文本被分词成[我, 喜, 欢, 自, 然, 语, 言]再映射成ID序列[105, 233, 466, 12, 33, 89, 99]。但模型不能吃一维数组——大多数序列模型需要的是二维张量[batch_size, seq_len]。也就是说把多条样本堆叠成一个矩阵行是一条样本列是时间步/位置。举个例子你有一个批次包含3句话句1我喜欢猫 →[105, 233, 466, 77]句2机器翻译真神奇 →[122, 532, 777, 233, 401]句3NLP真好玩 →[820, 34, 99, 555, 888, 777]这三句话长度分别是4、5、6没法直接堆叠成矩阵。这就引入了Padding填充。把短样本末尾补上[PAD]到最长长度6就得到了一个[3, 6]的矩阵[ [105, 233, 466, 77, 0, 0], [122, 532, 777, 233, 401, 0], [820, 34, 99, 555, 888, 777] ]注意这里我假设[PAD]的ID是0。这个矩阵就是模型的标准输入格式之一。如果你用的是BERT类模型还要加上token类型IDsegment ID和Attention Mask后面细说变成一个更复杂的输入结构。6.2 Padding的左右位置不是小事Padding放在句子的左边还是右边看似只是习惯问题实际会影响模型的表现。对于绝大多数Transformer类模型官方实现是在右边Padding即句子的末尾补[PAD]。原因在于Attention Mask把[PAD]位置遮掉后位置编码的分布偏移相对可控。但如果你用的是LSTM、GRU这类从左往右串联的循环神经网络在右边Padding会引入一个问题模型读到末尾时最后几个时间步全是[PAD]它的隐状态可能会被冲淡或产生无意义的更新。所以如果你是做LSTM类模型更常见的做法是在左边Padding把真正的句子放在序列尾部这样模型最后一次更新的隐状态就是真正句子的信息。BERT类模型因为是双向注意力位置编码左右Padding差别没有RNN那么大但一般仍遵循官方给的右侧Padding约定。6.3 为什么要限制最大长度不只是为了省显存NLP模型对输入长度都有上限要求。BERT-base是512GPT-2是1024GPT-4的上下文窗口可能几万到十几万。但实际处理时很少有人会把所有文本都填到上限。这里有一个计算复杂度的问题Transformer的Attention操作复杂度是O(n²)序列长度翻倍计算量翻4倍。把序列长度从64扩到128计算量直接涨4倍但实际模型效果的提升远没有这么多。所以合理的做法是统计你的语料长度分布截取一个覆盖大部分数据的长度比如95%分位然后把超长的部分截断或分块。统计分位数的代码大概是这样的lengths [len(tokenize(text)) for text in all_texts] p95 sorted(lengths)[int(len(lengths) * 0.95)]我手上做过的几个项目大多数中文新闻文本的95%分位数在128-256之间用256作为max_length通常已经够了。设得太长不仅费算力还会稀释短文本的语义密度。6.4 Attention Mask不是可选项是必须项填充了[PAD]之后你必须告诉模型哪些位置是真实内容哪些位置是填充的。这个信息就是Attention Mask。Attention Mask的形状和输入张量一样是[batch_size, seq_len]真实token位置填1[PAD]位置填0。模型在计算注意力权重时会把Mask为0的位置的注意力分数替换成一个极小的负数比如-10000经过Softmax后这些位置的概率趋近于零相当于模型完全忽略了这些位置。这里有一个小坑很多人直接用input_ids ! pad_token_id来生成Attention Mask这个逻辑在绝大多数情况下是对的。但如果你的词表构建有问题某个真实token的ID碰巧等于pad_token_id比如你手指一抖把[PAD]的ID和某个词的ID重复了Mask就会把真实内容也给遮掉。这就是我前面说的映射错误会一路传导到Attention机制里的原因。6.5 位置编码为什么会出现在张量表示里Self-Attention机制本身是不关心输入顺序的——我爱猫和猫爱我在注意力计算里如果没有任何位置信息模型看到的是一模一样的集合。所以需要把位置信息编码成向量加到输入嵌入上。现代Transformer的位置编码有两类绝对位置编码每个位置一个固定的向量比如正弦余弦函数Transformer原版或可学习的嵌入BERT、GPT。相对位置编码建模位置之间的相对距离比如T5、DeBERTa的编码方式。在做张量表示时位置编码一般不是你自己手动加的而是模型结构里自动处理的。但你要理解一个概念模型输入的Embedding其实是一个三维张量[batch_size, seq_len, hidden_size]——第二维是序列位置第三维是特征维度。位置编码的作用就是在这个三维空间里给每个位置一个独特的偏移量让模型能区分顺序。7. 嵌入层与词嵌入离散ID如何变成稠密向量7.1 One-hot编码为什么是低效的最朴素的张量表示方式是One-hot编码假设词表大小是50000每个词用50000维的向量表示其中只有自己对应的那一维是1其他都是0。One-hot的缺点大到令人绝望向量维度巨大50000维×50000词的存储是25亿个浮点数光存嵌入矩阵就几十GB。任意两个词的One-hot向量的内积都是0完全无法表达词与词之间的关系。训练时每个词只对嵌入矩阵中对应的一行做更新数据稀疏学习效率低下。现代模型全部使用可学习的稠密嵌入。每个词对应一个固定长度的稠密向量比如768维这个向量的初始值是随机或预训练好的训练过程中会随着反向传播不断调整使得语义相近的词在向量空间中的距离越拉越近。7.2 从ID到嵌入向量的查表操作从ID到嵌入向量这个操作本质上就是查表。PyTorch/TensorFlow里用nn.Embedding(num_embeddings, embedding_dim)来做输入是[batch_size, seq_len]的ID矩阵输出直接变成[batch_size, seq_len, embedding_dim]的三维张量。import torch.nn as nn embedding nn.Embedding(num_embeddings50000, embedding_dim768) # input_ids: [batch_size, seq_len] # embedded: [batch_size, seq_len, 768] embedded embedding(input_ids)有个细节值得注意nn.Embedding默认会对越界索引报错IndexError: index out of range in self这是好事——它能帮你发现词表映射的错误。但有些实现里你会看到自定义的padding_idx参数它的作用是让指定ID的嵌入向量始终为0且不参与梯度更新[PAD]的向量就应该这么设置。如果不设置padding_idx[PAD]的嵌入向量会被训练成一个随机向量虽然不影响功能但浪费了一部分模型容量在无意义的填充位置上。7.3 预训练词向量 vs 从零训练选哪个现在的主流做法是用预训练模型直接生成上下文相关的嵌入表示比如BERT的每层输出而不是单独训练一个词向量表。但如果你在做的是一个序列标注或分类的小任务不想上大模型那么选词向量时有几个判断标准领域词汇是否特殊如果做医疗、法律、代码相关任务通用词向量如Glove里大概率没有你的领域词还不如从零用大语料训个小的。词向量是否冻结冻结预训练词向量不更新训练速度快很多但效果通常不如微调。词语的多义性静态词向量每个词一个向量天然表达不了多义词苹果的水果和公司两个含义共用同一个向量。在这种场合下上下文化的表示几乎是必然选择。8. 组装端到端数据管线文本到张量的完整流程图8.1 一条清晰的流水线从raw string到模型输入写了这么多拆开的步骤我来把它们串成一个完整可复用的流程以PyTorch生态为例class TextPreprocessor: def __init__(self, tokenizer, max_length128): self.tokenizer tokenizer self.max_length max_length def __call__(self, text: str) - dict: # 1. 清洗与规范化 text normalize_whitespace(text) text unicode_normalize(text) # 2. 分词 tokens self.tokenizer.tokenize(text) # 3. 截断 tokens tokens[: self.max_length - 2] # 4. 加上特殊标记 [CLS] 和 [SEP] tokens [[CLS]] tokens [[SEP]] # 5. 映射为ID input_ids self.tokenizer.convert_tokens_to_ids(tokens) # 6. 构建Attention Mask真实token为1[PAD]为0 attention_mask [1] * len(input_ids) # 7. Padding到固定长度 padding_length self.max_length - len(input_ids) input_ids [0] * padding_length # [PAD] 的 ID attention_mask [0] * padding_length return { input_ids: input_ids, attention_mask: attention_mask } # 使用 preprocessor TextPreprocessor(tokenizer, max_length128) result preprocessor(我爱自然语言处理)这个流程里有几个细节值得注意先截断再加[CLS]和[SEP]确保这两个特殊token始终在有效范围内Padding时用列表加法而不是append防止意外修改了原始列表Attention Mask和input_ids保持同步更新。8.2 Mini-batch组织与collate_fn的写法上面只是单条样本的处理实际训练时你需要把多条样本组织成一个批次。PyTorch的DataLoader里有一个collate_fn参数专门用来将样本列表聚合成批次张量from torch.utils.data import DataLoader import torch def collate_fn(batch): input_ids torch.tensor([item[input_ids] for item in batch], dtypetorch.long) attention_mask torch.tensor([item[attention_mask] for item in batch], dtypetorch.long) if labels in batch[0]: labels torch.tensor([item[labels] for item in batch], dtypetorch.long) return {input_ids: input_ids, attention_mask: attention_mask, labels: labels} return {input_ids: input_ids, attention_mask: attention_mask}这里有个常见的初学者困惑为什么不直接torch.tensor(batch)因为每条样本里的input_ids长度可能不一样如果DataLoader设置drop_lastTrue且你做了动态Padding直接堆叠会报错。所以你可以在collate_fn里做动态Padding——找到该批次内的最大长度而不是固定到全局max_length然后只Padding到这个局部最大值。这能显著减少计算量尤其是你的数据长度分布差异很大的时候。8.3 端到端验证用一个小批量检查张量形状组完流水线第一件事不是训练而是打印出每个张量的形状人工验证一遍。我每次新建项目都会跑一遍这个检查sample_texts [ 我特别喜欢吃烤鸭, Machine learning is fun, 今天的天气怎么样适合户外运动吗 ] batch collate_fn([preprocessor(t) for t in sample_texts]) print(input_ids shape:, batch[input_ids].shape) print(attention_mask shape:, batch[attention_mask].shape) # 期望输出: # input_ids shape: torch.Size([3, max_len]) # attention_mask shape: torch.Size([3, max_len])如果形状不是[batch_size, seq_len]或者出现了[seq_len, batch_size]这种转置——那说明你大概率把维度的顺序搞错了。Transformer类模型通常默认第一维是batch第二维是seq但有些老代码不遵守这个约定如果你在复用一个自定义的模型结构一定要确认清楚。9. 高频踩坑清单这些错误我基本都犯过9.1 脏数据在线上和线下的分布差异最隐蔽的坑是线下训练集是精挑细选清洗过的线上却要面对千奇百怪的原始输入。你今天在清洗阶段删掉了URL但线上如果涌入大量带URL的文本你线下没有全角字符线上却来了一大堆——模型在训练时从没见过这些长尾噪声推理时就会行为异常。我的应对思路是在训练数据里有意识地注入一部分未清洗的脏样本让模型学习到遇到噪声时也别慌同时在评测集里保留针对噪声样本的专项评估。这不是常规做法但它非常有效——你不可能保证线上数据100%干净不如让模型提前适应这种脏。9.2 特殊token的ID错位灾难前面提过一次这里再展开讲。当你在多个模型之间切换比如从BERT切到RoBERTa或者加载别人的checkpoint[PAD]、[UNK]、[CLS]这些特殊token的ID很可能不一样。如果你直接用一个新的tokenizer去解析旧的ID序列模型会把[PAD]的位置误认为一个真实词注意力分布一下乱了。我的一次真实经历加载某个预训练模型做续写生成的文本开头总是有一个奇怪的[UNK]符号。排查了一个下午最后发现是加载词表时把[UNK]的ID从1改成了0而[PAD]从0变成了1整个ID映射前后错位模型把整个输入序列读歪了。解决方式很简单——写一个开机自检函数打印出前20个ID对应的token人工确认一遍。def sanity_check_tokenizer(tokenizer): ids list(range(min(20, tokenizer.vocab_size))) tokens tokenizer.convert_ids_to_tokens(ids) for idx, token in zip(ids, tokens): print(idx, repr(token))9.3 文本长度统计口径不一致训练时你用len(text)算长度分词后又用len(tokens)算长度两者的口径完全不同。尤其中文里一个词可能占2个字符但只产生1个token英文里一个长单词可能被BPE切成好多个token。如果你在统计语料的长度分位数时用字符数在设置max_length时却用的是token数那可能会错得很离谱。建议全程统一用token数量来统计长度不要用字符数或词数。9.4 分词器的缓存污染与多线程问题用HuggingFace Tokenizers做高并发数据加载时可能会踩到分词器的线程安全问题。某些老版本的分词器在num_workers0的DataLoader中会被多个进程同时调用导致分词结果随机出错——有时同一个句子在不同epoch里被切成不同的token序列。这个问题的排查难度极高因为它是间歇性的。预防措施使用tokenizers库Rust底层实现而不是transformers里纯Python的Tokenizer并发性能差很多。如果必须用Python Tokenizer把DataLoader的num_workers设为0或者预先做一次全量离线分词把token IDs存成缓存文件。9.5 Padding和Attention Mask不同步这是比较常见的低级bug修改了padding逻辑但忘了同步mask。模型会怎么表现它可能把[PAD]位置的嵌入向量当作真实内容参与Attention计算等于在序列末尾悄悄塞了几个随机噪声token。短期看损失下降正常因为[PAD]向量优化后被推到一个特别的值模型学到的其实是看到这个位置就别管它但遇到线上一段恰好以相似向量开头的文本时预测就可能出问题。排查手法很直接随机抽几个批次人工输出input_ids和attention_mask检查attention_mask为0的位置是不是都对应[PAD]的ID。10. 还没结束文本预处理与张量表示的边界在哪写到这里把文本预处理与张量表示这条链路基本走完了一遍。但我个人最深的感受是这条链路不是一次性建好就结束的它需要和数据一起成长。我现在的做法是每个项目都维护一个数据预处理变更记录——今天加了Unicode规范化、明天调整了分词器词表大小、后天改了Padding策略全部记录下来。因为数据预处理环节的改动影响面极大一条清洗规则的调整可能让模型的线上效果产生几个百分点的波动没有记录的话出了效果回退根本无从排查。还有一点想强调不要为了规范而过度处理。有时候保留原始文本里的噪声反而能提高模型的鲁棒性。数据预处理的目标不是做出最干净的文本而是做出最符合任务分布的文本。就像做饭菜洗得太干净反而没了口感——重点是让最终入口的体验达到最佳。如果你正在被为什么我的模型比别人的差为什么换了个数据集效果崩了这类问题困扰先别急着调模型结构回过头来审视一下你的文本预处理与张量表示链路。很多看起来玄学的问题根源都在数据入口处——只是入口太靠前出了问题不容易第一时间想到。从这篇开始把这条链路当成整个项目的基座工程来做你会少走很多弯路。