NLP流水线实践笔记

“NLP 项目最难的” —— 一个被脏文本折腾到凌晨的人

半年前接手了一个文本分类项目,当时的状况大致这样:数据散落在三个不同的数据库表里,预处理脚本版本不统一,模型训练全靠 Jupyter Notebook,部署是直接把 pkl 文件 rsync 到服务器。

问题的起点

半年前接手了一个文本分类项目,当时的状况大致这样:数据散落在三个不同的数据库表里,预处理脚本版本不统一,模型训练全靠 Jupyter Notebook,部署是直接把 pkl 文件 rsync 到服务器。

第一次线上事故是因为分词逻辑不一致导致的:训练环境用的 jieba 精确模式,部署环境用的全模式,输入"北京大学",训练时切分成"北京大学",部署时切分成"北京"“大学”,特征向量直接就对不上了。

排查了三个小时,最后在凌晨两点发现是一个实习生把部署脚本里的一行注释删了。那一刻就知道,这套方式必须要改。

先说场景

项目本身不复杂:用户上传一些文本,系统判断属于哪个类别(大概 20 个类别),然后分发到不同处理队列。数据量每天大概 3-5 万条,延迟要求控制在 200ms 以内。

看起来简单,但实际问题不少:

  1. 文本质量差异很大,有完整句子,也有半截话、乱码、emoji 混合
  2. 领域专业词汇多,通用分词器表现一般
  3. 类别不平衡,某些类别样本很少
  4. 业务端对准确率和召回率都有要求,不能只优化 F1
  5. 线上环境资源有限,模型不能太大

这些条件决定了我们不能直接拿预训练模型微调就完事,必须把预处理、特征工程、模型选择和部署优化都当成独立问题来处理。

文本预处理:看起来简单,实际麻烦

预处理这部分花的时间比预期多,主要是因为业务场景的特殊性。

基础清洗

一开始写了个很基础的清洗函数:

import re
import jieba
from typing import List

def basic_clean(text: str) -> str:
    """基础文本清洗"""
    if not isinstance(text, str):
        return ""

    # 去掉多余空格
    text = re.sub(r'\s+', ' ', text)

    # 去掉特殊符号,保留中英文、数字、基本标点
    text = re.sub(r'[^一-龥a-zA-Z0-9,。!?、;:""'',.!?;:"\'-]', ' ', text)

    # 统一标点符号
    text = text.replace(',', ',').replace('。', '。')

    return text.strip()

这个函数用了两个月,后来发现两个问题:

  1. 过滤掉特殊符号时把一些有用的信息删了,比如 $50 里的 $v1.2 里的 .C++ 里的 +
  2. 统一标点符号没有考虑中文半角混用的情况

改了一下:

def improved_clean(text: str) -> str:
    """改进后的文本清洗"""
    if not isinstance(text, str):
        return ""

    # 转半角标点为全角
    text = text.replace(',', ',').replace('.', '。')
    text = text.replace('!', '!').replace('?', '?')

    # 保留版本号、货币符号、代码相关的符号
    text = re.sub(r'[^一-龥a-zA-Z0-9$.,+#\-,。!?、;:""'',.!?;:"\'/\\]', ' ', text)

    # 去掉多余空格
    text = re.sub(r'\s+', ' ', text)

    return text.strip()

分词优化

通用分词器在专业词汇上的表现不好,比如"分布式一致性哈希",jieba 切成"分布式"“一致性"“哈希”,虽然也对,但语义信息会分散。

试了两种方案:

方案1:自定义词典

# 加载专业词汇词典
jieba.load_userdict('domain_words.txt')

# domain_words.txt 内容示例
# 分布式一致性哈希 3 n
# 向量数据库 3 n
# 零信任架构 3 n

这个方案简单有效,但需要人工维护词典,新词汇出现就要更新。

方案2:基于 BERT 的预分词

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained('bert-base-chinese')

def bert_tokenize(text: str) -> List[str]:
    """使用 BERT tokenizer 进行预分词"""
    tokens = tokenizer.tokenize(text)
    # 去掉 [CLS] 和 [SEP]
    return tokens[1:-1] if tokens else []

BERT tokenizer 的优势是能更好地处理未知词,但缺点是分词结果比较细粒度,可能会影响后续特征提取。

我们最终结合了两种方案:先加载专业词典,再用 BERT tokenizer 做一次后处理,把明显应该合并的 token 合并掉。

def combined_tokenize(text: str) -> List[str]:
    """组合分词方案"""
    # 先用 jieba + 自定义词典
    jieba_tokens = list(jieba.cut(text))

    # 再用 BERT tokenizer 细化
    bert_tokens = []
    for token in jieba_tokens:
        sub_tokens = tokenizer.tokenize(token)
        if len(sub_tokens) == 1:
            bert_tokens.append(sub_tokens[0])
        else:
            # 保留原始 token,除非明显是拆分错误
            if all(t.startswith('##') for t in sub_tokens[1:]):
                bert_tokens.extend(sub_tokens)
            else:
                bert_tokens.append(token)

    return bert_tokens

这个方案效果不错,但有个问题:速度慢。在 Intel i7-12700K 上处理 1000 条文本需要 2.3 秒,远超 jieba 的 0.15 秒。

后来做了个妥协:训练时用组合分词,推理时只用 jieba 加自定义词典。虽然损失一点精度,但延迟从 2.3 秒降到了 0.18 秒。

归一化处理

文本归一化这部分踩过几个坑:

def normalize_text(text: str) -> str:
    """文本归一化"""
    # 数字归一化:把连续数字替换为 <NUM>
    text = re.sub(r'\d+', '<NUM>', text)

    # URL 归一化
    text = re.sub(r'http[s]?://\S+', '<URL>', text)

    # 邮箱归一化
    text = re.sub(r'\S+@\S+', '<EMAIL>', text)

    # 日期归一化
    text = re.sub(r'\d{4}[-/年]\d{1,2}[-/月]\d{1,2}[日号]?', '<DATE>', text)

    return text

问题出在数字归一化上:我们业务里有些数字是有含义的,比如版本号 v1.2、价格 100元、ID user_123456。这些被统一替换成 <NUM> 后,信息就丢了。

改进版:

def smart_normalize(text: str) -> str:
    """智能归一化"""
    # 先保护一些特殊模式
    protected_patterns = {
        'version': r'\bv\d+\.\d+(\.\d+)?\b',  # v1.2.3
        'price': r'\d+(元|块|美元|USD)?',     # 100元
        'id': r'[a-z]+_\d+',                  # user_123456
    }

    protected = {}
    for name, pattern in protected_patterns.items():
        matches = re.finditer(pattern, text)
        for i, match in enumerate(matches):
            placeholder = f'<{name}_{i}>'
            protected[placeholder] = match.group()
            text = text.replace(match.group(), placeholder)

    # 再做通用归一化
    text = re.sub(r'\d{4}[-/年]\d{1,2}[-/月]\d{1,2}[日号]?', '<DATE>', text)
    text = re.sub(r'http[s]?://\S+', '<URL>', text)
    text = re.sub(r'\S+@\S+', '<EMAIL>', text)

    # 最后还原保护的文本
    for placeholder, original in protected.items():
        text = text.replace(placeholder, original)

    return text

特征工程:从 TF-IDF 到 Embedding

特征工程这块经历了几次迭代。

第一版:TF-IDF + 朴素贝叶斯

一开始为了快速上线,用了最基础的方案:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline

# 构建 pipeline
pipeline = Pipeline([
    ('vectorizer', TfidfVectorizer(
        max_features=5000,
        ngram_range=(1, 2),
        min_df=2,
        max_df=0.95
    )),
    ('classifier', MultinomialNB())
])

# 训练
pipeline.fit(X_train, y_train)

这个方案优点:训练快、推理快、容易解释。

缺点也很明显:准确率只有 78%,对短文本效果差,无法捕捉语义信息。

第二版:TF-IDF + LightGBM

换了更强的分类器:

import lightgbm as lgb

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import LabelEncoder

# 文本向量化
vectorizer = TfidfVectorizer(
    max_features=8000,
    ngram_range=(1, 3),
    min_df=3,
    max_df=0.90
)
X_train_tfidf = vectorizer.fit_transform(X_train)

# 标签编码
le = LabelEncoder()
y_train_encoded = le.fit_transform(y_train)

# 训练模型
model = lgb.LGBMClassifier(
    n_estimators=300,
    learning_rate=0.05,
    max_depth=6,
    num_leaves=31,
    min_child_samples=20,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42
)

model.fit(X_train_tfidf, y_train_encoded)

准确率提升到了 85%,但推理延迟从 15ms 增加到了 45ms,接近业务要求的上限。

第三版:Sentence Embedding + LightGBM

为了提升准确率,尝试了基于 BERT 的句向量:

from sentence_transformers import SentenceTransformer
import numpy as np

# 加载预训练模型
sentence_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# 生成句向量
def get_embeddings(texts: List[str]) -> np.ndarray:
    """批量生成句向量"""
    return sentence_model.encode(texts, show_progress_bar=False)

# 训练集嵌入
X_train_emb = get_embeddings(X_train)
X_test_emb = get_embeddings(X_test)

# 训练模型
model = lgb.LGBMClassifier(
    n_estimators=200,
    learning_rate=0.1,
    max_depth=8,
    num_leaves=63,
    random_state=42
)

model.fit(X_train_emb, y_train_encoded)

准确率提升到了 89%,但推理延迟飙到了 180ms,直接超出业务要求。

各方案在准确率和延迟之间的取舍,比单看 F1 更能解释为什么最终选了混合特征。

文本分类各迭代方案的准确率与推理延迟对比

Embedding 方案准确率最高却超标,混合特征在 88% 准确率和 75ms 延迟之间找到了可部署的平衡点。

最终方案:混合特征

最后定了混合特征方案:TF-IDF 负责捕捉关键词,Embedding 负责捕捉语义,拼接后用 LightGBM 分类。

from scipy.sparse import hstack

def create_hybrid_features(texts: List[str]) -> np.ndarray:
    """创建混合特征"""
    # TF-IDF 特征
    tfidf_features = vectorizer.transform(texts)

    # Embedding 特征
    embedding_features = get_embeddings(texts)

    # 拼接
    combined = hstack([tfidf_features, embedding_features])

    return combined

准确率 88%,延迟 75ms,在业务可接受范围内。

模型训练与优化

模型训练这部分遇到的坑比想象的多。

类别不平衡处理

训练集里类别分布很不均匀:最多的类别占 15%,最少的只有 1.2%。导致模型倾向于预测多数类别。

试了几种方案:

方案1:过采样少数类

from imblearn.over_sampling import SMOTE

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)

效果一般,而且 SMOTE 生成的样本质量不稳定。

方案2:调整类别权重

import numpy as np

# 计算类别权重
class_counts = np.bincount(y_train_encoded)
total_samples = len(y_train_encoded)

class_weights = {
    i: total_samples / (len(class_counts) * count)
    for i, count in enumerate(class_counts)
}

# 应用权重
model = lgb.LGBMClassifier(
    n_estimators=200,
    class_weight=class_weights,
    # ... 其他参数
)

这个方案效果不错,而且实现简单。

超参数优化

用 Optuna 做超参数优化:

import optuna

def objective(trial):
    """Optuna 优化目标函数"""
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 500),
        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.2),
        'max_depth': trial.suggest_int('max_depth', 4, 10),
        'num_leaves': trial.suggest_int('num_leaves', 15, 127),
        'min_child_samples': trial.suggest_int('min_child_samples', 10, 50),
        'subsample': trial.suggest_float('subsample', 0.6, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
        'reg_alpha': trial.suggest_float('reg_alpha', 0, 1.0),
        'reg_lambda': trial.suggest_float('reg_lambda', 0, 1.0),
    }

    model = lgb.LGBMClassifier(**params, random_state=42)
    model.fit(X_train_tfidf, y_train_encoded)

    # 交叉验证
    cv_scores = cross_val_score(model, X_train_tfidf, y_train_encoded, cv=5)
    return cv_scores.mean()

# 运行优化
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=50)

print(f"最佳参数: {study.best_params}")
print(f"最佳分数: {study.best_value:.4f}")

跑了 50 次试验,找到了一组不错的参数,但比默认参数只提升了 1.2% 的准确率。算是验证了 LightGBM 默认参数已经足够好用的说法。

模型部署:从 pkl 到 ONNX

部署这块经历了几次重构。

第一版:直接部署 pkl 文件

一开始用的是最直接的方式:

# 部署脚本
import joblib
import pickle

# 加载模型
model = joblib.load('model.pkl')
vectorizer = joblib.load('vectorizer.pkl')
label_encoder = joblib.load('label_encoder.pkl')

# 推理函数
def predict(text: str) -> dict:
    """单条预测"""
    # 预处理
    cleaned = improved_clean(text)
    normalized = smart_normalize(cleaned)
    tokens = ' '.join(jieba.cut(normalized))

    # 特征提取
    features = vectorizer.transform([tokens])

    # 预测
    proba = model.predict_proba(features)[0]
    predicted_class = model.predict(features)[0]
    label = label_encoder.inverse_transform([predicted_class])[0]

    return {
        'label': label,
        'confidence': float(max(proba)),
        'probabilities': {
            label_encoder.classes_[i]: float(p)
            for i, p in enumerate(proba)
        }
    }

用 Flask 起服务:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/predict', methods=['POST'])
def api_predict():
    data = request.json
    text = data.get('text', '')

    if not text:
        return jsonify({'error': 'text is required'}), 400

    result = predict(text)
    return jsonify(result)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

这个方案简单粗暴,但有几个问题:

  1. 每次请求都要重新加载分词词典,初始化成本高
  2. 模型加载在全局,多线程可能有问题
  3. 无法批量处理,吞吐量低

第二版:ONNX 优化

为了降低延迟,尝试了 ONNX 转换:

import onnxruntime as ort
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType

# 转换 LightGBM 模型到 ONNX
initial_type = [('float_input', FloatTensorType([None, X_train_tfidf.shape[1]]))]

onnx_model = convert_sklearn(
    model,
    initial_types=initial_type,
    target_opset=12
)

# 保存
with open('model.onnx', 'wb') as f:
    f.write(onnx_model.SerializeToString())

# ONNX 推理
ort_session = ort.InferenceSession('model.onnx')

def predict_onnx(text: str) -> dict:
    """使用 ONNX 进行推理"""
    # 预处理和特征提取(同上)
    features = vectorizer.transform([text]).astype(np.float32)

    # ONNX 推理
    ort_inputs = {ort_session.get_inputs()[0].name: features}
    ort_outputs = ort_session.run(None, ort_inputs)

    probabilities = ort_outputs[0][0]
    predicted_class = np.argmax(probabilities)

    return {
        'label': label_encoder.inverse_transform([predicted_class])[0],
        'confidence': float(max(probabilities))
    }

ONNX 转换后推理延迟从 45ms 降到了 32ms,提升了 29%。但转化的过程踩了不少坑:

  1. LightGBM 的某些参数在 ONNX 转换时不支持,需要手动降级
  2. TF-IDF 矩阵是稀疏的,转成密集后内存占用暴增
  3. ONNX Runtime 版本和 skl2onnx 版本要严格对应,否则会报错

第三版:批量处理 + 缓存

最终的部署方案支持批量处理和结果缓存:

import hashlib
from typing import List
from functools import lru_cache

class TextClassifier:
    """文本分类器"""

    def __init__(self):
        self.model = joblib.load('model.pkl')
        self.vectorizer = joblib.load('vectorizer.pkl')
        self.label_encoder = joblib.load('label_encoder.pkl')

    def preprocess_batch(self, texts: List[str]) -> List[str]:
        """批量预处理"""
        return [
            ' '.join(jieba.cut(smart_normalize(improved_clean(text))))
            for text in texts
        ]

    def predict_batch(self, texts: List[str]) -> List[dict]:
        """批量预测"""
        # 预处理
        processed = self.preprocess_batch(texts)

        # 特征提取
        features = self.vectorizer.transform(processed)

        # 批量预测
        proba = self.model.predict_proba(features)
        predictions = self.model.predict(features)

        # 格式化结果
        results = []
        for i, pred in enumerate(predictions):
            results.append({
                'label': self.label_encoder.inverse_transform([pred])[0],
                'confidence': float(max(proba[i]))
            })

        return results

    @lru_cache(maxsize=1000)
    def predict_cached(self, text: str) -> dict:
        """带缓存的预测"""
        return self.predict_batch([text])[0]

# FastAPI 服务
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
classifier = TextClassifier()

class PredictionRequest(BaseModel):
    text: str

class BatchPredictionRequest(BaseModel):
    texts: List[str]

@app.post('/predict')
def predict(request: PredictionRequest):
    if not request.text:
        raise HTTPException(status_code=400, detail='text is required')

    result = classifier.predict_cached(request.text)
    return result

@app.post('/predict_batch')
def predict_batch(request: BatchPredictionRequest):
    if not request.texts:
        raise HTTPException(status_code=400, detail='texts is required')

    results = classifier.predict_batch(request.texts)
    return {'results': results}

批量处理后吞吐量提升了 4 倍,缓存让相同文本的延迟降低到 2ms 以内。

监控与反馈

上线后做了基础的监控:

import time
from collections import defaultdict

class PredictionMonitor:
    """预测监控"""

    def __init__(self):
        self.request_count = 0
        self.latency_sum = 0
        self.label_distribution = defaultdict(int)

    def record(self, label: str, latency: float):
        """记录一次预测"""
        self.request_count += 1
        self.latency_sum += latency
        self.label_distribution[label] += 1

    def get_stats(self) -> dict:
        """获取统计信息"""
        avg_latency = self.latency_sum / self.request_count if self.request_count > 0 else 0

        return {
            'total_requests': self.request_count,
            'average_latency_ms': avg_latency * 1000,
            'label_distribution': dict(self.label_distribution)
        }

monitor = PredictionMonitor()

# 在预测函数里记录
def predict_with_monitor(text: str) -> dict:
    start_time = time.time()
    result = classifier.predict_cached(text)
    latency = time.time() - start_time

    monitor.record(result['label'], latency)
    return result

监控上线后发现了几个问题:

  1. 某些类别的预测频率远高于训练集分布,说明数据可能漂移了
  2. 延迟在凌晨 2-4 点会突然升高,是因为服务器有定时任务在跑
  3. 周末的请求量比工作日少 40%,但准确率反而高了 5%,推测周末的文本质量更高

这些信息让我们能及时调整训练数据和部署策略。

踩过的主要坑

1. 分词不一致导致的灾难

前面提到过,训练和部署用了不同分词模式。这个坑其实可以避免:把分词逻辑封装成独立的模块,训练和部署都用同一个。

# tokenizer.py
import jieba
from typing import List

class TextTokenizer:
    """文本分词器"""

    def __init__(self, user_dict_path: str = None):
        if user_dict_path:
            jieba.load_userdict(user_dict_path)

    def tokenize(self, text: str) -> List[str]:
        """分词"""
        text = improved_clean(text)
        text = smart_normalize(text)
        return list(jieba.cut(text, cut_all=False))  # 强制用精确模式

2. 内存泄露

服务运行一段时间后内存持续增长。排查发现有几个原因:

  1. LRU 缓存设置太大,导致大量长文本被缓存
  2. TF-IDF 向量没有及时释放
  3. 每次预测都创建新的临时对象

改进:

# 限制缓存大小和缓存对象的大小
@lru_cache(maxsize=500)
def predict_cached(text: str) -> dict:
    if len(text) > 1000:  # 长文本不缓存
        return self.predict_batch([text])[0]
    return self.predict_batch([text])[0]

# 及时释放资源
import gc

def predict_batch_with_cleanup(self, texts: List[str]) -> List[dict]:
    """批量预测并清理资源"""
    results = self.predict_batch(texts)
    gc.collect()  # 手动触发垃圾回收
    return results

3. 特征顺序不一致

某次部署后准确率突然下降。查下来是 TF-IDF 特征提取时,词汇表的顺序和训练时不一致。

解决:

# 训练时保存词汇表
import json

vocab_dict = {word: idx for idx, word in enumerate(vectorizer.get_feature_names_out())}
with open('vocab.json', 'w') as f:
    json.dump(vocab_dict, f)

# 部署时加载词汇表
with open('vocab.json', 'r') as f:
    vocab_dict = json.load(f)

# 使用固定词汇表
vectorizer = TfidfVectorizer(vocabulary=vocab_dict)

经验总结

折腾了半年,这条链路算是基本稳定了。回过头看,有几件事是对的:

  1. 先跑通,再优化。没有一开始就上最复杂的方案,而是从最基础的 TF-IDF + 朴素贝叶斯开始,逐步迭代。

  2. 监控比模型更重要。如果没有线上监控,永远不知道模型在真实环境下的表现。

  3. 性能优化要基于真实数据。很多时候优化方向是基于假设,但实际瓶颈可能完全不同。比如我们以为模型推理是瓶颈,结果发现分词才是。

  4. 简单方案往往足够。ONNX 优化了半天,但大部分延迟其实是在分词和特征提取上。

  5. 保留回退方案。每次重大改动都保留一个可回滚的版本,这是从多次事故中学会的教训。

留给未来的问题

现在这套方案还有几个地方没完全解决:

  1. 在线学习:数据会持续漂移,但定期重新训练成本较高。正在研究增量学习方案。

  2. 多语言支持:现在只支持中文,业务方有其他语言的需求。

  3. 模型解释:有时候业务方会问"为什么这么分类”,目前只能给个置信度,更细粒度的解释还做不到。

  4. A/B 测试:多版本模型同时上线,按流量分配,这个还没做。

这些问题有些是技术问题,有些是资源问题。技术部分可以继续搞,资源部分得看业务优先级。

最后

NLP 流水线不是一堆组件的简单堆砌,而是一个需要不断调整和优化的系统。从原始文本到线上预测,每一步都有坑,每一步都需要经验。

工具会换,模型会变,但核心问题不变:如何让文本数据变成可理解的、可计算的、可维护的特征,再用这些特征做出稳定的预测。

这件事没有终点,只有不断的踩坑和填坑。

希望这篇文章对正在路上的人有点用。


主要参考

  • jieba 分词文档:https://github.com/fxsjy/jieba
  • LightGBM 官方文档:https://lightgbm.readthedocs.io/
  • ONNX Runtime 文档:https://onnxruntime.ai/docs/
  • FastAPI 文档:https://fastapi.tiangolo.com/

版权声明: 本文首发于 指尖魔法屋-NLP流水线实践笔记https://blog.thinkmoon.cn/post/156-nlp-pipeline-practice-preprocessing-to-deployment/) 转载或引用必须申明原指尖魔法屋来源及源地址!