数据清洗与整理是数据科学工作流中耗时最长、却最常被低估的环节。我带过十几支数据分析团队,几乎每支队伍在项目初期都会把80%的时间花在读取、校验、修复、标准化和重构数据上——而不是建模或可视化。很多人以为“写个pandas .dropna() 就算清洗完了”,但真实场景里,一个电商订单表可能有23种空值表达方式(''、'N/A'、'NULL'、'missing'、-999、'Not Specified'、'—'、空格串、'\t\n'……),时间字段可能混着ISO格式、中文日期、Excel序列号、带时区偏移的字符串,而用户ID列里突然冒出一串邮箱地址,只因某次前端表单校验失效。这些不是边缘案例,而是每天都在发生的现场。本文聚焦用Python完成高质量、可复现、可维护的数据整理(Data Wrangling)——不讲概念,不堆API列表,只讲我在金融风控、医疗ETL、零售BI等6类真实项目中反复验证过的操作逻辑、判断依据和落地细节。关键词包括:pandas数据清洗、缺失值工程、类型强制转换、多源结构对齐、正则文本规整、重复逻辑抽象、异常检测阈值设定。适合已掌握基础pandas语法、正准备接手生产级数据管道,或正在被脏数据反复卡住进度的从业者。你不需要记住所有代码,但要理解每一行背后“为什么非得这么写”,因为线上环境不会给你第二次重跑的机会。
1. 数据整理的本质不是“修数据”,而是构建可信的数据契约
1.1 为什么90%的数据清洗脚本活不过三个月
我见过太多这样的清洗脚本:开头用pd.read_csv('data.csv')硬编码路径,中间一堆链式调用如df['age'].str.replace('years','').astype(int),最后.to_csv('cleaned.csv')完事。它在本地Jupyter里跑通了,但上线后第三天就崩——因为上游系统悄悄把字段名从user_id改成了uid,把int型age换成了字符串"32岁",甚至把CSV分隔符从逗号换成了分号。问题不在代码写得不对,而在于它默认了一个并不存在的“稳定契约”:假设数据源结构、语义、格式永远不变。
真正的数据整理,核心目标不是让当前这批数据“看起来干净”,而是建立一套可验证、可监控、可演进的数据契约(Data Contract)。这个契约包含三层:
- Schema层:字段名、类型、是否允许为空、业务含义(如“order_date”必须是datetime64[ns]且不早于2020-01-01);
- Value层:取值范围、枚举约束、正则模式(如“phone”需匹配11位数字或+86开头的国际格式);
- Relationship层:跨字段逻辑(如“discount_amount ≤ order_total”)、主外键一致性(如“product_id”必须存在于产品主表中)。
我在某银行反洗钱项目中,把整个清洗流程拆成三阶段验证:
- Ingestion Check(接入校验):读入原始DataFrame后立即执行schema断言,字段缺失?类型错配?直接报错中断,不往下走;
- Transformation Check(转换校验):每一步清洗操作后检查value约束,比如将“income”转为数值后,立刻验证是否全为正数且<1e8;
- Output Check(输出校验):写入前校验relationship约束,例如“customer_id”在清洗后是否仍100%存在于客户主维表中。
这看似多花了20%代码量,但使后续模型训练失败率下降73%,运维告警平均响应时间从4.2小时压缩到18分钟。因为错误被锁死在最早环节,而不是在建模阶段才发现“为什么auc突然掉到0.45”。
1.2 选择pandas而非Dask/Polars的底层逻辑
当前社区常讨论“pandas过时了吗”,但我的经验是:对于单机可承载的中等规模数据(<5GB内存占用),pandas仍是不可替代的交互式整理引擎。原因有三:
第一,调试友好性无可比拟。当你面对一个含37个嵌套JSON字段的API响应,需要逐层展开、筛选、重命名时,pandas的.explode()+.apply(pd.Series)+ 链式.assign()组合,配合Jupyter的df.head(3)实时预览,比任何分布式框架都快。Dask虽支持延迟计算,但.compute()一次就要等12秒;Polars的lazy模式调试时得反复.collect(),打断思维流。
第二,生态粘性极强。pandas-profiling(现为ydata-profiling)能一键生成数据质量报告,great_expectations可将上述三层契约转为可执行的validation suite,pandera提供type-hint风格的schema声明——它们全基于pandas DataFrame API设计,无缝集成。我试过用Polars重写一个医疗检验数据清洗管道,结果发现ydata-profiling根本不支持Polars DataFrame,而手动实现其缺失值热力图、相关性矩阵、分布直方图,花了整整两天,远超清洗本身耗时。
第三,内存控制更精细。很多人抱怨pandas吃内存,但这是使用方式问题。例如读取大CSV时,用pd.read_csv(..., dtype={'user_id': 'category', 'status': 'category'})可将字符串列内存降低60%-80%;用chunksize=10000分块处理,配合pd.concat(chunks, ignore_index=True),比Dask的read_csv在小数据集上反而更快——因为Dask要启动调度器、序列化任务图,开销固定在300ms以上。
当然,当单表超10GB或需跨集群join时,我会切到Dask;当做高频实时特征计算时,会用Polars。但Part 1的全部内容,都锚定在pandas这个“最熟悉也最容易失控”的工具上——因为失控点,恰恰是经验沉淀最密集的地方。
1.3 本系列的实操边界与交付物定义
本系列不覆盖以下内容:
- 不讲“如何安装pandas”或“Series与DataFrame区别”等入门知识;
- 不涉及Spark、Flink等分布式引擎;
- 不讨论数据库SQL清洗(如CTE、窗口函数),专注Python内存态操作;
- 不承诺“一行代码解决所有问题”,拒绝黑盒函数。
我们交付的是:
✅ 一套可直接嵌入生产pipeline的清洗模块(含schema校验、异常捕获、日志埋点);
✅ 每个方法附带真实数据样例(非iris、titanic等玩具数据),如电商订单、IoT传感器时序、客服对话日志;
✅ 所有参数选择均给出计算依据(如缺失率阈值为何设为75%而非90%);
✅ 每个陷阱都标注发生场景+复现步骤+修复成本(如“当字段含混合类型时,astype(int)会静默转为float64,导致后续groupby精度丢失,修复需回溯上游ETL逻辑”)。
现在,让我们进入第一类高频痛点:缺失值的工程化处理。
2. 缺失值不是“空”,而是携带业务语义的信号
2.1 识别缺失值的七种伪装形态
pandas默认只将None、np.nan、pd.NaT识别为缺失值,但现实数据中,缺失常以更狡猾的方式出现。我在某保险理赔系统中,发现同一张保单表里存在7类“伪缺失”:
| 伪装形态 | 示例值 | 业务含义 | pandas默认识别? |
|---|---|---|---|
| 空字符串 | '' | 用户未填写,但前端未做必填校验 | ❌ |
| 占位字符串 | 'N/A','NULL','missing' | 系统无法获取,主动填充占位符 | ❌ |
| 数值占位符 | -999,999999 | ETL脚本约定的“未知”标记 | ❌ |
| 中文占位符 | '未填写','暂无','--' | 运营人员手工录入的模糊表达 | ❌ |
| 空白字符 | ' '(空格),'\t','\n' | 前端trim失败或复制粘贴带入 | ❌ |
| 布尔混淆 | True/False被误存为字符串'true'/'false' | 类型错配导致后续布尔运算失效 | ❌ |
| 时间错位 | '1900-01-01','9999-12-31' | 数据库默认值,实际表示“未知时间” | ❌ |
关键点在于:不能统一替换为np.nan。例如,'N/A'在“学历”字段中表示“不适用”(如儿童无学历),而在“年收入”字段中表示“拒绝提供”,二者缺失机制完全不同,后续插补策略必须区分。
我的做法是:先用正则扫描全表,统计每列中各类伪装形态的出现频次,生成缺失语义报告:
import re import pandas as pd import numpy as np def scan_missing_patterns(df: pd.DataFrame) -> pd.DataFrame: """扫描DataFrame中所有列的缺失伪装形态,返回频次统计""" patterns = { 'empty_str': r'^\s*$', 'na_strings': r'^(?i)(n/a|null|na|missing|none|nil)$', 'numeric_placeholders': r'^-?9{3,}|^9{5,}$', 'chinese_placeholders': r'^(?i)(未填写|暂无|--|不详|未知|空)$', 'whitespace_only': r'^\s+$', 'boolean_strings': r'^(?i)(true|false|yes|no)$', 'date_placeholders': r'^1900-01-01|9999-12-31$' } report = [] for col in df.columns: series = df[col].astype(str) for pattern_name, regex in patterns.items(): count = series.str.contains(regex, na=False, regex=True).sum() if count > 0: report.append({ 'column': col, 'pattern': pattern_name, 'count': int(count), 'sample_values': list(series[series.str.contains(regex, na=False)].unique()[:3]) }) return pd.DataFrame(report).sort_values(['column', 'count'], ascending=[True, False]) # 实际调用 # report_df = scan_missing_patterns(raw_df) # print(report_df.to_string(index=False))这段代码输出的报告,会直接决定后续清洗策略。例如,若report_df[report_df['column']=='income']['pattern'].eq('numeric_placeholders').any()为True,则必须在astype(float)前,先将-999映射为np.nan,否则-999.0会被当作有效收入参与统计。
提示:永远不要在未生成此报告前,就执行
df.replace({'N/A': np.nan})。我曾因此导致某信贷评分模型将“不适用”学历的用户全部判为高风险——因为'N/A'被粗暴替换后,学历列变成全NaN,模型用0填充,而0在one-hot编码中对应“博士”,造成严重误判。
2.2 缺失率阈值的动态设定:为什么75%是临界点
很多教程说“缺失率>70%就删列”,但这个数字毫无依据。我在某物联网设备管理平台中,发现“last_heartbeat_ms”列缺失率达82%,但删除它等于放弃设备在线状态判断能力。最终保留该列,并用设备型号+地理位置聚类,构建了心跳间隔预测模型,将缺失值转化为“预计离线时长”特征。
真正决定列去留的,是业务影响度 × 可修复性。我用一个二维矩阵评估:
| 业务影响度 ↓ / 可修复性 → | 高(可精准插补) | 中(需领域知识) | 低(无法恢复) |
|---|---|---|---|
| 高(核心指标) | 保留+插补 | 保留+标记+人工审核 | 删除+记录原因 |
| 中(辅助维度) | 保留+插补 | 标记为缺失特征 | 删除 |
| 低(冗余字段) | 删除 | 删除 | 删除 |
其中,“可修复性”由三要素量化:
- 时空连续性:时间序列数据(如传感器读数)缺失前后有强相关性,可修复性高;
- 结构关联性:字段与其他字段存在确定函数关系(如
total = price * qty),可修复性高; - 外部可得性:能否通过API、数据库join、第三方服务补全(如用身份证号查公安库补全户籍地)。
而75%这个阈值,来自对23个历史项目的回溯统计:当缺失率>75%且“可修复性”评分为低时,人工补全成本超过该字段带来的业务价值提升的92%。换句话说,花3人日去补全一个仅用于报表备注的字段,不如优化报表逻辑。
实操中,我用以下函数动态计算每列的处置建议:
def get_column_disposition( col_series: pd.Series, business_impact: str = 'high', # 'high', 'medium', 'low' has_temporal_context: bool = False, has_structural_dependency: bool = False, external_source_available: bool = False ) -> str: """ 基于多维评估返回列处置建议 返回值: 'keep_impute', 'keep_flag', 'drop_record', 'drop_column' """ missing_rate = col_series.isna().mean() # 计算可修复性得分 (0-3分) repair_score = 0 if has_temporal_context: repair_score += 1 if has_structural_dependency: repair_score += 1 if external_source_available: repair_score += 1 if business_impact == 'high': if missing_rate < 0.3 and repair_score >= 2: return 'keep_impute' elif missing_rate < 0.75 and repair_score >= 1: return 'keep_flag' # 保留但添加is_missing标志列 else: return 'drop_column' elif business_impact == 'medium': if missing_rate < 0.5 and repair_score >= 1: return 'keep_impute' else: return 'drop_column' else: # low return 'drop_column' # 应用示例 # disposition = get_column_disposition( # raw_df['last_heartbeat_ms'], # business_impact='high', # has_temporal_context=True, # has_structural_dependency=False, # external_source_available=False # )这个函数不是银弹,但它强迫你在删列前,必须回答四个具体问题,避免凭感觉决策。
2.3 插补策略的业务对齐:均值/中位数只是最后的选择
教科书总说“数值型用均值,类别型用众数”,但真实场景中,这往往是最差解。例如,在某医院患者随访表中,“下次复诊日期”用中位数插补,会导致所有高风险患者被分配到同一个“平均复诊日”,完全丧失临床意义。
插补必须遵循业务因果链。我将插补方法按因果强度排序:
确定性规则插补(最高优先级)
当存在明确业务规则时,必须用规则。例如:- 订单表中
shipping_cost = 0当且仅当order_total >= 199(满减规则); - 用户表中
age = 2023 - int(birth_year),当birth_year为4位数字字符串时。
- 订单表中
结构依赖插补(次优先级)
利用表内其他字段的确定关系。例如:tax_amount = round(subtotal * tax_rate, 2),当tax_rate已知且subtotal完整时;full_name = first_name + ' ' + last_name,当两字段均非空时。
时序/空间邻近插补(中优先级)
适用于有自然顺序的数据:- 时间序列:用前向填充(
ffill)或线性插值(interpolate(method='time')); - 地理数据:用KNN根据经纬度找最近3个网点的均值。
- 时间序列:用前向填充(
统计模型插补(低优先级)
仅当以上均不可行时启用,且必须限定范围:- 用随机森林回归插补数值型,但仅对缺失率<40%的列;
- 用多重插补(
sklearn.experimental.enable_iterative_imputer)时,必须设置max_iter=3,避免过拟合。
标记缺失(底线策略)
当所有插补都不可靠时,创建is_{col}_missing布尔列,并将原列设为np.nan。这比胡乱插补更能保留数据真相。
我在某跨境电商物流表中,对estimated_delivery_days列采用分层插补:
- 先用规则:
if shipping_method == 'express' and country == 'US': 3; - 再用结构:
if weight < 0.5 and volume < 1000: 5; - 最后对剩余12%缺失,用同国家+同承运商的历史中位数填充。
结果使物流时效预测MAE下降21%,而单纯用全局中位数填充会使MAE上升8%。
注意:所有插补操作必须记录元数据。我要求团队在清洗脚本头部添加注释块:
# IMPUTATION LOG # Column: estimated_delivery_days # Rule-based: 62% filled (express+US, economy+CA etc.) # Structural: 25% filled (weight/volume thresholds) # Statistical: 12% filled (median by carrier+country group) # Remaining: 1% set to np.nan, is_estimated_delivery_days_missing created
没有日志的插补,等于没插补。
3. 类型强制转换:从“能跑通”到“零歧义”的跃迁
3.1 字符串转数值的三大死亡陷阱
pd.to_numeric(df['col'], errors='coerce')是最常用也最危险的写法。它有三个致命缺陷:
陷阱一:静默类型降级
当列含'123','45.6','abc'时,errors='coerce'会将'abc'转为np.nan,但'123'变成123.0(float64),而非123(int64)。后续若做groupby().size(),int列返回int64索引,float列返回float64索引,join时因类型不匹配失败。我曾因此导致月度经营报表中“门店数量”统计错乱,排查耗时17小时。
陷阱二:科学计数法误判'1.23e4'被转为12300.0,但'1.23E4'(大写E)在某些pandas版本中会报错。更糟的是,'12345678901234567890'这种超长整数,float64无法精确表示,会变成12345678901234567000.0,丢失末尾精度。
陷阱三:千分位逗号吞噬'1,234.56'被转为np.nan,除非显式指定thousands=',',但'1.234,56'(欧洲格式)又会出错。
我的解决方案是:永远分三步走,且每步可审计:
def safe_string_to_numeric( series: pd.Series, target_type: str = 'int', # 'int', 'float', 'decimal' thousands_sep: str = None, decimal_sep: str = None ) -> pd.Series: """ 安全字符串转数值,返回带元数据的Series """ # Step 1: 预清洗 —— 统一符号、移除无关字符 cleaned = series.astype(str).str.strip() if thousands_sep: cleaned = cleaned.str.replace(thousands_sep, '', regex=False) if decimal_sep and decimal_sep != '.': cleaned = cleaned.str.replace(decimal_sep, '.', regex=False) # 移除货币符号、单位等 cleaned = cleaned.str.replace(r'[^\d.-]', '', regex=True) # Step 2: 类型推断与验证 if target_type == 'int': # 检查是否全为整数字符串(允许负号) is_int_safe = cleaned.str.fullmatch(r'-?\d+') if not is_int_safe.all(): raise ValueError(f"Column contains non-integer values: {cleaned[~is_int_safe].unique()}") result = pd.to_numeric(cleaned, downcast='integer') # 自动选最小int类型 elif target_type == 'float': # 允许小数点,但拒绝科学计数法(除非明确需要) is_float_safe = cleaned.str.fullmatch(r'-?\d+\.?\d*') if not is_float_safe.all(): # 尝试解析科学计数法 try: result = pd.to_numeric(cleaned, errors='raise') except: raise ValueError(f"Float parsing failed for: {cleaned[~is_float_safe].unique()}") else: result = pd.to_numeric(cleaned, downcast='float') else: # decimal —— 用Decimal保持精度 from decimal import Decimal result = cleaned.apply(lambda x: Decimal(x) if x else pd.NA) # Step 3: 后校验 —— 检查溢出、精度损失 if target_type == 'int': max_val = result.max() if max_val > 2**63-1: raise OverflowError(f"Integer overflow: max value {max_val} exceeds int64") return result # 使用示例 # df['price'] = safe_string_to_numeric(df['price'], target_type='float', thousands_sep=',')这个函数的关键在于:失败即中断,不妥协。生产环境中,宁可停机10分钟定位问题,也不接受带精度损失的数据流入下游。
3.2 日期时间解析:时区、格式、模糊性的三角困局
pd.to_datetime(df['date_col'])在90%的玩具数据上能跑通,但在生产环境里,它是事故高发区。问题集中在三点:
时区混乱:'2023-05-01 10:00:00'是UTC?东八区?还是本地时区?不同系统写入时区信息不一致,to_datetime默认按本地时区解释,导致时间戳偏移8小时。
格式模糊:'01/05/2023'是1月5日还是5月1日?'2023/5/1'无歧义,但'23-05-01'又需指定yearfirst=True。
无效值容忍:'2023-02-30'(2月无30日)默认转为NaT,但业务上这可能是录入错误,需人工核查。
我的标准流程是:强制指定format + 显式时区 + 失败隔离:
from datetime import datetime import pytz def robust_datetime_parse( series: pd.Series, format_str: str = None, timezone: str = 'Asia/Shanghai', infer_format: bool = False, strict: bool = True ) -> pd.Series: """ 强健日期解析,返回带时区的datetime64[ns] """ # Step 1: 格式预校验(可选) if format_str: # 用正则快速过滤明显不匹配的值 pattern = format_str.replace('%Y', r'\d{4}') \ .replace('%y', r'\d{2}') \ .replace('%m', r'0\d|1[0-2]') \ .replace('%d', r'0\d|[12]\d|3[01]') \ .replace('%H', r'[01]\d|2[0-3]') \ .replace('%M', r'[0-5]\d') \ .replace('%S', r'[0-5]\d') mask = series.astype(str).str.fullmatch(pattern, case=False, na=False) if not mask.all() and strict: invalid_samples = series[~mask].unique()[:5] raise ValueError(f"Format mismatch for {format_str}: {invalid_samples}") # Step 2: 解析(严格模式) try: parsed = pd.to_datetime( series, format=format_str, infer_datetime_format=infer_format, errors='raise' # 关键!不静默失败 ) except Exception as e: if not strict: # 降级到模糊解析 parsed = pd.to_datetime(series, errors='coerce') else: raise e # Step 3: 时区绑定 if timezone: tz = pytz.timezone(timezone) if parsed.dt.tz is None: parsed = parsed.dt.tz_localize(tz) else: parsed = parsed.dt.tz_convert(tz) return parsed # 实际应用:电商订单时间必须是东八区 # df['order_time'] = robust_datetime_parse( # df['order_time_str'], # format_str='%Y-%m-%d %H:%M:%S', # timezone='Asia/Shanghai', # strict=True # )这个函数确保:
- 所有时间戳带明确时区(避免
dt.tz_localize(None)后无法比较); - 格式不匹配时立即报错,不流入下游;
- 时区转换逻辑集中,不散落在各处。
3.3 分类数据的语义固化:从object到category的质变
pandas中object类型字符串列,内存占用大、排序慢、groupby效率低。但更重要的是,它无法表达业务枚举约束。例如,“订单状态”应只有['pending', 'shipped', 'delivered', 'cancelled']四种,但object列允许任意字符串写入,导致报表中出现'shipped '(带空格)或'Delivered'(大小写不一致)。
我的做法是:在清洗早期就固化category,并绑定业务词典:
def enforce_categorical( series: pd.Series, categories: list, ordered: bool = False, case_sensitive: bool = False, strip_whitespace: bool = True ) -> pd.Series: """ 强制转换为有序/无序category,并执行业务校验 """ # 预处理 processed = series.astype(str) if strip_whitespace: processed = processed.str.strip() if not case_sensitive: processed = processed.str.lower() categories = [c.lower() for c in categories] # 校验非法值 invalid_mask = ~processed.isin(categories) if invalid_mask.any(): invalid_vals = processed[invalid_mask].unique() raise ValueError(f"Invalid categorical values in {series.name}: {invalid_vals}") # 创建category cat_dtype = pd.CategoricalDtype(categories=categories, ordered=ordered) result = processed.astype(cat_dtype) # 添加业务元数据(存储在Series.attrs中) result.attrs['business_meaning'] = f"Order status: {', '.join(categories)}" result.attrs['source_mapping'] = {orig: norm for orig, norm in zip(series.unique(), processed.unique())} return result # 使用示例 # df['order_status'] = enforce_categorical( # df['order_status'], # categories=['pending', 'shipped', 'delivered', 'cancelled'], # case_sensitive=False # )这样做的好处:
- 内存减少70%+(字符串列转category后,实际存储的是整数编码);
df['order_status'].value_counts()自动按业务顺序排列(若ordered=True);- 后续
pd.get_dummies()生成one-hot时,列名严格对应业务词典,不会出现order_status_Delivered这种不一致命名; attrs中存储的元数据,可导出为数据字典,供BI团队和下游系统使用。
4. 文本规整与结构对齐:让非结构化数据开口说话
4.1 正则清洗的四层防御体系
用户输入的文本是脏数据的重灾区。一个地址字段可能含:' 上海市浦东新区张江路123号A座 (近地铁2号线) '
表面看只需strip(),但深层问题有四层:
- 空白层:首尾空格、中间多余空格、制表符、换行符;
- 符号层:括号、破折号、星号等装饰符号;
- 语义层:括号内是补充说明(如“近地铁”),应剥离还是保留?
- 结构层:地址应拆为省、市、区、路、号、楼栋,但正则无法保证100%准确。
我的正则清洗不是单次替换,而是四层流水线:
import re def address_normalize(address_series: pd.Series) -> pd.Series: """地址标准化四层流水线""" # Layer 1: 空白清理 cleaned = address_series.astype(str).str.replace(r'\s+', ' ', regex=True).str.strip() # Layer 2: 符号归一化(保留必要分隔符,移除装饰符) # 将全角标点转半角,移除★☆●◆等 cleaned = cleaned.str.replace(r'[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff]', '', regex=True) # 日文字符 cleaned = cleaned.str.replace(r'[★☆●◆※①②③]', '', regex=True) # 装饰符号 cleaned = cleaned.str.replace(r'[()\[\]{}〈〉]', '(', regex=True) # 统一左括号 cleaned = cleaned.str.replace(r'[,。!?;:""'']', ',', regex=True) # 统一逗号分隔 # Layer 3: 语义剥离(可配置) # 移除括号内补充信息(如“近地铁”、“营业中”) cleaned = cleaned.str.replace(r'\([^)]*?(?:地铁|营业|开放|时间)[^)]*\)', '', regex=True) # 但保留行政区划括号,如“(浦东新区)” cleaned = cleaned.str.replace(r'\((?!.*?区|.*?市|.*?省)\)', '', regex=True) # Layer 4: 结构强化(添加分隔符便于后续拆分) # 在“省市区”后加竖线,如“上海市|浦东新区|张江路” patterns = [ (r'(?:省|市|区|县|镇|乡|街道|路|街|巷|号|栋|座|室|楼)', r'\1|'), (r'(\d+)[号|栋|座|室|楼]', r'\1|'), ] for pattern, repl in patterns: cleaned = cleaned.str.replace(pattern, repl, regex=True) return cleaned.str.replace(r'\|+', '|', regex=True) # 合并连续竖线 # 应用 # df['address_norm'] = address_normalize(df['raw_address'])这个函数的价值在于:每一层都可独立开关、单独测试。例如,若业务要求保留“近地铁”信息,则关闭Layer 3;若发现“路”和“街”需区分,则在Layer 4中拆分为两个pattern。
4.2 JSON嵌套字段的扁平化:避免爆炸式列膨胀
API返回的JSON常含多层嵌套,如:
{ "user": { "profile": {"name": "Alice", "age": 30}, "contact": {"email": "a@b.com", "phone": "138****1234"} }, "orders": [ {"id": "O001", "items": [{"sku": "S001", "qty": 2}]}, {"id": "O002", "items": [{"sku": "S002", "qty": 1}, {"sku": "S003", "qty": 3}]} ] }直接pd.json_normalize()会生成user.profile.name,user.contact.email,orders.0.id,orders.0.items.0.sku等爆炸式列名,且orders数组会横向展开,导致行数剧增。
我的原则是:按业务实体粒度扁平化,而非按JSON结构。即:
user部分:扁平为user_name,user_age,user_email,user_phone(单行);orders部分:不展开为宽表,而是提取关键聚合指标,如order_count,total_items,avg_order_value(单行);- 若需明细,另建
orders_detail子表,与主表通过user_id关联。
实现代码:
def flatten_api_json(df: pd.DataFrame, id_col: str = 'user_id') -> tuple[pd.DataFrame, pd.DataFrame]: """ 智能JSON扁平化:主表聚合,明细表分离 返回 (main_df, orders_detail_df) """ main_data = [] orders_detail = []