AI自然语言处理折腾手记
AI自然语言处理我没按教科书顺序做。
折腾自然语言处理(NLP)的人都有一种痛苦:书本上的概念一个个都很通透,但到了实际项目里,文本数据一塞进去,模型输出的东西往往让人哭笑不得。
背景和需求
去年接手了一个用户反馈分析的项目,需求简单粗暴:每天几千条用户评论,要自动分类、提取关键问题、情感分析,还要生成日报推给产品和运营团队。
刚开始觉得这事儿不难,现有的 NLP 模型一大堆,找个现成的调用一下不就完了?但实际跑起来才发现问题远比想象的多:
- 评论里有大量口语、表情符号、错别字,甚至夹杂英文
- 同一个问题用户说法千奇万怪,用硬规则根本覆盖不了
- 数据量不算大,但对准确率要求高,产品那边不接受模糊结果
- 需要每天出结果, Pipeline 的稳定性和可维护性就成了硬指标
说白了,这不是一个"模型调用"的问题,而是一个从文本到理解的完整工程问题。
整体思路
站在工程角度,NLP Pipeline 本质上就是一系列数据转换步骤,每一步都有明确的输入输出和失败边界。
这个图看起来很简单,但每个环节都有坑。下面按顺序讲一遍,把实际遇到的问题和解决方式摊开来说。
文本预处理
预处理是 NLP Pipeline 的基础,但也是最容易被轻视的环节。很多人直接把原始文本扔给模型,然后抱怨效果不好。
实际项目里,我发现预处理主要解决三件事:
清洗噪声
用户评论里的噪声比想象中多:
import re
def clean_text(text):
# 移除多余空白
text = re.sub(r'\s+', ' ', text).strip()
# 处理表情符号 - 选择性保留或转换为描述
emoji_pattern = re.compile("["
u"\U0001F600-\U0001F64F" # emoticons
u"\U0001F300-\U0001F5FF" # symbols & pictographs
u"\U0001F680-\U0001F6FF" # transport & map symbols
u"\U0001F1E0-\U0001F1FF" # flags (iOS)
"]+", flags=re.UNICODE)
text = emoji_pattern.sub('', text)
# 移除 HTML 标签
text = re.sub(r'<[^>]+>', '', text)
return text
这里有个坑:表情符号并不是都要删掉。在情感分析任务里,"😊“和”😢“携带了重要信息。我的做法是针对不同任务保留不同等级的预处理。
文本标准化
标准化主要是处理大小写、全半角、繁简体等问题:
def normalize_text(text):
# 转小写(英文场景)
text = text.lower()
# 全半角转换
text = text.replace(',', ',').replace('。', '.')
text = text.replace('(', '(').replace(')', ')')
# 繁简转换需要使用第三方库
# from opencc import OpenCC
# cc = OpenCC('t2s')
# text = cc.convert(text)
return text
但这个也要分场景。比如某些专有名词的大小写就是信息的一部分,一转小写反而丢失了语义。实际项目里,我会针对特定字段做特殊处理。
分词和标记化
中文分词是老生常谈,但真用到项目里还是要小心:
import jieba
import jieba.posseg as pseg
def tokenize_text(text):
# 基础分词
words = jieba.lcut(text)
# 词性标注过滤(保留名词、动词、形容词)
tagged_words = []
for word, flag in pseg.cut(text):
if flag.startswith(('n', 'v', 'a')):
tagged_words.append(word)
return tagged_words
这里踩过一个坑:有些短语本身就有固定语义,强行拆成单字反而破坏了信息。比如"用户体验"拆成"用户"和"体验”,在特定场景下可能丢失了组合语义。后来我在预处理里加了自定义词典,把这类短语作为一个整体处理。
特征提取
预处理之后的文本还是符号序列,模型能理解的是数值向量。特征提取就是把文本变成模型能吃的格式。
传统方法:词袋模型和 TF-IDF
在算力和数据都有限的情况下,TF-IDF 依然是不错的选择:
from sklearn.feature_extraction.text import TfidfVectorizer
# 训练 TF-IDF 向量化器
tfidf_vectorizer = TfidfVectorizer(
max_features=5000, # 限制特征维度
ngram_range=(1, 2), # 使用 unigram 和 bigram
min_df=2, # 忽略出现次数少于2的词
max_df=0.8, # 忽略出现在超过80%文档中的词
stop_words=None # 中文停用词需要自定义
)
# 在训练集上拟合
tfidf_matrix = tfidf_vectorizer.fit_transform(train_texts)
TF-IDF 的好处是可解释性强,能清楚地看到每个文档主要由哪些词组成。但它也有明显局限:无法捕捉词语间的语义关系,“汽车"和"车子"在 TF-IDF 空间里是完全不同的向量。
现代方法:词嵌入和预训练模型
预训练语言模型(PLM)的出现彻底改变了 NLP 游戏规则:
from transformers import AutoTokenizer, AutoModel
# 加载中文预训练模型
model_name = "hfl/chinese-roberta-wwm-ext"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
def get_embeddings(text):
# 分词并转换为模型输入
inputs = tokenizer(
text,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512
)
# 获取模型输出
outputs = model(**inputs)
# 使用 [CLS] token 的表示作为句子级嵌入
cls_embedding = outputs.last_hidden_state[:, 0, :]
return cls_embedding.detach().numpy()
但这事儿也没那么简单。预训练模型动辄几百 MB 甚至上 GB,在资源受限的环境下部署是个挑战。而且每次推理都要走一遍完整的 forward pass,延迟和成本都要考虑。
实际项目里,我的策略是:对于高价值、低频的深度理解任务用预训练模型;对于高吞吐、实时性要求高的场景,还是 TF-IDF 或者轻量模型更合适。
模型选择和推理
特征提取之后就是模型推理了。这里的选择更多是任务驱动的。
分类任务:评论主题分类
对于评论主题分类,我试过几种方案:
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from transformers import pipeline
# 方案1:传统机器学习 + TF-IDF
clf = LogisticRegression(
multi_class='multinomial',
solver='lbfgs',
max_iter=1000
)
clf.fit(tfidf_matrix, labels)
# 方案2:预训练模型微调
classifier = pipeline(
"text-classification",
model="hfl/chinese-roberta-wwm-ext-classification",
device=0 # 使用 GPU
)
我的经验是:如果标注数据不多(几千条以内),预训练模型 + 少样本学习效果更好;如果数据量够大(万级以上),传统机器学习模型配合工程化特征工程也能达到不错效果,而且部署简单。
情感分析:积极/消极/中性
情感分析是 NLP Pipeline 里的常见任务:
from textblob import TextBlob
from snownlp import SnowNLP
# 轻量级方案:SnowNLP
def sentiment_analysis_snownlp(text):
s = SnowNLP(text)
score = s.sentiments # 0-1之间,越接近1越积极
if score > 0.6:
return 'positive'
elif score < 0.4:
return 'negative'
else:
return 'neutral'
# 深度方案:预训练情感模型
sentiment_analyzer = pipeline(
"sentiment-analysis",
model="lxyuan/distilbert-base-multilingual-cased-sentiments-student",
return_all_scores=True
)
SnowNLP 的好处是轻量,但准确率有限;预训练模型效果好,但需要更多资源。实际项目里,我会在预处理层做一层快速筛选,对于明显倾向的评论先用轻量模型处理,把模糊的样本留给深度模型。
信息抽取:关键实体和关系
这个任务相对复杂,需要从自由文本中抽取出结构化信息:
from transformers import pipeline
# 命名实体识别
ner = pipeline(
"ner",
model="hfl/chinese-roberta-wwm-ext",
aggregation_strategy="simple"
)
def extract_entities(text):
entities = ner(text)
return {
'products': [e['word'] for e in entities if e['entity_group'] == 'PRODUCT'],
'features': [e['word'] for e in entities if e['entity_group'] == 'FEATURE'],
'issues': [e['word'] for e in entities if e['entity_group'] == 'ISSUE']
}
信息抽取的一个坑是:模型输出的实体类型往往和业务标签不完全匹配。比如产品里的"bug"和需求里的"bug”,在语义上一样但业务含义完全不同。这里需要在后处理里做一层映射和过滤。
后处理和结果聚合
模型输出并不是最终结果,还需要一系列后处理和聚合。
置信度过滤
每个模型的输出都带有不确定性,需要设置置信度阈值:
def filter_by_confidence(results, threshold=0.7):
filtered = []
for result in results:
if result['score'] >= threshold:
filtered.append(result)
else:
# 低置信度结果标记为需要人工审核
result['needs_review'] = True
filtered.append(result)
return filtered
阈值设置是个技术活:太高会漏掉很多正确结果,太低会引入大量噪声。我的做法是在验证集上做 precision-recall 曲线,找到业务可接受的平衡点。
结果聚合
单条评论的分析结果意义有限,需要聚合到时间维度和类别维度:
import pandas as pd
def aggregate_results(results, time_window='1D'):
df = pd.DataFrame(results)
# 按时间窗口聚合
df['timestamp'] = pd.to_datetime(df['timestamp'])
time_series = df.set_index('timestamp').resample(time_window)
# 计算各类指标
metrics = {
'total_comments': time_series.size(),
'positive_ratio': time_series.apply(
lambda x: (x['sentiment'] == 'positive').mean()
),
'top_issues': time_series.apply(
lambda x: x['issue_type'].value_counts().head(3).to_dict()
)
}
return metrics
聚合结果要结合业务上下文看。比如某个时间段负面情绪突然升高,但总评论数也在涨,这可能只是用户活跃度提高带来的自然波动,不一定代表产品真的出了问题。
踩坑记录
搭这个 Pipeline 的过程中,踩了不少坑。挑几个有代表性的说一下。
数据质量问题
最坑的是数据质量。刚开始没做数据清洗,结果模型把重复评论当成了独立样本,导致某些类别被高估。
后来写了个脚本专门做去重和质量检查:
def data_quality_check(df):
# 检查重复评论
duplicates = df.duplicated(subset=['comment_text'], keep=False)
# 检查异常长度(可能是垃圾数据)
length_outliers = (
(df['comment_text'].str.len() < 5) |
(df['comment_text'].str.len() > 1000)
)
# 检查特殊字符比例
special_char_ratio = df['comment_text'].apply(
lambda x: len(re.findall(r'[^\w\s一-鿿]', x)) / len(x)
)
text_outliers = special_char_ratio > 0.5
issues = duplicates | length_outliers | text_outliers
return issues
这一步加上之后,模型效果提升明显。教训就是:垃圾进,垃圾出,这个道理在 NLP 里特别明显。
模型漂移问题
部署初期效果还不错,但过了一段时间发现准确率在下降。查了半天才发现是数据分布变了——新产品功能上线后,用户关注的点和之前不一样了。
解决方案是定期监控模型表现,发现漂移迹象就重新训练:
def monitor_model_drift(new_data, reference_data, threshold=0.1):
# 计算新旧数据的分布差异
from scipy.stats import wasserstein_distance
drift_scores = {}
for feature in new_data.columns:
if new_data[feature].dtype in ['float64', 'int64']:
score = wasserstein_distance(
new_data[feature].dropna(),
reference_data[feature].dropna()
)
drift_scores[feature] = score
# 判断是否超过阈值
needs_retraining = any(score > threshold for score in drift_scores.values())
return needs_retraining, drift_scores
模型漂移是生产环境里很常见的问题,监控和重训机制必不可少。
多任务学习的平衡
在一个 Pipeline 里同时做多件事(分类、情感分析、实体抽取),任务之间会相互影响。比如情感分析模型的输出依赖于分类结果,如果第一步分类错了,后面的分析都跟着错。
后来改成了模块化设计,每个任务相对独立:
class NLPPipeline:
def __init__(self):
self.classifier = load_classifier()
self.sentiment_analyzer = load_sentiment_analyzer()
self.entity_extractor = load_entity_extractor()
def process(self, text):
results = {}
# 并行执行独立任务
results['category'] = self.classifier.predict(text)
results['sentiment'] = self.sentiment_analyzer.predict(text)
# 依赖前序结果的任务
if results['category'] == 'feedback':
results['entities'] = self.entity_extractor.extract(text)
return results
这样即使某个模块出问题,也不会影响整个 Pipeline 的可用性。
最终结果和经验总结
折腾了大概两个月,这个 Pipeline 终于跑顺了。实际效果怎么样呢?
- 准确率方面,在验证集上能达到 85% 左右,勉强满足产品那边的要求
- 每天能稳定处理几千条评论,生成日报推给各个团队
- 维护成本比预期低,模型重训频率控制在每周一次
回过头看,这个项目教给我几件事:
NLP 工程化比模型本身更重要。模型再厉害,数据处理、特征工程、后处理做不好,效果也好不到哪儿去。
简单方案往往够用。不是每个项目都要上最新最牛的模型,TF-IDF + 传统机器学习在很多场景下能达到 80% 的效果,而且部署简单。
数据质量是基础。花时间在数据清洗和质量检查上,比调参更划算。
监控和反馈闭环必不可少。模型表现会随时间衰减,需要持续监控和定期重训。
业务理解比技术细节重要。知道业务需要什么,才能在准确率、延迟、成本之间找到合适的平衡点。
NLP Pipeline 说白了就是把人类理解文本的过程工程化——有预处理(理解前整理信息)、有特征提取(提取关键信息)、有模型推理(运用知识)、有后处理(整理输出)。这个过程永远不会完美,但持续优化下去,总能达到一个可用的状态。
最后想说一句:NLP 这东西,书本上的概念只是起点,真正的理解来自于在实际项目中不断尝试、失败、再尝试的过程。就像文本理解本身一样,它是一个流动的、不断演进的过程,没有终点,只有更好。
版权声明: 本文首发于 指尖魔法屋-AI自然语言处理折腾手记(https://blog.thinkmoon.cn/post/325-ai-nlp-pipeline-text-understanding-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。