做文本分类的朋友给我发过一个压缩包里面躺着二十来个脚本clean.py、cut.py、vec.py、train.py、predict.py还有一个叫 utils_final_v3.py 的东西。跑起来第一步就卡住——predict.py 里加载的词表是三个月前那一版清洗逻辑中间被改过两次线上线下的输入形状对不上同一个测试集昨天 0.86今天 0.79。这个场景我见过太多次问题不在模型在 pipeline。NLP 的 pipeline说白了就是把一段原始字符串进去、一个结构化结果出来这条路上所有会改变数据形状的环节收拢成一条有明确契约的流水线谁先谁后、谁持有状态、谁只在训练时出现、谁必须原封不动地搬到线上。它解决的是我本地跑通了和这套东西能天天稳定跑之间的那道沟。这篇写给两类人一类是刚学完分词和词向量、还习惯把每步写成独立脚本的同学另一类是把 pipeline 用起来了、但线上指标老是比离线低一截、想找到漏在哪的工程同学。下文的代码都以中性写法给出换成你手头那套工具的同名类逻辑完全一致。1. 先想清楚管道在串什么再动手写第一行1.1 从一次真实需求倒推流水线的形状拿到做文本分类这种需求多数人的第一反应是选模型。我一般先画输入的形状。原始输入是一条长度不定的字符串可能带 HTML 残留、全角空格、一串重复标点、偶尔夹着表情符号最终输出是一个标签加一个置信度。中间这段距离就是 pipeline 要覆盖的全部地形清洗、切分、向量化、模型打分、阈值判定、结果回写。每一步都在改变数据的形状或类型而每一步的改动都会向下游传播。所以管道的形状不是拍脑袋定的是从输入长什么样和输出要交给谁这两头夹出来的。如果下游是运营后台那标签体系必须在管道里固定下来不能今天三分类明天五分类如果下游还要人工复核那中间结果切出来的词、命中的关键词要能被落盘方便事后追查。我习惯在纸上列一张表左边写进入这一步的数据长什么样右边写离开这一步的数据长什么样。列完你会发现真正的难点几乎都出现在形状变化最大的那两个接口上——字符串进、id 序列出id 序列进、定长向量出。这两个接口一旦没定死后面不管换什么模型都是白搭。1.2 为什么值得用 pipeline 而不是一堆零散脚本零散脚本的问题不是丑是不可复现。你今天调好了清洗规则明天为了修一个脏样本又改了正则训练脚本和推理脚本各自复制了一份改了一份忘了一份两边就分叉了。分叉之后的表现很隐蔽离线评估用的是新规则处理的数据线上跑的是旧逻辑模型本身没变指标却对不上。这类问题排查起来极其折磨因为你怀疑的是模型而 bug 在数据入口。封装成 pipeline 之后有几个直接收益。一是状态集中词表、idf 权重、标签映射这些必须被记住的东西跟着管道对象一起走序列化一次全部带走。二是顺序固定你在代码里写了什么顺序训练和推理就是什么顺序不存在忘了加这一步。三是可整体调参把整条管道当成一个黑盒去搜超参数而不是只调模型那一段。四是可整体回归拿一小批标注好的黄金样本每次改动后跑一遍输出和上次逐条比对改了哪里、影响了哪些样本一目了然。我个人的经验是只要这个 NLP 任务的生命周期超过两周或者参与的人超过一个就该封管道。哪怕只是三个环节封起来的那点额外代码量远小于后面挽回分叉的时间。1.3 训练态和推理态是两条不完全一样的管道这是最容易踩的坑值得单独说。训练态有标签、有全量数据、可以 fit 出状态推理态只有一条样本、没有标签、只能 transform。同一份代码里fit只能在训练集上调用推理时一个fit都不能有否则词表或 idf 会被当前这条样本污染结果不可复现。很多工具库提供了fit_transform这个便捷方法它在训练时是省事的但它也最容易被人顺手用在推理代码里——因为接口长得一样编译器不会拦你。我的做法是把管道对象拆成两个显式的方法名train_step和infer_step或者在类内部用一个fitted_前缀的布尔量做保护没 fit 过就直接抛异常。多写这两行能省掉后面无数次的为什么单条预测和批量预测结果不一样。另外推理态往往还比训练态多两件事输入的兜底校验空串、超长、编码异常和输出的后处理阈值、标签回映射、置信度截断。这两块在训练态是不需要的所以在设计管道时就要预留出训练时跳过、推理时执行的钩子而不是事后硬塞。2. 数据入口清洗、切分与样本构建的三个关键节点2.1 文本清洗先做减法别急着做加法清洗的目标是压掉噪声不是把文本改写成你想要的样子。我见过有人在这一步就做同义词替换、错别字纠正、繁简转换理由是让模型更容易学。这类操作的问题是不可逆它在改变语义的同时也把你和真实数据分布之间的距离拉开了线上遇到没纠正的错别字模型直接懵。我一般只做四类减法。第一类编码与空白统一把全角空格、不间断空格、零宽字符归一化这一步只在字符层面做不动内容。第二类结构残留清理比如网页抓下来的标签片段、重复的换行和分隔符。第三类超长重复压缩连续的同一个标点压成一个避免这种模式被当成强特征。第四类长度兜底太短的比如少于两个字标记为无效样本直接在上游过滤掉而不是让它进模型把 loss 拉偏。有两个原则我一直在守。一是幂等同一条文本清洗两次结果必须一致否则管道就没法重跑。二是可审计每类清洗都留一个开关和一条计数跑完能打印本次共清理标签残留 137 条、过滤超短样本 22 条。没有这行日志出了问题时你连怀疑的资格都没有。这里有个常被忽略的细节清洗规则本身也是模型的一部分。你今天加了去掉所有数字这条规则模型的输入分布就变了离线指标会跳而且这个跳动无法归因到模型。所以清洗规则的每次变更都要和模型版本一起记录最好在管道对象里带一个规则版本号。2.2 切分与子词把语言变成模型能吃的形状中文没有天然空格切分方式直接决定了词表长什么样。我倾向于按任务选短文本分类、关键词抽取这类词粒度够用涉及新词、专有名词、中英混排、拼写变体的场景子词粒度更稳因为它能把没见过的词拆成已知片段不会直接退化成未知标记。这块的经验是别把切分当成独立的前置步骤它应该是管道里的一个 transform训练时 fit 出词表推理时只查表。词表大小是个要算的账。假设你的语料有 5 万条短文本切完大概 80 万个词次去重后 3 万个词型。如果把词表直接定成 3 万那大量只出现一两次的词会占据词表位置模型在它们身上学不到东西还稀释了权重。常见的做法是设一个最低频次门槛比如出现次数少于 3 直接归入未知或者设一个词表上限比如取最高频的 1 万个加一个未知位。1 万这个数字不是随便来的在十万量级的短文本任务上1 万到 2 万的词表通常能覆盖 95% 以上的词次再往上加覆盖率涨得很慢参数量和内存却线性涨。还有一步是截断与补齐。文本长度分布先统计一遍看 95 分位在哪。假设你的数据 95 分位是 180 个字那截断长度定 200 就够定 512 只会让你的计算量翻倍还多。截断位置也有讲究分类任务通常保留头部因为结论往往在前半段但如果你的文本是结论在最后的结构比如长评论、长工单那就得做头尾各取一半或者干脆分段编码再做池化。补齐不要在一开始就做按 batch 内最长的那条动态补齐能省下大量无效计算。2.3 样本构建与标签对齐的坑样本构建这一环出问题最多的不是特征是索引。多文件、多段落切块之后很容易出现特征行数和标签行数差一行这种低级但要命的错。我的做法是在管道入口处强制做一次对齐断言特征矩阵的行数、标签数组的长度、样本 id 列表的长度三者必须相等不等就直接抛错绝不允许先跑起来看看。另一个坑是标签体系本身的漂移。同一条样本标注规范改过之后可能被标成不同类别多个标注源合并时同一个 id 出现两次。这类问题不会报错只会让你的验证集指标虚高或虚低。我一般会在样本构建的最后加一段去重和冲突检测同一个 id 出现多次且标签不一致的单独落到一个冲突文件里人工过一遍再决定去留。注意任何在切分之前做的全局统计比如用全量语料算 idf、用全量语料建词表都会造成数据泄漏。训练集和验证集必须各自独立地走完 fit 环节验证集只能 transform。3. 特征与向量化环节把文本变成可计算的数3.1 词袋与加权参数背后的算术稀疏特征这一路最常用的还是词频加权。它快、可解释、小样本下不容易过拟合作为基线非常合格。但参数不能乱设我一般按下面这张表来定参数常用取值作用与取舍最低文档频率 min_df2 到 5过滤只出现一两次的词压词表、降噪最高文档频率 max_df0.5 到 0.9过滤几乎每篇都有的词等价于去停用词n 元范围(1,2)引入二元组能抓不 满意这类否定结构是否做次线性缩放开压制长文本的词频优势归一化L2消除文本长度差异让向量可比词表上限1 万到 5 万控制内存和模型参数为什么建议开次线性缩放因为原始词频是线性增长的一个词出现 20 次带来的权重是出现 1 次的 20 倍这明显不合理。取对数之后20 次和 1 次的差距被压到 1 加 log20 比 1 加 log1大约 4 比 1更符合直觉。逆文档频率这一项也值得算一遍很多人只是调包不知道它在干什么。常见实现是 idf 等于 log((1n)/(1df)) 再加 1n 是文档总数df 是包含该词的文档数。举个具体的数假设你有 10000 篇文档某个专业术语只在 50 篇里出现那么 idf 约等于 ln(10001/51)1ln(196.1) 约 5.28结果是 6.28。同样的语料里的出现在 9900 篇里idf 约等于 ln(10001/9901)1约 0.01 加 1 等于 1.01。两者相差六倍多。这就是加权在干的事——把区分度高的词抬起来把到处都有的词压下去。理解这一点你就知道为什么设了 max_df 之后再加一张停用词表往往是多余的。3.2 稠密向量与上下文表示的接入稀疏特征的天花板很明显它不认识同义词手机和移动电话在它眼里是两个毫不相干的维度。稠密向量补的正是这块。接入方式有三种常见形态一是把整段文本编码成一个定长向量直接接分类头二是编码成序列再池化池化方式取平均、取首位标记位或者取最大三是长文本切块后逐块编码再把块向量做加权平均。长文本这块要多说一句。当文本长度超过编码器的最大输入长度时硬截断会丢信息。我的处理是切成有重叠的块重叠比例取 10% 到 20%避免正好把一个关键句切成两半。块向量汇总时不要简单平均因为不同块的信息量差别很大。常用的做法是按块长度加权或者接一个小的注意力层让模型自己学权重。这个技巧在长工单归类、长评论情感判断上效果提升很明显代价是推理耗时随块数线性增加所以要提前算好成本。成本怎么估粗略地讲编码器的计算量大致和批大小乘以序列长度成正比。假设单条 512 长度、批大小 16一次前向 200 毫秒那你把长度提到 1024同样的批大小大概会变成 400 毫秒上下显存也要跟着涨。中间层的缓存占用可以用一个粗公式估个量级批大小 × 序列长度 × 隐藏维度 × 层数 × 2键和值× 2半精度字节数。代入 16 × 512 × 768 × 12 × 2 × 2大约是 302MB。这只是个量级参考实际还要加上权重和激活值但有了这个数你就不会在 8G 显存的机器上把批大小设成 64 然后一脸茫然。3.3 特征拼接与维度管理把稀疏特征和稠密特征拼在一起通常在中小样本上是最划算的组合稀疏特征管精确匹配和高频模式稠密向量管语义泛化。拼接本身不难难在维度管理。我吃过一次亏稀疏块开了二元组之后维度涨到二十多万稠密块只有 768直接横向拼起来之后稀疏块在数值上完全压倒稠密块模型几乎学不到稠密特征。后来改成两块各自归一化后再拼并且给稠密块乘了一个缩放系数问题才解决。具体的做法是稀疏块做 L2 归一化稠密块做标准化减均值除标准差然后按经验给稠密块一个 0.3 到 1.0 之间的缩放因子这个因子作为超参数去搜。别看这一步很土它比换模型带来的提升更稳定。提示拼接前务必打印两块的维度和取值范围。我在调试时习惯打一行日志稀疏块维度、均值、最大最小值稠密块维度、均值、标准差。这行日志能提前暴露 90% 的数值问题。4. 把各环节串成管道三种写法与各自的适用面4.1 声明式管道用名字和步骤描述整条链这是最经典的一种把每个环节包装成有fit和transform两个方法的对象然后用一个列表按顺序串起来。核心逻辑其实很短你自己写一遍比调包理解得更透class MiniPipeline: def __init__(self, steps): # steps: [(name, transformer), ..., (clf, estimator)] self.steps steps def fit(self, X, yNone): Xt X for name, step in self.steps[:-1]: Xt step.fit_transform(Xt, y) self.steps[-1][1].fit(Xt, y) return self def transform(self, X): Xt X for name, step in self.steps[:-1]: Xt step.transform(Xt) # 注意推理只用 transform return Xt def predict(self, X): return self.steps[-1][1].predict(self.transform(X))这三十行的价值在于它把契约写死了前面的环节只做形状变换最后一个环节才做预测训练时用fit_transform推理时只用transform。你在任何工具库里见到的管道本质都是这个结构的加强版。用它的时候有几个必须注意的点。第一步骤顺序不能随性凡是会 fit 出状态的词表、idf、标准化参数都必须放在模型前面模型永远在最后。第二中间任何一步的输出必须是二维的样本数 × 特征数如果你在哪一步返回了一维数组下一步就会报维度错误。第三步骤命名要能反映内容pre和step2这种名字三个月后你肯定认不出来。第四一旦上线步骤顺序和名字都不能改因为序列化文件里存的就是这些名字改了就读不出来。我做过的项目里这套结构最大的好处是整体调参。把整条管道当成一个估计器丢进网格搜索它会自动在每个折内重新 fit 词表和 idf不会泄漏。这一点非常关键——如果你手动先在全量数据上算好特征再去交叉验证指标会虚高好几个点而且虚高的程度随数据量变小而变大小数据集上甚至能虚高十几个点。4.2 配置驱动管道让流程本身可版本化当管道环节多起来之后代码里的步骤列表会变成一坨不好读也不好改的东西。这时候我会把流程抽成配置文件代码只负责按配置装配和执行。配置大概长这样version: 3 steps: - name: normalize type: TextNormalizer params: fullwidth_to_halfwidth: true drop_html_tags: true - name: tokenize type: Tokenizer params: mode: subword vocab_size: 20000 min_freq: 3 - name: vectorize type: TfidfVectorizer params: min_df: 3 max_df: 0.8 ngram_range: [1, 2] sublinear_tf: true max_features: 50000 - name: classify type: LinearClassifier params: C: 1.0 class_weight: balanced配置文件的最大好处是它可以进版本库。每一次实验对应一个配置版本出了问题时你能精确回答线上跑的是哪套参数。另外它天然支持对比实验同一份代码换一份配置跑两组结果互相之间只有参数差异归因非常干净。代价是多了一层间接。我的建议是环节少于四个、且只有你一个人维护时别上配置直接写代码更快一旦环节超过六个或者要交给别人接手配置化带来的收益就盖过成本了。还有一个细节配置里的类型名最好和代码里的类名一一对应别搞别名映射否则你会在配置里写的这个 name 到底指哪个类上浪费很多时间。4.3 预训练模型推理管道任务声明加批量执行如果整条链的中间环节都是固定的编码、截断、补齐、池化都是标准动作那就不必自己拼直接用现成的任务级管道接口。写法通常是声明任务类型和模型路径剩下的交给它# 概念示意具体接口名以你手头的库为准 infer build_pipeline( tasktext-classification, modelyour-local-model-dir, tokenizeryour-local-model-dir, device0, batch_size16, truncationTrue, max_length200, top_k1, ) results infer(texts) # texts 是字符串列表这条路省事但有几个地方必须自己兜住。第一批量。一条一条调用会慢到无法接受因为每条都要走一次前向GPU 利用率极低。实测下来批大小从 1 提到 16吞吐量能提升一个数量级再往上提升就变缓因为预处理成了瓶颈。第二长度分布。如果批量里混着一条超长文本和一堆短文本动态补齐会把所有样本都补到最长浪费严重。做法是按长度分桶桶内成批。第三标签顺序。任务级接口返回的标签名顺序不一定和你的业务标签体系一致一定要做一次显式映射别直接拿返回的下标当业务类别。吞吐量可以这么估假设单批 16 条的前向耗时 150 毫秒预处理 50 毫秒那就是 16 条 / 0.2 秒约 80 条每秒。但这是理想值实际还要算上排队、序列化、网络往返。线上做容量规划时我一般按理想值的六折来估留出余量。注意任务级管道内部往往已经把截断、补齐、softmax 都封装好了如果你在外面又做了一次截断两边参数不一致就会静默出错。要么完全交给它要么完全自己写别混着来。5. 训练、评估与推理的一致性管道最容易崩的地方5.1 切分顺序决定指标真假数据泄漏在文本任务里特别隐蔽。最常见的三种一是在切分之前用全量语料建词表或算 idf验证集的词频信息渗进了训练二是同一条原始文档被切成了多个块分块后随机切分导致同一个文档的块同时出现在训练和验证里三是近重复样本同一条内容被转载多次改了几个字分散在两个集合里。这三种情况下离线指标都会漂亮得不真实上线就掉。第一种的解法是永远先切分再 fit用管道的整体 fit 来保证。第二种的解法是按文档 id 做分组切分同一个文档的块只能落在同一个集合。第三种的解法是在切分前做一次近重复检测我常用的是字符级 n 元组加最小哈希阈值设在 0.85 到 0.9 之间把相似度超阈值的聚成一簇整簇一起分配。评估指标也要跟任务匹配。多分类且类别不平衡时只看准确率会骗人——假设 90% 是负类你全判负也能拿 0.9。我一般同时看宏平均 F1 和加权平均 F1前者对少数类敏感后者反映整体。两者的差距本身就是信息差距大说明少数类学得差该考虑类别权重或者重采样了。二分类还要看阈值别用默认的 0.5拿验证集画一条精确率召回率曲线按业务成本选点。比如漏判的代价是误判的五倍那就把阈值往低调牺牲精确率换召回率。5.2 整体调参与序列化把状态完整带走调参时把管道当整体这一点前面提过这里说操作层面的细节。搜索空间不要贪大文本任务上真正影响结果的就那么几个正则化强度、特征维度上限、n 元范围、最低文档频率。这四个各取三到四档组合起来就是几十组配合三折交叉验证在中小数据集上十几分钟能跑完。加太多参数进去搜索成本指数上升收益却很有限。序列化这块我踩过最疼的一个坑是只存了模型没存词表。序列化管道对象时要确保所有带状态的东西都跟着走词表、idf 权重、标签映射、截断长度、清洗规则的版本号。存完之后一定要做一次往返测试——重新加载拿十条件黄金样本跑一遍输出必须和保存前逐条一致。不一致就说明有状态没被存下来。还有一个更隐蔽的坑是环境版本。同一份序列化文件在不同版本的工具库下加载有时候不报错但结果会偏。我的做法是在序列化文件旁边附一个元数据文件记录依赖版本、训练时间、训练数据指纹。加载时先比对版本不一致就打警告。这个习惯看起来啰嗦但在需要回滚的深夜里它能救命。5.3 上线前的一致性校验清单上线前的最后一步我固定做三件事。第一件黄金样本回归。挑两百条覆盖各类边界情况的样本超短的、超长的、中英混排的、特殊符号多的、各类别均衡的人工确认过标签作为固定测试集。每次改动后跑一遍输出和上一次逐条比对看变化集中在哪些样本上。第二件端到端对拍。离线环境和线上环境各跑一遍同一批请求逐条比对输出。这一步能抓出配置不一致、模型文件版本不一致、预处理逻辑分叉这三类问题。第三件压力与降级。把并发打上去看延迟分布同时确认超时和异常输入时的降级路径是什么——是返回兜底标签还是直接报错。这个必须提前定不能等出事再想。第三件事经常被跳过但它是唯一能保证系统在异常情况下行为可预期的手段。我的经验值是正常路径的测试覆盖八成工作量异常路径的测试只占两成但它拦下的线上事故比例远高于两成。6. 常见问题与排查实录6.1 典型现象与根因速查下面这张表是我这些年攒下来的基本覆盖了管道类问题的高频场景。现象常见根因处理办法单条预测和批量预测结果不同推理时误用了 fit_transform或补齐长度依赖批次推理只用 transform补齐参数固定离线好、线上差一截清洗规则或词表版本分叉端到端对拍锁定同一份产物特征矩阵维度对不上步骤顺序变了或某步输出了一维打印每步输出形状加形状断言大量样本预测为同一类特征几乎全零词表过严或类别极度不平衡调低最低文档频率加类别权重模型文件加载后结果偏移只存了模型没存词表或 idf整体序列化做往返一致性测试交叉验证分数虚高切分前做了全局统计或分块样本跨集先切分再 fit按文档 id 分组推理延迟随并发线性恶化逐条调用批处理缺失长度分桶后批量推理长文本效果明显差硬截断丢了关键信息分块加重叠块向量加权汇总内存随进程数翻倍每个工作进程各加载一份模型共享内存或单进程批处理排查顺序我一般是这么走的先确认输入把进入管道的原始字符串打印出来看是不是你以为了的样子再确认中间形状每步输出维度打一行然后确认状态词表大小、idf 长度、标签映射条数最后才怀疑模型。这个顺序的价值在于它从最便宜的地方开始查。改模型要重训看输入只要一行日志。6.2 长文本、批大小与并行的实操取舍长文本是管道里最费资源的一块。我的处理策略分三档长度在编码上限以内直接编码超出一倍以内做头尾截取各一半结论在尾部的任务特别适用超出一倍以上分块加池化。分块的块长取编码上限的八成左右重叠取块长的一到两成。块数不要太多超过八块的收益就很小了反而把噪声也平均进来。批大小的选择有个简单的判断法从小到大试每次翻倍直到显存占用到七成或者延迟不再改善为止。我一般会停在显存七成的位置剩下的三成留给峰值波动。别把显存吃满因为预处理和推理是异步的峰值往往出现在两者重合的那一瞬间。并行这块要特别注意进程和线程的区别。多进程适合 CPU 密集的预处理清洗、切分但每个进程都会加载一份模型内存会翻倍。多线程适合 IO 等待但文本处理大多是计算密集收益有限。我见过最省资源的做法是把预处理和推理拆成两个阶段中间用队列连接预处理多进程、推理单进程批处理两边各自调优。6.3 几条不在文档里的心得第一条先搭最丑的管道再优化。我刚开始做这类项目时喜欢先把每个环节打磨到最好结果两周过去管道还跑不通端到端。后来改成先串一条能跑通的最简版本——一个粗糙的清洗、一个高频词表、一个线性模型——跑通、量化、落盘然后再逐个环节替换和对比。有了这条基线任何改动都能立刻看出是涨还是跌。第二条给每个环节留一个旁路开关。清洗可以关、特征拼接可以关、分块可以关。调问题时把可疑环节一个个关掉看指标怎么变比读代码快得多。这个开关在排查到底是哪一步把效果搞坏的时候特别有用。第三条中间结果要落盘。我在管道里加了一个可选的数据落盘钩子把切分后的词、向量化的稀疏行、模型的原始分数都存一份。出问题时直接看这些文件不用重跑。代价是磁盘收益是排查速度我觉得非常值。第四条别迷信端到端。文本任务里规则和模型结合往往比纯模型稳。比如某些类别有明显的关键词特征加一条规则前置拦截能显著降低这类上的漏判而且规则的行为是完全可预期的。前提是规则要能开关、要能统计命中量、要在版本里记录否则它很快就会变成没人敢动的一块黑箱。最后分享一个我一直在用的检查方法每次改完管道不看总体指标先挑十条预测翻转的样本逐条读。总体指标涨了 0.5 个点可能是一百条修好、五十条弄坏看翻转样本能立刻告诉你改动到底在什么类型的文本上起作用。这个习惯帮我发现过好几次指标涨了但逻辑明显不对的情况比如模型开始靠标点符号判断类别——指标确实涨了但那是数据里的偶然相关换一批线上数据就崩。管道这件事做到最后你会发现拼的不是模型是纪律每个环节的输入输出契约写清楚每个状态的来源记清楚每次改动的影响能量清楚。这三件事做到了换个模型、换个任务套路基本不用变。