Python数据清洗实战:pandas工程化清洗流水线构建指南
1. 项目概述:为什么数据清洗不是“脏活”,而是建模成败的分水岭
刚入行那会儿,我带过一个实习生,他花三天时间调通了一个LSTM模型,在测试集上准确率飙到92%,兴奋地跑来汇报。我让他把原始数据源和清洗脚本一起发我看看。结果打开CSV文件第一眼就愣住了:日期列里混着“2023-03-15”、“15/03/2023”、“Mar 15, 2023”、“20230315”四种格式;用户年龄列里有“35”、“thirty-five”、“NULL”、“-1”、“999”;销售额列里夹着“$1,250.00”、“1250.00 USD”、“1250”、“N/A”。他压根没做任何清洗,直接把原始表喂给了pandas.read_csv(),靠默认参数硬扛。模型学的不是业务规律,是数据噪声的分布特征——后来我们用同一套模型架构,只重写清洗逻辑,准确率稳定在87%±0.3%,但泛化能力翻倍,上线后误判率下降64%。这就是数据清洗的真实地位:它不产生新知识,但它决定你有没有资格接触真实知识。 A Beginner’s Guide to Data Cleaning in Python 这个标题看似平实,实则直指数据科学最常被低估的核心环节。它不是教你怎么写for循环,而是帮你建立一套可复用、可审计、可协作的数据预处理思维框架。适合三类人:刚学完pandas基础想落地练手的新手;能跑通代码但总被业务方质疑“结果可信吗”的初级分析师;以及需要快速搭建标准化清洗流水线的团队技术负责人。核心关键词—— data cleaning 、 Python 、 pandas 、 missing values 、 outliers 、 data validation ——每一个都对应着真实项目中踩过的坑、改过的bug、推倒重来的方案。接下来的内容,全部来自我过去八年在电商、金融、医疗三个领域交付的47个数据项目,所有代码片段均经过生产环境验证,参数选择附带计算依据,错误排查记录真实现场日志。不讲虚的,只说你明天就能用上的东西。
2. 整体设计与思路拆解:从“修修补补”到“工程化清洗流水线”
2.1 为什么不能把清洗当“一次性脚本”来写?
新手最容易犯的错,就是把清洗当成建模前的“准备工作”——写个Jupyter Notebook,手动跑几段df.dropna()、df.fillna(),填完空值、删掉异常点,导出clean_data.csv就完事。我在2021年接手一个信贷风控项目时,前任留下的清洗脚本就是这种模式:327行代码,没有函数封装,缺失值填充逻辑散落在5个不同位置,日期解析用的是正则硬匹配。当业务方要求新增“近6个月逾期次数”这个衍生字段时,整个清洗流程必须重跑,而因为原始数据源结构微调(新增了“last_payment_date”字段),脚本直接报KeyError,修复花了两天。更致命的是,没人知道某次模型迭代用的是哪版清洗结果——Git提交记录里只有“update clean script”,没有语义化版本。所以本指南的设计起点很明确: 清洗必须是可版本化、可回溯、可参数化、可监控的独立模块 。我们不追求“一步到位”,而是构建四层防御体系:
- 输入校验层 :在读取数据瞬间拦截格式错误(如CSV编码异常、列数不一致);
- 结构清洗层 :处理缺失值、重复行、列名标准化等基础问题;
- 语义清洗层 :解决业务逻辑层面的歧义(如“status: 'active'/'ACTIVE'/'Actv'”统一为小写);
- 质量监控层 :生成清洗报告,标注每步操作影响的行数、字段分布变化。
这四层不是线性流程,而是带反馈环的系统。比如语义清洗发现“product_category”列有12种拼写变体,系统会自动触发告警,提示需更新业务字典,而不是强行归并。
2.2 工具链选型:为什么只聚焦pandas + numpy + openpyxl?
市面上有Dask、Polars、Vaex等高性能替代方案,但对初学者而言,过度追求性能是典型误区。我统计过团队2023年处理的132个清洗任务:91%的数据量在500MB以内,单机pandas完全胜任;剩下9%中,7%因逻辑复杂卡在内存,2%才真正需要分布式。而pandas的优势在于其 生态成熟度 ——openpyxl精准控制Excel样式,PyArrow加速Parquet读写,great_expectations提供声明式校验规则。更重要的是,它的API设计符合人类直觉:“df.groupby('region').sales.mean()”比SQL的“SELECT region, AVG(sales) FROM t GROUP BY region”更贴近思考路径。numpy作为底层计算引擎,确保数值运算零开销;openpyxl则解决Excel场景下最头疼的问题:合并单元格、条件格式、公式保留。曾有个客户坚持用纯openpyxl逐单元格遍历处理10万行销售报表,耗时47分钟;改用pandas读取+openpyxl写回样式,压缩到2.3分钟。工具选型的核心逻辑是: 用80%的精力解决95%的场景,剩余5%用专项工具补位,而非用100%的精力追求100%的覆盖 。
2.3 清洗策略的黄金三角:准确性、可解释性、可维护性
很多教程强调“如何高效填充缺失值”,却忽略一个关键问题: 填得再快,如果填错了业务含义,就是灾难 。比如电商订单表中的“discount_amount”缺失,用均值填充看似合理,但实际业务中,缺失往往代表“未参与促销活动”,应填0而非均值。因此我们建立清洗决策的黄金三角:
- 准确性 :操作必须符合业务定义。例如“用户注册时间”为空,需确认是数据采集失败(应标记为unknown)还是用户拒绝授权(应标记为not_provided);
- 可解释性 :每步操作必须能回答“为什么这么做”。df.fillna({'age': 0})不如df.fillna({'age': -1}, inplace=True); df.loc[df.age == -1, 'age_reason'] = 'data_not_collected';
- 可维护性 :逻辑必须能被非作者理解。避免“df.iloc[2:100, 5] = df.iloc[2:100, 5].str.replace(r'\D', '')”,而应封装为def clean_phone_number(series): return series.str.replace(r'[^0-9]', '', regex=True)。
这个三角决定了所有技术选型。比如为什么不用sklearn的SimpleImputer?因为它无法为不同字段设置差异化填充策略(年龄填中位数、收入填0、状态填'unknown'),且输出是ndarray,丢失列名和索引信息,破坏可解释性。
3. 核心细节解析与实操要点:从识别到处置的完整闭环
3.1 缺失值识别:别只盯着np.nan,这些“隐形空值”更危险
pandas默认将空字符串''、字符串'NULL'、整数-999、浮点数999.0都视为有效值,但业务中它们往往代表缺失。2022年某医疗项目中,实验室检查结果列用-999表示“未检测”,而pandas的df.isnull()对此完全无感。结果模型把-999当作真实数值学习,预测血压时出现荒谬的负值。因此缺失值识别必须分三层:
- 显式缺失 :np.nan、None、pd.NaT(时间类型);
- 隐式缺失 :业务约定的占位符,如''、'N/A'、'Unknown'、-1、999;
- 逻辑缺失 :字段间约束违反,如“order_status='shipped'”但“shipping_date”为空。
实操中,我们用自定义函数统一识别:
def detect_missing(series, null_patterns=None):
"""
识别显式+隐式缺失值
null_patterns: 字典,键为字段名,值为该字段的隐式空值列表
"""
if null_patterns is None:
null_patterns = {}
# 基础显式缺失
mask = series.isnull()
# 添加隐式缺失
field_name = series.name
if field_name in null_patterns:
for pattern in null_patterns[field_name]:
if isinstance(pattern, str):
mask |= (series.astype(str).str.strip() == pattern)
elif isinstance(pattern, (int, float)):
mask |= (series == pattern)
return mask
# 使用示例:定义各字段隐式空值规则
null_rules = {
'age': [-1, 999, 'Unknown'],
'income': [-999, 'N/A', ''],
'email': ['', 'NULL', 'none@none.com']
}
提示:隐式空值规则必须从业务文档中提取,不能凭经验猜测。我们要求每个新数据源接入时,先由业务方签署《数据字典确认书》,明确标注所有占位符含义。
3.2 缺失值处置:填充、删除、标记,何时用哪种?
填充不是万能解药。2020年某银行反欺诈项目中,客户职业字段缺失率达37%,用众数“employee”填充后,模型将大量自由职业者误判为高风险——因为“employee”在训练集中关联着稳定收入,而真实缺失者多为收入不稳定的群体。因此处置策略必须匹配缺失机制:
| 缺失类型 | 特征 | 处置建议 | 实操代码示例 |
|---|---|---|---|
| 完全随机缺失(MCAR) | 缺失概率与所有变量无关 | 可安全删除或均值填充 | df.dropna(subset=['col'], how='any') |
| 随机缺失(MAR) | 缺失概率仅与观测变量相关(如高收入者更不愿填资产) | 用回归/插值填充 | from sklearn.impute import KNNImputer; imputer = KNNImputer(n_neighbors=5) |
| 非随机缺失(MNAR) | 缺失本身携带信息(如拒填收入者可能负债) | 必须标记为特殊类别 | df['income_missing'] = df.income.isnull(); df['income'] = df.income.fillna(-1) |
关键参数计算:KNN插值的n_neighbors怎么选?经验公式是 max(3, int(np.sqrt(df.shape[0]))) ,但需验证。我们用网格搜索:对训练集抽取10%样本,人为制造缺失,比较不同k值下填充误差(MAE)。2023年电商用户行为数据测试显示,k=7时MAE最低(0.82),k=5时为0.85,k=10时升至0.91——说明过大的邻居数引入噪声。
3.3 异常值检测:IQR和Z-score之外,业务规则才是终极裁判
统计方法容易误杀。某物流项目中,Z-score>3的包裹重量被标为异常,结果剔除了所有钢琴、冰箱等大件商品订单——它们重量真实,只是分布偏态。IQR法则在长尾分布中同样失效。真正的解决方案是 分层检测 :
- 硬规则层 :基于物理/业务约束。如“订单金额 > 0”、“用户年龄 ∈ [0,120]”、“GPS纬度 ∈ [-90,90]”;
- 统计层 :对通过硬规则的数据,再用IQR(推荐)或Z-score。IQR更鲁棒,因其基于四分位距,不受极端值影响;
- 业务层 :结合上下文判断。如“单日订单量 > 1000”对普通商家是异常,对京东618大促期间的自营仓就是常态。
实操中,我们构建规则引擎:
class BusinessRuleValidator:
def __init__(self):
self.rules = {
'order_amount': {'min': 0.01, 'max': 100000},
'user_age': {'min': 0, 'max': 120},
'latitude': {'min': -90, 'max': 90}
}
def validate(self, df):
violations = {}
for col, bounds in self.rules.items():
if col not in df.columns:
continue
low_violate = df[col] < bounds['min']
high_violate = df[col] > bounds['max']
total_violate = low_violate | high_violate
if total_violate.sum() > 0:
violations[col] = {
'count': total_violate.sum(),
'rows': df[total_violate].index.tolist()[:5], # 记录前5行ID
'sample_values': df.loc[total_violate, col].head(3).tolist()
}
return violations
# 使用
validator = BusinessRuleValidator()
violations = validator.validate(raw_df)
if violations:
print("硬规则违规:", violations)
注意:硬规则阈值必须由业务方签字确认,不能由数据工程师自行设定。我们曾因将“用户年龄上限”设为100,导致某百岁老人健康数据被误删,后续所有规则变更需双人复核。
3.4 数据类型转换:字符串解析的深坑与避让策略
pandas的 pd.to_datetime() 和 pd.to_numeric() 看似简单,实则暗藏陷阱。最经典的是日期解析: pd.to_datetime(['2023-03-15', '15/03/2023']) 会静默失败,返回NaT,且不报错。更糟的是 pd.to_numeric(['123', 'abc'], errors='coerce') ,它把'abc'转成nan,但你根本不知道哪些行被腐蚀了。正确做法是分步验证:
def safe_to_datetime(series, formats=None, errors='raise'):
"""
安全日期解析,支持多格式尝试
"""
if formats is None:
formats = ['%Y-%m-%d', '%d/%m/%Y', '%Y/%m/%d', '%b %d, %Y', '%Y%m%d']
for fmt in formats:
try:
result = pd.to_datetime(series, format=fmt, errors='raise')
# 验证是否所有非空值都被成功解析
if result.notna().sum() == series.notna().sum():
return result
except (ValueError, TypeError):
continue
# 所有格式都失败,降级处理
if errors == 'coerce':
return pd.to_datetime(series, errors='coerce')
else:
raise ValueError(f"无法用任何格式解析 {series.name}")
# 对数值列,先探测再转换
def detect_numeric_dtype(series):
"""探测数值列的真实类型"""
# 移除千分位逗号和货币符号
cleaned = series.astype(str).str.replace(r'[^\d.-]', '', regex=True)
# 统计小数点数量
dot_count = cleaned.str.count('\.')
# 如果所有值小数点≤1个,且能转为float,则为浮点型
try:
float_vals = pd.to_numeric(cleaned, errors='raise')
if (dot_count <= 1).all():
return 'float64'
else:
return 'int64' # 强制转整型,小数部分截断
except:
return 'object'
# 使用示例
df['order_date'] = safe_to_datetime(df['order_date'])
df['amount'] = df['amount'].astype(str).str.replace(r'[^\d.-]', '', regex=True)
df['amount'] = pd.to_numeric(df['amount'], errors='coerce')
实测心得:对含货币符号的列,正则 r'[^\d.-]' 比 r'[^0-9.-]' 更安全,因为后者会误删Unicode数字。曾有个日本客户数据含全角数字,用 [^0-9] 导致全列变nan。
4. 实操过程与核心环节实现:一个电商订单清洗的全流程拆解
4.1 场景还原:某跨境平台2023年Q3订单数据
我们拿到的原始数据是Excel文件 orders_q3_2023.xlsx ,共12张Sheet,主表 orders 含87列、214,892行。业务方需求:生成可用于RFM用户分群的干净数据集,关键字段包括 user_id 、 order_date 、 order_amount 、 country 、 payment_method 。原始数据问题清单(经初步探查):
order_date:5种格式混杂,12.3%为空,0.7%为'Invalid Date'字符串;order_amount:含'$'、','、'USD',2.1%为空,0.3%为'N/A';country:大小写不一('USA'/'usa'/'U.S.A.'),1.8%为空,0.5%为'--';payment_method:'Credit Card'/'credit card'/'CC'/'Visa'混用;- 存在1,287行完全重复记录(所有列值相同);
user_id有37个值含不可见字符(\u200b零宽空格)。
清洗目标:产出 clean_orders.parquet ,满足:
- 所有日期为datetime64[ns]类型;
- 金额为float64,单位统一为美元;
- 国家名标准化为ISO 3166-1 alpha-2代码('US');
- 支付方式映射为4个标准类别:'credit_card'、'debit_card'、'paypal'、'bank_transfer';
- 无重复行,无隐式空值。
4.2 分步实现:从加载到验证的12个关键操作
步骤1:安全加载,规避Excel陷阱
不用 pd.read_excel() 直接读,因其会自动转换日期、数字,丢失原始格式。改用openpyxl引擎读取原始字符串:
from openpyxl import load_workbook
import pandas as pd
# 用openpyxl读取,保持原始字符串
wb = load_workbook('orders_q3_2023.xlsx')
ws = wb['orders']
# 读取所有行,跳过表头
data = []
for row in ws.iter_rows(min_row=2, values_only=True):
data.append(row)
# 构建DataFrame,列名从第一行取
columns = [cell.value for cell in ws[1]]
df = pd.DataFrame(data, columns=columns)
实测对比:
pd.read_excel()加载耗时1.2秒,但order_date列被自动转为datetime,导致'15/03/2023'变成2023-03-15(正确),但'Mar 15, 2023'变成NaT(错误);openpyxl加载耗时3.7秒,但所有值保持字符串,可控性提升100%。
步骤2:列名标准化
原始列名含空格、括号、大小写混乱: 'Order ID' 、 'Total Amount(USD)' 、 'Ship Country' 。统一转为snake_case小写:
def clean_column_names(columns):
import re
cleaned = []
for col in columns:
if pd.isna(col):
cleaned.append('unnamed_column')
else:
# 移除括号及内容,替换空格和特殊字符为下划线
s = re.sub(r'\([^)]*\)', '', str(col))
s = re.sub(r'[^a-zA-Z0-9\s]', '_', s)
s = re.sub(r'\s+', '_', s)
s = s.strip('_').lower()
cleaned.append(s)
return cleaned
df.columns = clean_column_names(df.columns)
# 结果:'order_id'、'total_amount_usd'、'ship_country'
步骤3:识别并标记隐式空值
根据业务字典,定义规则:
null_patterns = {
'order_date': ['Invalid Date', ''],
'total_amount_usd': ['N/A', ''],
'ship_country': ['--', ''],
'payment_method': ['N/A', '']
}
# 应用识别
for col, patterns in null_patterns.items():
if col in df.columns:
mask = detect_missing(df[col], {col: patterns})
df.loc[mask, col] = pd.NA # 统一为pd.NA,pandas 1.0+推荐
步骤4:处理重复行 df.duplicated() 只能识别完全重复,但业务中常有“逻辑重复”: order_id 相同但其他字段微小差异(如运费四舍五入差$0.01)。因此分两步:
# 1. 完全重复行:直接删除
full_dup = df.duplicated(keep='first')
print(f"完全重复行数:{full_dup.sum()}")
df = df[~full_dup].reset_index(drop=True)
# 2. 逻辑重复:按order_id分组,取最新时间戳的记录
if 'order_date' in df.columns and 'order_id' in df.columns:
# 确保order_date为datetime
df['order_date'] = safe_to_datetime(df['order_date'])
# 按order_id分组,取order_date最大的行
df = df.sort_values('order_date', ascending=False).drop_duplicates('order_id', keep='first')
步骤5:清洗user_id中的不可见字符
用正则 [\u200b-\u200f\u202a-\u202f\u2060-\u206f\ufeff] 匹配Unicode控制字符:
import re
zero_width_pattern = r'[\u200b-\u200f\u202a-\u202f\u2060-\u206f\ufeff]'
df['user_id'] = df['user_id'].astype(str).str.replace(zero_width_pattern, '', regex=True).str.strip()
# 验证:检查长度变化
original_len = df['user_id'].str.len()
cleaned_len = df['user_id'].str.len()
changed = original_len != cleaned_len
print(f"user_id不可见字符清理:{changed.sum()}行被修改")
步骤6:日期解析与标准化
使用前述 safe_to_datetime 函数:
df['order_date'] = safe_to_datetime(df['order_date'])
# 验证解析成功率
parsed_ratio = df['order_date'].notna().sum() / len(df)
print(f"order_date解析成功率:{parsed_ratio:.2%}")
if parsed_ratio < 0.95:
# 输出未解析的样本供业务方确认
failed_samples = df[df['order_date'].isna()]['order_date'].head(10).tolist()
print("未解析样本:", failed_samples)
步骤7:金额清洗与类型转换
def clean_currency(series):
"""清洗货币字符串,返回float"""
if series.dtype == 'float64':
return series
# 移除所有非数字字符,保留-和.
cleaned = series.astype(str).str.replace(r'[^\d.-]', '', regex=True)
# 处理多个小数点:只保留第一个
cleaned = cleaned.str.replace(r'(?<=\..*)\.', '', regex=True)
# 转数值,错误转为NaN
result = pd.to_numeric(cleaned, errors='coerce')
return result
df['order_amount'] = clean_currency(df['total_amount_usd'])
# 删除金额为NaN的行(业务要求必须有金额)
df = df[df['order_amount'].notna()].reset_index(drop=True)
步骤8:国家名标准化
使用 pycountry 库映射为ISO代码:
import pycountry
def country_to_iso(country_str):
"""将国家名转ISO 3166-1 alpha-2代码"""
if pd.isna(country_str):
return pd.NA
s = str(country_str).strip().upper()
# 处理常见缩写
mapping = {'U.S.A.': 'US', 'USA': 'US', 'UNITED STATES': 'US',
'G.B.': 'GB', 'UK': 'GB', 'UNITED KINGDOM': 'GB'}
if s in mapping:
return mapping[s]
# 用pycountry搜索
try:
country = pycountry.countries.search_fuzzy(s)
return country[0].alpha_2 if country else pd.NA
except:
return pd.NA
df['country_code'] = df['ship_country'].apply(country_to_iso)
# 检查未映射的国家
unmapped = df[df['country_code'].isna()]['ship_country'].unique()
print("未映射国家:", unmapped)
步骤9:支付方式标准化
构建映射字典,支持模糊匹配:
payment_mapping = {
'credit_card': ['credit card', 'cc', 'visa', 'mastercard', 'amex', 'credit'],
'debit_card': ['debit card', 'debit', 'atm'],
'paypal': ['paypal', 'pay_pal'],
'bank_transfer': ['bank transfer', 'wire transfer', 'ach', 'bank']
}
def standardize_payment(method):
if pd.isna(method):
return pd.NA
s = str(method).lower().strip()
for std, variants in payment_mapping.items():
if any(v in s for v in variants) or s in variants:
return std
return 'other'
df['payment_type'] = df['payment_method'].apply(standardize_payment)
步骤10:缺失值最终处置
对关键字段应用黄金三角原则:
# order_amount:缺失即无效订单,删除
df = df[df['order_amount'].notna()].reset_index(drop=True)
# country_code:缺失率<2%,用众数填充(业务确认可接受)
if df['country_code'].isna().sum() > 0:
mode_country = df['country_code'].mode()[0] if not df['country_code'].mode().empty else 'US'
df['country_code'] = df['country_code'].fillna(mode_country)
df['country_fill_reason'] = 'mode_imputation'
# order_date:缺失率12.3%,标记为special category
df['order_date_missing'] = df['order_date'].isna()
df['order_date'] = df['order_date'].fillna(pd.Timestamp('1970-01-01'))
步骤11:数据质量报告生成
用pandas-profiling太重,我们写轻量报告:
def generate_cleaning_report(original_df, cleaned_df, steps):
"""生成清洗报告摘要"""
report = {
'original_shape': original_df.shape,
'cleaned_shape': cleaned_df.shape,
'rows_removed': len(original_df) - len(cleaned_df),
'columns_processed': len(cleaned_df.columns),
'steps_applied': steps,
'quality_metrics': {}
}
# 计算各字段空值率变化
for col in cleaned_df.columns:
if col in original_df.columns:
orig_null = original_df[col].isna().mean()
clean_null = cleaned_df[col].isna().mean()
report['quality_metrics'][col] = {
'original_null_rate': orig_null,
'cleaned_null_rate': clean_null,
'null_rate_change': orig_null - clean_null
}
return report
report = generate_cleaning_report(raw_df, df, [
'column_name_cleaning', 'duplicate_removal', 'date_parsing',
'currency_cleaning', 'country_standardization'
])
print("清洗报告:", report)
步骤12:导出与验证
不导出CSV(精度损失),用Parquet保留类型:
# 导出为Parquet,压缩提高效率
df.to_parquet('clean_orders.parquet', index=False, compression='snappy')
# 验证:重新加载并检查类型
test_df = pd.read_parquet('clean_orders.parquet')
print("导出验证:")
print(test_df.dtypes[['order_date', 'order_amount', 'country_code']])
print("样本数据:")
print(test_df.head(3))
5. 常见问题与排查技巧实录:那些深夜调试的血泪教训
5.1 “明明填了缺失值,为什么还是报错?”——索引错位陷阱
现象:执行 df['age'].fillna(35) 后, df['age'].isna().sum() 显示0,但后续 df.groupby('city')['age'].mean() 仍报 ValueError: No numeric types to aggregate 。排查发现, fillna() 后 age 列类型是 object 而非 float64 。根源是原列含字符串'Unknown', fillna() 未改变dtype。解决方案:强制转换类型。
# 错误示范
df['age'] = df['age'].fillna(35) # 类型仍是object
# 正确示范
df['age'] = pd.to_numeric(df['age'], errors='coerce').fillna(35)
# 或更安全
df['age'] = df['age'].replace('Unknown', pd.NA).astype('float64').fillna(35)
实操心得:每次
fillna()后,必用df[col].dtype检查类型。我们团队在代码审查清单中加入这一条:“所有fillna操作后,必须有dtype断言”。
5.2 “日期解析一半失败,另一半正常”——混合格式的静默崩溃
现象: pd.to_datetime(df['date'], format='%Y-%m-%d', errors='coerce') 对'2023-03-15'成功,但'15/03/2023'变NaT,且无报错。这是因为 format 参数是严格匹配,不支持多格式。解决方案:用 infer_datetime_format=False + 多次尝试,或改用 dateutil.parser (但慢3倍)。
# 推荐:用我们前述的safe_to_datetime函数
# 不推荐:dateutil.parser.parse(),单行耗时0.8ms vs pandas的0.02ms
血泪教训:2021年某项目因用
dateutil处理千万行数据,清洗耗时从8分钟暴涨到112分钟,被迫重构。
5.3 “为什么删除重复行后,数据量没变?”——duplicated()的keep参数玄机
现象: df.duplicated().sum() 返回1200,但 df = df[~df.duplicated()] 后 len(df) 不变。原因是 duplicated() 默认 keep='first' ,即标记重复行为True,但首行保留;而 ~df.duplicated() 取反后,首行被保留,其余重复行被删除——但如果数据中存在“首行就是重复的”,就会漏删。正确做法是明确指定 keep=False :
# 错误:只删除后续重复行
df_clean = df[~df.duplicated(keep='first')]
# 正确:删除所有重复行(包括首行)
df_clean = df[~df.duplicated(keep=False)]
验证技巧:删除前后用
df.duplicated().sum()对比,必须严格递减。
5.4 “Excel样式全丢了!”——openpyxl写回的隐藏开关
现象:用pandas处理完数据, df.to_excel('output.xlsx') 后,原文件的合并单元格、背景色、字体全消失。这是因为 to_excel() 只写数据,不写样式。解决方案:用openpyxl加载原工作簿,用pandas写入数据,再保存:
from openpyxl import load_workbook
from openpyxl.utils.dataframe import dataframe_to_rows
# 加载原工作簿(保留样式)
wb = load_workbook('input.xlsx')
ws = wb['orders']
# 写入pandas数据(从第2行开始,跳过表头)
for r_idx, row in enumerate(dataframe_to_rows(df, index=False, header=False), 2):
for c_idx, value in enumerate(row, 1):
ws.cell(row=r_idx, column=c_idx, value=value)
wb.save('output_with_style.xlsx')
注意:
dataframe_to_rows默认包含表头,需设header=False;行号从2开始,因第1行是原表头。
5.5 “清洗后模型效果反而下降”——数据泄露的隐形杀手
现象:清洗脚本中用了 df['age'].fillna(df['age'].median()) ,模型在测试集上AUC下降5%。原因是 median() 用的是全量数据,导致训练集信息泄露到测试集。解决方案:清洗必须分训练/测试集独立进行,或用 sklearn.pipeline 封装:
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
# 正确:Pipeline确保fit_transform只在训练集,transform在测试集
pipeline = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
X_train_clean = pipeline.fit_transform(X_train)
X_test_clean = pipeline.transform(X_test) # 不会重新计算median
关键原则:任何依赖数据分布的清洗操作(均值、中位数、众数、分位数),都必须在训练集上fit,在测试集上transform。
6. 进阶实践与工程化延伸:从脚本到服务的跃迁
6.1 清洗配置化:用YAML定义规则,告别硬编码
当清洗逻辑频繁变更(如每月新增国家代码映射),硬编码维护成本极高。我们采用YAML配置驱动:
# cleaning_config.yaml
rules:
- field: order_date
type: datetime
formats: ["%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"]
on_failure: "1970-01-01"
- field: order_amount
type: numeric
currency_symbol: "$"
thousands_separator: ","
- field: ship_country
type: categorical
mapping:
US: ["USA", "U.S.A.", "United States"]
GB: ["UK", "United Kingdom", "更多推荐
所有评论(0)