1. 项目概述:数据清洗中“数据操作”环节到底在干什么?
如果你正在系统学习数据清洗,看到“Part 4: Data Manipulation in Data Cleaning”这个标题,别急着跳过——它不是泛泛而谈的“用pandas改几列名字”,而是整个清洗流水线里承上启下的核心枢纽环节。我带过二十多个真实业务清洗项目,从电商用户行为日志、IoT设备时序采样、到医疗电子病历结构化文本,所有项目在完成缺失值识别(Part 1)、异常值探测(Part 2)、重复记录处理(Part 3)之后,无一例外卡在这一环:原始数据已经“看起来干净了”,但离能进模型、能出报表、能被业务部门直接读取,还差最关键的一步——让数据真正“可用”。这就是Data Manipulation的本质:不是机械地增删改查,而是基于业务语义、分析目标和下游消费方式,对数据进行有目的、可追溯、可复现的语义重构。比如把“2023-05-12T14:30:22+08:00”这种ISO格式时间戳,按业务需求拆成“年份”“季度”“工作日/周末”“是否促销期”四列;再比如把“张三|李四|王五”这种竖线分隔的多值字段,展开成三行独立记录,同时保留原始订单ID做关联;又或者把“高”“中”“低”这类文字等级,映射为100/60/20的数值评分,用于后续加权计算。这些操作单看都很简单,但组合起来就构成清洗质量的分水岭:操作逻辑错一层,后续建模结果偏移三成;字段命名不统一,BI同事要花两天时间查字段血缘;没有保留原始字段快照,审计时根本说不清某次报表波动是数据问题还是逻辑变更。所以本篇不讲语法,只讲实战中怎么判断“该不该操作”、怎么设计“操作顺序”、怎么验证“操作没出错”、怎么留痕“谁在什么时候改了什么”。你不需要是pandas专家,但必须清楚每一步操作背后的业务动因和风险代价。
2. 内容整体设计与思路拆解:为什么“操作”必须放在清洗流程第四步?
2.1 清洗流程中的不可逆临界点
很多人误以为数据操作可以随时穿插在清洗各阶段,实则不然。在我经手的清洗项目中,超过65%的数据质量问题回溯,根源都出在操作顺序混乱。举个典型例子:某零售客户清洗销售数据时,先做了“销售额>100万的记录标记为大客户”,再做“剔除测试账号(user_id以‘TEST_’开头)”。结果发现大客户名单里混进了37个测试账号——因为标记操作发生在剔除之前,测试账号的销售额也被计入了大客户判定。这暴露了一个铁律:Data Manipulation是清洗流程中首个具有“状态依赖性”的环节,它的输入必须是经过前三步(缺失识别→异常探测→重复处理)净化后的“稳定基线数据”。为什么?因为缺失值填充策略(如均值填充vs前向填充)直接影响后续分组统计的分布;异常值未剔除就做标准化,会严重扭曲Z-score阈值;重复记录未去重就做聚合,会导致销量虚高。所以Part 4绝非随意排序,而是清洗流水线的“逻辑闸门”:前三步解决“数据有没有问题”,Part 4解决“数据怎么用才对”。
2.2 操作类型与业务场景的强耦合设计
市面上很多教程把数据操作笼统分为“筛选、排序、分组、聚合”,这在教学场景可行,但在真实业务中完全失效。我整理了近3年12个行业清洗项目的操作类型分布,发现真正的分类维度是下游消费场景:
面向机器学习建模:操作重心在特征工程前置。比如金融风控中,“近3个月逾期次数”需从交易流水表中按user_id滚动窗口计算;电商推荐中,“用户最近点击品类偏好”需对点击流做时间衰减加权。这类操作必须保留原始粒度字段(如每笔交易的时间戳),否则无法回溯特征生成逻辑。
面向BI报表展示:操作重心在维度规整与口径对齐。比如把“订单创建时间”“支付成功时间”“发货时间”三个字段,统一转换为“业务日期”(按公司定义:支付成功当日为业务日);再比如将“华东大区”“华南大区”等销售区域,映射到标准行政区划编码(GB/T 2260)。这类操作必须建立字段映射日志,否则财务核对时无法解释报表差异。
面向API接口输出:操作重心在结构扁平化与类型强约束。比如把嵌套JSON中的
{"address": {"city": "Shanghai", "district": "Xuhui"}}展开为address_city,address_district两列;强制将所有金额字段转为decimal(12,2)类型,避免浮点精度丢失。这类操作必须通过Schema校验工具(如Great Expectations)固化规则,否则下游系统解析失败。
因此,本篇的设计思路不是罗列pandas函数,而是按这三类场景拆解操作范式。你拿到一个新项目,第一反应不应该是“用df.loc还是df.query”,而是问:“这个数据最后给谁用?他们需要什么形态?”
2.3 避免“操作即修改”的认知陷阱
新手最容易踩的坑,是把Data Manipulation等同于“直接改原DataFrame”。我在某次银行项目评审中看到,开发同学写了200行代码直接在原始df上.drop(),.rename(),.fillna(),结果当风控模型需要回溯某次逾期预测时,根本找不到原始逾期标志字段——它在第三步就被df.drop('is_overdue_raw')删掉了。正确的做法是:所有操作必须遵循“不可变数据源”原则。即:
- 原始数据文件(CSV/Parquet)永远只读,禁止任何写入;
- 所有中间状态保存为带版本号的临时表(如
sales_clean_v20231015_interim); - 最终输出表明确标注衍生字段来源(如
customer_risk_score_v3字段注明“基于v20231010版信用分模型计算”)。
这看似增加存储开销,实则节省了80%以上的故障排查时间。某次电商大促期间,订单量突增导致清洗任务失败,运维同事3分钟内就定位到是“促销标签计算逻辑变更”引发的内存溢出,因为所有中间表都带时间戳和操作人信息。而隔壁团队还在翻Git历史找哪行代码改了groupby的key。
3. 核心细节解析与实操要点:四类高频操作的避坑指南
3.1 字段派生(Feature Derivation):别让时间计算毁掉你的分析
时间字段派生是清洗中最常做也最容易出错的操作。新手常犯的错误是直接用pd.to_datetime(df['date_str'])后调用.dt.year,却忽略了时区、格式歧义、空值陷阱。我们以一个真实案例说明:某物流公司的运单数据中,create_time字段存在三种格式——2023-05-12,12/05/2023,20230512,且含12%空值。如果强行to_datetime,pandas会默认按%Y-%m-%d解析,导致12/05/2023被误判为2023年12月5日(实际是5月12日),误差达7个月。
正确解法分三步:
- 格式探测与归一化:不用
infer_datetime_format=True(它在混合格式下准确率不足40%),而是用正则预分类:# 先用正则识别格式 df['format_type'] = np.select( [df['create_time'].str.match(r'^\d{4}-\d{2}-\d{2}$'), df['create_time'].str.match(r'^\d{2}/\d{2}/\d{4}$'), df['create_time'].str.match(r'^\d{8}$')], ['ymd', 'mdy', 'yyyymmdd'], default='unknown' ) - 分格式安全转换:对每类格式指定
format参数,空值设为NaT:df['create_dt'] = pd.NaT df.loc[df['format_type']=='ymd', 'create_dt'] = pd.to_datetime( df.loc[df['format_type']=='ymd', 'create_time'], format='%Y-%m-%d', errors='coerce' ) # 其他格式同理... - 业务时间派生:按公司规定,物流时效计算以“支付成功时间”为准,而非创建时间。所以最终派生字段应为:
# 先确保payment_time已清洗 df['payment_dt'] = pd.to_datetime(df['payment_time'], errors='coerce') # 派生“业务周”(周一为每周开始) df['biz_week_start'] = df['payment_dt'].dt.to_period('W-MON').dt.start_time # 派生“是否工作日”(排除周六日及法定节假日) cn_holidays = ['2023-01-21','2023-01-27','2023-04-05'] # 实际需对接节假日API df['is_workday'] = ((df['payment_dt'].dt.dayofweek < 5) & (~df['payment_dt'].dt.date.isin(pd.to_datetime(cn_holidays).date)))
提示:永远不要在派生字段名中省略业务上下文。
week_start不如biz_week_start清晰,is_weekend不如is_logistics_workday准确——后者明确告诉使用者这是物流业务定义的工作日,与HR考勤日历无关。
3.2 结构重组(Structural Reshaping):宽表与长表的抉择逻辑
宽表(Wide)与长表(Long)之争,在清洗中本质是查询效率与存储效率的权衡。某在线教育平台曾因盲目转宽表付出惨重代价:课程表原始是长表(student_id, course_id, score, subject),运营要求按学科汇总,开发直接pivot_table转成宽表(student_id, math_score, english_score, physics_score...)。结果当新增编程课时,所有BI报表SQL都要改SELECT字段,ETL任务因字段数超限失败。而更致命的是,90%的学生只选3-5门课,宽表产生大量NULL,存储膨胀3倍。
决策树如下:
- 如果下游是即席查询(Ad-hoc Query):优先长表。因为
WHERE subject IN ('math','english')比WHERE math_score IS NOT NULL OR english_score IS NOT NULL更高效,且新增学科无需改表结构。 - 如果下游是固定报表(Fixed Report):可考虑宽表,但必须用动态SQL生成。例如用Jinja2模板:
SELECT student_id, {% for subject in subjects %} MAX(CASE WHEN subject = '{{ subject }}' THEN score END) AS {{ subject }}_score {% if not loop.last %},{% endif %} {% endfor %} FROM long_table GROUP BY student_id - 如果涉及多层级嵌套(如订单→商品→SKU→批次):必须用长表+层级标识。例如:
这种结构支持任意层级钻取,且新增批次属性只需加列,不改表结构。order_id | level | entity_type | entity_id | quantity O123 | 1 | order | O123 | 1 O123 | 2 | item | I456 | 2 O123 | 3 | sku | S789 | 2 O123 | 4 | batch | B001 | 2
注意:Pandas的
melt()和pivot()不是万能钥匙。melt()后若未重置索引,value_vars列名会丢失业务含义;pivot()遇到重复索引会报错,必须先drop_duplicates()或aggfunc指定聚合逻辑。我在某次医疗数据清洗中,因未处理同一患者多次检查的重复记录,pivot()直接崩溃,耗时2小时才定位到是patient_id+test_date组合键不唯一。
3.3 类型转换与精度控制:数字字段的隐形杀手
数字类型转换的坑,往往在上线后才爆发。最经典的是Excel导出的“金额”字段:表面看是12345.67,实则存储为字符串"12345.67 "(末尾有空格),或科学计数法1.234567E+4。某支付公司曾因此导致对账差异:上游系统传来的"1000000"(字符串)被astype(float)转为1000000.0,下游Java系统解析为1000000(long),但当金额为"1000000.50"时,Python转float产生精度丢失(1000000.5000000001),Java解析为1000000,0.5元凭空消失。
安全转换四步法:
- 清洗前置空格与不可见字符:
df['amount_str'] = df['amount_str'].str.strip().str.replace(r'[^\d.-]', '', regex=True) # 移除全角空格、零宽空格等 - 严格格式校验:用正则确保符合金额模式:
# 匹配:可选负号+数字+小数点+两位小数 is_valid_amount = df['amount_str'].str.match(r'^-?\d+(\.\d{2})?$') df = df[is_valid_amount] # 直接过滤非法值,不尝试修复 - 定点数转换:不用
float,用Decimal保持精度:from decimal import Decimal df['amount'] = df['amount_str'].apply(lambda x: Decimal(x) if pd.notna(x) else None) - 数据库写入时显式声明:在SQLAlchemy中:
避免ORM自动映射为Column('amount', DECIMAL(precision=12, scale=2))Float。
实操心得:永远在转换后验证。我习惯加一行校验:
assert (df['amount'] * 100).apply(lambda x: x % 1 == 0).all(), "金额字段存在非两位小数"这行代码在某次清洗中揪出上游系统BUG:财务导出的Excel里,
"123.4"被当成123.40,但"123.456"被截断为123.45,导致校验失败,避免了百万级对账错误。
3.4 文本标准化与语义映射:别让“北京”和“北京市”毁掉你的分析
文本字段清洗的核心矛盾是业务一致性 vs 技术精确性。某政务数据平台清洗人口数据时,“籍贯”字段包含“北京”“北京市”“京”“Beijing”“PEKING”等17种写法。若用str.upper()统一转大写,"Beijing"变"BEIJING",但"PEKING"不变,仍无法合并;若用拼音库转,"PEKING"转"beijing",但"京"转"jing",反而扩大差异。
分层标准化策略:
- Level 1:基础清洗(必做)
- 去首尾空格、全角转半角、统一标点(中文逗号→英文逗号)
- 正则替换常见缩写:
r'(\bB\.? ?C\.?)' → 'BC'(处理“B.C.”“BC”)
- Level 2:业务词典映射(推荐)
- 构建
province_mapping.csv:source,standard,confidence 北京,北京市,0.95 京,北京市,0.85 Beijing,北京市,0.99 PEKING,北京市,0.70 - 用
fuzzywuzzy匹配,仅当相似度>0.8时替换:from fuzzywuzzy import fuzz def map_province(x): if pd.isna(x): return x best_match = max( [(src, std, conf) for src, std, conf in mapping_list], key=lambda t: fuzz.ratio(str(x), t[0]) ) return best_match[1] if best_match[2] > 0.8 else x df['province_std'] = df['province'].apply(map_province)
- 构建
- Level 3:上下文感知修正(高阶)
- 当
province为空但city为“朝阳区”时,自动补全为“北京市”; - 当
country为“China”且province为“Guangdong”时,强制转“广东省”。
- 当
关键经验:映射词典必须由业务方确认,而非技术单方面决定。某次我们把“新疆建设兵团”映射为“新疆维吾尔自治区”,被统计局驳回——前者是中央直属单位,行政级别等同省级,但不属于自治区辖区。最终采用双字段:
province_admin(行政归属)和province_jurisdiction(管辖主体)。
4. 实操过程与核心环节实现:一个完整电商订单清洗案例
4.1 场景设定与原始数据结构
我们以某跨境电商订单表orders_raw.csv为例,原始字段共23列,关键问题包括:
order_date:混合格式(2023/05/12,12-May-2023,20230512),15%空值shipping_address:JSON字符串,含{"country":"US","state":"CA","city":"Los Angeles"},但部分为null或"N/A"item_list:竖线分隔的字符串,如"SKU123|SKU456|SKU789",对应quantity_list="2|1|3"payment_status:"paid","pending","failed","refunded",但存在"PAID","Paid "等大小写/空格变体total_amount:字符串,含"$123.45","€99.99","12345"(无货币符号),5%为"N/A"
目标输出表orders_clean_v20231015需满足:
- 所有时间字段转为
datetime64[ns],派生order_year,order_quarter,is_weekend - 地址字段展开为
ship_country,ship_state,ship_city,空值转"UNKNOWN" - 每个SKU生成独立行,保留
order_id,sku_id,quantity,unit_price(需从total_amount按数量比例分摊) payment_status统一为小写无空格,"refunded"标记为is_refunded=Truetotal_amount_usd:统一转为USD,使用当日汇率(假设固定1 EUR = 1.08 USD,1 USD = 7.2 CNY)
4.2 分步实现与代码详解
Step 1:加载与基础清洗
import pandas as pd import numpy as np import re from decimal import Decimal # 加载时跳过空行,防止解析错误 df = pd.read_csv('orders_raw.csv', skip_blank_lines=True) # Level 1文本清洗:去空格、全角转半角、统一货币符号 df['payment_status'] = df['payment_status'].str.strip().str.lower() df['total_amount'] = df['total_amount'].str.strip() # 货币符号标准化:$123.45 → 123.45, €99.99 → 99.99, ¥888 → 888 def clean_currency(x): if pd.isna(x): return x # 移除所有非数字、小数点、负号的字符,但保留第一个负号 cleaned = re.sub(r'[^0-9.-]', '', str(x)) # 处理多个负号 if cleaned.count('-') > 1: cleaned = '-' + cleaned.replace('-', '') return cleaned df['total_amount_clean'] = df['total_amount'].apply(clean_currency)Step 2:时间字段安全解析
# 定义格式映射字典 format_patterns = { 'ymd_slash': r'^\d{4}/\d{1,2}/\d{1,2}$', 'dmy_dash': r'^\d{1,2}-[A-Za-z]{3}-\d{4}$', 'yyyymmdd': r'^\d{8}$' } def parse_order_date(x): if pd.isna(x): return pd.NaT x_str = str(x) # 尝试匹配格式 for fmt_name, pattern in format_patterns.items(): if re.match(pattern, x_str): try: if fmt_name == 'ymd_slash': return pd.to_datetime(x_str, format='%Y/%m/%d', errors='coerce') elif fmt_name == 'dmy_dash': return pd.to_datetime(x_str, format='%d-%b-%Y', errors='coerce') elif fmt_name == 'yyyymmdd': return pd.to_datetime(x_str, format='%Y%m%d', errors='coerce') except: continue return pd.NaT df['order_date_dt'] = df['order_date'].apply(parse_order_date) # 派生业务字段 df['order_year'] = df['order_date_dt'].dt.year df['order_quarter'] = df['order_date_dt'].dt.quarter df['is_weekend'] = df['order_date_dt'].dt.dayofweek >= 5Step 3:地址JSON解析与标准化
import json def parse_address(x): if pd.isna(x) or x in ['N/A', 'null', '']: return {'country': 'UNKNOWN', 'state': 'UNKNOWN', 'city': 'UNKNOWN'} try: addr_dict = json.loads(x) # 标准化键名(兼容不同大小写) country = addr_dict.get('country', addr_dict.get('Country', 'UNKNOWN')) state = addr_dict.get('state', addr_dict.get('State', 'UNKNOWN')) city = addr_dict.get('city', addr_dict.get('City', 'UNKNOWN')) return {'country': str(country).strip(), 'state': str(state).strip(), 'city': str(city).strip()} except: return {'country': 'UNKNOWN', 'state': 'UNKNOWN', 'city': 'UNKNOWN'} addr_df = df['shipping_address'].apply(parse_address).apply(pd.Series) df = pd.concat([df, addr_df], axis=1) # 国家代码标准化(US→United States) country_map = {'US': 'United States', 'GB': 'United Kingdom', 'DE': 'Germany'} df['ship_country_std'] = df['country'].map(country_map).fillna(df['country'])Step 4:订单明细展开(关键难点)
# 将item_list和quantity_list转为列表 df['item_list'] = df['item_list'].str.split('|') df['quantity_list'] = df['quantity_list'].str.split('|') # 过滤掉空列表的行 df = df[df['item_list'].apply(len) > 0] # 展开为多行 exploded_df = df.explode(['item_list', 'quantity_list']).reset_index(drop=True) exploded_df.rename(columns={'item_list': 'sku_id', 'quantity_list': 'quantity'}, inplace=True) # 转换quantity为整数 exploded_df['quantity'] = pd.to_numeric(exploded_df['quantity'], errors='coerce').fillna(0).astype(int) # 计算unit_price:total_amount_usd / quantity,但需处理quantity=0 def calc_unit_price(row): if row['quantity'] == 0: return 0.0 # 先转USD amount_clean = row['total_amount_clean'] if pd.isna(amount_clean) or not amount_clean: return 0.0 try: amount_val = float(amount_clean) # 简化汇率:假设原始为USD usd_amount = amount_val return round(usd_amount / row['quantity'], 2) except: return 0.0 exploded_df['unit_price_usd'] = exploded_df.apply(calc_unit_price, axis=1)Step 5:最终字段整合与验证
# 构建最终输出表 final_cols = [ 'order_id', 'order_date_dt', 'order_year', 'order_quarter', 'is_weekend', 'ship_country_std', 'ship_state', 'ship_city', 'sku_id', 'quantity', 'unit_price_usd', 'payment_status', 'is_refunded' ] result_df = exploded_df[final_cols].copy() # payment_status映射 result_df['is_refunded'] = result_df['payment_status'] == 'refunded' result_df['payment_status'] = result_df['payment_status'].replace({ 'paid': 'completed', 'pending': 'processing', 'failed': 'failed', 'refunded': 'refunded' }) # 强制类型 result_df['order_date_dt'] = pd.to_datetime(result_df['order_date_dt']) result_df['quantity'] = result_df['quantity'].astype(int) result_df['unit_price_usd'] = result_df['unit_price_usd'].round(2) # 关键验证:检查是否有quantity=0但unit_price>0的异常 anomaly_mask = (result_df['quantity'] == 0) & (result_df['unit_price_usd'] > 0) if anomaly_mask.any(): print(f"警告:发现{anomaly_mask.sum()}行quantity=0但unit_price_usd>0,已设为0") result_df.loc[anomaly_mask, 'unit_price_usd'] = 0.0 # 输出 result_df.to_parquet('orders_clean_v20231015.parquet', index=False) print(f"清洗完成:原始{len(df)}单 → 展开后{len(result_df)}行")4.3 输出质量验证清单
清洗不是跑完代码就结束,必须用数据验证结果。我坚持的验证清单包括:
| 验证项 | 方法 | 合格标准 | 实操备注 |
|---|---|---|---|
| 时间字段完整性 | df['order_date_dt'].isna().sum() | ≤原始空值率×1.1 | 若超限,说明格式解析漏匹配,需补充正则 |
| 地址字段覆盖率 | df['ship_country_std'].nunique() | ≥业务预期国家数 | 某次发现只有US/CA,追查是JSON解析时"country":"USA"未映射 |
| SKU展开一致性 | df.groupby('order_id')['sku_id'].count().value_counts() | 主要频次应为1(单SKU订单)或3(三件套) | 若出现频次27,说明某订单item_list含27个SKU,需人工核查 |
| 金额精度 | (df['unit_price_usd'] * df['quantity']).round(2).equals(df['total_amount_usd'].round(2)) | True | 必须用round(2)避免浮点误差 |
| 业务逻辑 | df[df['is_refunded']==True]['payment_status'].unique() | ['refunded'] | 防止is_refunded标记与payment_status冲突 |
我的个人习惯:每次清洗后,用
df.sample(5).to_markdown()生成5行样例,发给业务方确认“这5单的清洗结果是否符合你们理解”。某次因此发现,运营定义的“周末订单”是指下单时间在周五18:00至周日24:00,而非简单的dayofweek>=5,及时修正了is_weekend逻辑。
5. 常见问题与排查技巧实录:那些让你加班到凌晨的坑
5.1 “明明代码没报错,但结果就是不对”——隐式类型转换陷阱
现象:清洗后发现total_amount_usd列全是0.0,但原始数据明明有数值。
排查路径:
- 检查
total_amount_clean列:df['total_amount_clean'].head()→ 发现值为"123.45 "(末尾空格) - 检查
float()转换:float("123.45 ")正常,但float("123.45 \t")报错 → 原来str.strip()未处理制表符 - 深挖:
repr(df['total_amount'].iloc[0])→ 显示'123.45\\t'
根因:Excel导出时,单元格格式为“文本”,复制粘贴产生不可见制表符,str.strip()只清空格、换行、回车,不清理\t。
解决方案:
df['total_amount_clean'] = df['total_amount'].str.replace(r'[\s\u200b-\u200f\u2028-\u202f\u2060\ufeff]', '', regex=True).str.strip()(\u200b-\u200f是零宽空格族,\u2028-\u202f是Unicode换行符)
5.2 “explode()后行数爆炸”——分隔符嵌套问题
现象:item_list字段本应是"SKU123|SKU456",但某行是"SKU123|SKU456|SKU789|SKU123|SKU456",explode()后产生5行,远超预期。
排查路径:
- 统计
item_list长度分布:df['item_list'].str.count('\|').describe()→ 发现最大值为27,而业务说最多5件 - 抽样查看:
df[df['item_list'].str.count('\|')>5]['item_list'].sample(3)→ 发现"SKU123|SKU456|SKU789|SKU123|SKU456|SKU123|SKU456|SKU789|...",明显是循环拼接BUG
根因:上游系统在生成item_list时,未去重就直接join('|'),且存在定时任务重复执行。
解决方案:
- 短期:
df['item_list'] = df['item_list'].str.split('|').apply(lambda x: list(set(x)))去重 - 长期:推动上游修复,清洗脚本加告警:
if df['item_list'].str.count('\|').max() > 5: send_alert("上游数据异常:单订单SKU超限")
5.3 “时间字段转完全是NaT”——时区与本地化混淆
现象:order_date为"2023-05-12 14:30:22",pd.to_datetime(..., format='%Y-%m-%d %H:%M:%S')后全为NaT。
排查路径:
- 检查
len():len("2023-05-12 14:30:22")→ 19,但实际字符串含不可见字符 repr():repr(df['order_date'].iloc[0])→'2023-05-12\xa014:30:22'(\xa0是不间断空格)ord():ord('\xa0')→ 160,非ASCII空格
根因:Word文档复制的时间字符串,使用不间断空格 替代普通空格。
解决方案:
df['order_date'] = df['order_date'].str.replace('\xa0', ' ', regex=False)5.4 “映射后字段全变NaN”——大小写与空格的幽灵
现象:country_mapping字典有{'US':'United States'},但df['country'].map(country_map)后全为NaN。
排查路径:
df['country'].unique()→['US ', 'us', 'US'](注意'US '末尾空格)df['country'].str.len()→ 发现长度为3的值,确认有空格
根因:映射字典键是'US',但数据中有'US ',map()严格匹配。
解决方案:
- 数据端:
df['country'] = df['country'].str.strip().str.upper() - 字典端:构建键时也
strip():{k.strip():v for k,v in country_map.items()}
5.5 “清洗后数据量少了20%”——静默过滤的代价
现象:原始10万行,清洗后只剩8万行,dropna()未加how='any'导致整行丢弃。
排查路径:
- 对比各步骤行数:
len(df)→len(df.dropna())→ `len