Python数据清洗工程化:从语义校验到业务可审计的工业级实践
1. 项目概述为什么“数据清洗”不是脏活累活而是决定分析成败的临门一脚“Data Cleaning in Python”——这六个单词组合起来听起来像教科书目录里最不起眼的一节甚至有些新手会下意识跳过觉得“不就是删空行、改错别字嘛”。但我在金融风控建模团队带过三轮实习生亲手看过27份来自不同业务线的原始数据集结论很直接83%的模型上线失败、61%的报表口径偏差、92%的AB测试结果不可信根源不在算法选型而在清洗环节的某一行代码没写对或某个隐含假设没验证。这不是危言耸听是每天在Jupyter Notebook里真实发生的“静默崩塌”。我试过用pandas的dropna()一键清空缺失值——结果把客户连续三个月的逾期行为标记全删了因为那三列恰好是“最近一次还款日期”“当前逾期天数”“历史最高逾期期数”它们同时为空系统误判为“新客”直接踢出训练集。我也见过同事用str.replace( , )批量去空格顺手把“北京 市”变成“北京市”却把“张 三”变成“张三”身份证号校验全挂。这些都不是技术难度问题而是对数据语义、业务逻辑、清洗副作用三者关系的理解断层。所以这篇内容不是讲“怎么用Python清洗数据”而是讲怎么用Python把清洗这件事本身做成一个可追溯、可复现、可审计、可协作的工程动作。它适合三类人刚学完pandas基础、正对着CSV文件发懵的新手能写复杂SQL但一碰Python清洗就卡壳的分析师以及带团队做数据产品、需要建立清洗SOP的负责人。你会看到为什么fillna(0)在金融场景可能是灾难而fillna(methodffill)在IoT时序数据里又成了救命稻草为什么“去重”要分三层——物理重复、逻辑重复、业务重复为什么清洗脚本必须自带“数据指纹”和“变更日志”。所有内容都来自我过去十年在银行、电商、医疗SaaS项目中踩过的坑、攒下的checklist、压箱底的函数模板。现在我们从第一行import pandas as pd开始但目标不是跑通代码而是让每一行清洗操作都经得起业务方拍桌子质问“你凭什么这么洗”2. 核心思路拆解清洗不是“擦黑板”而是构建数据可信度的三道防火墙2.1 为什么传统“清洗清理脏数据”的认知必须被推翻很多教程把数据清洗简化为“缺失值处理异常值剔除格式统一”这就像把外科手术说成“切开缝合”。真正的问题在于清洗的本质是重建数据与现实世界之间的映射关系。当一份订单表里出现“下单时间2099-12-31”它不是“脏”而是“失真”——这个值可能代表“未知”“未确认”“系统默认占位符”强行填0或删掉等于抹掉业务流程中的一个关键状态节点。我在某电商平台做用户生命周期分析时发现“注册渠道”字段有12%的记录是空值。按常规做法fillna(Unknown)似乎合理。但深入查日志才发现这些空值全部来自APP端老版本SDKv2.3.1之前该版本因埋点逻辑缺陷根本不会上报渠道参数。如果统一标为Unknown就会把“技术缺陷导致的数据缺失”和“用户主动选择不透露渠道”混为一谈后续做渠道ROI归因时老版本用户的贡献会被系统性低估。最终方案是新增一列channel_source值为sdk_missing_v231并在清洗文档里明确标注“此标识仅反映埋点能力非用户属性”。这就是清洗的底层逻辑——不掩盖问题而是把问题显性化、结构化、可追踪化。2.2 构建三道防火墙语义层、逻辑层、业务层我把清洗流程拆成三个递进层次每层解决一类问题且后一层必须以前一层为基础语义层防火墙确保数据在计算机层面“能读”。比如字符串编码是否统一UTF-8 vs GBK、数值列是否混入文本123.45vs123.45、日期格式是否规范2023/01/01vs01-Jan-2023。这一层靠dtypes检查、正则校验、pd.to_datetime(errorscoerce)等基础工具就能覆盖90%问题。但关键在于所有强制类型转换必须附带“失败率统计”。例如pd.to_numeric(col, errorscoerce)后立刻计算col.isna().sum() / len(col)如果失败率0.1%说明原始数据存在系统性格式问题需回溯上游系统。逻辑层防火墙确保数据在数学和规则层面“说得通”。比如“订单金额”不能为负“用户年龄”不能超过150岁“结束时间”不能早于“开始时间”。这里最容易犯的错是“一刀切阈值”。我曾见一份医疗数据集把所有age 120的记录直接设为NaN结果漏掉了3个真实存在的百岁老人数据来自某长寿村调研。正确做法是先用describe()看分布再结合业务常识定界。对年龄可设age 0 or age 120为异常对订单金额用IQR法四分位距比固定阈值更鲁棒Q1 - 1.5*IQR到Q3 1.5*IQR之外才标记为潜在异常再人工抽样验证。业务层防火墙确保数据在现实场景中“有意义”。这是最难也最关键的层。比如电商的“支付成功时间”为空技术上可填NaT但业务上要问是支付失败还是异步回调延迟或是分账场景下主订单已付、子订单待付此时清洗动作不再是填空而是引入业务上下文字段。我们在支付表里加了payment_status_reason列值为callback_delay、split_payment_pending等让空值承载业务语义。这要求清洗工程师必须和产品经理、运营同学坐在一起画出核心业务流程图标出每个数据字段在流程中的生成节点和失效条件。提示三道防火墙不是线性执行而是循环迭代。比如语义层发现大量N/A字符串需回业务层确认这是“不适用”还是“未采集”逻辑层发现某字段99%为0需回语义层检查是否应为布尔型而非数值型。2.3 工程化清洗的四大支柱可复现、可审计、可协作、可演进手工清洗最大的风险是“只在此山中云深不知处”。我带的第一个项目实习生写了200行清洗脚本跑通后就扔进生产环境。三个月后业务方质疑“为什么新客数突降40%”我们花了两天才定位到是某次groupby().agg()时忘了加dropnaFalse导致region为空的记录被自动过滤。从此我坚持清洗脚本必须满足四个硬性标准可复现所有随机操作如抽样验证必须设random_state所有外部依赖如配置文件路径必须用相对路径环境变量输入数据必须带哈希校验hashlib.md5(open(file,rb).read()).hexdigest()输出前存快照。可审计每一步清洗操作必须生成“影响报告”。例如df.drop_duplicates(subset[user_id,order_id])执行后立即打印dup_count len(df) - len(df_clean) print(f【去重】基于[user_id,order_id]删除{dup_count}行重复记录占原数据{dup_count/len(df)*100:.2f}%)更进一步用pandas-profiling或自研的clean_report函数生成HTML报告包含清洗前后各列的缺失率、唯一值数、数值分布对比图。可协作清洗逻辑必须模块化。我把常用操作封装成Cleaner类每个方法对应一道防火墙class DataCleaner: def fix_semantic(self, df): ... # 语义层 def validate_logic(self, df): ... # 逻辑层 def enrich_business(self, df): ... # 业务层团队成员只需调用Cleaner().fit_transform(raw_df)内部自动记录每步耗时、参数、影响行数。新人接手时看方法名就知道这步在解决什么问题。可演进清洗规则必须版本化。我们用Git管理清洗脚本每次业务规则变更如“新客定义从注册7天内改为30天内”都提交新commit并在脚本头部写明# RULE_VERSION: 20231015_v2.1。线上调度任务通过读取该标签自动加载对应版本的清洗逻辑避免“同一份数据不同时间跑出不同结果”。3. 核心细节解析从10个高频痛点出发讲透清洗中的“为什么”和“怎么做”3.1 缺失值处理为什么fillna(0)在90%的场景下都是危险操作缺失值Missing Value常被简单理解为“没有数据”但现实中它至少有五种语义Not Available (NA)系统无法获取如新上线功能的埋点尚未部署Not Applicable (N/A)该属性对当前记录无意义如男性用户的“孕产史”Unknown知道存在但暂时未知如用户填写的“年收入”未提交Intentionally Withheld用户主动拒绝提供如隐私设置隐藏的“手机号”System Error程序异常导致的空值如API超时返回空响应。fillna(0)的致命伤在于它把所有语义强行统一为“零值”彻底抹杀业务差异。我在某信贷风控项目中吃过亏对“近6个月逾期次数”字段用fillna(0)后模型把“从未逾期的新客”和“因系统故障未采集到逾期记录的老客”同等对待导致新客通过率虚高12%。实操方案按语义分层填充且必须保留原始缺失标识# 步骤1创建缺失语义标记列 df[overdue_cnt_missing_reason] unknown df.loc[df[overdue_cnt].isna() df[is_new_user], overdue_cnt_missing_reason] na_new_user df.loc[df[overdue_cnt].isna() ~df[is_new_user], overdue_cnt_missing_reason] system_error # 步骤2按语义填充注意0只用于N/A场景 df[overdue_cnt_filled] df[overdue_cnt].copy() df.loc[df[overdue_cnt_missing_reason]na_new_user, overdue_cnt_filled] 0 # 新客确实无逾期 df.loc[df[overdue_cnt_missing_reason]system_error, overdue_cnt_filled] df[overdue_cnt].median() # 系统错误用中位数插补注意中位数插补不是万能药。对长尾分布如用户消费金额中位数可能远低于均值导致高价值用户被低估。此时可用KNNImputer用相似用户同城市、同年龄段、同设备的消费中位数来填充效果提升37%实测A/B结果。3.2 重复数据识别为什么drop_duplicates()只是起点不是终点drop_duplicates()能解决物理重复完全相同的行但业务中更常见的是逻辑重复和业务重复逻辑重复关键字段相同但其他字段有微小差异。如两笔订单order_id相同但payment_time差1秒因分布式系统时钟不同步status一个是paid一个是success。业务重复同一事实被多次记录。如用户点击“立即购买”按钮前端因网络重试发送了3次请求后端生成3条订单记录但实际只有一笔真实交易。实操方案构建“业务主键”“模糊匹配”双机制# 定义业务主键能唯一标识一笔业务的最小字段集 business_key [user_id, item_id, order_amount, date] # 计算业务主键的哈希值作为去重依据 df[business_hash] df[business_key].apply( lambda x: hashlib.md5(_.join(x.astype(str)).encode()).hexdigest(), axis1 ) # 对同一business_hash的记录按时间取最新一条解决逻辑重复 df_dedup df.sort_values(create_time).groupby(business_hash).tail(1) # 对剩余记录用Levenshtein距离检测业务重复如地址字段相似度0.85视为重复 from fuzzywuzzy import fuzz df_dedup[addr_similarity] df_dedup[shipping_addr].apply( lambda x: fuzz.ratio(x, df_dedup[shipping_addr].iloc[0]) )实操心得业务主键的设计是清洗的灵魂。我建议用“实体动作时间粒度”三要素组合。例如用户行为日志业务主键是[user_id, event_type, date]不是[user_id, event_type, timestamp]因为“一天内多次点击”本身就是有效行为模式不应被去重。3.3 异常值检测为什么IQR和Z-Score在真实数据中经常失灵IQR四分位距和Z-Score是教材标配但它们隐含两个强假设数据服从单峰分布、异常值是孤立点。而真实业务数据常是多峰、长尾、有聚类的。比如某外卖平台的“配送时长”正常订单集中在30-50分钟写字楼午市但医院订单集中在120-180分钟跨城送药用全局IQR会把所有医院订单判为异常。实操方案分组检测 业务规则兜底# 按业务维度分组不是盲目用全部数据 grouped df.groupby([delivery_type, city_tier]) # 对每组分别计算IQR避免跨组污染 def iqr_outlier(series): Q1 series.quantile(0.25) Q3 series.quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 1.5 * IQR upper_bound Q3 1.5 * IQR return (series lower_bound) | (series upper_bound) df[is_outlier] grouped[delivery_duration].transform(iqr_outlier) # 业务规则兜底医院订单配送时长200分钟需人工审核不直接剔除 df.loc[(df[delivery_type]hospital) (df[delivery_duration]200), is_outlier] False df.loc[(df[delivery_type]hospital) (df[delivery_duration]200), outlier_reason] hospital_long_distance注意异常值不等于错误值。在风控场景中“单日交易额突增100倍”可能是欺诈也可能是企业客户发工资。清洗动作应是“标记分流”而非“删除”。我们设计outlier_reason字段值为fraud_suspect、salary_payout、data_entry_error后续交由业务规则引擎处理。3.4 字符串清洗为什么正则表达式不是万能钥匙而是一把需要校准的手术刀str.replace(r\s, )看似优雅但它会把“C开发工程师”变成“C开发工程师”被当空格删了把“123-456-7890”电话号码变成“123-456-7890”连字符被删。字符串清洗的核心矛盾是既要消除噪声又要保留语义。实操方案分场景定制清洗策略且必须验证覆盖率# 场景1用户昵称需保留emoji、特殊符号 def clean_nickname(text): if pd.isna(text): return text # 只删首尾空白和中间连续空白保留内部符号 return re.sub(r^\s|\s$, , re.sub(r\s{2,}, , str(text))) # 场景2身份证号需标准化格式但不能破坏数字 def clean_id_card(text): if pd.isna(text): return text # 提取所有数字再按18位校验码规则补全 digits re.findall(r\d, str(text)) if len(digits) 18: return .join(digits) elif len(digits) 15: # 15位升18位补19和校验码 return upgrade_id_15_to_18(.join(digits)) else: return None # 格式严重错误标记为None供人工审核 # 验证覆盖率清洗后仍为None的比例必须0.5% cleaned_ids df[id_card].apply(clean_id_card) null_ratio cleaned_ids.isna().sum() / len(df) if null_ratio 0.005: raise ValueError(f身份证清洗失败率过高{null_ratio:.2%}请检查正则逻辑)实操心得正则调试必须用真实样本。我建了一个regex_test_cases.csv包含100个典型脏数据如 张三 、张三VIP、张三123每次改正则都跑一遍测试确保覆盖率100%。工具推荐regex101.com实时看匹配过程。3.5 日期时间清洗为什么pd.to_datetime()的errorscoerce是把双刃剑errorscoerce能把无法解析的字符串转为NaT看似安全。但问题在于它不告诉你哪些值被转成了NaT。我在某物流项目中delivery_date列有5%的记录是TBD、ASAP、---to_datetime后全变NaT但没人知道这5%是什么。更糟的是2023-02-30这种无效日期也会变NaT和真实缺失混在一起。实操方案强制捕获转换失败详情def robust_to_datetime(series, formatNone): 安全转换日期返回清洗后序列 失败详情DataFrame # 先尝试按指定格式转换 if format: converted pd.to_datetime(series, formatformat, errorscoerce) else: converted pd.to_datetime(series, errorscoerce) # 找出所有转换失败的原始值 failed_mask converted.isna() series.notna() failed_details series[failed_mask].to_frame(nameoriginal_value) failed_details[error_type] format_mismatch # 区分是格式问题还是无效日期 for idx, val in failed_details.iterrows(): try: # 尝试用其他常见格式解析 pd.to_datetime(val[original_value], format%Y/%m/%d) failed_details.loc[idx, error_type] format_mismatch_other except: pass return converted, failed_details # 使用 df[delivery_date_clean], fail_log robust_to_datetime(df[delivery_date]) print(f日期清洗失败{len(fail_log)}条详情\n{fail_log.head()})注意对TBD这类业务标识绝不转NaT而应映射为业务语义。我们约定TBD→2099-12-31远期占位符ASAP→1970-01-01近期占位符并在数据字典中标注让下游知道这不是错误而是业务约定。3.6 数值精度清洗为什么round()不是解决浮点误差的正确姿势0.1 0.2 ! 0.3是经典浮点误差但清洗中更常见的是业务精度丢失。比如金融系统中“金额”字段数据库存的是DECIMAL(10,2)但Python读取后变成float64199.99 0.01可能得200.00000000000003。用round(x, 2)看似解决但round(2.675, 2)结果是2.67Python的“银行家舍入”规则而财务要求“四舍五入”。实操方案用decimal模块进行精确计算且统一精度声明from decimal import Decimal, ROUND_HALF_UP def precise_round(value, precision2): 精确四舍五入避免float误差 if pd.isna(value): return value try: # 转Decimal避免float初始化误差 d Decimal(str(value)) # 指定四舍五入模式 return float(d.quantize(Decimal(f1e-{precision}), roundingROUND_HALF_UP)) except: return value # 应用到金额列 df[amount_clean] df[amount].apply(precise_round, precision2) # 验证检查是否有超出精度的值 invalid_precision df[amount_clean].apply(lambda x: len(str(x).split(.)[-1]) 2 if . in str(x) else False) if invalid_precision.any(): raise ValueError(金额精度校验失败请检查precise_round逻辑)实操心得精度清洗必须贯穿全链路。我们要求数据库字段声明精度、Python读取时用dtype{amount: string}先转字符串、计算时用Decimal、输出时再转float。任何环节跳过都会在某个深夜报警。3.7 分类变量清洗为什么map()和replace()不能解决所有问题df[gender].map({M:Male, F:Female})很常见但问题在于它不处理未定义的键。当数据中突然出现UUnknown或OOthermap()返回NaN而replace()会静默忽略。更隐蔽的是“大小写混合”问题如male、MALE、Male被当成三个类别。实操方案构建可扩展的分类映射器支持模糊匹配和默认值class CategoryMapper: def __init__(self, mapping_dict, defaultUnknown, case_sensitiveFalse): self.mapping mapping_dict.copy() self.default default self.case_sensitive case_sensitive def transform(self, series): # 统一大小写处理 if not self.case_sensitive: series_lower series.str.lower() # 先做精确匹配 result series_lower.map(self.mapping) # 对未匹配的用模糊匹配如mal→Male unmatched result.isna() series.notna() for raw_val in series[unmatched].unique(): if pd.isna(raw_val): continue val_lower str(raw_val).lower() # 查找编辑距离最小的映射键 candidates [(k, edit_distance(val_lower, k)) for k in self.mapping.keys()] if candidates: best_match min(candidates, keylambda x: x[1]) if best_match[1] 2: # 编辑距离2认为是拼写错误 result.loc[seriesraw_val] self.mapping[best_match[0]] result result.fillna(self.default) return result else: return series.map(self.mapping).fillna(self.default) # 使用 gender_mapper CategoryMapper( {m: Male, f: Female, u: Unknown, o: Other}, defaultUnknown, case_sensitiveFalse ) df[gender_clean] gender_mapper.transform(df[gender])注意分类清洗必须有“未映射值监控”。我们在清洗脚本末尾加unmapped df[gender].isin(gender_mapper.mapping.keys()) False print(f性别字段未映射值{df[unmapped][gender].value_counts().head(10)})一旦发现新值如X立即告警避免业务变化导致数据漂移。3.8 数据一致性清洗为什么跨表关联不是清洗的终点而是起点清洗常聚焦单表但真实问题常在表间。比如用户表user_id是字符串U12345订单表user_id是整数12345merge时全为空。或者时间字段用户表用2023-01-01订单表用01/01/2023pd.to_datetime()后时区不同。实操方案构建跨表一致性校验器在清洗流水线中嵌入def cross_table_consistency_check(table1, table2, join_key, time_colNone): 检查两表在join_key上的值域一致性 t1_keys set(table1[join_key].dropna().astype(str)) t2_keys set(table2[join_key].dropna().astype(str)) only_in_t1 t1_keys - t2_keys only_in_t2 t2_keys - t1_keys print(f【跨表校验】{join_key}字段) print(f 仅在表1存在{len(only_in_t1)}个值示例{list(only_in_t1)[:3]}) print(f 仅在表2存在{len(only_in_t2)}个值示例{list(only_in_t2)[:3]}) if time_col and time_col in table1.columns and time_col in table2.columns: # 检查时间格式一致性 t1_fmt infer_datetime_format(table1[time_col].dropna().sample(100)) t2_fmt infer_datetime_format(table2[time_col].dropna().sample(100)) if t1_fmt ! t2_fmt: print(f 时间格式不一致表1为{t1_fmt}表2为{t2_fmt}) return {only_in_t1: only_in_t1, only_in_t2: only_in_t2} # 在清洗流程中调用 cross_table_consistency_check(user_df, order_df, user_id, create_time)实操心得一致性校验必须自动化。我们把它做成Airflow的一个独立task每天凌晨跑发现差异立即飞书告警并附上差异样本。这比人工核对快100倍且永不疲倦。3.9 清洗日志与元数据为什么“清洗了什么”比“清洗成什么样”更重要很多团队只保存清洗后的数据却不记录“为什么这样洗”。当业务方问“为什么把‘北京’改成‘北京市’”没人能回答。清洗日志不是流水账而是数据血缘的DNA。实操方案用YAML定义清洗元数据脚本自动注入# cleaning_config.yaml version: 20231015_v2.1 description: 电商订单数据清洗规则 rules: - step: fix_semantic description: 修复日期格式和编码 actions: - column: order_time operation: to_datetime params: {format: %Y-%m-%d %H:%M:%S, errors: coerce} - column: product_name operation: encode params: {target_encoding: utf-8} - step: validate_logic description: 逻辑校验 actions: - column: order_amount operation: range_check params: {min: 0.01, max: 100000}清洗脚本读取该配置执行时自动生成结构化日志import yaml with open(cleaning_config.yaml) as f: config yaml.safe_load(f) # 执行清洗并记录 log_entries [] for rule in config[rules]: for action in rule[actions]: start_time time.time() # 执行action... end_time time.time() log_entries.append({ step: rule[step], column: action[column], operation: action[operation], duration_sec: end_time - start_time, impact_rows: len(df) - len(df_before), config_version: config[version] }) # 存为parquet与清洗后数据同目录 pd.DataFrame(log_entries).to_parquet(cleaning_log.parquet)注意日志必须可查询。我们用DuckDB建了个轻量日志库随时可查“SELECT * FROM cleaning_log WHERE columnorder_amount AND operationrange_check ORDER BY duration_sec DESC LIMIT 5;”3.10 性能优化为什么apply()是清洗脚本的性能杀手以及如何替代df[col].apply(lambda x: clean_func(x))写起来爽但pandas会逐行调用Python函数速度比向量化操作慢10-100倍。我在处理10GB用户行为日志时一个apply()让清洗从8分钟涨到2小时。实操方案优先级排序的性能优化策略向量化操作优先str.contains()代替apply(lambda x: keyword in x)np.where()代替apply()做条件赋值。numba加速计算密集型函数from numba import jit jit(nopythonTrue) def fast_edit_distance(s1, s2): # numba编译的编辑距离比纯Python快50倍 ...swifter自动选择最优执行引擎import swifter df[cleaned_text] df[raw_text].swifter.apply(clean_text_func) # swifter会自动检测数据量小数据用pandas大数据用dask分块处理超大文件chunk_size 100000 results [] for chunk in pd.read_csv(big_file.csv, chunksizechunk_size): cleaned_chunk cleaner.fit_transform(chunk) results.append(cleaned_chunk) final_df pd.concat(results, ignore_indexTrue)4. 完整实操流程从原始CSV到可交付数据集的12步工业级清洗流水线4.1 第1步环境准备与数据探查15分钟不要急着写代码先花15分钟做三件事确认数据来源和时效性是离线导出还是实时API最后更新时间这决定清洗策略如实时数据要容忍更高错误率。快速探查数据轮廓# 用dask快速读大文件不加载内存 import dask.dataframe as dd ddf dd.read_csv(raw_data.csv) print(ddf.dtypes) print(ddf.describe().compute()) # compute()触发计算 print(ddf.isna().sum().compute()) # 各列缺失数抽样人工检查df.sample(100, random_state42).to_excel(data_sample.xlsx)发给业务方确认“这些样本是否符合预期”。注意这一步发现的问题往往比后面10步清洗都重要。我曾在一个项目中抽样发现“用户等级”字段全是VIP1查日志才发现上游系统BUG修复后省了两周返工。4.2 第2步创建清洗工作区与版本控制5分钟新建目录结构强制规范/data_cleaning/ ├── raw/ # 原始数据只读 ├── cleaned/ # 清洗后数据每日增量 ├── logs/ # 清洗日志按日期分区 ├── configs/ # YAML清洗配置 ├── scripts/ # 清洗脚本git管理 └── notebooks/ # 探查和调试notebook所有脚本开头加import os import sys # 设置工作区根目录 ROOT_DIR os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(ROOT_DIR)4.3 第3步加载数据并生成初始质量报告10分钟用pandas-profiling现为ydata-profiling生成首份报告from ydata_profiling import ProfileReport profile ProfileReport(df, titleRaw Data Quality Report) profile.to_file(reports/raw_profile.html)重点关注Alerts页签红色警告、Correlations字段间异常相关性、Missing缺失模式。4.4 第4步语义层清洗——编码、类型、格式统一20分钟按顺序执行# 1. 统一编码读取时指定 df pd.read_csv(raw.csv, encodingutf-8) # 2. 强制类型转换记录失败率 for col in df.select_dtypes(include[object]).columns: