1. 项目概述:为什么数据清洗不是“脏活”,而是建模成败的分水岭
“The Art Of Data Cleaning Using Pandas”——这个标题里藏着一个被严重低估的真相:数据清洗从来不是建模前的附属步骤,它本身就是建模过程的第一轮逻辑推演。我带过二十多个从零起步的数据分析项目,其中17个在模型上线后两周内出现预测漂移,回溯发现,15个问题根源不在算法调参,而在清洗阶段对“缺失值”的一刀切填充、对“重复记录”的机械去重、对“异常值”的盲目截断。Pandas不是Excel的替代品,它是你和数据之间第一道也是最精密的逻辑接口。它用Series和DataFrame封装了数据的语义结构,用groupby暴露业务逻辑的聚合粒度,用apply把领域知识翻译成可执行的函数。比如,处理电商订单中的“下单时间”字段,不能只看pd.to_datetime()是否报错;你要判断:凌晨2点批量生成的测试订单是否该剔除?同一用户10分钟内3次下单,是薅羊毛还是真实需求?这些决策没有标准答案,但每一步都必须在Pandas的链式操作中留下可追溯、可复验的痕迹。这篇文章不教你怎么写df.dropna(),而是带你拆解:当原始数据像一筐混着泥沙的稻谷,Pandas如何成为那台可调节风速、筛网孔径、出料角度的智能清选机——风太猛会吹走饱满谷粒(误删有效样本),筛太密会卡住石子(遗漏关键异常),角度偏了则良莠同流(混淆业务边界)。适合刚学完Pandas基础语法、正为Kaggle入门赛卡在“Public Score 0.78”上反复挣扎的朋友;也适合带团队做BI落地、却总被业务方质疑“为什么报表数字和我Excel里差3%”的分析师。你不需要记住所有API,但必须理解:.fillna(method='ffill')背后是时间序列的连续性假设,.duplicated(keep='last')隐含着“最新状态覆盖历史”的业务契约,而.astype('category')不只是节省内存,更是向后续模型宣告“这个字段的取值集合是封闭且有序的”。
2. 核心思路拆解:从“修修补补”到“构建数据契约”
2.1 为什么90%的清洗脚本无法复用?——缺乏显式的数据契约
新手常犯的致命错误,是把清洗当成“让代码跑通”的技术动作。他们写:
df['price'] = df['price'].fillna(df['price'].median()) df['category'] = df['category'].fillna('Unknown')这看似解决了缺失值,实则埋下三颗雷:
- 第一颗雷:缺失机制未验证。
price缺失是因为传感器故障(随机缺失),还是高端商品故意隐藏标价(非随机缺失)?用中位数填充前者合理,填充后者则直接扭曲价格分布。 - 第二颗雷:业务语义被抹杀。“Unknown”在分类字段中是占位符,但在风控场景中,“未知类别”可能意味着高风险,需要单独建模。
- 第三颗雷:契约不可追溯。半年后新同事看到这段代码,无法判断
'Unknown'是原始数据就存在,还是清洗时人为添加的。
真正的清洗思路,是先定义数据契约,再编写契约验证器。以电商用户表为例,契约应包含三层:
- 结构层契约:
user_id必须唯一且非空,reg_date必须早于last_login_date; - 统计层契约:
age应在16-99之间,total_spent的99.5%分位数不应超过avg_order_value * 1000; - 业务层契约:
vip_level为'Gold'的用户,其total_spent必须≥5000元,否则触发人工复核。
Pandas的assert语句和自定义校验函数就是实现契约的工具。我坚持在每个清洗模块开头写:
# 契约校验:注册日期不能晚于最后登录日 assert (df['reg_date'] <= df['last_login_date']).all(), \ f"发现{sum(df['reg_date'] > df['last_login_date'])}条记录违反注册时间逻辑"这比任何文档都可靠——代码即契约,失败即报警。
2.2 清洗不是单向流水线,而是带反馈的闭环系统
教科书常把清洗画成线性流程:缺失值→异常值→重复值→类型转换。但真实项目中,清洗步骤必须支持反向迭代。举个典型场景:你在处理销售数据时,发现sales_amount有大量0值。常规做法是删掉或填充,但更优解是:
- 先用
df[df['sales_amount']==0].groupby('product_id').size().sort_values(ascending=False)查出高频0值商品; - 发现TOP3全是新品,进一步查
launch_date字段,发现它们上市不足7天; - 此时结论不是“0值是脏数据”,而是“新品冷启动期的0销售额是有效业务信号”,需在后续特征工程中构造
days_since_launch变量。
Pandas的query()和loc让这种探索式清洗成为可能。我习惯把清洗脚本拆成原子化函数,并用字典管理状态:
cleaning_state = { 'raw_shape': df.shape, 'zero_sales_products': set(), 'outlier_thresholds': {} } # 探索阶段 zero_prods = df.query('sales_amount == 0')['product_id'].unique() cleaning_state['zero_sales_products'] = set(zero_prods) # 决策阶段:若新品占比>30%,则保留0值并标记 if len([p for p in zero_prods if p in new_product_ids]) / len(zero_prods) > 0.3: df['is_new_product_zero'] = df['product_id'].isin(new_product_ids) & (df['sales_amount']==0)这种设计让清洗过程可审计、可回滚、可解释——当你向产品总监解释“为什么Q3销量预测下调5%”,你能指着is_new_product_zero特征说:“因为23款新品上市节奏比预期慢,这个信号在清洗阶段就被我们捕获并量化了。”
2.3 工具选型逻辑:为什么不用Dask或Polars替代Pandas?
常有人问:“数据量大时,该换Dask还是Polars?”我的答案很直接:除非你的单机内存撑不住,否则别换。原因有三:
- 生态成本:Pandas的
plot()、describe()、corr()与Matplotlib/Seaborn/SciPy无缝集成,而Dask的.compute()会频繁触发全量计算,Polars的.to_pandas()又失去性能优势; - 调试成本:Pandas报错信息直指
line 42, in clean_price: TypeError: can't multiply sequence by non-int of type 'float',Dask报错却是distributed.worker - WARNING - Compute failed,你得翻日志定位; - 认知成本:业务方要查某条订单,你给他发个Pandas DataFrame截图,他能立刻看懂;发个Polars LazyFrame的
explain()结果,他只会问“这串英文啥意思”。
我处理过单表2.3亿行的物流轨迹数据,没换引擎,而是用Pandas的chunksize+category优化:
# 分块读取,避免内存爆炸 for chunk in pd.read_csv('logistics.csv', chunksize=50000): # 关键字段转category,内存直降70% chunk['status'] = chunk['status'].astype('category') chunk['city'] = chunk['city'].astype('category') # 处理后追加到结果文件 chunk.to_parquet('cleaned_chunk.parquet', engine='pyarrow', compression='snappy', append=True)Pandas的真正威力,在于它把复杂的数据操作,压缩成人类可读的、符合直觉的API。.str.contains('iPhone')比SQL的REGEXP_LIKE(product_name, 'iPhone')更贴近业务语言,.dt.floor('D')比DATE_TRUNC('day', order_time)更直观。选择工具的本质,是选择与业务方沟通的“共同语言”,而不是追求参数上的绝对最优。
3. 核心细节解析:那些文档里不会写的清洗陷阱与技巧
3.1 缺失值处理:中位数、众数、插值,哪个才是“正确答案”?
填充分析师最爱用fillna(),但它的危险在于:默认填充方式会悄悄改写数据的统计分布。我做过一个实验:对服从正态分布的10万行income字段,分别用均值、中位数、前向填充、随机采样填充20%缺失值,结果如下:
| 填充方式 | 填充后均值误差 | 填充后标准差误差 | 填充后偏度变化 |
|---|---|---|---|
| 均值填充 | +0.2% | -18.7% | +0.42 |
| 中位数填充 | -0.1% | -15.3% | +0.38 |
| 前向填充 | +1.8% | -22.1% | +0.65 |
| 随机采样(同分布) | -0.03% | +0.8% | +0.05 |
表格说明:即使是最“稳妥”的中位数填充,也会让标准差缩水15%,这意味着你后续做的Z-Score标准化,实际是在一个被压缩的分布上计算。真正的解法不是选填充方法,而是选填充时机:
- 时机一:在特征工程前填充。适用于
age这类强分布约束字段,用KNNImputer基于相似用户填充,但必须确保KNN的特征子集不含目标变量(防数据泄露); - 时机二:在模型训练时填充。XGBoost/LightGBM原生支持
np.nan,直接传入缺失值,让树模型自己学习分割逻辑; - 时机三:用缺失本身作为特征。
is_income_missing = df['income'].isna(),这个布尔列在信贷风控中,其重要性常排进Top5。
我处理银行客户数据时,发现monthly_income缺失率高达37%,但is_income_missing与default_flag的卡方检验p值<0.001。最终模型里,这个“缺失指示器”比填充后的income数值本身贡献更大。所以,下次看到缺失值,先别急着fillna(),打开df.isna().sum()/len(df),问问自己:“这个缺失,是不是业务给我们的第一个信号?”
3.2 异常值检测:IQR和Z-Score为何总在业务场景中失效?
教科书推荐的IQR(四分位距)和Z-Score,在真实业务中常闹笑话。比如检测电商订单金额异常值:
- IQR法:Q1=29元,Q3=156元,IQR=127,上限=156+1.5×127=346.5元。结果把所有iPhone订单(均价5299元)全判为异常;
- Z-Score法:设阈值3,但
order_amount右偏严重,均值128元,标准差210元,Z>3对应金额>758元,漏掉大量高价订单。
根本问题在于:异常值检测不是数学问题,而是业务归因问题。我的解决方案是“三层过滤法”:
- 规则层过滤:硬编码业务常识。如“单笔订单金额>10万元,且支付方式为‘余额宝’,则触发人工审核”;
- 分布层过滤:用
scipy.stats.gaussian_kde拟合非参数密度,取概率密度<0.001的点为异常; - 关系层过滤:检查字段间逻辑矛盾。如
shipping_address_province=='Xinjiang'且delivery_days==1,物理上不可能,必为录入错误。
Pandas的query()是实现这三层的利器:
# 规则层:新疆订单配送天数不能为1 df = df.query("not (shipping_address_province == 'Xinjiang' and delivery_days == 1)") # 关系层:用groupby发现异常模式 abnormal_users = df.groupby('user_id')['order_amount'].agg(['count','sum']).query('sum > 100000 and count < 5') df = df[~df['user_id'].isin(abnormal_users.index)] # 疑似刷单用户记住:Z-Score不是万能钥匙,它是为正态分布设计的。而现实世界的数据,就像菜市场里的土豆——大小不一、形状各异、还带着泥,强行用圆规去量,量出来的不是土豆,是你自己的执念。
3.3 重复记录处理:duplicated()的五个致命误区
df.duplicated()看似简单,但我在代码审查中发现,83%的重复处理逻辑存在隐患。最常见的五个误区:
提示:
keep=False不是万能解药。它会把所有重复记录标为True,但业务中常需区分“主记录”和“冗余记录”。比如用户表中,user_id相同但update_time不同的两条记录,应保留update_time最新的那条,而非全删。
误区一:未指定subset参数,导致全字段比对
# 错误:比较所有20个字段,耗时且无意义 df = df[~df.duplicated()] # 正确:只比对业务主键 df = df[~df.duplicated(subset=['user_id', 'order_id'], keep='last')]误区二:忽略浮点数精度问题
# 'amount'字段是float64,0.1+0.2!=0.3,导致本该重复的记录没被识别 df['amount_rounded'] = df['amount'].round(2) # 先四舍五入再比对 df = df[~df.duplicated(subset=['user_id', 'amount_rounded'])]误区三:未处理空值逻辑
Pandas默认把NaN视为相等,但业务中phone=None和phone=' '(空格)含义不同。需先标准化:
df['phone'] = df['phone'].str.strip().replace('', np.nan)误区四:未考虑时间衰减效应
同一用户1小时内的3次下单,可能是正常行为;但3个月内的3次,大概率是测试数据。用diff()计算时间间隔:
df = df.sort_values(['user_id', 'order_time']) df['time_diff_hours'] = df.groupby('user_id')['order_time'].diff().dt.total_seconds() / 3600 df = df[~((df['time_diff_hours'] < 0.5) & (df['time_diff_hours'] > 0))]误区五:未验证去重后的业务一致性
去重后,检查关键指标是否突变:
# 去重前:10万订单,总GMV 2.3亿元 # 去重后:9.8万订单,总GMV 2.28亿元 → 合理 # 若总GMV变成1.5亿元,则说明误删了高价值订单,需回溯subset选择清洗不是追求“数据变少”,而是追求“数据变真”。每删一条记录,都要能说出它为什么不该存在——不是因为代码报错,而是因为业务逻辑不允许。
3.4 字段类型优化:category、datetime、nullable int的实战价值
Pandas默认把字符串存为object,整数存为int64,这在小数据集上无感,但处理千万级数据时,内存和速度差异巨大。我整理了三个最值得投入时间的类型优化:
category类型:专治高基数字符串
适用场景:product_category(200个类目)、city(300个城市)、status('pending','shipped','delivered')。
# 优化前:100万行,'city'列占内存120MB # 优化后:转category,内存降至8MB,且groupby速度提升3倍 df['city'] = df['city'].astype('category')但注意:category不适合低基数且频繁更新的字段(如user_comment),因为每次append()都会重建分类索引。
datetime类型:解锁时间智能
别再用字符串存时间!pd.to_datetime()后,.dt访问器提供20+种时间操作:
# 提取星期几(周一=0),用于分析用户活跃周期 df['day_of_week'] = df['order_time'].dt.dayofweek # 计算距离春节的天数,捕捉节日效应 spring_festival = pd.to_datetime('2024-02-10') df['days_to_sf'] = (spring_festival - df['order_time']).dt.days.abs() # 按小时聚合,发现配送高峰在18-20点 df['hour_bin'] = df['order_time'].dt.hour // 2 * 2 # 每2小时一桶Int64(nullable integer):解决“缺失整数”的世纪难题
传统int64不能存NaN,导致你不得不填-999或0,污染统计。Pandas 1.0+的Int64(首字母大写)支持pd.NA:
# 将'children_count'转为可空整数 df['children_count'] = df['children_count'].astype('Int64') # 此时df['children_count'].mean()会自动忽略NA,结果更真实 # 而不是像int64那样,把-999当有效值计入均值类型优化不是炫技,而是为后续分析铺路。当你能用.dt.dayofweek一行代码替代lambda x: datetime.strptime(x,'%Y-%m-%d').weekday()十行代码时,你就赢得了每天2小时的调试时间。
4. 实操全流程:从原始CSV到可交付数据集的12个关键环节
4.1 环境准备与数据探查:建立清洗的“作战地图”
清洗开始前,我强制执行三步“侦察”:
- 快速概览:用
df.info()看字段类型、非空计数、内存占用; - 分布快照:用
df.describe(include='all')看数值和分类字段的统计摘要; - 缺失热力图:用
missingno.matrix(df)可视化缺失模式(需安装missingno库)。
但更重要的是业务探查——打开原始CSV,用文本编辑器看前100行。我曾在一个医疗数据集中,发现diagnosis_code字段前50行是ICD-10编码(如J45.909),第51行起变成中文(如“支气管哮喘”),原因是医院HIS系统升级,新旧数据混在一起。如果只跑df['diagnosis_code'].nunique(),你会得到“2843个唯一值”的假象,而实际是两套编码体系。
我的标准探查脚本模板:
def quick_audit(df, sample_rows=100): print("=== 数据基础信息 ===") print(df.info(memory_usage='deep')) print("\n=== 数值字段分布 ===") num_cols = df.select_dtypes(include=np.number).columns.tolist() print(df[num_cols].describe()) print("\n=== 分类字段高频值 ===") cat_cols = df.select_dtypes(include='object').columns.tolist() for col in cat_cols[:5]: # 只看前5个,避免刷屏 print(f"\n{col} Top5:") print(df[col].value_counts(dropna=False).head(5)) print("\n=== 原始数据样本 ===") print(df.head(sample_rows).to_string(max_rows=10, max_cols=None)) # 执行探查 quick_audit(raw_df)这12行代码,能帮你避开80%的“清洗到一半发现字段含义不对”的灾难。记住:数据清洗的第一步,永远是放下键盘,用眼睛阅读数据。
4.2 缺失值深度处理:从填充到重构的完整路径
基于探查结果,我按缺失率将字段分为三类,采用不同策略:
| 缺失率区间 | 字段类型 | 处理策略 | Pandas实现要点 |
|---|---|---|---|
| <5% | 数值型 | 删除含缺失的行 | df.dropna(subset=['col_a','col_b']),但先df.shape记录删除量 |
| 5%-30% | 数值型 | 多重插补(MICE) | 用sklearn.experimental.enable_iterative_imputer,IterativeImputer,注意设置initial_strategy='median'避免循环依赖 |
| >30% | 分类型 | 创建“缺失”新类别 | df['col'].fillna('MISSING_CATEGORY'),并在后续one-hot编码时保留该列 |
但最高阶的技巧是用领域知识重构缺失字段。例如,电商中user_age缺失率达42%,但user_id前缀含注册年份(如U20180523XXXX),order_history中有first_order_date。此时应放弃填充,转而构造代理变量:
# 从user_id提取注册年份 df['reg_year'] = df['user_id'].str.extract(r'U(\d{4})').astype('Int64') # 计算注册时长(年) df['years_as_user'] = (pd.Timestamp('today') - pd.to_datetime(df['reg_year'], format='%Y')).dt.days / 365.25 # 构造年龄区间(比单点值更鲁棒) df['age_group'] = pd.cut(df['years_as_user'], bins=[0,1,3,5,10,np.inf], labels=['New','1-3y','3-5y','5-10y','10+y'])这个age_group比任何填充的age数值都更接近业务本质——用户价值取决于其与平台的共生时长,而非生理年龄。清洗的终极目标,是让数据字段承载可行动的业务洞察,而非追求统计意义上的“完整”。
4.3 异常值精准打击:结合业务规则的混合检测框架
我搭建了一个可复用的异常检测框架,核心是AnomalyDetector类:
class AnomalyDetector: def __init__(self, df): self.df = df.copy() self.anomalies = {} # 存储各字段异常索引 def rule_based(self, condition, field_name, reason): """基于业务规则的硬过滤""" mask = self.df.eval(condition) self.anomalies[field_name] = { 'indices': self.df[mask].index.tolist(), 'reason': reason, 'count': mask.sum() } return self.df[~mask] def statistical(self, field, method='iqr', threshold=1.5): """统计方法检测""" if method == 'iqr': Q1 = self.df[field].quantile(0.25) Q3 = self.df[field].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - threshold * IQR upper = Q3 + threshold * IQR mask = (self.df[field] < lower) | (self.df[field] > upper) elif method == 'zscore': z_scores = np.abs(stats.zscore(self.df[field].dropna())) mask = self.df[field].isin(self.df[field].dropna().iloc[np.where(z_scores > threshold)[0]]) self.anomalies[field] = { 'indices': self.df[mask].index.tolist(), 'reason': f'{method.upper()} outlier', 'count': mask.sum() } return self.df[~mask] def report(self): """生成异常报告""" for field, info in self.anomalies.items(): print(f"{field}: {info['count']} anomalies ({info['reason']})") # 使用示例 detector = AnomalyDetector(df) df_clean = detector.rule_based("order_amount > 100000 and payment_method == 'Balance'", 'order_amount', 'High-risk manual review') df_clean = detector.statistical('order_amount', method='iqr', threshold=2.0) detector.report()这个框架的价值在于:所有异常决策都被记录、可审计、可复现。当业务方质疑“为什么删了张三的订单”,你只需打开detector.anomalies,展示'order_amount': {'indices': [12345], 'reason': 'High-risk manual review', 'count': 1}——证据确凿,无需争辩。
4.4 重复记录治理:从技术去重到业务归并
duplicated()只能解决“完全重复”,但真实场景中更多是“语义重复”。比如用户表中:
- 记录1:
user_id=U123, name='张三', phone='138****1234', email='zhang@xxx.com' - 记录2:
user_id=U456, name='张三', phone='138****1234', email='zhang.san@xxx.com'
这是同一人,但user_id不同。我的解决方案是构建实体解析管道:
- 标准化:
phone去空格、统一格式,email转小写; - 阻塞(Blocking):用
phone或email分组,缩小比对范围; - 相似度计算:对同组内
name用fuzzywuzzy.fuzz.token_sort_ratio计算相似度; - 归并决策:相似度>85%且
phone相同,则归并为user_id=U123,email取两个邮箱的并集。
Pandas实现关键代码:
from fuzzywuzzy import fuzz # 标准化 df['phone_clean'] = df['phone'].str.replace(r'\D', '', regex=True) df['email_clean'] = df['email'].str.lower().str.strip() # 阻塞:按phone分组 blocks = df.groupby('phone_clean') merged_records = [] for _, block in blocks: if len(block) == 1: merged_records.append(block.iloc[0]) continue # 计算name相似度矩阵 names = block['name'].tolist() scores = np.array([[fuzz.token_sort_ratio(n1, n2) for n2 in names] for n1 in names]) # 聚类:相似度>85的归为一类 from sklearn.cluster import AgglomerativeClustering clustering = AgglomerativeClustering( n_clusters=None, distance_threshold=15, # 100-85=15 metric='precomputed', linkage='complete' ) labels = clustering.fit_predict(100 - scores) # 转换为距离矩阵 # 每类归并 for label in set(labels): cluster = block.iloc[np.where(labels == label)[0]] # 取最新记录为主记录 main = cluster.loc[cluster['update_time'].idxmax()] # 合并邮箱 emails = ';'.join(cluster['email_clean'].dropna().unique()) main['email_clean'] = emails merged_records.append(main) df_merged = pd.DataFrame(merged_records)这比单纯drop_duplicates()多花3倍时间,但换来的是真实的用户视图——这才是BI报表、精准营销、风控模型的基石。
4.5 类型转换与特征衍生:清洗即建模的起点
清洗的终点,是特征工程的起点。我坚持在清洗脚本末尾,加入“特征衍生区”:
# 时间特征 df['is_weekend'] = df['order_time'].dt.dayofweek >= 5 df['hour_sin'] = np.sin(2 * np.pi * df['order_time'].dt.hour / 24) df['hour_cos'] = np.cos(2 * np.pi * df['order_time'].dt.hour / 24) # 解决23点到0点的跳跃 # 行为特征 df['order_rank'] = df.groupby('user_id')['order_time'].rank(method='first') df['days_since_last_order'] = df.groupby('user_id')['order_time'].diff().dt.days.fillna(0) # 统计特征 user_stats = df.groupby('user_id')['order_amount'].agg(['mean','std','count']).rename( columns={'mean':'user_avg_order','std':'user_order_std','count':'user_order_count'}) df = df.merge(user_stats, on='user_id', how='left')这些衍生特征不是“锦上添花”,而是把清洗过程中发现的业务规律,固化为模型可学习的信号。hour_sin/cos让模型理解“23点和1点在时间上很近”,days_since_last_order把用户流失风险量化,user_avg_order则让单笔订单金额有了参照系。清洗脚本至此,已不再是数据预处理代码,而是一份用Python写就的业务洞察白皮书。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的坑
5.1 “明明代码没报错,但结果完全不对”——时间戳时区陷阱
最隐蔽的坑:pd.to_datetime()默认把无时区时间当作本地时区,但服务器时区和开发机时区不同。
- 现象:本地测试
df['order_time'].dt.date输出2024-01-01,生产环境输出2023-12-31; - 根因:服务器在UTC+0,开发机在UTC+8,
2024-01-01 00:00:00在UTC+0是2023-12-31 16:00:00; - 解法:强制指定时区,再转换:
# 错误:依赖系统默认时区 df['order_time'] = pd.to_datetime(df['order_time']) # 正确:明确声明原始时区,再转目标时区 df['order_time'] = pd.to_datetime(df['order_time']).dt.tz_localize('Asia/Shanghai').dt.tz_convert('UTC')我现在的清洗脚本,第一行必是:
import os os.environ['TZ'] = 'UTC' # 统一时区环境然后所有时间操作都在UTC下进行,输出时再转本地。时区不是玄学,是必须写进SOP的硬性规范。
5.2 “内存爆了,但df.info()显示才2GB”——字符串对象的内存黑洞
Pandas的object类型字符串,每个值都是独立的Python对象,内存开销巨大。
- 现象:读入100万行CSV,
df.info()显示内存1.8GB,但psutil.Process().memory_info().rss显示进程占用8.2GB; - 根因:
object列的字符串被重复存储,且Python对象头额外占用16字节; - 解法:三步走——
- 用
df.memory_usage(deep=True)定位最大内存消耗列; - 对高基数字符串列,用
df['col'].str.lower().str.strip().astype('category'); - 对低基数字符串,用
df['col'].map({'A':0,'B':1,'C':2}).astype('uint8')编码。
- 用
我处理过一个日志表,ip_address列占内存3.2GB,转category后降至120MB,且value_counts()速度提升5倍。记住:内存不是瓶颈,是设计缺陷的警报灯。
5.3 “fillna()后,模型效果反而变差”——缺失值的因果陷阱
填充本身不改变数据,但会改变模型对数据生成过程的理解。
- 案例:用
mean填充credit_score缺失值,XGBoost特征重要性显示credit_score排第一,但线上A/B测试显示,用该模型的用户授信通过率下降12%; - 根因:
credit_score缺失者多为新用户,其真实信用风险低于均值,填充均值导致模型高估其风险; - 解法:用
pd.cut()将credit_score分箱,再用mode填充每箱的缺失值:
# 按现有值分5箱 df['score_bin'] = pd.cut(df['credit_score'].dropna(), bins=5, labels=False) # 每箱用众数填充 fill_values = df.groupby('score_bin')['credit_score'].apply(lambda x: x.mode().iloc[0] if not x.mode().empty else x.median()) df['credit_score'] = df.apply(lambda row: fill_values[row['score_bin']] if pd.isna(row['credit_score']) else row['credit_score'], axis=1)这确保了填充值符合局部分布,而非全局均值。清洗不是让数据“看起来整齐”,而是让数据“讲出真实故事”。
5.4 “groupby().agg()结果和Excel不一样”——Pandas的聚合默认行为陷阱
Pandas的agg()对缺失值的处理与Excel不同:
- Excel的
AVERAGE()自动忽略空值; - Pandas的
df.groupby('A')['B'].mean()也忽略空值,但df.groupby('A')['B'].agg('mean')在某些版本中会报错; - 更危险的是
df.groupby('A')['B'].size()vs `