先说一个经常被忽略的事实:很多做情感分析的朋友,把精力全放在模型选型和调参上,对数据预处理却是一笔带过,觉得“无非就是清洗一下文本、分个词”。我见过不少项目,前期预处理做得糙,后面换什么模型都救不回来,准确率卡在某个阈值上不去,还以为是模型容量不够。实际上,情感分析这种任务,文本里的噪声、分布偏差、分词粒度,都会直接变成模型学到的“伪特征”。这篇文章是情感分析系列的上篇,重点拆数据预处理这一步——它到底在解决什么问题、每一步为什么要这么做、有哪些坑是代码不报错但结果会崩的。
1. 先认清预处理在情感分析流程里的分量
很多人把情感分析想成“输入一句话,输出正面/负面”,中间丢给模型就完事。但真实项目里,数据预处理占整个流程的工作量通常超过一半,尤其在中文场景下。
1.1 情感分析的数据链路
一个完整的情感分析项目大致是:
- 数据采集(爬虫、公开数据集、业务日志)
- 数据清洗与标准化
- 分词与去停用词
- 样本均衡与数据集划分
- 文本向量化(词表/词向量/TF-IDF)
- 模型训练与评估
- 推理部署
预处理横跨前四步。它的输出质量,直接决定后面向量化和模型训练的天花板。模型能学到什么,取决于你喂给它的词是什么;而词是什么,取决于分词和清洗怎么做。
1.2 预处理到底在解决哪几类问题
我一般把预处理拆成三个层次去理解:
- 噪声层:HTML标签、URL、@用户、乱码、特殊符号,这些字符不携带情感信息,却会污染词表,白白扩大特征空间。
- 语言规范层:中文里的简繁混写、全角半角、口语化表达、网络新词,需要统一和规范化,否则同一个意思会被拆成多个特征。
- 分布层:正负样本不均衡、重复样本、训练测试重叠,这些问题在预处理阶段不处理,后面模型评估的分数就是“虚高”的。
提示:预处理不是“越干净越好”。情感分析里,有些看似噪声的内容反而携带情绪强度——比如连续感叹号、表情符号。完全删掉和完全保留都不对,需要策略性地处理。这个后面细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原始语料长什么样:动手之前先做一次数据体检
拿到数据别急着写清洗函数,先花十分钟“体检”。我踩过的教训是:跳过体检直接清洗,结果清洗规则和真实数据不匹配,返工了两天。
2.1 常见情感分析数据集的结构
做情感分析,你大概率会碰到这几类数据:
- IMDb影评:英文,5万条,正负各半,字段是
review和sentiment。 - ChnSentiCorp:中文电商评论(酒店、书籍、数码等),字段是
text和label,label为0负面、1正面。 - 电商/社交平台爬取数据:字段通常带
id、content、score、created_at,甚至还有图片链接、@用户信息。
不管是哪种,落到DataFrame里大概长这样:
python复制import pandas as pd
df = pd.read_csv('senti_data.csv', encoding='utf-8')
print(df.head())
# id text label
# 0 101 发货很快,物流给力 1
# 1 102 质量太差,客服态度恶劣 0
# 2 103 一般般吧,没有想象中好 0
2.2 体检清单
我每次拿到数据都会先跑这几行:
python复制print("shape:", df.shape)
print("缺失值:\n", df.isnull().sum())
print("标签分布:\n", df['label'].value_counts())
# 看文本长度分布
df['char_len'] = df['text'].apply(lambda x: len(str(x)))
print(df['char_len'].describe())
# 看重复文本
print("重复文本数:", df['text'].duplicated().sum())
这几个数字能告诉你三件关键的事:
- 缺失值:某些行可能是空的,直接喂模型会报错。
- 标签分布:如果正负样本是9:1,后面训练策略要调整,不能直接默认用accuracy评估。
- 文本长度分布:如果大多数评论都在几十到一两百字,但有个别几千字的长文,后面做序列截断时要心里有数。
还有一个容易被忽略的点:df['text'].duplicated()统计的是完全一样的文本,但有些评论是“部分重复”的,比如同一用户在不同商品下复制粘贴同一句话。这种在真实业务数据里很常见,后面划分数据集时会造成训练集和测试集内容重叠,模型“背答案”导致评估分数虚高。这种问题只能在划分前处理,模型阶段解决不了。
3. 文本清洗清单:哪些字符该删,哪些该留
数据体检完,进入清洗阶段。清洗规则不是越多越好,而是要和你的数据类型匹配。我总结了一套比较通用的顺序,每一步都有讲究。
3.1 顺序很重要:先规范化,后去噪声
很多人上来就删HTML标签,结果发现标签里的URL是全角冒号,正则匹配不上。正确顺序是先做字符规范化,再处理具体噪声。
python复制import re
import unicodedata
def normalize_text(text):
# 1. 统一为小写(英文场景必须,中文不影响)
text = text.lower()
# 2. 全角转半角,NFKC把全角字母数字以及全角符号转换成半角
text = unicodedata.normalize('NFKC', text)
# 3. 去HTML标签(处理完NFKC之后,标签里的特殊字符已经半角化)
text = re.sub(r'<[^>]+>', '', text)
# 4. 去URL
text = re.sub(r'http\S+|www\.\S+', '', text)
# 5. 去@用户
text = re.sub(r'@\S+', '', text)
# 6. 合并多余空白
text = re.sub(r'\s+', ' ', text).strip()
return text
注意第2步,unicodedata.normalize('NFKC', text)会把中文语境下的全角感叹号“!”变成半角“!”,也会把全角数字“123”变成“123”。这一步做完,后面的正则规则才能稳定匹配。
3.2 中文语料特有的几个麻烦
中文清洗比英文多出几件破事:
简繁混写:社交媒体上经常有人用繁体字评论,如果不统一,同一个词在词表里会变成两个特征。常用方案是zhconv或OpenCC转换,我习惯用zhconv,轻量且够用:
python复制from zhconv import convert
text = convert(text, 'zh-cn') # 繁体转简体
注意这步放在最前面,一定要在分词和正则之前做,否则后面的标点、词表匹配都会出问题。
表情和Emoji:这要看业务场景。如果是电商评论,“商品很满意😊”里的笑脸是有情绪含义的。硬删会丢失信息,不删又会变成一堆\ud83d\ude0a这种无法训练的字符。我的做法是:用一个映射表把常见Emoji替换成占位符,比如把笑脸换成<HAPPY>, 哭脸换成<SAD>,让模型把它们当成特殊标记学习。
标点符号:情感分析里,标点不是纯噪声。“真的吗”和“真的吗???”表达的情绪强度完全不同。我的方案是:连续三个及以上感叹号/问号替换成<EXCIT>标记,保留单个标点;同时把所有剩下的标点统一保留或统一去掉,不要一部分留一部分删,否则特征很乱。
代码示例:
python复制def process_punct(text):
# 连续感叹号/问号替换为情绪强度标记
text = re.sub(r'[!!]{2,}', ' <EXCIT> ', text)
text = re.sub(r'[??]{2,}', ' <EXCIT> ', text)
# 其他标点视任务决定,这里演示保留中文标点、去掉英文符号
text = re.sub(r'[^\w\s\u4e00-\u9fa5,。!?]', '', text)
return text
提示:不要盲目把
<EXCIT>这种标记当成普通词。如果你的模型是自己构建词表,这些标记会自然进入词表,没问题。如果用预训练模型,就要确认模型的词表是否包含这些特殊标记,不包含的话要预留[UNK]。
3.3 数字要不要删
数字在情感分析里分情况。像“五星好评”、“给1分差评”里的数字是有语义的。我的建议是:不要粗暴删除所有数字,而是结合业务判断。如果数字对你的任务没有意义,统一替换成<NUM>标记,而不是直接删掉——因为“给1分”删掉数字变成“给分”,语义就丢了。
4. 分词、停用词与自定义词典:中文情感分析的分水岭
英文按空格切词,中文必须分词。这一步做得粗,后面全是坑。
4.1 分词器选型与jieba的工作原理
中文分词工具很多:jieba、pkuseg、THULAC、LTP、HanLP。如果你做的是通用情感分析且希望快速验证,jieba仍然是首选,社区成熟、词典丰富、部署简单。pkuseg对某些领域的准确率更高,但速度慢,且部分场景下分词风格“太学术”,不太适合口语化评论。
jieba的核心机制是:
- 加载前缀词典,构造有向无环图(DAG);
- 用动态规划计算最大概率路径,按词频选择最可能的切分组合;
- 对词典中未登录的新词,用HMM模型和Viterbi算法识别。
所以jieba的效果高度依赖词典和词频。比如“不好用”这个词,如果词典里没有,可能被切成“不好/用”,情感极性就被切碎了。解决办法是自定义词典。
4.2 自定义词典:把业务词喂给分词器
做情感分析,我强烈建议你维护一个user_dict.txt。里面放业务里高频出现、但通用词典没有或切分不稳定的词,比如:
text复制不好用 5 nz
物流快 5 nz
客服态度差 5 nz
666 5 nz
yyds 5 nz
加载方式:
python复制import jieba
jieba.load_userdict('user_dict.txt')
自定义词典的优先级高于默认词频,能强制分词器把整段词切在一起。网络新词像“yyds”、“绝绝子”,通用词典更新没那么快,自定义词典是解决这类词的最佳手段。
4.3 停用词表的一个致命误区
停用词表的目的是去掉“的、了、吗、呢、在”这类高频无实义词。但情感分析里有个非常经典的坑:如果停用词表里包含否定词,模型会直接把“不好”学成“好”。
很多公开停用词表是通用NLP语境下做的,里面可能包含“不”、“没”、“莫”、“别”这类否定词。你的清洗流程如果做了word not in stopwords,这些否定词会被全部删掉,情感极性直接被逆转。所以拿到停用词表后第一件事,是搜一遍否定词,把它们从表里移出去。
python复制stopwords = set()
with open('stopwords.txt', 'r', encoding='utf-8') as f:
stopwords = {line.strip() for line in f}
# 检查否定词是否在停用词表里
neg_words = ['不', '没', '莫', '别', '无', '非']
for w in neg_words:
if w in stopwords:
print(f"警告:否定词 {w} 在停用词表中!")
常用的停用词表有哈工大停用词表、百度停用词表。我的习惯是:在哈工大停用词表的基础上,去掉否定词和部分语气词,再按业务数据不断增补。
分词加去停用词的最终函数:
python复制import jieba
def tokenize(text):
# 精确模式分词,HMM默认开启
words = jieba.lcut(text, cut_all=False)
# 过滤空白、停用词,保留否定词和情绪标记
result = []
for w in words:
w = w.strip()
if not w:
continue
if w in stopwords:
continue
result.append(w)
return result
提示:分词之后你会发现每一条评论变成了一串词列表。不要在清洗阶段就把词列表用空格拼成字符串,否则后面做词表映射、序列填充还得再切一次,白白浪费时间。保持列表形态直到向量化阶段。
5. 样本均衡与数据集划分:别让模型“偏科”和“背答案”
预处理不只是文本层面的操作,数据分布和划分也是预处理的一部分。这两个问题不解决,模型评估分数全是虚的。
5.1 类别不平衡怎么处理
先看标签分布:
python复制print(df['label'].value_counts(normalize=True))
如果正负样本接近,不需要特殊处理。如果出现7:3甚至9:1的失衡,直接训练会出现两个问题:
- 模型倾向于预测多数类,accuracy虚高但少数类F1很低;
- 情感分析的核心业务往往是少数类(比如差评),漏掉差评的代价远高于误判好评。
常用手段:
- 随机过采样:复制少数类样本,简单有效,适合文本;
- 随机欠采样:丢弃多数类样本,适合数据量很大时;
- 类别权重:在模型训练时给少数类更高的loss权重,这个在传统ML和深度学习里都能用。
文本场景不太建议用SMOTE做插值,因为文本特征空间是稀疏且高维的,插值出来的“合成文本”往往没有真实的语义合法性。对于情感分析任务,先试随机过采样或类别权重,大多数情况下够用。
5.2 分层抽样划分数据集
划分训练/验证/测试集时,要用stratify保证每个集合里的正负比例和原始数据一致:
python复制from sklearn.model_selection import train_test_split
train_text, temp_text, train_label, temp_label = train_test_split(
df['text'], df['label'],
test_size=0.2,
stratify=df['label'],
random_state=42
)
val_text, test_text, val_label, test_label = train_test_split(
temp_text, temp_label,
test_size=0.5,
stratify=temp_label,
random_state=42
)
如果你的数据带时间戳,比如评论的发布时间,不要随机打乱划分,要按时间切分,否则模型在训练时看到了“未来”的数据分布,上线后效果会大打折扣。这是很多论文复现时容易踩的坑。
还有一个隐蔽问题:重复样本。前面体检时如果发现重复文本较多,要先做去重:
python复制df = df.drop_duplicates(subset=['text'], keep='first')
否则同一个评论可能同时出现在训练集和测试集里,模型相当于提前见过了答案,测试分数虚高,上线后断崖式下跌。我之前在一个电商评论项目里就遇到过这种情况,去重后F1从0.86掉到0.79,真实性能反而更可信。
6. 从清洗后的文本到模型可读的数字序列
文本本身是字符串,模型读不懂。预处理最后一个环节,是把分词后的词列表转换成数字序列,同时统一长度。
6.1 词级还是字级
中文情感分析有个选择:按词切还是按字切。
- 词级:语义完整,比如“很难用”切成
[难, 用]或[很难, 用],一个词是一个特征。缺点是分词错误会传导到模型。 - 字级:单字特征,没有分词错误,但会丢失词组语义,特征维度更高。
如果走传统机器学习路线(TF-IDF + 分类器),词级效果更好。如果走深度学习路线,尤其是BERT这类预训练模型,它本身用WordPiece切分,不需要jieba分词,直接输入原始文本(清洗后)即可。如果你用的是自训练的LSTM/GRU,我建议词级,语义更完整。
6.2 构建词表与序列填充
这里以深度学习路线为例。分词后的文本是[['发货', '快', '物流', '给力'], ['质量', '差'], ...],接下来:
python复制from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
# 构建词表,限制词表大小
tokenizer = Tokenizer(num_words=20000, oov_token='<OOV>')
tokenizer.fit_on_texts([' '.join(words) for words in train_tokenized])
# 词列表转ID序列
train_seq = tokenizer.texts_to_sequences([' '.join(words) for words in train_tokenized])
# 统一长度:截断和填充
max_len = 100
train_padded = pad_sequences(train_seq, maxlen=max_len, padding='post', truncating='post')
这里有两个细节:
- Tokenizer接收的是空格分隔的文本,所以要把词列表用空格拼回去。也可以直接传
flatten的列表,具体看keras版本,用空格拼接最稳妥。 padding='post'表示在句子末尾填充0,truncating='post'表示从末尾截断。对情感分析来说,中文评论的结论往往在最后几句,截断策略选post比pre更合理——如果从开头截断,可能把“总的来说很满意”这类收尾句截掉。
词表构建完成后,一定要把词表保存下来,推理阶段要用同一个词表做映射,不能重新fit:
python复制import json
with open('tokenizer_word_index.json', 'w', encoding='utf-8') as f:
json.dump(tokenizer.word_index, f, ensure_ascii=False)
如果走传统ML路线,就用TfidfVectorizer来做向量化,这个我在下一篇讲模型的时候再展开。
6.3 传统机器学习路径的预处理差异
如果你用TfidfVectorizer配jieba分词,要注意sklearn的接口要求:
python复制from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(
tokenizer=lambda text: tokenize(text),
ngram_range=(1, 2),
min_df=2,
max_df=0.8,
max_features=20000
)
X_train = vectorizer.fit_transform([' '.join(words) for words in train_tokenized])
ngram_range=(1,2)对情感分析很有帮助,能把“不/好”这种相邻词组合成不_好,部分缓解否定词问题。min_df=2可以滤掉只出现一次的词,max_df=0.8滤掉在80%以上文本中出现的词(这类词基本没有区分度)。
提示:情感分析里,先分词再进TfidfVectorizer比直接传原始文本效果好得多。无脑传原始文本,jieba的
tokenizer回调会对每个样本重复加载停用词表,性能极差,后面会专门讲这个问题。
7. 踩坑实录:代码没报错,结果为什么还是崩了
最后一个章节,分享几个我实际工作中踩过的坑。这些坑的特点是:程序不报错,训练能跑,但你最后就是拿不到理想的评估结果。
7.1 编码问题:中文NLP的头号隐形杀手
Windows下用pandas读CSV,默认编码可能是gbk,如果文件是UTF-8编码,直接报错或者读到乱码。但更隐蔽的问题是,清洗后写文件时编码不一致,模型训练时读进来乱码,词表全是乱码字符,模型没法学到任何有效信息。
解决习惯是:所有读写操作统一指定encoding='utf-8',如果遇到兼容性问题再用gb18030(它覆盖了所有中文字符,比gbk更宽容)。另外,读入数据后可以用df['text'].str.contains('�')检查是否存在替换字符,这个字符出现基本就是编码解析出了问题。
7.2 清洗函数性能慢到怀疑人生
对几万条评论用apply跑正则清洗,速度还能接受;但如果数据量到几十万条,正则+分词全在Python层跑,等一小时都是常事。
提升方案有两个:
一是把分词放到多进程里跑。jieba本身是单线程的,原生库也支持jieba.enable_parallel(),但你自定义的清洗函数它是管不了的。我一般直接上multiprocessing:
python复制from multiprocessing import Pool
def process_line(line):
return tokenize(clean_text(line))
with Pool(processes=8) as pool:
results = pool.map(process_line, df['text'].tolist())
二是把经常重复的清洗逻辑尽量向量化。比如去HTML标签、去URL这种纯字符替换,可以用str.replace配合正则直接在pandas列上跑,能快很多:
python复制df['text'] = df['text'].str.replace(r'<[^>]+>', '', regex=True)
df['text'] = df['text'].str.replace(r'http\S+', '', regex=True)
7.3 分词结果前后不一致
jieba在加载自定义词典的情况下,首次调用时会构建词典cache,如果你改了user_dict.txt,有些环境不会自动重建,导致分词结果还是旧版本。解决办法是第一次修改后手动清掉cache目录下的jieba.cache文件,再重跑:
python复制jieba.dt.cache_file = '/tmp/jieba_new.cache' # 换个路径即可
还有一个容易忽视的点:如果你在Tokenize函数里用了外部全局变量(比如stopwords集合),在多进程环境下,子进程不一定继承了全局变量,会导致所有停用词都不过滤。多进程的worker里要重新加载停用词表,或者用initializer把停用词传进去。
7.4 训练集和测试集的预处理必须完全分离
这个项目里最容易犯的错是:对整个数据集先做去停用词、构建词表,再划分训练测试。这在传统机器学习里叫数据泄露——测试集的统计信息(比如词频、词表)在训练时已经被模型“看到”了,分数自然虚高。
正确做法是:
- 先划分训练/验证/测试;
- 只在训练集上拟合
Tokenizer或TfidfVectorizer; - 验证集和测试集只用
transform或texts_to_sequences做映射,不重新fit。
python复制# 错误示范:在划分前就对全体数据做 tokenizer.fit_on_texts
# 正确示范:只在训练集上 fit
tokenizer = Tokenizer(num_words=20000, oov_token='<OOV>')
tokenizer.fit_on_texts([' '.join(words) for words in train_tokenized])
# 测试集直接用 texts_to_sequences,不重新 fit
test_seq = tokenizer.texts_to_sequences([' '.join(words) for words in test_tokenized])
这个细节和“测试集标签不能参与训练”同样重要,但很多人一开始想不到。
7.5 词表大小和OOV的处理
设置num_words=20000时,Tokenizer只会保留训练集里出现频率最高的前2万个词。不在词表里的词会被映射成oov_token对应的id。
这里有个细节:OOV token要显式设置,并且要预留在词表里。我见过不设置oov_token的情况——遇到新词时texts_to_sequences直接丢弃,导致同一句话训练和测试时的长度和内容完全不同,模型评估结果没法看。
设置oov_token='<OOV>'后,词表里天然多出这个标记,推理阶段在词表里查不到的词全部归入<OOV>,模型至少可以把这个当成一个特征来处理。
收个尾:预处理是“脏活”,但值得认真做
我个人的体会是,数据预处理这个阶段,写代码的时间只占一半,剩下的一半全在“看数据、调规则、再验证”。每次看到清洗后的词列表,我都会随机打印几十条出来过一遍——分词是否合理、否定词是否保留、网络新词是否被切碎、停用词是否误删了情感词。这一步肉眼检查的效率,比任何自动化指标都高。
上面这套流程,我在多个情感分析项目里跑过,从公开数据集到业务数据都能稳定落地。处理完的语料,接传统TF-IDF也好,接深度学习模型也好,基本都能直接使用。下一篇会讲特征表示和模型选型,以及如何评估情感分析的输出效果——到时候我们会发现,只要预处理地基打得稳,模型部分反而简单很多。
