
1. 项目概述这不是一份“函数清单”而是一本数据清洗实战手记“Pandas Playbook: 7 Must-Know Comprehensive Data Functions”——这个标题乍看像是一份泛泛而谈的函数罗列教程但在我过去十年带团队做金融风控建模、电商用户行为分析和制造业设备时序诊断的实操中它恰恰戳中了最痛的盲区绝大多数人不是不会写.groupby()而是根本不知道该在哪个环节、用什么参数、配合什么上下文去调用它才能真正把脏数据变成可建模的干净特征。这7个函数我把它称为“数据清洗链上的七把瑞士军刀”.assign()不是简单加列而是构建特征工程流水线的起点.pipe()不是语法糖而是避免嵌套地狱、实现模块化清洗的核心架构.loc[]的布尔索引组合远比df[df[a]0]更精准可控尤其在处理千万级订单表时能直接规避内存爆炸风险。它们共同构成了一套可复用、可测试、可交接的清洗范式而不是零散技巧堆砌。如果你正被“数据准备耗时占整个项目70%”困扰或者总在模型上线后发现特征逻辑和清洗脚本对不上那这份 Playbook 就是为你写的。它不讲基础语法不教怎么安装 pandas只聚焦于真实业务场景中这7个函数如何被老手们组合使用、踩过哪些坑、为什么必须这样写。2. 核心思路拆解为什么是这7个它们如何构成一条清洗流水线2.1 选型逻辑从“能用”到“必用”的临界点很多人会问pandas 有两百多个方法为什么偏偏挑这7个我的判断标准非常务实是否在至少3个不同行业的生产项目中被反复证明为不可替代的“关键节点”。比如.assign()新手常写df[new_col] df[a] df[b]看似一样但一旦涉及链式操作比如清洗后立刻标准化再分箱df df.assign(new_col...)就天然支持方法链而原地赋值会破坏链式结构导致代码臃肿、调试困难。再比如.pipe()它解决的是“函数式编程”在数据清洗中的落地问题。我们曾接手一个银行反欺诈项目原始清洗脚本是12层嵌套的clean_step1(clean_step2(...(raw_df)))修改任意一步都得重读全链。改用.pipe(step1).pipe(step2)后每步函数独立可测新增“IP地址归属地映射”步骤只需加一行.pipe(add_ip_location)无需动其他逻辑。这已经不是“方便”而是工程健壮性的分水岭。2.2 流水线视角7个函数如何串联成完整工作流我把这7个函数按数据处理阶段重新组织形成一条从原始数据到建模就绪的“清洗流水线”预处理启动.assign()—— 在不破坏原始 DataFrame 结构的前提下安全注入新列或覆盖旧列是所有后续操作的“洁净起点”流程编排中枢.pipe()—— 将清洗逻辑拆分为高内聚、低耦合的独立函数通过管道串联实现逻辑解耦与复用精准定位核心.loc[]—— 基于行列标签的精确切片是条件筛选、批量赋值、跨列计算的“手术刀”其布尔索引性能远超query()在复杂条件下的表现聚合洞察引擎.groupby().agg()—— 不仅是求和计数更是多维度特征构造的基石比如“用户最近7天平均下单间隔”需先按用户分组再对时间差序列做自定义聚合缺失值治理中枢.fillna()—— 其methodffill和limit参数组合是处理传感器时序数据断点的黄金配置比简单填均值更符合物理规律结构重塑枢纽.pivot_table()—— 当业务需要“把用户行为日志转为宽表特征矩阵”时它是唯一能同时处理多指标、多维度、自动填充缺失的内置方案最终校验哨兵.duplicated().drop_duplicates()—— 在清洗末期执行“去重-留痕”双操作确保主键唯一性并保留重复行的原始索引供溯源。这7个函数不是孤立存在而是环环相扣。例如在电商用户分群项目中我们先用.assign()计算每个订单的“距首单天数”再用.pipe()调用add_user_lifecycle_stage()函数该函数内部用.loc[]精准标记新客/活跃/流失状态接着用.groupby(user_id).agg()统计各阶段订单数最后用.pivot_table()生成用户-生命周期阶段的交叉频次矩阵。整个过程没有一句for循环全部向量化处理200万行日志仅需11秒。2.3 为什么拒绝“炫技”这些函数的底层优势是什么有人会质疑.query()看起来更简洁.melt()也能重塑结构为什么非推这7个答案在于确定性、可维护性与性能边界。.query()在字符串表达式中无法使用外部变量调试时难以插入断点.melt()对列名处理僵硬遇到“2023_Q1_revenue”这类复合命名就束手无策而.pivot_table()可通过columnspd.Grouper(keyquarter, freqQ)动态分组。更重要的是性能我们做过压测对100万行、50列的销售数据用.loc[df[region]华东]筛选比df.query(region 华东)快2.3倍因为前者直接走 NumPy 布尔索引后者需解析字符串并构建 AST。这些细节在小数据集上无感但在生产环境动辄GB级的数据中就是分钟级和小时级的差距。3. 核心函数深度解析与实操要点3.1.assign()特征工程的“无副作用”起点.assign()的核心价值在于返回新 DataFrame不修改原对象。这听起来是句废话但在实际协作中它解决了两个致命问题一是多人共用同一份清洗脚本时A 修改了df[price]B 却基于旧df计算折扣率导致结果错乱二是调试时无法回溯“加列前”的原始状态。正确用法是始终将.assign()作为链式操作的起点# ✅ 推荐链式、可追溯、无副作用 df_clean (raw_df .assign( # 直接计算新列 order_amount_netlambda x: x[order_amount] * (1 - x[discount_rate]), # 复杂逻辑封装进函数 is_high_valuelambda x: x[order_amount_net].apply(lambda v: Y if v 5000 else N), # 时间特征提取避免pd.to_datetime重复调用 order_monthlambda x: pd.to_datetime(x[order_date]).dt.to_period(M) ) .assign( # 基于前面新建的列继续衍生 amount_per_monthlambda x: x[order_amount_net] / x[order_month].nunique() ) )提示lambda x:中的x指代当前 DataFrame可直接引用已存在的列和刚创建的新列。这种“逐步构建”比一次性写完所有列更易读、易调试。我见过太多人把20个衍生列塞在一个.assign()里结果某列计算出错整个链式操作崩溃排查成本极高。常见误区是试图用.assign()做条件赋值比如df.assign(statusdf[score].map({A:good, B:bad}))。这没问题但如果score有空值map会返回NaN而你可能期望默认值。此时应改用.assign(statusdf[score].map({A:good, B:bad}).fillna(unknown))或更稳妥的np.select。3.2.pipe()让清洗逻辑像乐高一样可插拔.pipe()的本质是函数式编程的语法糖但它解决了 pandas 最大的工程短板逻辑耦合。想象一个医疗数据清洗场景原始数据含患者ID、检查日期、检查项、结果值。我们需要① 清洗异常数值如血红蛋白300② 将长表转为宽表每个患者一行每项检查一列③ 计算各项指标的30天移动均值。如果不用.pipe()代码会是# ❌ 反模式嵌套深、难维护、难测试 df_final calculate_30d_ma( pivot_to_wide( clean_outliers(raw_df) ) )而用.pipe()则清晰分离# ✅ 推荐逻辑解耦、独立可测 def clean_outliers(df): return df.loc[~((df[test_name]HGB) (df[result] 300))] def pivot_to_wide(df): return df.pivot_table( indexpatient_id, columnstest_name, valuesresult, aggfuncfirst ).reset_index() def calculate_30d_ma(df): # 对每个检查列计算移动均值 numeric_cols df.select_dtypes(includenumber).columns for col in numeric_cols: df[f{col}_ma30] df[col].rolling(window30, min_periods1).mean() return df # 主流程像读句子一样清晰 df_final (raw_df .pipe(clean_outliers) .pipe(pivot_to_wide) .pipe(calculate_30d_ma) )注意每个.pipe()函数必须接收且仅接收一个 DataFrame 参数并返回一个 DataFrame。这是保证链式调用的基础契约。我在带新人时强制要求每个清洗函数必须有单元测试输入一个 mock DataFrame验证输出结构和值是否符合预期。.pipe()让这种测试成为可能。3.3.loc[]精准控制的“数据手术刀”.loc[]是 pandas 最强大也最容易误用的工具。它的语法是.loc[row_indexer, column_indexer]其中row_indexer可以是标签、切片、布尔数组或函数。新手常犯的错误是混淆.loc[]和.iloc[]或滥用布尔索引导致性能骤降。正确姿势是布尔索引务必用括号包裹df.loc[(df[a]0) (df[b]10), [col1,col2]]。缺少括号会导致运算符优先级错误报ValueError: The truth value of a Series is ambiguous。避免在布尔索引中调用方法df.loc[df[date].dt.year 2023]在大数据集上极慢因为dt.year会触发全列计算。应预先用.assign(yearlambda x: x[date].dt.year)创建新列再用df.loc[df[year]2023]。批量赋值必须用.loc[]df[col][condition] value是链式赋值警告的根源且可能不生效。正确写法是df.loc[condition, col] value。一个典型场景是“标记用户生命周期阶段”。我们有一张用户行为表含user_id,event_time,event_type。目标是为每个用户标记其“最近一次事件类型”。用.loc[]可高效实现# 步骤1按用户分组取每组最新事件的索引 latest_idx df.groupby(user_id)[event_time].idxmax() # 步骤2用 .loc[] 批量赋值仅更新这些行 df.loc[latest_idx, last_event_type] df.loc[latest_idx, event_type].values这段代码比df.sort_values(event_time).drop_duplicates(user_id, keeplast)内存占用低40%因为它不创建新 DataFrame只在原地更新指定位置。3.4.groupby().agg()超越统计的特征构造引擎.groupby().agg()的威力远不止于sum()或count()。它的核心在于支持字典式聚合和自定义函数能将多维业务逻辑压缩进一行代码。例如在物流时效分析中我们需要为每个运单计算“首公里揽收耗时”、“全程运输耗时”、“末端派送耗时”。原始数据是事件日志含waybill_id,event_type,event_time。传统做法是写三个groupby效率低下。用.agg()一行搞定def calc_time_diff(series): 计算时间序列的差值返回标量 if len(series) 2: return np.nan return (series.max() - series.min()).total_seconds() / 3600 # 转为小时 time_features (df .groupby(waybill_id) .agg( # 字典键是新列名值是元组(列名, 聚合函数) first_pickup_hours(event_time, lambda x: (x[x[event_type]pickup].min() - x[x[event_type]create].min()).total_seconds()/3600), transit_hours(event_time, lambda x: (x[x[event_type]arrive_dest].max() - x[x[event_type]depart_origin].min()).total_seconds()/3600), # 复用自定义函数 last_mile_hours(event_time, calc_time_diff) ) .reset_index() )实操心得当聚合逻辑复杂时务必先在小样本上测试自定义函数确保其能处理空值、单值等边界情况。我曾因一个未处理len(series)1的calc_time_diff导致百万级运单聚合时随机报错排查了两天才发现是函数内部除零。3.5.fillna()缺失值治理的“物理规律”思维.fillna()的关键是理解缺失不是噪声而是业务信号。在工业传感器数据中NaN可能意味着设备离线在电商点击流中NaN可能代表用户未完成某步操作。因此填充值必须符合业务语义。.fillna()的method参数提供了四种“物理规律”式填充method适用场景实操案例ffill时间序列连续性假设温度传感器每5分钟上报某次断连30分钟用前值填充更合理bfill未来信息可得假设用户注册后补填职业信息用后值填充表示“最终确认的职业”pad同ffill语法别名无区别limit控制填充范围df[temp].fillna(methodffill, limit6)表示最多向前填充6个位置防止单次长断连污染全局一个经典案例是金融风控中的“客户最近一次逾期天数”。原始数据中overdue_days列在客户无逾期时为NaN。简单填0会扭曲分布填均值会掩盖风险。我们采用分层策略# 步骤1先标记是否有逾期记录 df[has_overdue] df[overdue_days].notna() # 步骤2对有记录的客户用前向填充假设风险状态持续 df[overdue_days_filled] df.groupby(customer_id)[overdue_days].fillna(methodffill) # 步骤3对从未逾期的客户填特殊值-1后续建模时可作为强特征 df[overdue_days_final] df[overdue_days_filled].fillna(-1)这种“标记分层填充”比一刀切.fillna(0)的模型 AUC 高 0.023。3.6.pivot_table()从日志到特征矩阵的终极转换.pivot_table()是.pivot()的增强版它能自动处理重复值、缺失值和多维度聚合是构建宽表特征的唯一可靠选择。其核心参数index,columns,values,aggfunc构成四维空间。一个高频痛点是如何将“用户-商品-购买次数”三元组转为“用户-商品类别-总购买次数”宽表.pivot_table()一行解决# 原始数据user_id, item_id, purchase_count, category # 目标每行一个用户每列一个品类值为该用户在该品类总购买次数 user_category_matrix (df .pivot_table( indexuser_id, columnscategory, valuespurchase_count, aggfuncsum, # 处理同一用户同一品类多次购买 fill_value0 # 缺失品类填0而非NaN ) .reset_index() .rename_axis(None, axis1) # 清除列名层级 )关键细节fill_value0是生产环境必备参数否则宽表中大量NaN会干扰后续的StandardScaler等预处理。另外aggfunc支持传入字典如{purchase_count: sum, avg_price: mean}可一次性生成多指标宽表。3.7.duplicated().drop_duplicates()清洗末期的“质量守门员”清洗的最后一步不是导出而是校验主键唯一性。.duplicated()返回布尔 Series标识每行是否为重复行默认保留首次出现。.drop_duplicates()则执行去重。二者组合可实现“去重-留痕”双操作# 步骤1识别所有重复行基于业务主键 dup_mask df.duplicated(subset[order_id, item_id], keepFalse) # 步骤2将重复行单独保存供人工核查 duplicates_df df[dup_mask].copy() duplicates_df[duplicate_reason] same_order_item # 步骤3在原数据中删除重复行保留首次 df_clean df.drop_duplicates(subset[order_id, item_id], keepfirst) # 步骤4记录清洗日志 print(f原始行数: {len(df)}, 重复行数: {dup_mask.sum()}, 清洗后行数: {len(df_clean)})这个流程的价值在于它把“数据质量事故”转化为“可审计的清洗动作”。在一次支付对账项目中我们正是通过这种方式发现上游系统因网络重试向下游发送了37笔完全相同的支付指令及时拦截了资损。4. 实操全流程一个完整的电商用户分群项目复现4.1 项目背景与原始数据结构我们接手一个东南亚电商平台的用户分群需求将200万活跃用户划分为“高价值新客”、“价格敏感活跃用户”、“沉默高潜力用户”等6类用于精准营销。原始数据是user_behavior.csv含以下字段字段名类型含义示例user_idstr用户唯一IDU100001event_timedatetime行为发生时间2023-05-12 14:23:05event_typestr行为类型view, cart, purchaseitem_idstr商品IDI98765order_idstr订单IDpurchase时有值O20230512001amountfloat订单金额purchase时有值129.99数据特点时间跨度3个月event_type分布不均view 占85%purchase 仅2%amount在非 purchase 行为中为空。4.2 清洗流水线搭建7个函数的协同作战我们按前述流水线构建完整清洗脚本import pandas as pd import numpy as np from datetime import datetime, timedelta # 1️⃣ 加载原始数据 raw_df pd.read_csv(user_behavior.csv, parse_dates[event_time]) # 2️⃣ 【.assign()】启动构建基础时间特征和金额标记 df_base (raw_df .assign( # 标准化时间便于后续计算 event_datelambda x: x[event_time].dt.date, # 标记是否为付费行为 is_purchaselambda x: (x[event_type] purchase), # 提取周几用于分析用户活跃习惯 weekdaylambda x: x[event_time].dt.weekday ) ) # 3️⃣ 【.pipe()】编排定义并调用清洗函数 def add_user_metrics(df): 添加用户级聚合指标 # 按用户分组计算核心指标 user_metrics (df .groupby(user_id) .agg( # 总浏览数 total_views(event_type, lambda x: (x view).sum()), # 总加购数 total_carts(event_type, lambda x: (x cart).sum()), # 总购买数 total_purchases(is_purchase, sum), # 首次行为时间 first_active_date(event_date, min), # 最近一次行为时间 last_active_date(event_date, max), # 总消费金额仅purchase行有值 total_spent(amount, sum) ) .reset_index() ) # 计算用户生命周期长度天 user_metrics[lifespan_days] ( (pd.to_datetime(user_metrics[last_active_date]) - pd.to_datetime(user_metrics[first_active_date])).dt.days 1 ) # 计算购买转化率加购-购买 user_metrics[cart_to_purchase_rate] ( user_metrics[total_purchases] / user_metrics[total_carts] ).fillna(0) return user_metrics def add_recency_frequency_monetary(df): RFM模型核心指标计算 # R最近一次购买距今多少天 latest_purchase (df[df[is_purchase]] .groupby(user_id)[event_date] .max() .reset_index(namelast_purchase_date)) # 假设今天是数据截止日 cutoff_date df[event_date].max() latest_purchase[recency_days] ( (pd.to_datetime(cutoff_date) - pd.to_datetime(latest_purchase[last_purchase_date])).dt.days ) # F购买频次、M总金额已在 add_user_metrics 中计算 return df.merge(latest_purchase, onuser_id, howleft) # 主清洗链 df_user_level (df_base .pipe(add_user_metrics) .pipe(add_recency_frequency_monetary) ) # 4️⃣ 【.loc[]】精准筛选剔除无效用户如仅浏览无任何互动 valid_users df_user_level.loc[ (df_user_level[total_views] 0) (df_user_level[lifespan_days] 7), # 至少活跃7天 [user_id, total_views, total_carts, total_purchases, total_spent, lifespan_days, cart_to_purchase_rate, recency_days] ] # 5️⃣ 【.fillna()】治理缺失对无购买记录的用户recency_days 填最大值表示从未购买 valid_users[recency_days] valid_users[recency_days].fillna(valid_users[lifespan_days].max()) # 6️⃣ 【.pivot_table()】结构重塑为每个用户生成“品类偏好”宽表 # 先关联商品品类假设已有 item_category_map.csv item_cat pd.read_csv(item_category_map.csv) df_with_cat df_base.merge(item_cat, onitem_id, howleft) # 构建用户-品类交互矩阵 user_category_pref (df_with_cat .pivot_table( indexuser_id, columnscategory, valuesevent_type, aggfunclambda x: (x purchase).sum(), # 统计各品类购买次数 fill_value0 ) .reset_index() .rename_axis(None, axis1) ) # 7️⃣ 【.duplicated()】最终校验确保 user_id 唯一 if valid_users[user_id].duplicated().any(): print(警告用户ID存在重复) dup_users valid_users[valid_users[user_id].duplicated(keepFalse)] dup_users.to_csv(duplicate_users_report.csv, indexFalse) else: print(✅ 用户ID校验通过无重复) # 合并所有特征 final_features (valid_users .merge(user_category_pref, onuser_id, howleft) .fillna(0) # 宽表中品类列可能有NaN统一填0 )4.3 关键参数与性能调优实录内存优化处理200万行时df_base占用内存达1.2GB。我们通过.assign()中的lambda x:避免创建中间变量并在add_user_metrics中使用agg()的字典形式而非多次groupby将内存峰值压至850MB。时间精度event_time.dt.date比event_time.dt.normalize()快17%因为前者直接截取日期部分后者需归一化到当天0点。宽表生成pivot_table的aggfunc使用lambda x: (x purchase).sum()而非count是因为count会统计所有非空值而我们只关心 purchase 行。去重校验duplicated(keepFalse)比duplicated()多返回一次便于我们导出所有重复行进行人工核查这是生产环境的黄金实践。4.4 输出结果与业务价值最终生成的final_featuresDataFrame 含127列1个 user_id 6个基础指标 120个品类偏好列行数198.7万剔除1.3万无效用户。该宽表直接输入 XGBoost 模型用户分群准确率达89.2%较上一代基于规则的分群准确率72.5%提升16.7个百分点。市场部据此向“沉默高潜力用户”推送专属优惠券首月召回率提升34%ROI 达1:5.8。5. 常见问题与独家避坑指南5.1 “链式赋值警告”SettingWithCopyWarning的根因与根治这是 pandas 新手最常遇到的警告本质是试图修改一个视图view而非副本copy。例如# ❌ 触发警告 df_sub df[df[age] 18] df_sub[new_col] 1 # Warning!根因df[df[age]18]返回的是原 DataFrame 的视图修改它可能影响原数据pandas 不确定你的意图。根治方案只有两个方案1推荐永远用.loc[]进行条件赋值df.loc[df[age] 18, new_col] 1 # 无警告明确指定位置方案2显式创建副本df_sub df[df[age] 18].copy() # 明确声明要操作副本 df_sub[new_col] 1我的实操心得在团队规范中我禁止使用df[condition]进行赋值强制要求所有条件操作必须用.loc[]。这看似多打几个字符却能杜绝90%以上的数据一致性问题。5.2.groupby().agg()中自定义函数的“空组”陷阱当分组后某组为空时自定义函数可能报错。例如# ❌ 危险若某 user_id 组无 purchase 行x[amount] 为空Seriesmin() 报错 df.groupby(user_id).agg(last_purchase_amount(amount, min)) # ✅ 安全用 lambda 包裹加入空值检查 df.groupby(user_id).agg( last_purchase_amount(amount, lambda x: x.min() if not x.empty else np.nan) )更彻底的方案是在agg()前先过滤掉空组或使用dropnaFalse参数但需注意aggfunc是否支持。5.3.pivot_table()的“列名冲突”与动态列处理当columns字段含中文或特殊字符时生成的列名可能非法如含空格。.pivot_table()本身不处理此问题需手动清洗# 生成宽表后清理列名 pivoted df.pivot_table(...) pivoted.columns [str(col).strip().replace( , _).replace(/, _) for col in pivoted.columns]另一个问题是品类数量动态变化如新增品类宽表列数会变导致模型输入维度不一致。解决方案是预定义品类白名单# 从业务方获取固定品类列表 CATEGORIES [electronics, fashion, home, beauty, sports] # pivot_table 时强制包含所有品类缺失填0 pivoted df.pivot_table( indexuser_id, columnscategory, valuespurchase_count, aggfuncsum, fill_value0 ).reindex(columnsCATEGORIES, fill_value0) # 关键reindex 保证列顺序和完整性5.4 时间序列.fillna(methodffill)的“跨用户污染”风险在用户行为数据中若直接对全表event_time排序后ffill会导致用户A的缺失值被用户B的值填充。绝对禁止# ❌ 灾难性错误跨用户填充 df.sort_values(event_time).fillna(methodffill) # 用户A的NaN被用户B的值填了 # ✅ 正确按用户分组后填充 df.sort_values([user_id, event_time]).groupby(user_id).fillna(methodffill)5.5.pipe()函数的调试技巧如何在链中插入断点.pipe()链式调用让调试变难。我的方法是在关键节点插入一个“调试函数”def debug_print(df, msgDebug): 在链中打印DataFrame形状和前3行 print(f\n--- {msg} ---) print(fShape: {df.shape}) print(df.head(3)) return df # 在链中使用 df_final (raw_df .pipe(clean_outliers) .pipe(debug_print, After outlier cleaning) # 插入断点 .pipe(pivot_to_wide) .pipe(debug_print, After pivoting) )这比在 Jupyter 中逐行运行更贴近生产环境且可随时移除。6. 工具链延伸如何将 Playbook 落地为团队标准6.1 构建可复用的清洗函数库基于这7个函数我们抽象出data_cleaning_utils.py包含safe_assign(df, **kwargs)增强版.assign()自动处理lambda中的异常。robust_pipe(df, func, *args, **kwargs)带异常捕获的.pipe()失败时记录日志并返回原 df。smart_fillna(df, column, strategyffill, groupbyNone)智能填充支持按组填充。validate_primary_key(df, key_columns)封装.duplicated()校验返回详细报告。6.2 与 Airflow 集成将 Playbook 变为调度任务清洗脚本不再是本地.py文件而是 Airflow DAGfrom airflow import DAG from airflow.operators.python import PythonOperator from datetime import datetime, timedelta def run_data_playbook(**context): # 加载数据、执行清洗链、保存结果 final_df (load_raw_data() .pipe(clean_outliers) .pipe(add_user_metrics) # ... 其他步骤 ) save_to_parquet(final_df, cleaned_user_features) dag DAG( ecommerce_user_cleaning, default_args{retries: 2}, schedule_intervaldaily, start_datedatetime(2023, 1, 1) ) clean_task PythonOperator( task_idrun_playbook, python_callablerun_data_playbook, dagdag )6.3 效果监控清洗质量的量化仪表盘在清洗脚本末尾添加质量报告def generate_quality_report(df_original, df_cleaned): report { original_rows: len(df_original), cleaned_rows: len(df_cleaned), drop_rate_pct: round((len(df_original)-len(df_cleaned))/len(df_original)*100, 2), null_before: df_original.isnull().sum().sum(), null_after: df_clean