30秒速览
- 中文数据清洗远不止strip()和去HTML,无意义重复、乱码、混入的日期订单号都得干掉。
- 标签错了比数据脏更可怕,用主动学习小步迭代修正比盲目信任原始标签靠谱。
- 别把所有文本都填充到固定长度,动态批次能省计算还能让模型更公平看待长短文本。
- 数据增强别乱用同义词替换,小心语义反转,安全随机删除或回译更稳妥。
- 划分数据集务必按时间顺序,随机划分带来的时间泄露会让线上表现虚高。
上个月,我差点因为一堆脏数据把BERT模型给扔了
事情是这样的。上个月我接手了一个朋友的活儿,帮他们公司——一家日活大概5万用户的垂直电商平台——做一个评论情感自动分类系统。他们想用这个系统自动把用户评论分成“好评”、“中评”、“差评”,然后给运营和产品团队看。听起来挺简单的对吧?不就是个文本分类嘛。我心想,用Hugging Face上现成的BERT模型微调一下,分分钟搞定。
他们给了我一个CSV文件,里面是过去两年攒下来的大概10万条用户评论。我瞟了一眼,第一行数据看起来还挺正常:“物流很快,包装完好,给五分”。我直接上代码,用`pandas`读数据,然后用`transformers`库加载了一个`bert-base-chinese`,按8:1:1切了训练集、验证集和测试集,就开始训练了。第一批结果出来,验证集准确率只有78%。我当时就懵了,BERT在这个任务上不该这么差啊。朋友那边给的反馈是:“这准确率还不如我们运营小姑娘手动分得快呢。” 压力一下就上来了。
我决定把数据拿出来好好瞅瞅。结果这一瞅,差点没背过气去。我写了个简单的脚本,随机抽了500条数据打印出来,然后一行一行地看。好家伙,那场面简直是一场灾难。下面是我当时看到的一些“杰作”:
import pandas as pd
import random
# 加载数据
df = pd.read_csv('user_comments_raw.csv')
print(f"总数据量:{len(df)}")
# 随机采样500条看看
sample_indices = random.sample(range(len(df)), 500)
for idx in sample_indices:
comment = df.iloc[idx]['comment']
label = df.iloc[idx]['label'] # 他们之前有运营手动标过一部分
# 打印一些“典型”样例
if idx % 50 == 0: # 每50条打印一条
print(f"Index {idx}: [{label}] -> {comment[:100]}...") # 只打印前100字符
跑完这个脚本,我看到的输出包括但不限于:
- “快递员态度*********(此处省略20个星号)差!!!!”
- “商品很好666666666”
- “2023-11-11 下单, 11-13收到, 速度可以” (日期和评论混在一起)
- “客服回复太慢了!!!!!!!!!!!!!” (感叹号多到像在咆哮)
- “不错” (就俩字,这算好评还是中评?)
- “……” (一整条评论就是六个点,这是无语凝噎吗?)
- “APP闪退闪退闪退闪退” (这看起来像是差评,但标签是“好评”,明显标错了)
- 还有一大堆乱码,像“锟斤拷锟斤拷”,估计是编码问题。
那一刻我明白了,问题不在模型,而在数据。这10万条数据里,干净的、能直接喂给模型的我估计连一半都不到。接下来的两周,我啥也没干,就跟这10万条评论死磕。头发是真掉了好几根,但模型准确率也从78%一路干到了92%。下面就是我磕出来的五个最深、最痛的坑,以及我是怎么填上它们的。
坑一:你以为的“清洗”只是去掉空格,但中文的脏数据能玩出花来
说到数据清洗,很多人的第一反应就是用`strip()`去掉首尾空格,顶多用个正则去掉HTML标签。对付英文数据,这招可能够用。但对付中文用户生成的文本,尤其是电商评论这种充满情绪和随意性的文本,这简直就是隔靴搔痒。
我最开始也是这么天真。我写了一个“标准”清洗函数:
def naive_clean(text):
"""最初天真的清洗函数"""
if not isinstance(text, str):
return ""
# 去掉首尾空格和换行
text = text.strip()
# 去掉HTML标签(虽然评论里一般没有)
import re
text = re.sub(r']+>', '', text)
return text
df['comment_clean'] = df['comment'].apply(naive_clean)
跑完一看,毛用没有。那些星号、重复字符、乱码、混在一起的日期,全都在。BERT看到这些玩意儿,估计内心也是崩溃的。我得进行一场“深度清创手术”。
第一个棘手问题:无意义的重复字符。 用户为了表达强烈情绪,会打一堆重复的标点或数字,比如“666666”、“!!!!!”。这些对模型来说是噪声,而且会让模型过分关注这些重复模式。我的解决方法是限制连续重复字符的次数:
import re
def reduce_repeated_patterns(text, max_repeat=3):
"""
减少过度重复的字符(标点、数字、字母)。
例如:‘!!!!!!’ -> ‘!!!’, ‘666666’ -> ‘666’
参数:
text: 输入文本
max_repeat: 允许的最大连续重复次数
"""
# 模式1:匹配连续重复的中文标点或常见重复字符(如‘哈哈哈哈哈’)
# 使用正向回顾后发断言来匹配重复的字符
# 这个正则匹配任何连续出现超过max_repeat次的字符,并将其替换为重复max_repeat次
pattern = r'(.)1{' + str(max_repeat) + ',}'
# 替换函数:将超过max_repeat的部分替换为重复max_repeat次的字符
def replace_func(match):
char = match.group(1)
return char * max_repeat
text = re.sub(pattern, replace_func, text)
return text
# 测试一下
test_texts = ["商品很好6666666666!!!", "客服态度太差!!!!!!!!", "哈哈哈哈哈笑死我了"]
for t in test_texts:
cleaned = reduce_repeated_patterns(t, max_repeat=2)
print(f"原始: '{t}' -> 清洗后: '{cleaned}'")
# 输出:
# 原始: '商品很好6666666666!!!' -> 清洗后: '商品很好66!!'
# 原始: '客服态度太差!!!!!!!!' -> 清洗后: '客服态度太差!!'
# 原始: '哈哈哈哈哈笑死我了' -> 清洗后: '哈哈笑死我了'
注意,这里我设置`max_repeat=2`,对于“哈哈哈”这种本身有意义的重复,保留两次也能传递“笑”的情绪,但又不至于过长。这是一个权衡。
第二个问题:屏蔽词和乱码。 用户会用星号、井号等屏蔽敏感词,或者因为编码问题产生“锟斤拷”。对于星号、井号等组成的无意义长串,我直接当成噪声移除。但对于“锟斤拷”这种经典的GBK转UTF-8错误产生的乱码,需要识别并处理:
def remove_garbage_chars(text):
"""移除无意义的字符长串和常见乱码"""
# 1. 移除连续的无意义符号(如********, ###)
text = re.sub(r'[*#@]{4,}', ' ', text) # 连续4个及以上替换为空格
# 2. 处理常见的乱码字符(如锟斤拷、烫烫烫)
garbage_patterns = ['锟斤拷', '烫烫烫', '屯屯屯', '��']
for pattern in garbage_patterns:
text = text.replace(pattern, '')
# 3. 移除不可打印字符(除了中文、英文、数字、常用标点)
# 保留中文、英文、数字、空格和常见中文标点,其他都移除
keep_pattern = re.compile(
r'[^u4e00-u9fa5a-zA-Z0-9s,。!?;:“”‘’、()【】《》…—-~.,!?;:"'()[]]'
)
text = keep_pattern.sub('', text)
return text.strip()
第三个问题:混入的结构化信息。 评论里经常有“订单号:123456”、“2023-11-11 购买”这样的信息。这些信息对情感分类毫无用处,反而可能让模型学到错误的关联(比如某个订单号总是对应差评)。我写了一个正则来过滤掉日期、订单号、手机号等模式:
def remove_structured_noise(text):
"""移除评论中混入的日期、订单号、手机号等结构化噪声"""
# 移除日期格式:2023-11-11, 2023/11/11, 2023年11月11日
text = re.sub(r'd{4}[-/年]d{1,2}[-/月]d{1,2}[日号]?', ' ', text)
# 移除时间:12:30, 12:30
text = re.sub(r'd{1,2}[::]d{2}', ' ', text)
# 移除可能的长数字串(如订单号、手机号),但保留短数字(如评分“5分”)
# 匹配6位及以上的连续数字
text = re.sub(r'd{6,}', ' ', text)
# 移除“订单号:”、“单号:”等前缀及其后内容(简单版本)
text = re.sub(r'(订单号|单号|编号)[::]?s*S+', ' ', text)
return text.strip()
把这些函数组合起来,我的清洗流程变成了一个管道(pipeline):
def deep_clean_chinese_text(text):
"""深度清洗中文文本的管道"""
if not isinstance(text, str):
return ""
cleaners = [
str.strip,
remove_garbage_chars,
remove_structured_noise,
lambda x: reduce_repeated_patterns(x, max_repeat=2),
# 最后,合并多个空格为一个
lambda x: re.sub(r's+', ' ', x).strip()
]
for cleaner in cleaners:
text = cleaner(text)
return text
# 应用到整个数据集
print("开始深度清洗...")
df['comment_deep_clean'] = df['comment'].apply(deep_clean_chinese_text)
print("清洗完成。")
# 对比一下
print("n清洗前后对比示例:")
for i in range(3):
orig = df.iloc[i]['comment']
cleaned = df.iloc[i]['comment_deep_clean']
print(f"原始[{i}]: {orig[:80]}...")
print(f"清洗后[{i}]: {cleaned}")
print("-" * 40)
光是这一步深度清洗,我重新训练模型,准确率就提升了差不多5个百分点,从78%到了83%左右。数据干净了,模型才能听得懂人话。
坑二:标签噪声比数据噪声更致命,信数据不如信直觉(有时)
数据脏,顶多是让模型学得慢、学得偏。但标签错了,那就是直接教模型学错的东西,相当于老师教错了答案。在这个项目里,有大约3万条数据是之前运营同学手动标注的(好评/中评/差评),剩下的7万条是没标签的。我一开始完全信任那3万条标注数据,觉得人工标的还能有错?结果被现实狠狠打脸。
在模型准确率卡在83%上不去的时候,我开始怀疑是不是标签有问题。我写了个脚本,找出模型预测结果和原始标签差异最大的那些样本:
# 假设我们已经有了一个训练好的模型 `model`,并且对验证集 `val_df` 做了预测
# val_df 包含 ‘comment_deep_clean’, ‘true_label’, 以及模型预测的 ‘pred_label’
# 计算预测错误的样本
val_df['is_wrong'] = val_df['true_label'] != val_df['pred_label']
wrong_samples = val_df[val_df['is_wrong']].copy()
# 找出那些模型非常“自信”但预测错误的样本(可能是标签错了)
# 假设我们的模型能输出预测概率(这里用伪代码表示获取概率的方法)
# 例如,对于分类模型,我们可以取预测概率的最大值作为置信度
# wrong_samples[‘confidence’] = np.max(model.predict_proba(...), axis=1)
# 按置信度降序排列,模型越自信却错了,标签有问题的可能性越大
wrong_samples = wrong_samples.sort_values(by='confidence', ascending=False)
print("潜在的错误标签样本(前10个):")
for idx, row in wrong_samples.head(10).iterrows():
print(f"评论:{row['comment_deep_clean'][:100]}")
print(f"真实标签:{row['true_label']}, 模型预测:{row['pred_label']}, 置信度:{row['confidence']:.3f}")
print("-" * 50)
结果一看,很多“差评”标签的评论,内容明显是好评。比如有一条:“APP闪退闪退闪退闪退”,标签是“好评”。这显然是标反了,或者是运营手滑。还有一条:“物流快,包装好,五分”,标签是“中评”。这估计是运营对“五分”理解有偏差,或者觉得内容不够详细所以不给好评?
面对几千条可能标错的数据,我总不能自己一条条改吧?我试了三种方案:
- 方案一:用规则修正明显的错误。 我写了一些启发式规则,比如评论里包含“闪退”、“崩溃”、“用不了”等词,但标签是“好评”的,自动改为“差评”。反之,包含“五分”、“很棒”、“推荐”但标了“差评”的,改为“好评”。这个方法快速修正了大概500条特别明显的错误。
- 方案二:用聚类找异常。 我把所有评论用BERT转换成向量,然后做K-means聚类。理想情况下,同一个簇里的评论情感应该相似。如果某个簇里大部分评论的标签都是“好评”,但混进去几条标“差评”的,那这几条就很可疑。我用这个办法又揪出来300多条可能错误的标签。
- 方案三:主动学习(Active Learning)迭代清洗。 这是最终让我突破瓶颈的方法。我手动标注了一个1000条的小型“干净”数据集作为黄金标准。然后:
- 用这个黄金数据集训练一个初始模型。
- 用这个模型去预测那3万条“脏”标签数据,得到预测概率。
- 选出那些模型预测概率低(即模型不确定)的样本,以及模型预测结果与原始标签冲突且置信度高的样本。
- 我亲自(或者找朋友公司的运营复核)检查这些“可疑”样本,修正标签。
- 把修正后的数据加入黄金数据集,重新训练模型,回到第2步。
这个过程我迭代了3轮,总共复核了大约2000条数据,修正了其中接近800条的标签。代码框架大概长这样:
import numpy as np
from sklearn.model_selection import train_test_split
# 假设 gold_df 是1000条我手动确认过的干净数据
# dirty_labeled_df 是那3万条原始标注数据
# 初始训练
model = train_model(gold_df)
# 主动学习循环
for round in range(3):
print(f"n=== 主动学习第 {round+1} 轮 ===")
# 用当前模型预测脏标签数据
dirty_probs = model.predict_proba(dirty_labeled_df['comment_deep_clean'])
dirty_preds = np.argmax(dirty_probs, axis=1)
dirty_conf = np.max(dirty_probs, axis=1) # 置信度
# 策略1:找出模型置信度低的样本(模型自己都不确定)
low_conf_mask = dirty_conf 0.8)
# 合并可疑样本
suspect_mask = low_conf_mask | conflict_high_conf_mask
suspect_df = dirty_labeled_df[suspect_mask].copy()
suspect_df['model_pred'] = dirty_preds[suspect_mask]
suspect_df['model_conf'] = dirty_conf[suspect_mask]
print(f"本轮发现 {len(suspect_df)} 条可疑样本。")
if len(suspect_df) == 0:
break
# 人工复核(这里用打印模拟,实际需要人工界面)
corrected_samples = []
for idx, row in suspect_df.head(100).iterrows(): # 每轮只复核最多100条,避免疲劳
print(f"n评论:{row['comment_deep_clean']}")
print(f"原始标签:{row['true_label']}, 模型预测:{row['model_pred']} (置信度:{row['model_conf']:.2f})")
# 模拟人工输入正确标签,这里我们假设有一个函数 get_human_label() 来获取
# new_label = get_human_label()
# 假设我们通过某种方式得到了正确标签 new_label
new_label = row['model_pred'] # 这里为了示例,假设我们采纳了模型的预测(实际需人工判断)
if new_label != row['true_label']:
corrected_samples.append({'text': row['comment_deep_clean'], 'true_label': new_label})
print(f"标签已修正为:{new_label}")
else:
print(f"确认原始标签正确。")
# 将修正后的样本加入黄金数据集
new_gold_df = pd.DataFrame(corrected_samples)
gold_df = pd.concat([gold_df, new_gold_df], ignore_index=True)
print(f"黄金数据集扩充至 {len(gold_df)} 条。")
# 用扩充后的黄金数据集重新训练模型
model = train_model(gold_df)
三轮下来,黄金数据集从1000条变成了约1800条,但质量极高。用这个数据集重新训练模型,再在清洗后的完整数据上微调,准确率直接飙到了89%。标签干净了,模型才学到了正确的判断标准。
坑三:文本长度分布是个隐形杀手,别让模型只学会看开头
BERT这类Transformer模型有最大长度限制(通常是512个token)。对于中文,一个汉字大概是一个token。我们的评论长度分布极不均匀:
# 分析清洗后文本的长度分布(按字符数)
df['comment_length'] = df['comment_deep_clean'].apply(len)
length_stats = df['comment_length'].describe()
print(length_stats)
# 绘制直方图(这里用文字描述代替)
# 大部分评论在10-50字之间,但有少量“小作文”长达500-1000字,还有一大堆“不错”、“好”这种1-5字的超短评。
我当时偷懒,在`tokenizer`里直接设置了`max_length=128`,`padding=’max_length’`,`truncation=True`。这意味着:
- 对于超长评论(>128字),我只截取前128个字。用户写了500字的吐槽,最重要的细节可能在后面,但我根本不给模型看。
- 对于超短评论(如“好”),我会在它后面补一大堆`[PAD]`符号(比如补127个)。模型要处理这么多无意义的`[PAD]`,计算效率低,而且可能干扰学习。
这会导致两个问题:1)模型可能过分依赖评论开头的几句话;2)模型对短文本的表示可能被大量的填充符号稀释。
我的解决方案是动态批次(Dynamic Batching)和更智能的截断。PyTorch的`DataLoader`配合`collate_fn`可以实现动态批次,即一个批次内的样本都填充到该批次内的最大长度,而不是一个固定的全局最大长度。这大大减少了不必要的填充。
from torch.utils.data import Dataset, DataLoader
from transformers import BertTokenizer
import torch
class CommentDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len=512):
self.texts = texts
self.labels = labels
self.tokenizer = tokenizer
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = str(self.texts[idx])
label = self.labels[idx]
encoding = self.tokenizer.encode_plus(
text,
add_special_tokens=True,
max_length=self.max_len,
truncation=True, # 还是需要截断,但max_len设大点
padding=False, # 这里不填充!填充交给collate_fn
return_tensors=None,
)
return {
'input_ids': encoding['input_ids'],
'attention_mask': encoding['attention_mask'],
'label': torch.tensor(label, dtype=torch.long)
}
def smart_collate_fn(batch):
"""
动态填充一个批次内的数据到该批次的最大长度。
"""
batch_input_ids = [item['input_ids'] for item in batch]
batch_attention_mask = [item['attention_mask'] for item in batch]
batch_labels = [item['label'] for item in batch]
# 找到这个批次中最长的序列长度
max_len_in_batch = max(len(ids) for ids in batch_input_ids)
# 动态填充
padded_input_ids = []
padded_attention_mask = []
for ids, mask in zip(batch_input_ids, batch_attention_mask):
pad_len = max_len_in_batch - len(ids)
padded_input_ids.append(ids + [tokenizer.pad_token_id] * pad_len)
padded_attention_mask.append(mask + [0] * pad_len) # 注意力掩码,填充部分为0
return {
'input_ids': torch.tensor(padded_input_ids, dtype=torch.long),
'attention_mask': torch.tensor(padded_attention_mask, dtype=torch.long),
'labels': torch.stack(batch_labels)
}
# 初始化tokenizer和数据集
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
dataset = CommentDataset(df['comment_deep_clean'].tolist(), df['label'].tolist(), tokenizer, max_len=256) # 设置一个较大的max_len,如256
# 使用DataLoader,设置collate_fn
dataloader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
collate_fn=smart_collate_fn, # 使用我们的动态填充函数
num_workers=4
)
# 现在每个batch内的数据长度是统一的,且没有多余的填充。
对于超长文本的截断,我放弃了简单的“截取前128字”,而是尝试了两种策略:
- 截取首尾: 对于超过`max_len`的评论,我保留开头的`(max_len – 100)`个token和结尾的100个token。因为用户可能在开头描述事实,在结尾总结情绪。代码就是在`tokenizer`之前手动切片文本。
- 分块处理然后聚合: 对于极长的“小作文”,我把它们分成多个不超过`max_len`的块,分别用BERT编码,然后对得到的多个向量求平均或取最大值,作为整个文本的表示。这个方法计算量更大,但信息保留最完整。我最后只在验证集上对少数超长样本用了这个方法,因为大部分评论没那么长。
另外,对于超短文本(比如少于5个字),我特意没有做任何处理(比如不强行拉长),因为动态批次已经减少了填充。但我增加了一个数据增强的步骤(下一个坑会讲),来缓解超短样本可能带来的信息不足问题。
做了长度分布的优化后,模型准确率又有了约1.5%的提升,到了90.5%。模型现在能更公平地看待长评和短评了。
坑四:数据增强不是简单的同义词替换,搞不好会引入新噪声
看到还有不少超短评论,我自然想到了数据增强(Data Augmentation)。一来可以增加数据量,二来可以让模型对不同的表达方式更鲁棒。我一开始图省事,直接用了一个开源的同义词替换库,比如把“很好”替换成“不错”。代码很简单:
# 伪代码,使用像是 `textattack` 或 `nlpaug` 这样的库
import nlpaug.augmenter.word as naw
# 初始化同义词替换增强器(使用WordNet或中文同义词词林)
aug = naw.SynonymAug(aug_src='wordnet', aug_max=1) # 每次最多替换一个词
short_texts = df[df['comment_length'] < 5]['comment_deep_clean'].tolist()
augmented_texts = []
for text in short_texts[:1000]: # 只对一部分短文本做增强
augmented = aug.augment(text)
augmented_texts.append(augmented)
# 然后把增强后的文本和原标签加入训练集
结果训练完,准确率不仅没升,反而在验证集上微跌了!我赶紧检查增强后的数据,发现问题大了:
| 原评论 | 增强后评论 | 问题 |
|---|---|---|
| 物流快 | 物流迅速 | 这个还行。 |
| 垃圾 | 废物 | 情感没变,但“废物”在中文里攻击性更强,可能让模型过拟合负面情绪强度。 |
| 一般般 | 普普通通 | 意思接近。 |
| 不推荐 | 不介绍 | 意思完全变了! “不介绍”是个中性陈述,“不推荐”是明确的负面建议。这直接改变了标签语义。 |
| 客服不行 | 客服不可以 | “不可以”在这里非常生硬,不是自然的中文表达,引入了语法噪声。 |
看,简单的同义词替换,很容易因为词的多义性或者上下文关联,生成不合理甚至改变语义的句子。特别是对于电商评论这种非常口语化、依赖上下文情感的文本,风险很高。
我放弃了这种“词级”的增强,转向了更安全、更符合任务特性的方法:
- 回译(Back Translation): 把中文评论翻译成英文,再翻译回中文。这能改变句式但大概率保留原意。我用`googletrans`库(注意API限制)或者Hugging Face的`opus-mt`模型试了试。效果不错,但速度慢,且对于“APP闪退”这种专有名词,翻译可能会出错(变成“Application flash retreat”再翻回来就怪了)。
- 随机删除或交换(EDA, Easy Data Augmentation): 随机删除非核心词,或者交换相邻词的顺序。对于中文,需要先分词。我采用了随机删除,但设置了保护词列表(如“不”、“别”、“很”、“太”等情感关键词不能删)。
import jieba
import random
def safe_random_deletion(text, deletion_prob=0.1, protected_words=None):
"""
安全地随机删除词语,避免删除情感关键词。
"""
if protected_words is None:
protected_words = {'不', '没', '别', '很', '太', '非常', '极其', '差', '好', '垃圾', '棒', '赞', '烂'}
words = list(jieba.cut(text))
if len(words) deletion_prob or word in protected_words:
new_words.append(word)
# 如果删光了,返回原句
if len(new_words) == 0:
return text
return ''.join(new_words)
# 测试
test_text = "这个商品的材质感觉不是很好,做工也比较粗糙。"
for _ in range(3):
print(safe_random_deletion(test_text, deletion_prob=0.2))
# 可能输出:
# 这个商品材质感觉不是很好,做工也比较粗糙。
# 这个商品的材质感觉不是很好,做工比较粗糙。
# 这个商品的材质感觉不是,做工也比较粗糙。
- 针对性的生成式增强: 这是我最满意的方法。我利用已有的、高质量的“黄金数据集”(就是主动学习清洗出来的那1800条),让ChatGPT(或任何好的生成式模型)基于给定的情感标签和关键词,生成新的、合乎情理的评论。比如,我给提示:“请生成一条关于电商购物体验的差评,需要包含‘物流’和‘包装’这两个关键词。” 这样生成的数据不仅语义正确,而且多样性好。当然,这需要手动设计提示词,并且要控制生成数量,避免生成的数据主导训练集。
我最终采用了“安全随机删除”+“小规模回译”的组合,只对短评(长度<20)进行增强,将它们的数量大约增加了50%。重新训练后,模型对短文本的泛化能力明显增强,准确率又提升了约0.8%,达到了91.3%。数据增强就像做菜加调料,手重了整锅菜就毁了,适量且对路才行。
坑五:别忘了划分数据集——时间泄露比特征泄露更隐蔽
这是最后一个坑,也是最容易忽略的一个。我们通常用`train_test_split`随机划分数据集。但在真实业务中,用户评论是随着时间产生的。如果你随机划分,可能会把2024年1月的评论放在训练集,把2023年12月的评论放在测试集。这意味着模型在训练时已经“看到”了未来数据的信息(比如2024年1月流行的新梗、新商品名称),然后用它去预测过去的数据。这会导致测试集上的性能虚高,因为模型利用了未来的“信息”,在实际线上滚动预测时,性能会下降。这就是时间泄露(Temporal Leakage)。
我一开始没注意,随机划分后准确率有92%,高兴了半天。后来一想不对劲,线上模型是要预测未来的评论,而不是过去的。我应该按时间顺序划分。数据里刚好有一个`create_time`字段。
# 错误的做法:随机划分
from sklearn.model_selection import train_test_split
X = df['comment_deep_clean']
y = df['label']
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.2, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5, random_state=42)
# 正确的做法:按时间划分
df = df.sort_values('create_time') # 确保按时间排序
split_idx_1 = int(len(df) * 0.7) # 前70%作为训练集(时间最早的部分)
split_idx_2 = int(len(df) * 0.85) # 接下来15%作为验证集,最后15%作为测试集(时间最新的部分)
train_df = df.iloc[:split_idx_1].copy()
val_df = df.iloc[split_idx_1:split_idx_2].copy()
test_df = df.iloc[split_idx_2:].copy()
print(f"训练集时间范围:{train_df['create_time'].min()} 到 {train_df['create_time'].max()}")
print(f"验证集时间范围:{val_df['create_time'].min()} 到 {val_df['create_time'].max()}")
print(f"测试集时间范围:{test_df['create_time'].min()} 到 {test_df['create_time'].max()}")
用时间划分后重新训练,准确率从92%降到了90.8%。这下降的1.2%才是模型面对未来数据的真实能力。虽然数字低了,但心里更踏实了。这还没完,时间划分还暴露了另一个问题:概念漂移(Concept Drift)。比如,训练集(早期数据)里很多差评是关于“物流慢”,但测试集(近期数据)里,因为公司换了物流,差评可能更多是关于“APP卡顿”。如果模型只学到了“物流慢=差评”,那对近期数据的判断就会不准。
为了缓解概念漂移,我做了两件事:
- 在验证集和测试集上监控不同主题的准确率。 我简单用关键词(如“物流”、“客服”、“质量”、“APP”)把评论分了个类,然后看模型在各类别上的表现。果然,在“APP”相关的评论上,模型在测试集上的准确率比验证集低不少。
- 在训练时引入一部分时间上靠近测试集的数据。 我没有严格按70%/15%/15%划分,而是采用了“滚动窗口”的思路:训练集用时间中间段(比如第30%-80%的数据),验证集用后15%(80%-95%),测试集用最后5%(95%-100%)。这样训练集里也包含了一些相对“新”的模式。这算是一种折衷。
最终,我按时间划分数据集,并采用了滚动窗口的策略,得到的测试集准确率是92.1%(比最初随机划分的虚高92%还要高一点点,因为整体数据经过前面四步清洗和质量提升,变得更好了)。这个数字是可靠的,模型上线后,朋友公司的反馈是实际准确率在90%-93%之间波动,基本符合预期。
所以,数据清洗到底洗的是什么?
折腾完这10万条评论,我最大的感触是:数据清洗从来不是一连串`df.apply()`的机械操作。它是一场对数据的全方位“诊断”和“手术”,目的是让数据能更真实、更清晰地向模型传递业务世界的信号。
我最后的清洗和预处理流程,总结起来是一张图(用文字描述):
- 原始数据输入
- 深度清洗层:去乱码、去无意义重复、去结构化噪声、标准化格式。
- 质量评估与修正层:检测并修正标签噪声(主动学习)、处理缺失值(极少数,直接删了)。
- 分析与工程化层:分析长度分布,设计动态批次和智能截断策略;针对短文本进行安全的数据增强。
- 数据集划分层:严格按照时间顺序划分,避免时间泄露,考虑概念漂移。
- 输出干净、可用的训练/验证/测试集,喂给模型。
整个过程,代码写了不下千行,但核心的逻辑就围绕上面五个坑。这里我再贴一个最终版的“一体化”处理函数入口,它体现了我最终的策略选择:
def prepare_data_for_comment_sentiment(df_path):
"""
从原始数据文件到准备好喂给模型的数据集的完整流程。
参数:
df_path: 原始CSV文件路径
返回:
train_loader, val_loader, test_loader (PyTorch DataLoader)
label_encoder (用于解码标签)
"""
# 1. 加载和深度清洗
raw_df = pd.read_csv(df_path)
raw_df['comment_clean'] = raw_df['comment'].apply(deep_clean_chinese_text)
raw_df = raw_df[raw_df['comment_clean'].str.len() > 0] # 删除清洗后为空的评论
# 2. 标签编码和噪声处理(假设我们有一个小规模干净标签集 gold_standard.csv)
# 这里省略主动学习的复杂代码,假设我们已经有了一个校正后的标签列 ‘label_corrected’
# 如果没有,就用原始标签,但心里要清楚可能有噪声
if 'label_corrected' not in raw_df.columns:
raw_df['label_corrected'] = raw_df['label'] # 降级使用原始标签
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
raw_df['label_encoded'] = le.fit_transform(raw_df['label_corrected'])
# 3. 按时间排序和划分
raw_df = raw_df.sort_values('create_time')
total = len(raw_df)
train_end = int(total * 0.7)
val_end = int(total * 0.85)
# 使用滚动窗口:训练集取中间部分(20%-85%),验证集取后15%,测试集取最后5%
train_start = int(total * 0.2)
train_df = raw_df.iloc[train_start:train_end].copy()
val_df = raw_df.iloc[train_end:val_end].copy()
test_df = raw_df.iloc[val_end:].copy()
print(f"训练集: {len(train_df)} 条, 时间: {train_df['create_time'].min()} 至 {train_df['create_time'].max()}")
print(f"验证集: {len(val_df)} 条, 时间: {val_df['create_time'].min()} 至 {val_df['create_time'].max()}")
print(f"测试集: {len(test_df)} 条, 时间: {test_df['create_time'].min()} 至 {test_df['create_time'].max()}")
# 4. (可选) 对训练集中的短文本进行安全增强
short_mask = train_df['comment_clean'].str.len() < 20
short_texts = train_df.loc[short_mask, 'comment_clean'].tolist()
short_labels = train_df.loc[short_mask, 'label_encoded'].tolist()
augmented_texts = []
augmented_labels = []
for text, label in zip(short_texts[:len(short_texts)//2], short_labels[:len(short_labels)//2]): # 只增强一半
aug_text = safe_random_deletion(text, deletion_prob=0.15)
if aug_text != text: # 如果有变化才加入
augmented_texts.append(aug_text)
augmented_labels.append(label)
# 将增强数据加入训练集
aug_df = pd.DataFrame({'comment_clean': augmented_texts, 'label_encoded': augmented_labels})
train_df = pd.concat([train_df, aug_df], ignore_index=True)
print(f"数据增强后,训练集增至 {len(train_df)} 条。")
# 5. 创建Dataset和DataLoader
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
train_dataset = CommentDataset(train_df['comment_clean'].tolist(), train_df['label_encoded'].tolist(), tokenizer, max_len=256)
val_dataset = CommentDataset(val_df['comment_clean'].tolist(), val_df['label_encoded'].tolist(), tokenizer, max_len=256)
test_dataset = CommentDataset(test_df['comment_clean'].tolist(), test_df['label_encoded'].tolist(), tokenizer, max_len=256)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, collate_fn=smart_collate_fn, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, collate_fn=smart_collate_fn, num_workers=4)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, collate_fn=smart_collate_fn, num_workers=4)
return train_loader, val_loader, test_loader, le
# 使用
# train_loader, val_loader, test_loader, label_encoder = prepare_data_for_comment_sentiment('user_comments_raw.csv')
这套流程下来,从原始78%的准确率到最终可靠的92%,提升的14个点里,我觉得数据清洗的贡献至少占了10个点。模型还是那个BERT,但喂给它的“饲料”从发霉的馒头变成了精心准备的营养餐。所以,下次当你模型效果不佳时,别急着换更大的模型或者调更复杂的参数,先沉下心来,好好看看你的数据。它可能正在对你发出无声的抗议。