清洗了10万条训练数据后,我总结的5个最常见坑

30秒速览

  • 别信“数据已清洗”的鬼话,自己跑一遍健康检查脚本是底线
  • 缺失值填充不是选择题,预测性填充比直接填均值或删数据更保真
  • 近重复数据是模型过拟合的温床,SimHash/MinHash去重能显著提升泛化能力
  • 标签噪声用cleanlab+规则引擎半自动修正,比纯手动或纯自动都靠谱
  • 时间序列数据务必按时间戳划分数据集,随机划分会导致线上效果血崩

数据到手就开训?先等等,我差点被脏数据埋了

上周在帮一个电商客户“优评电商”做评论情感分析模型,他们日活大概10万,攒了半年多的用户评论数据,一股脑给了我大约10万条。产品经理拍着胸脯说数据很干净,让我直接喂给BERT。我信了,花了两天时间写好PyTorch的训练脚本,跑起来一看,验证集准确率死活卡在78%上不去,而且loss曲线跳得跟心电图似的。直觉告诉我,这锅模型不背。我抽了100条预测错的样本一看,好家伙,什么妖魔鬼怪都有:“这个手机”(标签是“负面”),“快递慢得像乌龟,商品”(标签是“正面”)。标签和内容根本对不上,还有些评论干脆是“。。。。。。”或者“123456”。那一刻我明白了,所谓“干净”的数据,在工程师眼里跟产品经理眼里的,压根不是一回事。

我立马停掉了训练,决定先跟数据死磕。我的第一板斧是肉眼抽查,但这对于10万条数据来说无疑是杯水车薪。必须得用脚本进行系统性筛查。我写了个快速诊断脚本,用的就是最朴素的Pandas和正则,但异常好使。

import pandas as pd
import re

def data_health_check(df, text_col='text', label_col='label'):
    """
    快速数据健康检查
    参数:
        df: 包含文本和标签的DataFrame
        text_col: 文本列名
        label_col: 标签列名
    """
    print(f"总数据量: {len(df)}")
    
    # 1. 检查标签分布
    print("n=== 标签分布 ===")
    print(df[label_col].value_counts())
    
    # 2. 检查文本基本统计
    df['text_len'] = df[text_col].astype(str).apply(len)
    df['char_count'] = df[text_col].astype(str).apply(lambda x: len(re.findall(r'[u4e00-u9fff]', x))) # 中文字符数
    df['digit_ratio'] = df[text_col].astype(str).apply(lambda x: len(re.findall(r'd', x)) / max(len(x), 1))
    
    print(f"n=== 文本长度统计 ===")
    print(f"平均长度: {df['text_len'].mean():.2f}")
    print(f"长度中位数: {df['text_len'].median()}")
    print(f"最小长度: {df['text_len'].min()}, 最大长度: {df['text_len'].max()}")
    print(f"长度<=3的样本数: {(df['text_len'] 0.5)样本数 ===")
    print(f"{(df['digit_ratio'] > 0.5).sum()}")
    
    # 3. 检查常见“脏”模式
    print(f"n=== 可疑模式检测 ===")
    # 纯标点或重复字符
    pure_punct_pattern = r'^[,。!?;:“”‘’~@#¥%……&*()——+-=[]{}|、;:'",./?\`~!@#¥%……&*()【】{}|;:’“”‘’《》?,。]+$'
    df['is_pure_punct'] = df[text_col].astype(str).apply(lambda x: bool(re.match(pure_punct_pattern, x.strip())))
    print(f"疑似纯标点样本: {df['is_pure_punct'].sum()}")
    
    # 常见无意义字符串(根据业务调整)
    nonsense_patterns = ['测试', '123', 'asdf', '。。。。。。', '!!!']
    nonsense_count = 0
    for pattern in nonsense_patterns:
        nonsense_count += df[text_col].astype(str).str.contains(pattern, regex=False).sum()
    print(f"包含常见无意义字符串的样本(去重前): {nonsense_count}")
    
    return df

# 加载数据
df = pd.read_csv('user_comments_raw.csv')
df_checked = data_health_check(df, text_col='comment', label_col='sentiment')

这个脚本一跑,问题全暴露了:有200多条评论长度小于等于3,500多条评论里一个中文字都没有,还有几十条是纯标点符号。标签分布也不均衡,正面标签占了70%,负面只有30%。这哪能直接训练?我立刻拉上产品和运营对了一遍,发现他们的标注规则中途变过,早期“一般”算负面,后期“一般”算中性(被我们过滤掉了),导致标签不一致。另外,有些评论是用户乱敲的,或者系统自动生成的测试数据,根本没过滤掉。

折腾了一下午,我们定下了第一轮清洗规则:1) 删除长度小于4字符的评论;2) 删除中文字符数为0的评论(我们是中文情感分析);3) 删除纯标点或明显无意义的评论;4) 统一标签映射规则。光这一步,就干掉了将近8%的数据(约8000条)。清洗后再训练,准确率波动小了,但只升到81%,还有大坑等着我。

缺失值别只会填均值——我这样处理把F1分数抬了0.15

数据里有些字段是后续分析加的,比如“评论所属商品品类”、“用户等级”。这些字段在构建多模态或特征融合模型时很有用。但问题来了,差不多15%的样本缺少“商品品类”信息。我的第一反应(也是很多人的条件反射)是:用众数(mode)填呗,或者单独设个“未知”类别。我试了,模型效果平平。后来我仔细看数据,发现缺失是有规律的:大部分缺失品类的评论来自一个旧的第三方插件接口,那个接口本身就不返回品类信息。而这部分评论的用户,恰好多是低等级用户,评论内容也更简短、情绪化。

简单用众数填充,等于强行把这部分具有鲜明特征的数据(低等级、短文本、可能情绪偏激)打散,混入了其他品类,模糊了模型本该学到的模式。这本质上是一种信息破坏。我试了三种方案:

  1. 方案A(粗暴填充):所有缺失品类填为“其他”。结果:模型对“其他”品类的预测一塌糊涂,因为这里面混杂了各种真正不同的模式。
  2. 方案B(直接删除):扔掉所有缺失品类的样本。结果:数据量少了15%,且丢失了低等级用户群体的特有模式,模型在预测这部分用户评论时表现变差。
  3. 方案C(预测填充):用已有的“评论文本”、“用户等级”等特征,训练一个小的文本分类器(比如FastText或简单的TF-IDF + Logistic Regression)来预测缺失的品类。这个思路最好,但实现起来有坑。

坑就在于,你不能用全部数据去训练这个填充模型,否则会造成“数据泄露”(Data Leakage)—— 你用到了可能包含未来信息的数据去填充训练集,导致评估不真实。正确的做法是,模仿交叉验证的思路。我最终采用的流程如下,虽然代码量上去了,但能保证填充的“干净”。

import numpy as np
from sklearn.model_selection import KFold
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

def predictive_imputation_cv(df, text_col, cat_col_to_impute, features_for_impute, n_splits=5):
    """
    使用交叉验证策略进行预测性填充,避免数据泄露。
    参数:
        df: 原始DataFrame
        text_col: 用于预测的文本列名
        cat_col_to_impute: 需要填充的类别列名
        features_for_impute: 除了文本外,用于预测的其他特征列名列表
        n_splits: 交叉验证折数
    返回:
        填充后的DataFrame副本
    """
    df_filled = df.copy()
    # 标记出需要填充的样本索引
    missing_mask = df_filled[cat_col_to_impute].isna()
    missing_indices = df_filled[missing_mask].index.tolist()
    known_indices = df_filled[~missing_mask].index.tolist()
    
    if not missing_indices:
        print("没有缺失值需要填充。")
        return df_filled
    
    # 准备已知数据用于训练填充模型
    X_known = df_filled.loc[known_indices]
    y_known = X_known[cat_col_to_impute].astype('category').cat.codes.values
    categories = X_known[cat_col_to_impute].astype('category').cat.categories
    
    # 为缺失数据准备“空”预测数组
    imputed_values = np.full(len(missing_indices), -1, dtype=int)
    
    # 使用KFold在已知数据上训练,并预测缺失数据
    # 注意:这里是对已知数据划分,模拟缺失数据是“未知测试集”的一部分
    # 一种简化但有效的方法是,将已知数据分成n_splits份,每次用其中一份模拟“缺失”并进行预测。
    # 但更严谨的做法是为每个缺失样本,用所有已知数据训练一个模型来预测它(耗时)。
    # 这里采用一个折衷方案:用全部已知数据训练一个模型,但通过特征工程和模型选择来缓解过拟合。
    # 在实际中,我用了更复杂的分层K折,确保每折的类别分布一致。
    print(f"开始预测性填充,缺失样本数: {len(missing_indices)}")
    
    # 构建特征:文本TF-IDF + 其他数值/类别特征(需要先编码)
    # 这里简化处理,只使用文本特征
    vectorizer = TfidfVectorizer(max_features=500, min_df=3)
    X_text_known = vectorizer.fit_transform(X_known[text_col].fillna(''))
    
    # 训练填充模型
    impute_model = LogisticRegression(max_iter=500, class_weight='balanced')
    impute_model.fit(X_text_known, y_known)
    
    # 为缺失样本生成特征并预测
    X_text_missing = vectorizer.transform(df_filled.loc[missing_indices, text_col].fillna(''))
    pred_codes = impute_model.predict(X_text_missing)
    
    # 将预测的编码转换回原始类别字符串
    df_filled.loc[missing_indices, cat_col_to_impute] = [categories[code] for code in pred_codes]
    
    print(f"填充完成。")
    # 检查填充后的分布
    print(f"n填充后类别分布:")
    print(df_filled[cat_col_to_impute].value_counts())
    
    return df_filled

# 假设我们除了评论,还有`user_level`这个特征
# 在更复杂的版本里,可以把user_level也one-hot后和TF-IDF特征拼接
df['user_level'] = df['user_level'].fillna('unknown') # 其他特征的缺失也先简单处理
df_filled = predictive_imputation_cv(df, text_col='comment', cat_col_to_impute='product_category', features_for_impute=['user_level'])

用这个预测填充方法,再结合业务规则(比如,对于某些明确可归为“客服问题”的评论,即使接口没返回品类,也强制指定为“售后”类),最终“商品品类”这个特征的缺失被合理填补。在后续的融合模型中,使用处理后的品类特征,比简单填充“未知”或删除样本,在负面评论的F1分数上提升了足足0.15。这个提升主要来自于模型能更好地区分不同品类下的投诉点(比如“数码产品”吐槽性能,“服装”吐槽尺码)。

重复和近重复数据:你以为在练模型,其实它在背答案

清洗了脏数据和缺失值,模型准确率到了84%,我有点小得意。但一看训练集和验证集的loss,训练loss降得飞快,验证loss却早早地开始平台甚至微微上扬。典型的过拟合迹象。我第一反应是加大Dropout、加正则化(L2),或者简化模型。试了一圈,验证集指标勉强稳住,但提升有限。直到我怀疑到数据本身——是不是训练集里有什么“捷径”让模型太好学了?

我写了个脚本来找重复评论。简单的精确匹配(`df.duplicated(subset=[‘comment’])`)找到了几百条完全一样的,这已经是个问题了。但更隐蔽的是“近重复”数据。比如:“快递太快了,非常满意!”和“物流速度超快,很满意!”。再比如,同一个用户对同一个商品在不同时间点了两次“好评”,内容高度相似。这些数据在训练时,会让模型过度强化某些词汇组合与标签的关联,而不是学习真正的语义逻辑,严重损害泛化能力。

去重,尤其是近重复去重,是个计算和算法权衡的活。对于10万条文本,两两比较的O(n²)复杂度是不可接受的。我选了SimHash算法,它能把文本降维成一个固定长度的指纹(比如64位整数),相似文本的指纹汉明距离很小。这样,比较复杂度就从O(n²)降到了O(n),再通过分段存储(如根据指纹前缀)还能进一步优化。

import re
from datasketch import MinHash, MinHashLSH
import jieba
from typing import List

def text_to_shingles(text: str, shingle_size: int = 3) -> List[str]:
    """将中文文本转化为shingles(字符级n-gram),用于MinHash"""
    # 简单清洗:去空格,去标点(保留部分情感标点?这里先去掉)
    text = re.sub(r'[^wu4e00-u9fff]+', '', text)
    if len(text)  1:
            # 将当前idx也加入结果,形成一个群组
            group = set(result) | {idx}
            # 确保我们不会重复处理同一个群组
            if not any(group.issubset(g) for g in duplicate_groups):
                duplicate_groups.append(group)
                processed.update(group)
        processed.add(idx)
    
    print(f"发现 {len(duplicate_groups)} 个近重复群组。")
    
    # 决定每个群组保留哪一条(例如,保留第一条,或保留最长的)
    indices_to_drop = []
    for group in duplicate_groups:
        group_list = list(group)
        # 策略:保留文本最长的一条,假设它信息量最大
        keep_idx = max(group_list, key=lambda x: len(str(df.at[x, text_col])))
        drop_indices = [i for i in group_list if i != keep_idx]
        indices_to_drop.extend(drop_indices)
    
    print(f"计划删除 {len(indices_to_drop)} 条近重复数据。")
    df_deduped = df.drop(index=indices_to_drop).reset_index(drop=True)
    print(f"去重后数据量: {len(df_deduped)}")
    
    return df_deduped, indices_to_drop

# 注意:MinHashLSH对于10万条数据,内存消耗可能较大。
# 在生产中,我用了分块处理,或者使用更节省内存的SimHash(位运算)实现。
# 这里提供一个更轻量的SimHash示例(需要simhash库)
try:
    from simhash import Simhash
except ImportError:
    print("请安装 simhash: pip install simhash")
    # 模拟一个简化版
    class Simhash:
        def __init__(self, tokens, f=64):
            self.value = hash(tuple(sorted(tokens))) % (1 << f) # 简化,勿用于生产

def deduplicate_with_simhash(df, text_col: str, bit_diff_threshold: int = 3):
    """
    使用SimHash进行去重(位差异阈值法)。
    参数:
        df: DataFrame
        text_col: 文本列名
        bit_diff_threshold: SimHash值之间允许的位差异,小于等于此值视为相似。
    """
    print("开始SimHash去重...")
    def get_features(text: str):
        """提取文本特征,这里使用分词后的词"""
        words = jieba.lcut(text)
        # 可以过滤停用词,这里简化
        return words
    
    fingerprints = []
    indices = []
    
    for idx, row in df.iterrows():
        text = str(row[text_col])
        features = get_features(text)
        if features:
            fp = Simhash(features)
        else:
            fp = Simhash(['']) # 空文本给个默认指纹
        fingerprints.append(fp)
        indices.append(idx)
    
    # 简单的两两比较(对于10万条,仍较慢,可优化为分段比较)
    # 这里仅演示逻辑,实际应用需要更高效的索引结构
    duplicate_sets = []
    seen = set()
    
    for i in range(len(fingerprints)):
        if indices[i] in seen:
            continue
        current_duplicates = {indices[i]}
        for j in range(i+1, len(fingerprints)):
            if indices[j] in seen:
                continue
            if fingerprints[i].distance(fingerprints[j])  1:
            duplicate_sets.append(current_duplicates)
            seen.update(current_duplicates)
    
    indices_to_drop = []
    for dup_set in duplicate_sets:
        # 同样,保留一条(如第一条)
        keep = next(iter(dup_set))
        indices_to_drop.extend([idx for idx in dup_set if idx != keep])
    
    print(f"SimHash方法发现待删除 {len(indices_to_drop)} 条数据。")
    df_deduped = df.drop(index=indices_to_drop).reset_index(drop=True)
    return df_deduped

# 在实际项目中,我最终采用了一个混合策略:
# 1. 先用精确匹配去掉完全重复。
# 2. 对剩余数据,用SimHash(64位)并构建索引快速查找近邻。
# 3. 对于SimHash判定的候选对,再用更精细的(如TF-IDF余弦相似度>0.9)做二次确认,避免误杀。

经过这一轮去重,又干掉了大概5%的数据(其中近重复是大头)。重新训练后,验证集loss的过拟合现象明显缓解,准确率从84%稳步提升到了87%。模型不再死记硬背那些高频出现的“好评模板”,而是更关注评论中实质性的描述词。这个教训让我明白,数据质量不仅关乎“有没有”,还关乎“像不像”。过多的重复数据不是在训练模型,而是在让它做记忆练习。

标签噪声:众包标注的坑,我用半自动修正填了

数据干净了,也不重复了,模型到了87%的准确率似乎遇到了瓶颈。我们的人工评估发现,模型在一些“拧巴”的评论上容易出错,比如“除了电池不耐用,其他都完美,给个好评吧!”。模型有时判正面,有时判负面。我去查这些样本的原始标签,发现它们本身的标签就不一致!这就是标签噪声(Label Noise)。我们这个项目的部分数据是众包标注的,质量真是参差不齐。

手动修正10万条数据的标签不现实。我调研了几种方法:

  • 置信学习(Confident Learning): 比如用`cleanlab`库。它通过模型预测的概率来估计标签错误,效果不错,但依赖一个表现尚可的初始模型。
  • 共识投票: 如果一条数据有多个标注,取多数票。但我们没有多标注数据。
  • 基于规则的修正: 对某些明显模式进行自动修正。比如,评论中包含“差评”、“垃圾”、“千万别买”但标签是正面的,大概率是标错了。

我决定搞一个半自动的流水线。先用`cleanlab`找出高置信度的潜在错标样本,再结合一些强规则进行自动修正,最后把最不确定的那部分(比如模型预测概率在0.5附近徘徊的)抽出来,让人工快速审核。这里有个踩坑点:`cleanlab`找出的“错误标签”不一定全错,它可能会把一些真正难以判别的边缘样本(Ambiguous Examples)也揪出来,这些样本对模型学习边界其实是有益的,不能简单删除或直接翻转标签。我的策略是:对`cleanlab`标记的样本,只有同时满足“模型预测概率极高(>0.9)但标签相反”且“符合某些负面/正面关键词规则”时,才进行自动修正。其余的,交给人工判断。

import numpy as np
from cleanlab.filter import find_label_issues
from sklearn.calibration import CalibratedClassifierCV

def semi_auto_label_correction(df, text_col, label_col, model, vectorizer, sample_for_human=200):
    """
    半自动标签修正流水线。
    参数:
        df: DataFrame
        text_col, label_col: 列名
        model: 已训练的预测模型(最好已经过校准)
        vectorizer: 文本向量化器
        sample_for_human: 抽取多少条不确定样本给人看
    返回:
        修正后的DataFrame,以及修正记录
    """
    df_corrected = df.copy()
    correction_log = []
    
    # 1. 获取模型预测概率(需要在清洗后的数据上重新训练一个干净的模型)
    X = vectorizer.transform(df_corrected[text_col])
    # 假设model是sklearn兼容的,有predict_proba方法
    probas = model.predict_proba(X) # shape (n_samples, n_classes)
    # 将标签映射到0,1索引
    from sklearn.preprocessing import LabelEncoder
    le = LabelEncoder()
    y_encoded = le.fit_transform(df_corrected[label_col])
    
    # 2. 使用cleanlab找出潜在的标签问题
    # ranked_label_issues是一个索引列表,从最可能错标到最不可能
    ranked_label_issues = find_label_issues(
        labels=y_encoded,
        pred_probs=probas,
        return_indices_ranked_by='self_confidence'
    )
    print(f"cleanlab 发现 {len(ranked_label_issues)} 条潜在标签问题样本。")
    
    # 3. 定义一些强规则(根据业务场景定制)
    negative_keywords = ['差评', '垃圾', '很差', '不好', '失望', '后悔', '别买', '坑', '不耐用', '慢']
    positive_keywords = ['好评', '推荐', '完美', '满意', '很好', '不错', '喜欢', '超值', '快']
    
    def rule_based_check(text, current_label):
        text = str(text)
        neg_hit = any(kw in text for kw in negative_keywords)
        pos_hit = any(kw in text for kw in positive_keywords)
        # 简单逻辑:如果强烈负面词出现而标签是正面,可能错了。反之亦然。
        # 更复杂的规则可以考虑词频、上下文等。
        if neg_hit and not pos_hit and current_label == '正面':
            return '负面' # 建议修正为负面
        if pos_hit and not neg_hit and current_label == '负面':
            return '正面' # 建议修正为正面
        return None # 规则无法判断
    
    auto_correct_count = 0
    human_review_samples = []
    
    # 4. 处理潜在问题样本
    for idx in ranked_label_issues[:500]: # 只看前500个最可疑的,避免处理太多
        original_label = df_corrected.at[idx, label_col]
        pred_prob = probas[idx]
        pred_class_idx = np.argmax(pred_prob)
        pred_class = le.inverse_transform([pred_class_idx])[0]
        confidence = pred_prob[pred_class_idx]
        
        # 条件A: 模型置信度很高 (>0.9) 且预测标签与原始标签相反
        condition_a = (confidence > 0.9) and (pred_class != original_label)
        
        # 条件B: 规则引擎也建议修正
        rule_suggestion = rule_based_check(df_corrected.at[idx, text_col], original_label)
        condition_b = (rule_suggestion is not None) and (rule_suggestion != original_label)
        
        # 自动修正条件:A和B同时满足,说明证据很强
        if condition_a and condition_b and rule_suggestion == pred_class:
            df_corrected.at[idx, label_col] = pred_class
            correction_log.append({
                'index': idx,
                'original': original_label,
                'corrected_to': pred_class,
                'method': 'auto (high confidence + rule)',
                'confidence': confidence
            })
            auto_correct_count += 1
        else:
            # 否则,加入待人工审核池(特别是那些模型置信度在0.4-0.6之间的)
            if 0.4 < confidence < 0.6:
                human_review_samples.append({
                    'index': idx,
                    'text': df_corrected.at[idx, text_col],
                    'original_label': original_label,
                    'pred_label': pred_class,
                    'pred_confidence': confidence,
                    'rule_suggestion': rule_suggestion
                })
    
    print(f"自动修正了 {auto_correct_count} 条标签。")
    
    # 5. 人工审核抽样
    if human_review_samples:
        # 随机抽取或按置信度排序抽取
        import random
        human_review_samples = random.sample(human_review_samples, min(sample_for_human, len(human_review_samples)))
        print(f"n=== 需要人工审核的样本(共{len(human_review_samples)}条)===")
        # 这里可以生成一个CSV文件,方便人工用Excel或标注工具查看
        human_review_df = pd.DataFrame(human_review_samples)
        human_review_df.to_csv('samples_for_human_review.csv', index=False, encoding='utf-8-sig')
        print("已保存到 'samples_for_human_review.csv',请人工审核并更新标签。")
        # 假设人工审核后,我们有一个更新后的CSV,然后可以合并回来
        # df_human_corrected = pd.read_csv('samples_for_human_review_corrected.csv')
        # ... 合并逻辑
    
    return df_corrected, correction_log

# 注意:运行此函数前,需要先在一个干净的数据子集上训练一个初始模型`model`和对应的`vectorizer`。
# 这个初始模型可以不用很完美,但最好能反映数据的大致模式。

我们花了大概一个人天,审核了200条最不确定的样本,修正了其中大约150条的标签(有些确实是模棱两可,我们统一了标准)。结合自动修正的300多条,总共修正了不到500条标签(占总数0.5%)。可别小看这0.5%,它们往往是模型学习的“绊脚石”或“模糊地带”。修正后重新训练,模型准确率突破了90%,达到了91%。更重要的是,模型对于那种“优点缺点并存”的复杂评论,判断的稳定性提高了不少。

数据泄露:最隐秘的杀手,它让线上效果一塌糊涂

一切就绪,模型在保留的测试集上达到了92%的准确率,大家都很高兴,准备上线A/B测试。上线第一天,线上实时预测的准确率监控直接掉到了85%以下!我头皮都麻了,赶紧拉日志看。很快就发现了问题:很多“看起来”应该能分对的评论,模型都分错了。比如,突然出现了一波关于“618大促”的评论,模型对“物流”相关的负面判断还行,但对“促销套路”、“价保”相关的评论判断很差。

我猛然意识到,我们犯了数据泄露中非常典型的一类错误:时间信息泄露。我们的数据是按评论提交时间存储的,跨度半年。我们在做数据集划分(train/val/test)时,用的是`sklearn.model_selection.train_test_split`,默认是随机划分。这导致了严重的后果:训练集中包含了发生在测试集时间点之后的评论。模型在训练时,“看到”了未来才会出现的模式和词汇(比如“618”、“价保规则”),并学习了它们与标签的关系。当线上环境真正进入“618”时期时,模型的表现似乎还行?不,因为测试集也是随机分的,同样包含了“未来”信息,所以测试集指标虚高。一旦上线,面对真正的、模型在训练时从未在“历史上下文”中见过的未来事件,它就懵了。

这是个大坑,而且非常隐蔽。对于与时间相关的数据(用户行为、评论、交易、传感器读数),随机划分是致命的。正确的做法是按时间戳划分。比如,用前5个月的数据做训练,第6个月的前15天做验证,最后15天做测试。这样能最大程度模拟线上环境:用过去的数据预测未来。

import pandas as pd
from sklearn.model_selection import train_test_split
import warnings
warnings.filterwarnings('ignore')

# 假设我们有一个时间戳列 `create_time`
df['create_time'] = pd.to_datetime(df['create_time'])
df = df.sort_values('create_time').reset_index(drop=True)

print(f"数据时间范围: {df['create_time'].min()} 到 {df['create_time'].max()}")

# **错误做法:随机划分(会导致时间泄露)**
# X_train, X_temp, y_train, y_temp = train_test_split(df['comment'], df['sentiment'], test_size=0.3, 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)

# **正确做法:按时间顺序划分**
train_ratio, val_ratio, test_ratio = 0.7, 0.15, 0.15
n_total = len(df)
n_train = int(n_total * train_ratio)
n_val = int(n_total * val_ratio)

train_df = df.iloc[:n_train].copy()
val_df = df.iloc[n_train:n_train + n_val].copy()
test_df = df.iloc[n_train + n_val:].copy()

print(f"n按时间划分结果:")
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()}")

# 检查划分是否有重叠(理论上不应该有,因为按索引切分)
assert train_df['create_time'].max() <= val_df['create_time'].min(), "训练集时间晚于验证集开始时间!"
assert val_df['create_time'].max()  1}
    return dict(sorted(filtered_counts.items(), key=lambda x: x[1], reverse=True)[:top_n])

train_top_words = get_top_words(train_df['comment'])
val_top_words = get_top_words(val_df['comment'])
test_top_words = get_top_words(test_df['comment'])

print(f"n训练集Top词: {list(train_top_words.keys())}")
print(f"验证集Top词: {list(val_top_words.keys())}")
print(f"测试集Top词: {list(test_top_words.keys())}")

# 计算词汇重叠度(Jaccard相似度)
def jaccard_similarity(set1, set2):
    intersection = len(set1.intersection(set2))
    union = len(set1.union(set2))
    return intersection / union if union > 0 else 0

similarity_train_val = jaccard_similarity(set(train_top_words.keys()), set(val_top_words.keys()))
similarity_train_test = jaccard_similarity(set(train_top_words.keys()), set(test_top_words.keys()))
print(f"n训练集 vs 验证集 Top词 Jaccard相似度: {similarity_train_val:.3f}")
print(f"训练集 vs 测试集 Top词 Jaccard相似度: {similarity_train_test:.3f}")
# 如果相似度过低,说明概念漂移可能很严重,模型需要更频繁地更新或使用适应能力更强的架构。

我们按时间重新划分了数据集,用“古老”的数据训练,用“近期”的数据测试。果然,模型在时间隔离的测试集上的准确率从虚高的92%回落到了89%。虽然数字下降了,但这个89%是真实可信的,它更接近线上表现的上限。我们基于这个模型进行了优化(比如引入更多近期数据的增量训练),最终将线上A/B测试的准确率稳定在了88%左右,虽然比最初幻想的92%低,但业务方对这个稳定可预期的效果非常满意。

回头来看这10万条数据的清洗之旅,从78%到92%再到真实的88%,每一个百分点的提升,背后都是对数据更深一层的理解和“折腾”。数据清洗不是个一次性动作,而是一个贯穿项目始终的、需要结合业务、算法和工程经验的持续过程。它没有银弹,但有这些常见的坑和应对策略垫底,至少下次再拿到一份“干净”的数据时,我知道该从哪里开始“盘问”它了。

发表评论