1. 这不是教科书里的数据清洗——而是我每天在Jupyter里真实敲出来的那几十行
“Common Data Cleaning Tasks in Everyday Work of a Data Scientist/Analyst in Python”——这个标题看起来平平无奇,甚至有点像培训课大纲。但如果你真在一线做过半年以上数据分析或建模工作,就会明白:所谓“常见”,不是指教材里列的那几条定义,而是指你每天早上打开Notebook,还没喝完第一口咖啡,就不得不面对的、带着业务气味的混乱现实。缺失值不是NaN,是销售同事手填Excel时写的“暂无”“待确认”“???”;重复记录不是df.duplicated()一跑就完事,而是同一客户在CRM里被录入三次,电话、邮箱、身份证号各错一位;时间字段不是ISO格式字符串,而是“2023-03-15”“23/03/15”“三月十五日”“昨天”混在同一个字段里。我做过27个不同行业的数据清洗项目,从电商订单流水到医院检验报告,从银行信贷审批表到社区网格员手写台账的OCR识别结果——所有项目里,真正花掉70%以上时间的,从来不是模型调参,而是把原始数据“扶正坐直”,让它能被pandas读进去、算得准、画得出图。这篇文章不讲抽象理论,不列函数手册,只复盘我在真实工单里反复敲、反复改、反复验证过的12类高频清洗场景,每一条都附带:为什么这么写(不是语法,是业务逻辑)、哪一行容易翻车(实测踩坑位置)、怎么一眼看出它没洗干净(验证技巧)、以及——最关键的一点:当业务方突然说“这个字段含义变了”,你该从哪一行代码开始改。适合刚转行的数据新人、想摆脱“清洗靠猜”的中级分析师,以及需要快速交付清洗脚本给下游团队的TL。你不需要背函数,但得知道什么时候该打断点、什么时候该加assert、什么时候该拉业务方一起看原始表头。
2. 整体设计思路:为什么不用“全自动清洗库”,而坚持手写每一段逻辑
2.1 清洗不是标准化流水线,而是带上下文的诊断过程
很多人一上来就想找“最强数据清洗库”,比如dataprep、autofeat,甚至尝试用LLM生成清洗代码。我试过——在三个项目里全推倒重写了。原因很简单:清洗的本质不是“把脏数据变干净”,而是把业务逻辑错误显性化、可追溯、可复验。举个真实例子:某次处理用户注册时间字段,自动工具把所有非标准格式统一转成NaT,结果上线后发现漏掉了23%的早期用户(他们注册时系统还没接入时间服务,字段存的是“1970-01-01”)。而手动清洗时,我加了这一段:
# 注册时间字段清洗 —— 关键:区分“无效时间”和“默认时间” df['reg_time'] = pd.to_datetime(df['reg_time'], errors='coerce') # 检查是否大量集中于1970-01-01(Unix纪元起点) if (df['reg_time'].dt.year == 1970).mean() > 0.1: print("⚠️ 警告:10%以上注册时间为1970-01-01,疑似系统未就绪期数据") # 此时必须人工确认:是bug?还是真实时间?是否需单独标记? df['reg_time_flag'] = np.where( df['reg_time'].dt.year == 1970, 'system_not_ready', 'valid_time' )这段代码的价值不在“转时间”,而在把模糊的业务判断变成可审计的日志。自动库做不到这点——它只会默默吞掉异常,或者报错中断。而真实工作中,你得告诉产品经理:“这1278条1970年注册记录,我们按‘系统未就绪’打标,后续分析会排除,您确认吗?”——这句话,比任何clean_df()函数都重要。
2.2 我的清洗脚本结构:四层防御体系
我所有清洗脚本都强制遵循这个骨架,不是为了炫技,而是为了应对三种最常发生的意外:
- 意外1:上游数据源字段名/类型突变(如昨天叫
user_id,今天叫uid) - 意外2:业务规则临时调整(如“VIP等级”从1-5级变成A-E级)
- 意外3:下游模型对缺失值容忍度变化(如原来允许10%缺失,现在要求0%)
所以我的脚本永远包含这四层:
- Schema声明层:用字典明确定义每个字段的预期类型、允许值、业务含义
- 原始快照层:保存清洗前的df.shape、缺失率、唯一值分布(用
df.nunique()/len(df)) - 原子操作层:每个清洗动作独立函数,输入df+参数,输出df+日志字典
- 断言验证层:每个函数后紧跟
assert检查关键约束(如“清洗后user_id不能为空”)
这样做的好处是:当某天凌晨三点报警说“模型AUC暴跌”,你能5分钟内定位到是清洗脚本第37行的fillna()逻辑被上游新字段干扰了,而不是花两小时翻Git历史。下面这张表是我最近一个电商项目清洗脚本的结构对照:
| 层级 | 代码位置 | 核心作用 | 实际案例 |
|---|---|---|---|
| Schema声明 | SCHEMA = { "order_id": {"dtype": "str", "required": True}, "amount": {"dtype": "float", "min": 0.01} } | 定义字段契约,作为所有清洗的基准 | 当上游新增order_amt_yuan字段,脚本立即报错“未知字段”,而非静默跳过 |
| 原始快照 | raw_stats = { "n_rows": len(df), "null_rate": df.isnull().mean().to_dict() } | 记录清洗前状态,用于对比验证 | 发现phone字段清洗后缺失率从5%升到12%,立刻回溯是正则替换过度 |
| 原子操作 | def clean_phone(df: pd.DataFrame) -> Tuple[pd.DataFrame, dict]: ... | 单一职责,可单独测试、复用、调试 | 同一clean_phone函数,在用户表、客服通话表、物流单表中复用 |
| 断言验证 | assert df['order_id'].notna().all(), "order_id存在空值" | 强制校验清洗结果符合业务底线 | 防止因fillna('')导致ID变为空字符串,后续join失效 |
提示:不要把断言写成
assert not df['order_id'].isna().any()——这种写法报错时只显示AssertionError,你得再跑一遍才知道哪行出问题。一定要写成带描述的assert ... , "错误信息",这是节省调试时间的关键细节。
2.3 为什么拒绝“清洗-建模”一体化Pipeline
很多教程推荐用sklearn.Pipeline把清洗和模型打包。我在生产环境坚决不用,原因有三:
- 调试成本指数级上升:当模型预测异常,你得在Pipeline里一层层
set_params()去排查,而实际中90%的问题出在清洗环节(比如StandardScaler对含空值的列报错,但错误堆栈指向模型层); - 版本管理灾难:清洗逻辑迭代快(每周可能改3次),模型迭代慢(每月1次),绑在一起会导致每次清洗小修都要触发模型重新训练和部署;
- 协作壁垒:数据工程师要部署清洗脚本到Airflow,算法工程师要调用清洗后数据,如果混在Pipeline里,双方得协调Python环境、依赖版本、甚至Jupyter内核配置。
我的解法是:清洗脚本输出Parquet文件 + 数据质量报告(JSON),模型代码只读取清洗后的Parquet。两者通过文件路径和schema.json解耦。例如:
# 清洗脚本输出 /data/cleaned/orders_20240515.parquet # 清洗后数据 /data/cleaned/orders_20240515_report.json # 包含缺失率、异常值数量、清洗耗时等模型代码只需:
df = pd.read_parquet("/data/cleaned/orders_20240515.parquet") # 不关心怎么洗的,只关心数据是否达标 with open("/data/cleaned/orders_20240515_report.json") as f: report = json.load(f) assert report["null_rate"]["amount"] < 0.01, "金额字段缺失超阈值"这种解耦让清洗团队可以独立发布、灰度、回滚,而算法团队专注特征工程——这才是真实团队协作的常态。
3. 核心清洗任务拆解:12类高频场景的实操细节与避坑指南
3.1 缺失值处理:别急着fillna(),先问“它为什么空”
缺失值是最容易被草率处理的部分。新手常犯的错误是:看到df.isnull().sum()就直接df.fillna(0)或df.dropna()。我在某金融风控项目里因此返工过两次——第一次用fillna(0)填充“逾期天数”,结果把“从未借款”的用户和“逾期0天”的用户混为一谈;第二次用dropna()删掉“收入”为空的记录,却漏掉了大量自由职业者(他们收入字段存的是“面议”“协商”)。
正确流程是三步诊断法:
分类缺失类型(业务驱动,非技术驱动):
- 结构性缺失:字段本就不适用于该记录(如“孕妇并发症”字段对男性用户为空)→ 应填充
"N/A"或pd.NA - 采集性缺失:本该有但没采到(如用户跳过填写“年收入”)→ 需分析缺失模式(是否与年龄/地域强相关?)
- 逻辑性缺失:值存在但无法解析(如“2023-13-01”)→ 先尝试修复,再判断是否真缺失
- 结构性缺失:字段本就不适用于该记录(如“孕妇并发症”字段对男性用户为空)→ 应填充
量化缺失影响(用数据说话,不是拍脑袋):
# 计算每个字段缺失率,并关联业务指标 def analyze_null_impact(df: pd.DataFrame, target_col: str = "is_churn") -> pd.DataFrame: null_stats = df.isnull().mean().sort_values(ascending=False) # 关键:计算缺失组 vs 非缺失组的目标变量差异 impact = {} for col in null_stats.index[:10]: # 只看缺失率最高的10个 if null_stats[col] > 0.01: # 缺失率>1% missing_target = df[df[col].isna()][target_col].mean() non_missing_target = df[~df[col].isna()][target_col].mean() impact[col] = { "null_rate": null_stats[col], "churn_rate_missing": missing_target, "churn_rate_non_missing": non_missing_target, "delta": abs(missing_target - non_missing_target) } return pd.DataFrame(impact).T.sort_values("delta", ascending=False) # 实际输出示例: # null_rate churn_rate_missing churn_rate_non_missing delta # last_login_days 0.123 0.456 0.123 0.333 # income 0.087 0.210 0.205 0.005看到last_login_days缺失组流失率是正常组的3.7倍,你就知道:这个缺失不是随机噪声,而是强信号,应该创建is_last_login_missing特征,而不是简单填充。
- 选择填充策略(按优先级排序):
- 业务规则填充(最高优):如“注册渠道”为空,但用户手机号归属地是浙江,且浙江用户99%来自“微信小程序”,则填
"wechat_mini" - 统计量填充(次优):用中位数(数值型)或众数(分类型),但必须加后缀标记,如
amount_median_filled - 模型预测填充(慎用):仅当缺失率<5%且有强相关特征时,用XGBoost预测缺失值,但必须验证预测误差<业务容忍度
- 业务规则填充(最高优):如“注册渠道”为空,但用户手机号归属地是浙江,且浙江用户99%来自“微信小程序”,则填
注意:永远不要用
df.fillna(method="ffill")处理时间序列外的字段!我在某次处理用户等级变更日志时误用了,导致把“VIP3→VIP4”的记录,用上一条“VIP1→VIP2”的时间填充,整个用户生命周期分析全错。记住:ffill只适用于明确有序的时序数据,其他场景一律禁用。
3.2 重复记录识别:不只是df.duplicated(),要看业务语义
df.duplicated(subset=["user_id", "order_id"])能解决80%的重复,但剩下20%才是坑。比如电商订单表:
场景1:同一订单,支付成功和支付失败各记一条
字段几乎全同,只有status和update_time不同。技术上不重复,但业务上应保留status=="success"的那条。场景2:用户下单后修改地址,产生两条记录
order_id相同,但shipping_address不同。此时不能删,而要标记为“地址变更版本”。
我的解决方案是:先做技术去重,再做业务去重。
# 步骤1:技术去重(保留最新时间) df_sorted = df.sort_values(["order_id", "update_time"], ascending=[True, False]) df_dedup_tech = df_sorted.drop_duplicates(subset=["order_id"], keep="first") # 步骤2:业务去重(识别需保留多条的场景) # 规则:同一order_id下,若status包含"success",则只留success记录 def business_dedup(group): if (group["status"] == "success").any(): return group[group["status"] == "success"].iloc[0:1] else: return group.iloc[0:1] # 否则留最早一条 df_final = df_dedup_tech.groupby("order_id", group_keys=False).apply(business_dedup)更关键的是建立重复检测的SOP:每次清洗前,固定运行这三行代码,把结果写入日志:
# 重复检测黄金三行 print("🔍 技术重复(全字段):", df.duplicated().sum()) print("🔍 业务重复(关键字段):", df.duplicated(subset=["user_id", "product_id", "order_date"]).sum()) print("🔍 潜在冲突(同ID不同值):", df.groupby("user_id").filter(lambda x: x["phone"].nunique() > 1).shape[0])最后一行特别重要——它揪出“同一用户ID对应多个手机号”的脏数据,这往往是CRM和APP数据未打通的征兆,必须反馈给数据治理团队。
3.3 文本字段清洗:正则不是万能的,业务词典才是核心
文本清洗最容易陷入“狂写正则”的陷阱。比如清洗用户留言中的联系方式:
# 错误示范:试图用一个正则匹配所有手机号 df["message_clean"] = df["message"].str.replace(r"1[3-9]\d{9}", "[PHONE]", regex=True) # 问题:漏掉座机(010-12345678)、微信ID(wxid_abc123)、邮箱(user@domain.com)我的做法是:分层清洗 + 业务词典兜底。
第一层:标准化格式(消除书写差异)
# 统一空格、换行、全角字符 df["message"] = df["message"].str.replace(r"\s+", " ", regex=True) # 多空格→单空格 df["message"] = df["message"].str.replace(r"[,。!?;:""'‘’“”()\[\]{}]", "", regex=True) # 删中文标点 df["message"] = df["message"].str.replace(r"[^\x00-\x7F]+", "", regex=True) # 删emoji和生僻字(可选)第二层:实体识别(用业务词典,非NER模型)
构建contact_terms.txt(业务方确认的联系方式关键词):手机号 微信号 QQ号 邮箱 电话 联系方式然后:
# 加载词典 with open("contact_terms.txt") as f: contact_keywords = [line.strip() for line in f if line.strip()] # 标记含联系方式的记录 df["has_contact_hint"] = df["message"].str.contains("|".join(contact_keywords), case=False, na=False)第三层:精准脱敏(正则只处理已知模式)
# 分别处理不同模式,便于调试 df["message"] = df["message"].str.replace(r"1[3-9]\d{9}", "[MOBILE]", regex=True) # 国内手机号 df["message"] = df["message"].str.replace(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", regex=True) # 邮箱 df["message"] = df["message"].str.replace(r"(?:微信|WX|VX)[::\s]*([A-Za-z0-9_]+)", r"[WECHAT:\1]", regex=True) # 微信号
实操心得:永远把正则替换写成
str.replace(pattern, replacement, regex=True),不要省略regex=True参数!我曾在某次升级pandas后发现替换失效,排查3小时才发现新版本默认regex=False,而旧代码没显式声明。
3.4 时间字段解析:从混乱字符串到可计算的datetime
时间字段是清洗中最痛苦的部分。我见过的格式包括:"2023-03-15T14:30:00Z"、"15/03/2023 14:30"、"2023年3月15日"、"昨天14:30"、"20230315"、"Mar 15, 2023"……pd.to_datetime()的errors='coerce'只能帮你把错的变NaT,但你得知道哪些是真错误,哪些是格式差异。
我的标准流程是:先聚类,再分治,最后验证。
- 聚类分析(用value_counts观察分布):
# 取前1000条样本,看时间字段格式分布 sample_times = df["event_time"].dropna().head(1000).astype(str) format_dist = sample_times.str.extract(r"^(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})").dropna().shape[0] print(f"YYYY-MM-DD类占比: {format_dist/1000:.1%}") # 同样方法检查其他格式:YYYYMMDD、DD/MM/YYYY等- 分治解析(为每种主流格式写专用解析器):
def parse_event_time(series: pd.Series) -> pd.Series: # 创建结果数组,初始为NaT result = pd.Series(pd.NaT, index=series.index, dtype="datetime64[ns]") # 规则1:ISO格式(最快,先匹配) iso_mask = series.str.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") result.loc[iso_mask] = pd.to_datetime(series.loc[iso_mask], errors="coerce") # 规则2:YYYY-MM-DD ymd_mask = series.str.match(r"^\d{4}-\d{1,2}-\d{1,2}") result.loc[ymd_mask] = pd.to_datetime(series.loc[ymd_mask], format="%Y-%m-%d", errors="coerce") # 规则3:YYYY/MM/DD ymd_slash_mask = series.str.match(r"^\d{4}/\d{1,2}/\d{1,2}") result.loc[ymd_slash_mask] = pd.to_datetime(series.loc[ymd_slash_mask], format="%Y/%m/%d", errors="coerce") # 规则4:中文日期(需先替换) cn_mask = series.str.contains("年|月|日") if cn_mask.any(): # 替换中文字符为英文符号 temp_series = series.loc[cn_mask].str.replace("年", "-").str.replace("月", "-").str.replace("日", "") result.loc[cn_mask] = pd.to_datetime(temp_series, format="%Y-%m-%d", errors="coerce") return result df["event_time_parsed"] = parse_event_time(df["event_time"])- 验证与兜底(确保没有意外):
# 检查解析后是否还有大量NaT null_after_parse = df["event_time_parsed"].isna().sum() if null_after_parse > len(df) * 0.05: # 超5%未解析 print(f"⚠️ 解析失败{null_after_parse}条,查看未解析样本:") print(df[df["event_time_parsed"].isna()]["event_time"].head(10)) # 此时必须人工介入,补充新规则注意:永远不要用
infer_datetime_format=True!它在pandas 1.5+版本中已被弃用,且在混合格式下极易出错。显式指定format参数虽然多写几行,但稳定性和可读性高十倍。
3.5 数值字段清洗:警惕“看起来是数字”的陷阱
"123.45"是数字,"123.45元"不是,"123,456.78"也不是,"1.23e+05"是,"N/A"不是……数值清洗的难点在于:字符串和数字的边界在业务中是模糊的。
我的检查清单:
- 先看数据类型(
df["price"].dtype):如果是object,别急着astype(float),先探查内容; - 用
pd.api.types.is_numeric_dtype()验证:它比dtype == 'float64'更准确; - 用
pd.to_numeric(errors='coerce')转换,但必须配合isna()分析失败原因。
实战代码:
def clean_numeric_col(series: pd.Series, col_name: str) -> pd.Series: # 步骤1:移除常见干扰字符 cleaned = series.astype(str).str.replace(r"[¥$€, ]", "", regex=True) # 删货币符号、逗号、空格 # 步骤2:处理特殊标记 cleaned = cleaned.str.replace(r"^(?i)(null|none|nan|n/a|—)$", "NaN", regex=True) # 步骤3:转换为数值 numeric_series = pd.to_numeric(cleaned, errors="coerce") # 步骤4:分析转换失败情况 failed_mask = cleaned.isna() & series.notna() # 原始不空,但转换后空 if failed_mask.sum() > 0: print(f"🔍 {col_name} 转换失败{failed_mask.sum()}条,样本:") print(series[failed_mask].head(5).tolist()) # 常见失败:单位混入("123kg")、范围值("100-200")、分数("1/2") return numeric_series df["price_clean"] = clean_numeric_col(df["price"], "price")对于“范围值”这类难题(如"100-200"),我从不强行拆分。而是创建新特征:
# 提取范围信息 df["price_min"] = df["price"].str.extract(r"(\d+)-\d+").astype(float) df["price_max"] = df["price"].str.extract(r"\d+-(\d+)").astype(float) df["price_is_range"] = df["price"].str.contains("-")这样既保留了原始信息,又提供了可计算的数值,业务方要均值还是区间,自己选。
3.6 分类字段标准化:别迷信map(),要建业务映射表
df["gender"].map({"M": "Male", "F": "Female", "m": "Male"})看似简洁,但当业务方说“从下周起,增加‘Non-binary’选项,代码是‘NB’”时,你得改三处:map字典、缺失值处理、下游模型label编码。
我的方案是:用CSV维护业务映射表(gender_mapping.csv):
raw_value,standard_value,description M,Male,男性 F,Female,女性 m,Male,男性(小写) f,Female,女性(小写) NB,Non-binary,非二元性别 Unknown,Unknown,未知然后清洗时动态加载:
def standardize_category(series: pd.Series, mapping_file: str, col_name: str) -> pd.Series: mapping_df = pd.read_csv(mapping_file) # 创建映射字典,注意处理大小写 map_dict = dict(zip(mapping_df["raw_value"].str.lower(), mapping_df["standard_value"])) # 应用映射(忽略大小写) standardized = series.astype(str).str.lower().map(map_dict) # 标记未映射项 unmapped_mask = standardized.isna() & series.notna() if unmapped_mask.sum() > 0: print(f"⚠️ {col_name} 存在{unmapped_mask.sum()}个未映射值:{series[unmapped_mask].unique()}") # 此时必须更新mapping.csv,不能硬编码 return standardized df["gender_std"] = standardize_category(df["gender"], "gender_mapping.csv", "gender")优势:业务方可以直接编辑CSV,无需动代码;审计时可追溯每次映射变更;新增值时,脚本会主动报错提醒,而不是静默填
NaN。
3.7 异常值检测:IQR和Z-score只是起点,业务阈值才是终点
df["age"].between(0, 120)比zscore < 3有用得多。我在某健康APP项目中,用Z-score筛出“年龄异常”,结果把一群102岁的老红军用户标记为异常——他们的数据完全真实,只是超出了常规分布。
我的异常值处理铁律:先定业务阈值,再用统计方法辅助发现。
业务阈值优先(必须由业务方签字确认):
- 年龄:0-150岁(医疗场景允许更高)
- 订单金额:≥0.01元(低于1分钱视为无效)
- 登录次数:0-1000次/天(超过需人工审核)
统计方法辅助(仅用于发现“阈值外的合理值”):
# 对金额字段,业务阈值是0.01-100000,但用IQR找中间异常 Q1 = df["amount"].quantile(0.25) Q3 = df["amount"].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR # 标记:在业务阈值内,但IQR外的值(可能是新业务模式) df["amount_outlier_iqr"] = ( (df["amount"] < lower_bound) | (df["amount"] > upper_bound) ) & df["amount"].between(0.01, 100000)处理策略分层:
- 业务阈值外:直接
drop或clip(如df["age"] = df["age"].clip(0, 150)) - IQR外但业务内:创建
is_amount_suspicious特征,供风控模型使用 - 两者都外:人工复核(如
amount=0且status=="paid",明显矛盾)
- 业务阈值外:直接
3.8 ID类字段清洗:去重只是开始,一致性才是关键
ID字段(用户ID、订单ID、设备ID)清洗的核心不是“有没有重复”,而是“跨表ID是否一致”。比如用户表里user_id="U123",订单表里却是user_id="u123"(大小写不一致),join时就全为空。
我的ID清洗五步法:
统一大小写(除非业务规定区分):
df["user_id"] = df["user_id"].str.upper() # 或lower(),全项目统一清理不可见字符(最隐蔽的坑):
# 删除零宽空格、BOM头等 df["user_id"] = df["user_id"].str.replace(r"[\u200b\u200c\u200d\uFEFF]", "", regex=True)标准化分隔符(如
"U-123"→"U123"):df["user_id"] = df["user_id"].str.replace(r"[^A-Za-z0-9]", "", regex=True)长度校验(业务方确认的ID长度):
expected_len = 5 df["user_id_len_ok"] = df["user_id"].str.len() == expected_len if (~df["user_id_len_ok"]).sum() > 0: print("❌ 用户ID长度异常,请检查:", df[~df["user_id_len_ok"]]["user_id"].unique())跨表一致性检查(关键!):
# 假设有user_df和order_df user_ids_in_orders = set(order_df["user_id"].unique()) valid_user_ids = set(user_df["user_id"].unique()) orphan_orders = user_ids_in_orders - valid_user_ids if orphan_orders: print(f"⚠️ 订单表存在{len(orphan_orders)}个用户ID在用户表中不存在")
3.9 地址字段清洗:别追求“完美标准化”,要保“可分组性”
把“北京市朝阳区建国路8号SOHO现代城C座2305室”标准化成“北京/朝阳/建国路/8号/2305”是理想,但现实中,你更需要的是:让“朝阳区”和“朝阳路”能被分到同一地理层级。
我的地址清洗策略是:分层提取 + 业务关键词匹配。
分层提取(用正则抓大放小):
# 提取省级(省/自治区/直辖市) df["province"] = df["address"].str.extract(r"(北京市|上海市|广东省|新疆维吾尔自治区)") # 提取市级(市/自治州/盟) df["city"] = df["address"].str.extract(r"(北京市|广州市|深圳市|杭州市)") # 提取区级(区/县/旗) df["district"] = df["address"].str.extract(r"(朝阳区|福田区|西湖区|武侯区)")业务关键词兜底(处理简写和别名):
# 创建区级别名映射 district_aliases = { "朝阳": "朝阳区", "福田": "福田区", "杭城": "杭州市", "魔都": "上海市" } df["district"] = df["district"].fillna( df["address"].str.extract(f"({'|'.join(district_aliases.keys())})")[0] .map(district_aliases) )创建地理分组码(核心产出):
# 生成可用于聚类的code df["geo_code"] = ( df["province"].str[:2] + "_" + df["city"].str[:2] + "_" + df["district"].str[:2].fillna("XX") ) # 结果如:"北_北_朝"、"广_深_福"
这样即使地址没完全标准化,geo_code也能保证同区域用户被分到一组,满足90%的分析需求。
3.10 多语言混合文本清洗:中文为主,英文为辅,其他字符谨慎处理
国内项目常遇到中英混排(“订单状态:Order Status”)、中日韩字符(“東京”)、甚至阿拉伯数字(“١٢٣”)。我的原则:中文环境以中文为准,英文仅作辅助,其他文字除非业务必需,否则统一转拼音或删除。
清洗步骤:
检测主要语言(用langdetect库):
from langdetect import detect def detect_lang(text): try: return detect(text) except: return "unknown" df["lang"] = df["title"].apply(detect_lang)按语言分流处理:
# 中文为主:保留中文,删英文括号内内容 df.loc[df["lang"]=="zh", "title_clean"] = df["title"].str.replace(r"([^)]*)", "", regex=True) # 英文为主:转小写,删中文字符 df.loc[df["lang"]=="en", "title_clean"] = df["title"].str.replace(r"[^\x00-\x7F]", "", regex=True).str.lower()
3