我一开始接触AI工程(AI engineering)时,目标朴素得连自己都想笑:能跑通一个项目就够了。可真把项目拉到生产环境跑,第一次就被线上评论分类器的乱判打脸了——训练时明明有87%的准确率,上线后却把正常评论当成垃圾。排查了一圈,问题居然出在一行没有调用的文本清洗函数上。
从那时起,我决定用最笨的方式自己从头搭一遍AI工程基础链路:环境、数据、模型、训练、部署、监控,每一步都亲手做,不依赖一键库,不复制现成模板。于是有了这篇 ai-engineering-from-scratch 的实战记录,目的是给那些和我一样——学过一点机器学习知识、但面对真实项目总是无从下手的人,提供一条可以照着走的完整路线。
这篇文章不要求你懂复杂数学,也不需要显卡。我会用一个评论情感分类器作为贯穿案例,从两万条原始文本开始,逐步构建出一个可以真实调用的API服务。整个过程里我踩过的坑,也会原原本本说出来,包括那些查了一下午才发现是自己造成的低级错误。
1. 为什么“从零开始”比直接调库更有价值——AI工程与传统开发的分水岭
1.1 调包侠陷阱:能跑通demo和能做工程是两回事
很多入门者会陷入一种错觉:在notebook里加载一个预训练模型,跑通官方示例,就觉得已经掌握AI工程了。我曾经也这么想。直到第一次接手一个实际项目,才发现传统的“调包”经验在AI工程里根本不通用。
传统软件开发里,调用一个库函数,输入输出是可预期、确定性的。你传两个数字给加法函数,它永远返回正确的和。但机器学习系统完全不一样:模型的行为是由训练数据决定的,数据分布一变,模型输出就可能完全不可控。这意味着,做AI工程的日常不是“写代码”,而是“管理不确定性”。
举个真实场景。我线上部署过一个评论分类器,模型本身没改一行代码,某天突然开始把大量正常评论判断成垃圾。排查到最后,原因竟然是线上接收的新评论里出现了之前从未见过的全角标点,预处理环节没清洗干净,模型在“分布之外”的输入上就瞎了。这种问题,没有从数据到部署的全局视角,根本不知道从哪查起。
1.2 为什么选评论分类器作为从头搭建的第一个项目
我最终选定的贯穿案例,是一个英文评论情感二分类器:输入一段评论文本,输出正面还是负面。这个项目看起来不起眼,但该踩的坑一个都不少。
选它有几个非常现实的理由:数据好找,公开的评论数据集一抓一大把,不需要折腾爬虫;标签明确,正面负面靠常识就能判断,不需要领域专家;文本预处理环节丰富,编码、清洗、分词、向量化全部覆盖;模型可以做得小而精,一个两层神经网络就够了,CPU就能训练,不用为显卡发愁。
更重要的是,从工程角度看,它覆盖了AI工程闭环里的所有关键节点:数据的获取与清洗、特征表示、模型训练与评估、部署上线与监控。等于用最低的成本,把完整链路走了一遍。
1.3 AI工程闭环的四个阶段
做AI工程跟做传统项目最大的不同,在于它是一个“闭环”而不是一条“直线”。我习惯把它拆成四个阶段:数据工程——把原始数据变成机器能吃的干净格式;特征与模型——选择合适的方式表示数据,设计模型结构;训练与评估——迭代优化,判断模型是否真的“会了”;部署与监控——把模型封装成服务,持续观察它在真实环境中的表现。
这四个阶段不是依次执行的,而是会反复循环。模型上线后表现不好,可能回去改数据清洗逻辑;监控发现输入分布变了,可能又要重新训练。这篇文章的章节顺序,就是按这个闭环推进的,每一步都会写清楚我是怎么做、为什么这么做、踩了什么坑。
2. 环境与项目骨架:被大多数人忽略的工程第一步
2.1 Python环境:我为什么没有用Anaconda
很多人起步时图省事,直接装Anaconda,所有包都塞进base环境,用的时候pip install一下。前两个星期很爽,三个月后就开始痛苦:依赖冲突、环境臃肿、版本不明,项目根本没法复现。
我现在的习惯是用 pyenv 管理Python版本,再用 venv 为每个项目建独立环境。Anaconda也不是不能用,但它默认的包管理和base环境策略,会把“环境隔离”这件事的约束变得太弱。你试过的包越多,环境越脏,最后连自己都说不清项目到底依赖了什么。
实测下来,pyenv加venv这套组合的优点是:每个项目从Python版本到第三方库完全隔离,删掉一个项目不会污染其他项目;切目录自动切环境,不会出现“在A项目里import了B项目的包”这种离谱问题;部署到服务器时,环境构建过程干净透明。
pyenv install 3.11.9 pyenv local 3.11.9 python -m venv .venv source .venv/bin/activate pip install --upgrade pip四行命令,环境就绪。注意 pyenv local 会在当前目录生成一个 .python-version 文件,整个团队只要装了pyenv,进入目录自动切换到对应版本,这是它能复现的第一步。
2.2 项目目录结构:从第一天就按工程化组织
搭建环境之后,紧接着要做的不是写代码,而是把项目目录设计好。我看到太多人的AI项目长这样:项目文件夹里躺着几个命名混乱的notebook,数据散落在Downloads和桌面,模型权重随便存了个model_v2_final真的不改了.pkl。
我的做法是分四个目录,职责分明:
data/raw/ 原始数据,只读不写 data/processed/ 清洗后的数据,可复现生成 models/ 模型参数和配置 src/ 所有源代码 tests/ pytest测试这样划分的逻辑是:原始数据是不可再生的资产,必须保持只读;处理后的数据是流水线产物,任何时候删掉都能重新生成;模型参数是制品,可以存档可以替换;源代码是整个工程的心脏,必须纳入版本管理。
不是说要一上来就把工程往大了做,而是要在第一天就养成“每个文件该放哪”的习惯。后面前置那些让人崩溃的坑,有一半都是因为文件乱放、环境混乱造成的。如果从一开始就按这个结构组织,排查问题的范围会小很多。
2.3 依赖锁定:让两周后的自己能再跑起来
环境搭建的最后一步,是锁定依赖版本。很多人不重视这一点,觉得写个requirements.txt就够了。问题是,pip freeze 生成的版本列表只记录了顶层包,如果哪天numpy升级了,即使你没主动升级任何包,新装环境时也可能装上不兼容的版本,结果就是项目跑出来的结果和之前不一样,而且完全不知道为什么。
我的经验是,直接用 pip freeze 把当前环境所有包版本固定下来,生成的requirements.txt提交到Git里。这虽然粗暴,但对于单人项目和中小团队已经足够了。更讲究一点可以用pip-tools维护 pyproject.toml,但那是另一个复杂度级别的话题,新手先把 pip freeze 版本锁好就成功了一大半。
3. 数据管道:从两万条原始文本到干净向量的完整过程
3.1 读取CSV时最容易忽略的编码问题
我的数据是一份两万条左右的公开英文评论CSV,每行两列:text和label。看起来再简单不过,第一行代码就给我上了课。
import pandas as pd df = pd.read_csv('data/raw/comments.csv', encoding='utf-8-sig')注意encoding参数,我写的不是常见的utf-8,而是utf-8-sig。很多从Excel或者Windows系统导出的CSV文件,文件开头会带一个不可见的BOM标记。如果你用utf-8读取,第一个字段名会变成“\ufefftext”,你按“text”去访问就直接KeyError了。这种错误很气人,因为它报错的位置和真正的根因离了十万八千里。
读取之后,先别急着清洗,我习惯做三件例行检查:看看每列的空值情况,有多少行是空文本;看看label的分布,正面负面是否严重失衡;看看文本长度的粗略分布,有没有异常长的广告文或者异常短的废话。这几步能帮你建立对数据的直觉,而不是一上来就盲写清洗逻辑。
3.2 文本清洗:哪些能删,哪些必须留
文本清洗是数据管道里最容易被低估的一环。很多人上来就是一句 pattern 正则替换掉所有非字母字符,结果把“!!!”这种强烈的情感信号也一并删了,对情感分类来说这等于主动毁掉一个重要特征。
我在这个项目里的清洗规则,每条都经过了实测:
import re def clean_text(text): # 去HTML标签和URL,它们跟情感无关 text = re.sub(r'http\S+|www\.\S+', ' ', text) text = re.sub(r'<[^>]+>', ' ', text) # 统一小写,降低词表规模 text = text.lower() # 保留字母、数字、空格,以及感叹号和问号 text = re.sub(r'[^a-z0-9\s!?]', ' ', text) # 合并多余空格 text = re.sub(r'\s+', ' ', text).strip() return text注意正则里保留了感叹号、问号和字母数字,其他的符号全部清掉。做过一个对照实验,如果把感叹号也删掉,验证集准确率掉了大约两个百分点。原因很简单:“amazing!!”和“amazing.”表达的情绪强度完全不同。
另外我还试过是否该去停用词,最终结论是:不去。停用词在大多数NLP任务里是噪音,但在情感分类里,“not good”里那个“not”就是决定性信号,你把它删了句子就从否定变肯定了。这个坑不少资料都提过,但只有亲手跑一遍对比实验,才会真的记住。
3.3 分词与词表构建
英文文本清洗完后,分词就简单了,直接按空格切开:
df['tokens'] = df['text'].apply(lambda x: x.split())中文文本到这里就会比较麻烦,需要额外引入分词工具,但本文案例是英文评论,最简单的空格切分足够。关键是切完之后怎么构建词表。
我的做法是:统计全部文本的词频,只保留出现次数不少于5次的词。这样做的目的是过滤掉低频噪声——比如某条评论里出现一次的人名、拼写错误,它们对模型训练不但没有帮助,还会增加词表规模,让输入向量维度虚高。过滤完之后,我这边两万条评论的词表从三万多个词收敛到了一万二左右,计算压力小了很多。
接着给每个词分配一个固定编号,外加两个保留符号:UNK(未知词)和PAD(填充)。实际实验里,“UNK一定要有”这条经验非常关键。因为模型部署后遇到新评论,一定会出现训练数据里没见过的词,如果你没有UNK机制,线上预处理就会直接报错或者产生一个错误的词表下标。
3.4 向量化:先别急着上TF-IDF和Embedding
向量化阶段,大多数人第一反应是上TF-IDF或者预训练Embedding。我的建议是,从零搭项目的时候先用最简单的方式:词袋向量,也就是统计每个词在当前文本中出现的次数。
def text_to_vector(tokens, vocab, vocab_size): vec = np.zeros(vocab_size, dtype=np.float32) for tok in tokens: idx = vocab.get(tok, vocab.get('UNK')) vec[idx] += 1 return vec这么朴素的表示,准确率当然不会惊艳。但它的价值在于,所有环节你都能一眼看懂——模型输入、矩阵维度、梯度计算,每一步都是透明的。TF-IDF和预训练Embedding确实更强,但它们的引入会掩盖很多基础问题。从零开始做工程的第一目标不是刷分数,而是建立正确的心智模型,词袋向量就是你用来理解模型机制的最短路径。
数据的最后一步是把向量和标签组装成模型可用的格式,并保存到data/processed目录。这里有个细节:处理后的数据文件命名要包含数据集的统计信息,比如processed_vocab12300_mincount5.csv,这样下次想复现时,根据文件名就能知道当时的配置。
4. 手写一个两层神经网络并训练评论分类器
4.1 模型结构设计与参数初始化
数据准备好之后,终于到了最核心的部分:手写神经网络。我用的是一个经典的两层全连接结构:输入层(词表大小)→ 隐藏层(64个神经元)→ ReLU激活 → 输出层(2个类别)→ Softmax。
选择两层结构的原因是:单层网络本质上是线性模型,对评论这种复杂文本几乎无能为力;而三层以上的网络对新手来说,调参难度会急剧上升,一旦模型不收敛,你很难判断问题出在代码还是超参数。所以从零开始,两层是最优解。
再来看参数初始化。这一步很多人会忽略,直接全零初始化,结果训练很久准确率纹丝不动。问题在于:如果所有神经元初始参数相同,反向传播时的梯度也会相同,神经元之间永远不会产生差异,整个隐藏层形同虚设。
我用的方法是He初始化,也就是针对ReLU激活函数设计的初始化方式:
def init_params(input_dim, hidden_dim, output_dim): W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) b1 = np.zeros(hidden_dim) W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) b2 = np.zeros(output_dim) return W1, b1, W2, b2初始化权重时,用标准差缩放控制每一层输出的方差,让信号在传播过程中既不会爆炸也不会消失。
4.2 前向传播与交叉熵损失
前向传播就是把输入数据从第一层传到输出层,得到预测概率。具体来说,输入向量x先经过第一层线性变换,得到z1,然后经过ReLU激活函数,把负值全部归零,得到激活值a1;再经过第二层线性变换得到logits,最后通过Softmax函数将logits转换成两个类别的概率分布。
def forward(x, params): W1, b1, W2, b2 = params z1 = x @ W1 + b1 # (batch, hidden) a1 = np.maximum(0, z1) # ReLU logits = a1 @ W2 + b2 # (batch, 2) exp_logits = np.exp(logits - np.max(logits, axis=1, keepdims=True)) probs = exp_logits / exp_logits.sum(axis=1, keepdims=True) return a1, probs代码里有一个小细节:计算Softmax时先减去批次内最大的logits,这叫数值稳定技巧。如果不做这一步,当logits里的数值稍微大一点,比如变成100,计算np.exp(100)会直接溢出成inf,损失函数变成NaN。这是我实际踩过的坑,而且是整整查了一个小时才发现的。
损失函数选交叉熵,公式是 L = -log(p_y),其中p_y是模型分配给真实类别的概率。它的好处是,如果模型对正确类别非常有信心,概率接近1,损失接近0;如果模型自信满满地预测错了,概率接近0,损失会变成无穷大,给优化器一个极强的修正信号。
4.3 反向传播的推导与实现
反向传播是整个手写神经网络里最容易写错的部分。但它的核心思想就一句话:链式法则——从最终损失出发,逐层计算每个参数对损失的影响程度,也就是梯度。
先讲一个关键结论:对于Softmax加交叉熵这个组合,损失对logits的导数有一个非常漂亮的简化形式,直接就是模型预测概率减去真实标签的one-hot向量:
dlogits = probs - y_onehot这个公式是我当年第一次见到时拍案叫绝的。它说明当预测概率和真实标签完全一致时,梯度为零;不一致时,梯度方向指向真实标签。直观又好记。
剩下的梯度计算就是沿用链式法则逐层往回传:
def backward(x, y_onehot, a1, probs, params): W1, b1, W2, b2 = params dlogits = probs - y_onehot # (batch, 2) dW2 = a1.T @ dlogits # (hidden, 2) db2 = dlogits.sum(axis=0) da1 = dlogits @ W2.T # (batch, hidden) dz1 = da1 * (a1 > 0) # ReLU的导数 dW1 = x.T @ dz1 # (input, hidden) db1 = dz1.sum(axis=0) return dW1, db1, dW2, db2这里最需要小心的是ReLU的梯度。ReLU的导数是:输入大于0时导数为1,输入小于等于0时导数为0。用代码实现时,直接用 (a1 > 0) 作为掩码乘上去就行,因为 a1 是经过ReLU激活后的值,如果它是0,说明对应神经元没有被激活,梯度就应该在这里截断。
4.4 梯度检查:训练前必须做的一步
手写反向传播最恐怖的事情不是推不出公式,而是推出来的公式有个符号错了、维度错了,却死活看不出来。所以我强烈建议,在正式训练之前,先做一次梯度检查——用数值方法近似计算梯度,和解析梯度对比。
数值梯度的原理很简单:对每个参数,加一点点扰动e,然后看损失函数的变化量,也就是 (L(x+e) - L(x-e)) / 2e。
def numerical_grad(f, params, idx, eps=1e-5): params_plus = params.copy() params_plus[idx] += eps params_minus = params.copy() params_minus[idx] -= eps return (f(params_plus) - f(params_minus)) / (2 * eps)把解析梯度和数值梯度放在一起,逐项对比它们的相对误差。如果某个参数的误差大于1e-4,基本就可以断定反向传播的相应部分写错了。我第一次做梯度检查时,就发现b2的梯度符号反了,用了半小时就看出来了,而没有梯度检查的话,这个问题可能要到训练环节才会以“损失不降”的形式暴露出来,到那时候排查成本就高多了。
5. 训练流程里的性价比动作:损失曲线、过拟合与Mini-Batch
5.1 数据集拆分:训练、验证、测试各归其位
模型代码写对之后,下一步是训练。但训练前还有一件重要的事:把数据集拆分成三个部分。
我这里的比例是60%训练、20%验证、20%测试,并且拆完之后先验证一件事:确保三份数据之间没有任何重叠。这一步看似多余,但后面踩坑里会专门讲,这是我最悔恨的教训之一。
三个数据集各有职责,千万别混用。训练集用来更新模型参数,验证集用来调整超参数、判断是否过拟合,测试集最后只用一次,用来衡量模型在全新数据上的真实表现。最常见的错误是,拿测试集来回试不同超参数,试到最后测试集已经泄露了信息,显示在测试集上的分数虚高,上线就露馅。
5.2 mini-batch训练与学习率的选择
训练时我用的批大小是64,也就是每次从训练集里随机抽64条样本,计算梯度并更新参数。全部样本过完一遍叫做一个epoch,我默认跑了100个epoch。
Mini-batch的逻辑也很直观:用5个样本更新一次参数,太不稳定;把两万条全部算一遍再更新,又太慢。取一个中间量,既能利用矩阵运算的并行加速,又能让梯度更新方向带有一定随机性,反而有助于跳出局部极小点。
训练中最关键的超参数是学习率。我做了几个对照实验,结果非常直观:
| 学习率 | 训练集表现 | 验证集表现 | 现象观察 |
|---|---|---|---|
| 1.0 | 损失变成NaN | 不可用 | 梯度爆炸 |
| 0.1 | 震荡剧烈 | 稳定在50%左右 | 从一开始就在发散边缘 |
| 0.01 | 平稳下降 | 稳步提升 | 收敛,最终约87% |
| 0.001 | 下降缓慢 | 几乎不涨 | 学到太慢,100个epoch不够 |
0.01是效果与效率的平衡点。这个结论只对这个模型和这份数据有效,但“先粗扫一个量级范围,再在最优量级附近精调”的方法是可复用的。
5.3 过拟合信号与我的干预方式
随着epoch增加,我注意到一个典型的过拟合模式:训练集准确率一路涨到97%,验证集却停在87%左右不动了。这说明模型开始“背诵”训练数据,而不是“理解”评论内容了。
标准的应对手段有三件:提前停止、L2正则化、Dropout。我这个项目里用了前两种。
提前停止就是监控验证集指标,连续几个epoch不涨了就停止训练,防止模型在训练集上越背越熟。实现起来非常简单,每次epoch结束保存当前验证损失,如果验证损失连续5个epoch没有创下新低,就停止训练并回滚到历史最优参数。
L2正则化是在损失函数里加一项参数平方和的惩罚,让权重系数不要变得太大。实现方式是在原始损失上加一个 lambda * (sum(W1 ** 2) + sum(W2 ** 2)) / 2。这个惩罚的直观理解是:权重太大说明模型过度依赖个别特征,而正则化能迫使模型使用更多的特征、更平滑地进行预测。
我把lambda设为0.01,最终测试集准确率稳定在89%左右,F1分数0.86。看上去只比定点87%好了一点,但关键不是分数提升,而是模型在验证集上的表现变得稳定了,不再出现训练集97%、验证集87%这种刺眼的落差。
6. 从Notebook到线上API:模型序列化、接口封装与最小监控
6.1 模型对象的序列化设计:不要无脑pickle
训练结束后,摆在面前的问题是怎么把模型保存下来,并且部署到线上使用。最直觉的做法是pickle.dump整个模型对象,我做过,后来后悔了。
因为pickle是Python专用的二进制格式,它保存的是整个对象的内存状态。问题是,如果你的环境版本变了,比如numpy从1.24升到2.0,老文件很可能反序列化失败。而且pickle的文件里还可能携带不必要的Python内部信息,既不安全也不跨语言。
更工程化的做法是:只保存模型的权重参数,以及重建模型所需的元数据。用我的做法就是三个文件:
np.savez_compressed('models/model_params.npz', W1=W1, b1=b1, W2=W2, b2=b2) with open('models/vocab.json', 'w', encoding='utf-8') as f: json.dump(vocab, f) with open('models/config.json', 'w', encoding='utf-8') as f: json.dump({'hidden_dim': 64, 'vocab_size': len(vocab)}, f)这三个文件各自独立,模型参数、词表、配置全都解耦。以后想换更大模型、新增类别、修改词表,只需要更换对应文件即可,不会互相拉扯。
6.2 FastAPI封装推理接口
模型加载与推理逻辑我用一个类来封装,核心是load方法。这个类的好处在于:训练和部署时引用的是同一套预处理逻辑,从源头上杜绝了“训练时做了清洗、推理时忘了”这种事故。
from fastapi import FastAPI from pydantic import BaseModel class CommentRequest(BaseModel): text: str class CommentResponse(BaseModel): label: int score: float app = FastAPI() model = CommentClassifier.load('models/') @app.post('/predict', response_model=CommentResponse) def predict(req: CommentRequest): label, score = model.predict(req.text) return CommentResponse(label=label, score=score)接口本身极其简单,POST请求携带一段文本进来,返回预测标签和置信分数。但有两个工程细节是不可省的。
第一个是Pydantic的请求模型,FastAPI在接收请求时会自动校验字段类型。如果有人传了个missing text字段,接口直接返回400错误,不用我们自己写各种if判断。
第二个是输入长度限制。线上评论动辄几万字符,如果不加保护,服务很可能被一个超长文本拖垮。我的做法是:超过2000字符的文本只截取前2000个,并在日志里记录截断标记。这既保护了服务稳定性,又不影响绝大多数正常评论。
6.3 最小监控:结构化日志
模型上线之后,最怕的不是出bug,而是出了bug你完全不知道。所以从第一天起就要做监控。最实用的最小方案是结构化日志——把预测的关键信息全部写入日志文件。
def predict_with_log(text): start = time.time() label, score = model.predict(text) elapsed = (time.time() - start) * 1000 log_data = { 'text_len': len(text), 'label': label, 'score': float(score), 'latency_ms': round(elapsed, 2), 'truncated': len(text) > 2000 } logger.info(json.dumps(log_data)) return label, score日志里记录文本长度、预测标签、置信分数、推理耗时、是否截断。这样一旦线上出现问题,我可以直接分析日志:是不是突然出现大量超高置信的误判?是不是某类文本的耗时暴涨?数据全部有迹可循。等流量大了以后,还可以把这些结构化日志接入监控系统做实时指标展示,但那是后话了,先把日志留好是第一步。
6.4 用Docker固定运行时环境
本地调试通过后,部署时我用了Docker。这一步的好处是把运行环境整个固化下来,服务器上不用再装Python、不用管numpy版本,一个镜像就是一份可复现的环境。
我用的Dockerfile是两阶段构建,先在一个临时容器里安装依赖,再把产物复制到干净的运行时镜像里,这样最终镜像体积能小很多。模型文件我建议用volume挂载,而不是打进镜像里。原因是模型参数会经常更新,如果每次重新训练模型都要重新构建镜像,工作流会非常笨重。挂载的话,只替换服务器上的模型文件就能完成升级,接口代码都不用改。
到这里,整个项目已经从一个notebook变成一个有接口、有监控、可复现的服务了。但真正让这个项目有价值的,反而是那些我反复折腾、差点放弃的时刻。
7. 栽过的几个跟头:完整排查链路
7.1 验证集比测试集好太多?原来是数据泄露
我在早期版本中发现一个诡异的信号:验证集准确率高达94%,但去跑测试集只有87%,差了7个百分点。按常理,验证集和测试集都来自同一个数据分布,二者表现不会差这么多。
最开始我怀疑过拟合,就加了正则化,没用。然后怀疑训练集和验证集的分布不一致,准备去重新抽样,赌气之下我决定先做个简单的检查:把训练集和验证集的文本逐一比对。结果发现,因为原始CSV里存在重复评论,有些相同的句子被随机分到了训练集和验证集两边。模型在训练里见过这些话,到了验证集自然“背得”出来,准确率虚高。
完整的排查链路是:先怀疑过拟合——加了正则化,指标没有改善;再怀疑数据切分逻辑——看拆分代码,没发现问题;最后打印训练集与验证集的重复数量——发现大量重复,确认是数据泄露。修复方法很简单,在切分前先按文本去重,并把重复数据单独清理掉。修复后验证集准确率下降到和测试集差不多的水平,虽然数字不好看了,但这份指标才是可信的。
这条踩坑经历告诉我:任何验证集上的指标,第一步都要质疑它是不是真的来自“没见过的数据”。对文本类任务,去重不是可选项,而是必须项。
7.2 推理时忘了调用文本清洗函数
第二个坑是把我坑得最惨的:模型在本地测试完美,部署到线上接口后,几乎所有评论都被判成负面。我一度怀疑是模型文件损坏,重新训练了两轮也没解决。后来偶然对比了本地调用和线上API接收的同一段文本,才发现问题。
本地测试时,我习惯写了一个 text = clean_text(text) 再传给模型。但部署时封装接口,我竟然忘了调用clean_text,导致线上收到的原始文本直接进入分词和向量化流程。那些带HTML标签、URL、大写字母的文本,在词表里匹配不到,全部变成UNK,模型的输入几乎等于噪音。
这个问题的修复只需要一行代码:在接口的predict里加上 text = clean_text(text)。但它引发的思考是:为什么光靠代码审查没发现?因为本地和线上走了两套不同的调用路径,训练习惯性地把预处理写在notebook里,而接口另有入口。从那之后我强制规定:所有项目必须有一个统一的预处理模块,训练和推理都从同一个模块导入,不允许各自维护一份清洗逻辑。
7.3 依赖版本漂移导致结果不可复现
还有一次,项目搁置了两个星期再跑,同样的代码、同样的数据,训练出来的准确率却比之前低了2%。我没有改任何代码,很费解。后来用pip list对比当前环境与requirements.txt,发现numpy从1.24.x被升级到了1.26.x——因为我在别的项目里执行过pip install,把全局环境里的numpy版本带高了一点。
这事的教训就是环境隔离不到位,以及版本锁定不严格。解决的方案就是第二章里说的:给每个项目独立venv,用pip freeze锁定版本。搁置再久,只要用requirements.txt重建环境,就能完全复现之前的结果。文章写到这里,你可以看到,很多所谓的“玄学问题”,根源都是工程习惯问题。
7.4 类别不平衡带来的指标幻觉
最后再讲一个我把“准确率”当唯一指标的教训。这个数据集的类别分布不太均衡,负面评论大约占六成。模型很快学会了一个偷懒策略:全预测负面,就能拿到60%的准确率。
只看准确率的话,会觉得模型还不错。但看分类正类别的召回率和精确率,就会发现正面评论几乎全被漏掉了。这个问题的本质是:准确率对类别不平衡非常不敏感。正确做法是用混淆矩阵加F1分数来评估,综合考虑精确率和召回率。我在训练时加入了类别权重,让模型在预测数量较少的类别时,误分类代价更高,最终把F1从0.71拉到了0.86,正负两类的召回率都变得合理。
这类问题属于评估指标选错,不属于代码bug。但它比代码bug更危险,因为它会让模型带着明显的缺陷上线。现在我做任何AI项目,第一步就是把正负样本比例、混淆矩阵、F1这些基础指标全部列出来,再决定要不要往下走。
项目跑通那天,我做了件相当没出息的事——把博客评论区那些“博主在吗,加我微信”的垃圾评论逐条喂给我自己搭的接口。看到它们大多被判定为垃圾内容,概率还不低,我是真的有点高兴。这个系统不算聪明,可它是我从数据到模型、从代码到服务一路亲手搭起来的,每一处都清楚底细。
如果你也在从零开始学AI工程,我的建议只有一条:别急着追新模型新框架,先完整地走一遍这个最基础的项目。过程中你会真正明白,数据、模型、训练、部署、监控这些事情是怎么拧在一起的。这个体会,是看再多教程也换不来的。