📊 数据清洗 · 表格/机器学习

表格数据 · 预处理与清洗全流程

脏数据是模型的头号杀手:缺失、异常、重复、格式混乱……本页用一份 15 行的真实脏数据,带你走完从「体检」到「干净」的完整六步。

① 脏数据样本演练场

下面是一份典型的用户行为表(7 列 × 15 行),集中了表格数据里最常见的 8 类脏问题。先学会「看懂脏在哪」,后面的每一步清洗都是在解决这里标注出来的问题。

总行数 15 ⚠️ 问题行 11 ✓ 正常行 4 脏问题 8 类:缺失 / 异常 / 重复 / 大小写 / 日期格式 / 千分位 / 无效列 / 标签缺失

🧪交互式演练:质检一份表格脏数据

id age income city register_date note label 问题标注

🔴 红底行 = 含脏数据的问题行;红色字段 = 具体出问题的单元格。注意 note 列 15 行全空 —— 这是典型的「无效字段」,会在 ⑤ 号章节直接删除。下载 CSV 后可以用 Excel / pandas 打开,跟着后面章节一步步清洗。

② 缺失值:填充还是删除?

几乎所有模型都不接受 NaN。处理缺失值没有银弹,核心是先看缺失比例和字段重要性,再决定「删」还是「填」:

策略 适用场景 本页样本示例
删除行关键字段(如 label)缺失、缺失量很少且随机label 缺失的 U010 / U015
均值填充数值列、近似正态分布income(收入近似正态时)
中位数填充数值列、分布偏斜或有异常值age(存在 999 这种极端值)
众数填充分类列(城市、性别)city 缺失时填 Beijing
前向填充时间序列数据(股价、传感器)按日期排序后用上一行补
import pandas as pd

df = pd.read_csv('table_dirty_sample.csv')

# ① 先体检:每列各缺失多少?
print(df.isna().sum())

# ② 删除:label(训练目标)缺失的行无法补救 → 直接删
df = df.dropna(subset=['label'])

# ③ 填充:数值列 / 分类列策略不同
df['age'] = df['age'].fillna(df['age'].median())    # 中位数:抗异常值干扰
df['income'] = df['income'].fillna(df['income'].mean())  # 均值:近似正态分布时
df['city'] = df['city'].fillna(df['city'].mode()[0])  # 众数:分类列专用

# ④ 时间序列场景:前向填充(用上一行的值补上)
df = df.ffill()
⚠️ 常见坑:① 顺序很重要——先用中位数填充再剔异常可以,但用均值填充前若不处理 999 这种异常值,均值会被拉偏(本页 demo 采用「删行」策略规避这个问题);② label 缺失不要填充标签,那是造假数据,直接删行;③ 填充所用的均值/中位数只能用训练集计算,否则就是数据泄露。

③ 异常值检测与剔除

样本里的 age = -5、age = 999 就是典型异常值。它们会严重拉偏均值和方差,让模型被少数几个点「绑架」。三种主流检测方法:

📦
IQR 箱线图法(推荐)
超出 [Q1−1.5×IQR, Q3+1.5×IQR] 即异常。基于分位数,对极端值本身稳健,最常用。
🔔
3σ 法则
超出「均值 ± 3 倍标准差」即异常。简单直观,但要求数据近似正态分布。
📏
业务规则
年龄 0~120、折扣 0~1……直接按领域常识写死边界,最可靠,优先使用。
📦 箱线图原理示意(超出上下限的点 = 异常值):
异常值 下限 Q1−1.5×IQR Q1 中位数 Q3 上限 Q3+1.5×IQR 异常值
import numpy as np

# 方法① IQR 箱线图法(推荐:对极端值稳健)
Q1 = df['age'].quantile(0.25)   # 25% 分位数
Q3 = df['age'].quantile(0.75)   # 75% 分位数
IQR = Q3 - Q1                   # 四分位距
lower = Q1 - 1.5 * IQR         # 下限
upper = Q3 + 1.5 * IQR         # 上限
df = df[(df['age'] >= lower) & (df['age'] <= upper)]

# 方法② 3σ 法则(要求数据近似正态分布)
mean, std = df['income'].mean(), df['income'].std()
df = df[np.abs(df['income'] - mean) <= 3 * std]

# 方法③ 业务规则(最直接:年龄合法范围 0~120)
df = df[(df['age'] >= 0) & (df['age'] <= 120)]
⚠️ 常见坑:① 异常 ≠ 错误——欺诈检测、故障预测里,异常样本恰恰是最重要的目标,先和业务确认再删;② 3σ 法则遇到偏态分布(如收入)会大量误杀;③ 剔除后记得重新看一眼分布,异常值可能是「分批录入」造成的系统性问题。

④ 重复值去重

样本第 8 行和第 1 行所有字段完全一致,是采集/合并时的重复录入。重复行有两个危害:模型对重复样本「过度学习」;更隐蔽的是,同一条样本同时落进训练集和测试集,会让评估指标虚高。

# ① 先看看哪些行完全重复
print(df[df.duplicated()])

# ② 删除完全重复行(默认保留第一次出现的)
df = df.drop_duplicates()

# ③ 按关键列去重:同一个 id 只保留一条
df = df.drop_duplicates(subset=['id'], keep='last')

# ④ keep 参数:'first' / 'last' / False(重复的全部删掉)
#    配合排序可以实现「同一用户只保留最新一条记录」
df = df.sort_values('register_date').drop_duplicates(subset=['id'], keep='last')
⚠️ 常见坑:① subset 选错列——只按 name 去重会误删同名不同人的数据;② 用 keep='last' 保留「最新记录」前,一定要先按时间排序;③ 表格里很多「假不同」:多空格、大小写差异(Beijing vs beijing)会让 duplicated 失效——先做文本规范化(见 ⑥ 节),再去重。

⑤ 无效字段删除

不是所有列都值得喂给模型。三类典型「无效字段」,删掉反而让模型更专注:

🕳️
常量 / 全空列
所有行的值都一样(或全空),区分度为 0。本页的 note 列 15 行全空,直接删。
🆔
唯一 ID 列
每行都不同,模型学不到规律,反而可能「背答案」过拟合。本页的 id 列即如此。
📉
高缺失列(>80%)
缺失率太高时,填充出来的值基本是噪声,留着弊大于利。
# ① 常量列 / 全空列:note 15 行全空,没有任何信息量
df = df.drop(columns=['note'])

# ② 唯一 ID 列:每行都不一样,模型学不到规律(还会记住噪声)
if df['id'].nunique() == len(df):
    df = df.drop(columns=['id'])

# ③ 缺失率超过 80% 的列
threshold = 0.8
drop_cols = df.columns[df.isna().mean() > threshold]
print('高缺失列:', list(drop_cols))
df = df.drop(columns=drop_cols)
⚠️ 常见坑:① 删 id 前确认后续不再需要「回查原始数据」,必要时另存一份 id 映射表;② 常量列有时是采集 bug 的信号(字段根本没写进去),先查原因再删;③ 80% 不是铁律——重要业务字段(如「投诉原因」)哪怕 90% 缺失也可能是宝贵特征。

⑥ 数据类型转换

CSV 读进来的列经常「类型不对」:"8,500" 是字符串,算不了均值;2024/01/03、03-01-2024、2024.02.15 三种日期格式混在一起,无法排序和提取特征;beijing / Beijing / BEIJING 会被当成三个不同城市。统一类型是进模型前的必经之路:

# ① 千分位字符串 → 数值:先去掉逗号,再转类型
df['income'] = df['income'].astype(str).str.replace(',', '')
df['income'] = pd.to_numeric(df['income'], errors='coerce')
# errors='coerce':转不动的(比如 'abc')一律变成 NaN,交给缺失值流程

# ② 日期统一:显式列出常见格式,逐个尝试
def parse_date(s):
    for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d-%m-%Y', '%Y.%m.%d'):
        try:
            return pd.to_datetime(s, format=fmt)
        except (ValueError, TypeError):
            continue
    return pd.NaT   # 实在认不出 → 置为缺失,走缺失值流程

df['register_date'] = df['register_date'].apply(parse_date)

# ③ 文本规范化:去首尾空格 + 统一首字母大写
df['city'] = df['city'].str.strip().str.title()   # beijing / BEIJING → Beijing
⚠️ 常见坑:① errors='coerce' 会静默产生 NaN——转换后立刻 df.isna().sum() 检查有没有误伤;② "03-01-2024" 有歧义(3月1日 还是 1月3日?),必须和业务方确认后再选 %d-%m-%Y 还是 %m-%d-%Y;③ astype(int) 遇到 NaN 会直接报错,需要可空整型时用 'Int64'(大写 I)。

⑦ 特征编码与归一化

模型只吃数字。两件事要做:类别 → 数字(编码),量纲统一(缩放)——年龄几十、收入几万,不缩放的话收入会「淹没」年龄的作用。

🔢 类别编码对比
方法 适用 注意
One-Hot无序类别(城市)类别多则列爆炸
Label Encoding有序类别(大/中/小)、树模型会引入虚假大小关系,线性模型慎用
⚖️ 数值缩放对比
方法 结果 适用
StandardScaler均值 0、方差 1大多数模型的默认选择
MinMaxScaler压到 [0, 1]神经网络 / 距离类(KNN);怕异常值
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# ① One-Hot:无序类别(城市)→ 每个类别变成一列 0/1
df = pd.get_dummies(df, columns=['city'], prefix='city')

# ② 有序映射 / 标签编码:label 普通→0,高价值→1
df['label'] = df['label'].map({'普通': 0, '高价值': 1})

# ③ 标准化:均值 0、方差 1(逻辑回归 / SVM 等首选)
scaler = StandardScaler()
df[['age', 'income']] = scaler.fit_transform(df[['age', 'income']])

# ④ 归一化:压缩到 [0, 1](神经网络 / KNN 常用)
mm = MinMaxScaler()
df[['age', 'income']] = mm.fit_transform(df[['age', 'income']])
⚠️ 常见坑:① 先划分训练/测试集,再 fit scaler——用全量数据 fit 就是数据泄露,测试集只能 transform;② 树模型(随机森林 / XGBoost)对量纲不敏感,不需要归一化;③ 高基数类别(几千个城市)别做 One-Hot,考虑目标编码或 Embedding。

⑧ 亮点 Demo:清洗前后逐步对比

把前面六步串起来。点击按钮,每点一次执行一步清洗(数据就是 ① 号章节那 15 行脏数据):左边被删除的行会逐渐淡出,右边实时展示当前的干净数据。

剩余 15 行 · 已删除 0 行 · 当前步骤:待执行
点击按钮,按 ① → ⑥ 的顺序逐步清洗,观察左右两表的变化。
🧹 清洗前(15 行原始脏数据)
id age income city register_date note label
✨ 清洗后(随步骤实时更新)

💡 最终剩下 7 行干净数据:age / income 归一化到 [0,1],city 展开为 One-Hot 列,label 变成 0/1 —— 可以直接喂给模型训练。

⑨ 完整 Pipeline:从脏 CSV 到干净数据

把 ① 号章节下载的 table_dirty_sample.csv 放到代码同目录,运行下面这段端到端脚本(pandas + sklearn),即可复现上面 demo 的完整六步:(15, 7) → (7, 9),输出可直接建模的 table_clean.csv。

import pandas as pd
from sklearn.preprocessing import StandardScaler

# ========== 0. 读取脏数据 ==========
df = pd.read_csv('table_dirty_sample.csv')
print('原始:', df.shape)                        # (15, 7)

# ========== ① 缺失值:关键字段缺失 → 删行 ==========
df = df.dropna(subset=['age', 'income', 'label'])
print('删缺失后:', df.shape)                    # (10, 7)

# ========== ② 异常值:业务规则,合法年龄 0~120 ==========
df = df[(df['age'] >= 0) & (df['age'] <= 120)]
print('剔异常后:', df.shape)                    # (8, 7):-5 和 999 被剔除

# ========== ③ 重复值:完全重复的行只留一条 ==========
df = df.drop_duplicates()
print('去重后:', df.shape)                       # (7, 7)

# ========== ④ 无效字段:全空列 + 唯一 ID 列 ==========
df = df.drop(columns=['note'])                  # 全空列,无信息量
if df['id'].nunique() == len(df):
    df = df.drop(columns=['id'])               # 每行唯一 → 对模型无意义

# ========== ⑤ 类型转换:数值 / 日期 / 文本规范化 ==========
df['income'] = df['income'].astype(str).str.replace(',', '')
df['income'] = pd.to_numeric(df['income'], errors='coerce')  # "8,500" → 8500

def parse_date(s):                              # 多格式日期统一
    for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d-%m-%Y', '%Y.%m.%d'):
        try:
            return pd.to_datetime(s, format=fmt)
        except (ValueError, TypeError):
            continue
    return pd.NaT

df['register_date'] = df['register_date'].apply(parse_date)
df['city'] = df['city'].str.strip().str.title()  # beijing / BEIJING → Beijing

# ========== ⑥ 编码 + 归一化 ==========
df = pd.get_dummies(df, columns=['city'], prefix='city')   # 无序类别 → One-Hot
df['label'] = df['label'].map({'普通': 0, '高价值': 1})    # 标签 → 0/1
scaler = StandardScaler()
df[['age', 'income']] = scaler.fit_transform(df[['age', 'income']])

print('清洗完成:', df.shape)                    # (7, 9)
df.to_csv('table_clean.csv', index=False, encoding='utf-8-sig')
💡 输出说明:最终得到 7 行 × 9 列——age、income(已标准化)、register_date(datetime 类型)、5 列 city One-Hot、label(0/1)。实际项目中,第 ⑥ 步的 scaler 应改为「先在 train/test 划分之后只对训练集 fit」,详见 ⑦ 号章节的常见坑。