1. 这不是教科书里的“数据预处理”,而是你明天就要跑通的生产级流水线
如果你刚打开Jupyter Notebook,准备处理一份来自业务系统的CSV文件,发现里面有空值、时间戳格式混乱、用户年龄跨度从3岁到98岁、商品类别是中文字符串、而“高价值客户”标签只占0.7%——这时候翻Pandas官方文档查StandardScaler参数,不如直接看这篇。它不讲“什么是归一化”,只告诉你为什么用Min-Max而不是Z-score处理用户停留时长,为什么分箱必须避开业务断点,为什么LabelEncoder在部署时会突然报错,以及如何让过采样后的模型在线上A/B测试中真正提升转化率。核心关键词全部落在标题里:标准化&归一化、离散化/分箱/分桶、分类数据处理、时间类型数据处理、样本类别分布不均衡数据处理、数据抽样——每一个都是你在真实项目里踩过坑、改过三次代码、被算法同事拉着复盘两小时才搞明白的硬核环节。适合两类人:一类是刚学完df.fillna()就接需求的新人,另一类是手握成熟Pipeline却在新业务场景下反复调试特征工程模块的资深数据工程师。这篇文章没有理论推导,只有我用某电商用户行为日志、某金融风控申请表、某IoT设备上报数据这三类真实结构反复验证过的操作路径、参数阈值和避坑清单。
2. 内容整体设计与思路拆解:为什么预处理不能“先标准化再分箱”?
2.1 预处理不是线性流水线,而是带依赖关系的有向图
很多人把预处理理解成“按顺序执行6个函数”:先填空、再标准化、接着分箱、然后编码……这是最大的认知陷阱。真实场景中,步骤之间存在强耦合和逆向依赖。举个典型例子:某信贷风控项目需要对“月均消费金额”做分箱,但分箱边界必须参考业务规则(如“5000元以下为普通用户,5000-20000为优质用户”),而原始数据中该字段存在大量异常值(单笔消费120万元)。如果先做Z-score标准化,再按标准差切分箱,就会把120万这个异常点拉进计算,导致所有分箱边界偏移;但如果先用IQR法剔除异常值再分箱,又可能误删真实的大额消费用户。最终方案是:先用业务逻辑定义合理范围(0-50万),再在此范围内做等频分箱,最后对分箱结果做One-Hot编码。这个顺序不是凭经验拍脑袋,而是由三个约束共同决定的:① 业务可解释性要求(分箱必须对应明确客群定义);② 模型鲁棒性要求(异常值不能污染统计量);③ 部署一致性要求(训练和推理阶段分箱边界必须完全一致)。
2.2 六大模块的优先级排序:时间处理永远第一,类别编码永远最后
在超过20个跨行业项目中,我总结出预处理步骤的强制优先级链:
- 时间类型数据处理→ 必须最先执行,因为后续所有操作都依赖时间维度(如滑动窗口统计、周期性特征提取);
- 缺失值填充策略选择→ 不是简单
fillna(0),而是根据字段语义决定:数值型用中位数(抗异常值)、时间型用前向填充(保持时序连续)、分类型用“Unknown”新类别(避免信息泄露); - 标准化&归一化→ 仅对参与距离计算的数值特征(如KNN、SVM输入)执行,树模型特征无需此步;
- 离散化/分箱/分桶→ 必须在标准化之后?错!必须在缺失值处理之后、标准化之前。因为分箱本质是将连续值映射为有序类别,其边界计算依赖原始分布形态,标准化会扭曲分布;
- 分类数据处理→ 必须在所有数值变换完成后执行,否则LabelEncoder会对分箱生成的字符串类别产生错误序号;
- 样本类别分布不均衡数据处理→ 必须在所有特征工程完成后执行,且仅作用于训练集,验证集和测试集必须保持原始分布。
这个顺序不是教科书结论,而是我在某智能客服系统中因颠倒第3和第4步,导致模型在上线后F1值暴跌12个百分点后,用三天时间回溯日志、重跑特征版本才确认的铁律。
2.3 工具选型逻辑:为什么坚持用Pandas原生方法而非Sklearn Pipeline?
虽然Sklearn提供StandardScaler、KBinsDiscretizer等封装,但在生产环境中我坚持用Pandas原生方法实现核心预处理,原因有三:
- 可追溯性:Pandas链式操作(如
df['age'].clip(lower=0, upper=100).apply(lambda x: 'young' if x<30 else 'mid' if x<60 else 'senior'))每一步都可单独打印中间结果,而Sklearn Pipeline的transform方法返回黑盒数组,调试时需额外写inverse_transform反查; - 内存效率:处理千万级用户行为日志时,Pandas的
cut和qcut支持retbins=True直接获取分箱边界,后续只需保存边界数组即可复现,而Sklearn的KBinsDiscretizer每次transform都要重新计算边界; - 部署兼容性:某金融项目需将预处理逻辑嵌入Java服务,我们用Jython调用Pandas脚本比对接Sklearn的pickle模型更稳定——后者在不同Python版本间存在序列化兼容问题。当然,对于标准化这类纯数学运算,我会用Sklearn的
StandardScaler,但会立即用scaler.scale_和scaler.mean_参数重建Pandas表达式,确保全链路可控。
3. 核心细节解析与实操要点:每个模块的生死线参数
3.1 标准化&归一化:别再无脑用Z-score,这三种场景必须换方案
标准化(Z-score)和归一化(Min-Max)的选择,取决于后续模型对特征尺度的敏感度和业务语义。我整理了三个决定性判断条件:
| 判断维度 | Z-score适用场景 | Min-Max适用场景 | 无缩放适用场景 |
|---|---|---|---|
| 模型类型 | SVM、逻辑回归、神经网络 | KNN、聚类、图像像素值 | 决策树、XGBoost、LightGBM |
| 业务语义 | 字段无天然上下界(如用户活跃度得分) | 字段有明确物理边界(如年龄0-120岁、评分0-5分) | 字段本身是计数或比率(如点击次数、转化率) |
| 异常值容忍度 | 数据近似正态分布,异常值<1% | 存在少量异常值但需保留(如医疗费用中的罕见大额支出) | 异常值即业务信号(如黑产账号的单日登录200次) |
实操要点:
- 对“用户月均访问频次”字段,我曾用Z-score处理,结果发现Top 0.5%的高频用户(日均访问>50次)被压缩到2.3σ以内,导致模型无法区分“高频用户”和“超高频黑产”。改用Min-Max并设置
feature_range=(0, 10)后,将50次映射为10,既保留区分度又避免数值溢出; - 对“订单金额”字段,直接
MinMaxScaler(feature_range=(0,1))会因个别百万级订单拉高分母,导致99%订单集中在0.001区间。正确做法是:先用df['amount'].quantile(0.99)获取99分位数作为上限,再执行df['amount']/upper_bound; - 致命陷阱:标准化必须在训练集上拟合,在验证集/测试集上仅
transform。我见过最惨的事故是某推荐系统将验证集fit_transform,导致AUC虚高0.15,上线后效果归零。解决方案是:用scaler.fit(train_df[cols])后,立即将scaler.mean_和scaler.scale_存为JSON,推理时直接加载参数计算(x - mean) / scale。
3.2 离散化/分箱/分桶:业务断点比统计断点重要十倍
分箱的本质是用业务语言翻译数字。某电商做用户价值分层时,“RFM模型中的R(最近购买天数)”字段,用qcut(df['recency'], q=5)做等频分箱,结果分出“0-3天”、“4-7天”、“8-15天”、“16-30天”、“31+天”五档。但业务方反馈:“8-15天”和“16-30天”的用户运营策略完全相同,强行拆分会增加不必要的复杂度。最终方案是:先与业务方确认运营动作断点(如7天内发召回短信、30天未购发优惠券),再用cut按这些断点分箱:
bins = [0, 7, 30, 90, float('inf')] labels = ['recent', 'warning', 'at_risk', 'churned'] df['recency_level'] = pd.cut(df['recency'], bins=bins, labels=labels, right=False)关键参数详解:
right=False:左闭右开区间,避免7同时属于两个区间;include_lowest=True:当bins[0]为0时,确保0值被包含;duplicates='drop':处理重复边界值(如[0,10,10,20]),否则报错。
避坑心得:
- 分箱后务必检查各档样本量,某IoT项目对“设备运行时长(小时)”分5档,结果“>10000小时”档仅3台设备,导致One-Hot编码后稀疏矩阵维度爆炸。解决方案是合并末尾小档:“0-1000、1000-5000、5000-10000、10000+”;
- 时间序列分箱禁用
qcut:某股票预测项目用qcut对“分钟级涨跌幅”分箱,因市场波动导致分位数剧烈漂移,周初分箱边界和周末完全不同。改用固定阈值cut([-5,-2,0,2,5], labels=['crash','down','flat','up','boom'])后稳定性提升。
3.3 分类数据处理:LabelEncoder不是万能钥匙,One-Hot有隐藏成本
分类变量编码的核心矛盾是:可解释性 vs 维度灾难 vs 模型兼容性。我按字段特性制定编码策略:
| 字段类型 | 推荐方案 | 原因 | 实操示例 |
|---|---|---|---|
| 有序类别(教育程度:小学<初中<高中<大学) | map({'小学':1,'初中':2,...}) | 保留序数关系,避免One-Hot破坏顺序 | df['edu_level'] = df['education'].map(edu_map) |
| 无序高基数(商品ID、用户ID,>1000类) | 目标编码(Target Encoding) | One-Hot会生成万维稀疏矩阵,LabelEncoder的序号无意义 | df['item_target'] = df.groupby('item_id')['is_click'].transform('mean') |
| 无序低基数(性别、省份,<50类) | One-Hot + 特征合并 | 避免维度爆炸,如将32个省份按经济水平聚为4组再One-Hot | df['province_group'] = pd.cut(df['gdp_per_capita'], bins=4, labels=['low','mid','high','top']) |
| 稀疏类别(用户填写的“兴趣标签”,单样本多标签) | MultiLabelBinarizer | 直接One-Hot会丢失多标签关系 | mlb = MultiLabelBinarizer(); df_ml = pd.DataFrame(mlb.fit_transform(df['tags']), columns=mlb.classes_) |
血泪教训:
- 某风控项目用LabelEncoder处理“职业”字段,训练时编码为
{'teacher':0, 'doctor':1, 'engineer':2},上线后遇到新职业“data_scientist”,transform报错。正确方案是:训练前用df['job'].value_counts().index.tolist()获取所有职业,对未见职业统一映射为-1,并在One-Hot中预留unknown列; - One-Hot后必须删除原始列并重命名,否则
df.columns会出现'job_teacher'和'job'共存,后续df.drop('job', axis=1)会漏删,导致特征重复。我的标准操作是:df = pd.get_dummies(df, columns=['job'], prefix='job')后立即df = df.rename(columns=lambda x: x.replace(' ', '_'))。
3.4 时间类型数据处理:从字符串到业务特征的七步炼金术
时间字段处理不是pd.to_datetime()就完事。以“订单创建时间”为例,完整流程如下:
Step 1:格式清洗
原始数据常混杂'2023/01/01'、'2023-01-01 10:30:00'、'Jan 1, 2023',直接to_datetime会报错。正确做法:
# 定义多种格式尝试 formats = ['%Y/%m/%d', '%Y-%m-%d %H:%M:%S', '%b %d, %Y', '%Y-%m-%d'] for fmt in formats: try: df['order_time'] = pd.to_datetime(df['order_time'], format=fmt, errors='raise') break except ValueError: continue # 仍失败则用infer_datetime_format=True(牺牲精度换成功率) if df['order_time'].dtype != 'datetime64[ns]': df['order_time'] = pd.to_datetime(df['order_time'], infer_datetime_format=True, errors='coerce')Step 2:缺失值处理
时间缺失不能填NaT后忽略,需区分原因:
- 若是ETL过程丢失,用
ffill()前向填充(保持时序连续); - 若是用户未填写,用
df['order_time'].mode()[0]填众数(如多数订单在工作日9-18点); - 绝对禁止用
fillna(pd.Timestamp('1970-01-01')),会导致后续dt.dayofweek计算出错。
Step 3:时区对齐
某跨境业务数据含UTC、CST、IST时间,必须统一为UTC再处理:
df['order_time'] = pd.to_datetime(df['order_time']).dt.tz_localize('UTC') # 或已有时区信息则转换:df['order_time'].dt.tz_convert('UTC')Step 4:基础属性提取
df['hour'] = df['order_time'].dt.hour # 0-23 df['day_of_week'] = df['order_time'].dt.dayofweek # 0=Monday df['is_weekend'] = (df['order_time'].dt.dayofweek >= 5).astype(int)Step 5:周期性编码
直接用hour会导致23点和0点距离为23,实际应为1。用正弦变换:
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)Step 6:业务窗口特征
# 计算用户过去7天订单数 df['user_id'] = df['user_id'].astype(str) # 确保ID为字符串 df = df.sort_values(['user_id', 'order_time']) df['7d_order_cnt'] = df.groupby('user_id')['order_time'].transform( lambda x: x.rolling('7D', on=x.index).count() )Step 7:时间衰减权重
对“用户历史行为”加权,越近行为权重越高:
# 计算距今天数 df['days_since'] = (pd.Timestamp.now() - df['order_time']).dt.days # 应用指数衰减:权重 = e^(-days/30) df['decay_weight'] = np.exp(-df['days_since'] / 30)3.5 样本类别分布不均衡数据处理:过采样不是银弹,这三点决定成败
某金融风控项目坏账率仅0.8%,直接过采样导致模型在验证集AUC达0.92,上线后逾期率不降反升。根本原因是:过采样改变了样本的分布假设,而模型评估未同步调整。解决方案是分三层防御:
第一层:数据层面——选择匹配业务风险的采样策略
- SMOTE过采样:适用于特征空间连续、坏样本聚集的场景(如信用分集中于500-550分区间)。但某贷款项目坏样本分散在信用分300-700全范围,SMOTE生成的合成样本偏离真实分布,改用ADASYN(自适应合成少数类)后,模型对长尾坏样本识别率提升23%;
- 欠采样:绝不能随机删除好样本!用
RandomUnderSampler会丢失重要模式。正确做法是Tomek Links:删除那些与异类最近邻构成“不可分对”的样本,保留决策边界清晰的样本; - 混合采样:对某电商欺诈检测,先用SMOTE对“交易金额>5000且设备ID新注册”的坏样本过采样,再用ENN(Edited Nearest Neighbors)清理好样本中的噪声点。
第二层:特征层面——构建不平衡感知特征
- 添加相对频率特征:
df['category_freq'] = df.groupby('category')['is_bad'].transform('mean'),让模型知道“奢侈品类目坏账率是均值的3倍”; - 构建距离类特征:计算每个样本到最近坏样本的欧氏距离,距离越小风险越高;
- 引入时序风险特征:
df['7d_bad_rate'] = df.groupby('user_id')['is_bad'].transform(lambda x: x.rolling(7).mean())。
第三层:模型层面——损失函数重加权
不依赖采样,直接在XGBoost中设置:
model = XGBClassifier( scale_pos_weight=len(y_train[y_train==0]) / len(y_train[y_train==1]), # 坏样本权重 objective='binary:logistic', eval_metric='aucpr' # 用AUCPR替代AUC,对不平衡数据更敏感 )关键参数验证:
scale_pos_weight必须用训练集计算,且每次交叉验证需重新计算;- AUCPR(PR曲线下的面积)比AUC更能反映不平衡数据下的模型性能,某项目AUC提升0.02但AUCPR下降0.15,说明模型在高精度区间失效。
3.6 数据抽样:为什么分层抽样必须按目标变量分层?
抽样目的决定方法。某AB测试需从1亿用户中抽取10万做实验,若随机抽样,可能导致实验组中高价值用户比例(5%)与总体(8%)偏差过大,结论失效。正确方案是分层抽样(Stratified Sampling):
from sklearn.model_selection import train_test_split # 按目标变量y分层,确保训练/验证/测试集分布一致 train_df, test_df = train_test_split( df, test_size=0.2, stratify=df['is_premium'], # 关键!按目标变量分层 random_state=42 )但分层抽样有陷阱:
- 若
is_premium在某些子群体中为常量(如某地区所有用户都是免费版),stratify会报错。解决方案:先用df.groupby(['region','is_premium']).size()检查,对常量子群组单独处理; - 时间序列数据禁用随机分层!某用户留存预测项目将2023年数据随机打乱分层,导致训练集包含2023年12月数据而验证集是2023年1月,模型学到未来信息。正确做法是时间分割:
train_df = df[df['date'] < '2023-06-01']; val_df = df[(df['date'] >= '2023-06-01') & (df['date'] < '2023-09-01')]。
4. 实操过程与核心环节实现:一个端到端的电商用户分群项目
4.1 项目背景与数据概览
某电商平台需构建用户价值分群模型,输入为2023年全年1.2亿条订单记录,字段包括:user_id,order_time,amount,category,province,device_type。目标输出:每个用户归属高价值、潜力股、流失预警、低活跃四类。我们按前述六大模块构建预处理流水线,全程使用Pandas 1.5.3,内存占用控制在16GB内。
4.2 完整代码实现与参数详解
import pandas as pd import numpy as np from datetime import datetime, timedelta # Step 0:数据加载与初始清洗 df = pd.read_csv('orders_2023.csv', dtype={'user_id': 'string', 'category': 'category'}, # 提前指定类型省内存 parse_dates=['order_time']) # 删除明显错误数据:金额<=0或>100万 df = df[(df['amount'] > 0) & (df['amount'] <= 1e6)] # Step 1:时间类型数据处理(按3.4节七步法) df['order_time'] = pd.to_datetime(df['order_time'], errors='coerce') df = df.dropna(subset=['order_time']) # 删除时间无效的订单 df = df.sort_values(['user_id', 'order_time']) # 提取基础时间特征 df['hour'] = df['order_time'].dt.hour df['day_of_week'] = df['order_time'].dt.dayofweek df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) # 周期性编码 df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24) # 计算距今天数(用于衰减) df['days_since'] = (datetime.now() - df['order_time']).dt.days df['decay_weight'] = np.exp(-df['days_since'] / 30) # Step 2:数值型特征处理(标准化&归一化) # 用户生命周期价值(LTV):过去180天总金额 df['ltv_180d'] = df.groupby('user_id')['amount'].transform( lambda x: x.rolling('180D', on=df.loc[x.index, 'order_time']).sum() ) # 对ltv_180d做Min-Max归一化(有业务上限:VIP用户年消费<50万) ltv_upper = df['ltv_180d'].quantile(0.995) # 取99.5分位数防异常 df['ltv_norm'] = df['ltv_180d'] / ltv_upper # Step 3:离散化/分箱 # 将ltv_norm分4档:低(0-0.2)、中(0.2-0.5)、高(0.5-0.8)、超高(0.8-1.0) df['ltv_level'] = pd.cut(df['ltv_norm'], bins=[0, 0.2, 0.5, 0.8, 1.0], labels=['low', 'mid', 'high', 'ultra'], include_lowest=True) # Step 4:分类数据处理 # 省份按GDP分组(业务知识) province_gdp = {'Beijing': 4.2, 'Shanghai': 4.7, 'Guangdong': 12.9, 'Henan': 5.9} # 单位:万亿 df['province_gdp'] = df['province'].map(province_gdp).fillna(df['province_gdp'].median()) df['province_group'] = pd.qcut(df['province_gdp'], q=3, labels=['low_gdp', 'mid_gdp', 'high_gdp']) # 设备类型One-Hot df = pd.get_dummies(df, columns=['device_type'], prefix='device') # Step 5:样本不均衡处理(本项目目标变量为四分类,需平衡) # 按ltv_level分层抽样,确保每类至少10万样本 target_sample = 100000 sampled_df = pd.concat([ df[df['ltv_level'] == level].sample(n=target_sample, replace=True, random_state=42) for level in ['low', 'mid', 'high', 'ultra'] ]) # Step 6:数据抽样(最终训练集) # 按user_id分层,避免同一用户数据分散在训练/验证集 users = sampled_df['user_id'].unique() train_users, val_users = train_test_split( users, test_size=0.2, stratify=sampled_df.groupby('user_id')['ltv_level'].first(), random_state=42 ) train_df = sampled_df[sampled_df['user_id'].isin(train_users)] val_df = sampled_df[sampled_df['user_id'].isin(val_users)] # 输出特征列表(供后续模型使用) feature_cols = ['ltv_norm', 'hour_sin', 'hour_cos', 'is_weekend'] + \ [col for col in train_df.columns if col.startswith('device_')] + \ ['province_group_low_gdp', 'province_group_mid_gdp', 'province_group_high_gdp'] print(f"最终特征数:{len(feature_cols)}") print(f"训练集样本量:{len(train_df)}, 验证集:{len(val_df)}")参数选择依据:
ltv_upper取99.5分位数而非均值:因LTV呈长尾分布,均值受极值影响大,99.5分位数能覆盖99.5%用户且保留业务上限感;province_gdp手动映射而非自动聚类:因省份经济水平有明确政策定义,自动聚类可能将北京和上海分到不同组,违背业务常识;replace=True在过采样中启用:因low档原始样本仅8万,需复制样本达到10万,但复制前已用sample(..., random_state=42)确保可重现。
4.3 性能优化技巧:处理千万级数据的三把刀
- 内存优化:
df.astype('category')将category字段内存降低80%;pd.to_numeric(df['amount'], downcast='float')将float64转为float32; - 计算加速:
groupby().agg()比groupby().apply()快5倍,如df.groupby('user_id').agg({'amount':['sum','count']}); - 磁盘缓存:对耗时操作(如滚动窗口计算)结果用
df.to_parquet('ltv_cache.parquet')保存,下次直接pd.read_parquet()加载,速度提升10倍。
5. 常见问题与排查技巧实录:那些让你加班到凌晨的Bug
5.1 时间处理类问题速查表
| 问题现象 | 根本原因 | 解决方案 | 验证方法 |
|---|---|---|---|
pd.to_datetime()报OutOfBoundsDatetime | 字符串含非法日期(如'2023-02-30') | 用errors='coerce'转为NaT,再df[df['time'].isna()]定位脏数据 | df['time'].min(), df['time'].max()检查范围 |
dt.dayofweek返回-1 | NaT值参与计算 | 在dt操作前df = df.dropna(subset=['time']) | df['time'].isna().sum()为0 |
| 滚动窗口计算结果为空 | order_time未排序或索引非DatetimeIndex | df = df.sort_values('order_time').set_index('order_time') | df.index.dtype == 'datetime64[ns]' |
5.2 分类编码类问题速查表
| 问题现象 | 根本原因 | 解决方案 | 验证方法 |
|---|---|---|---|
| One-Hot后列名含空格或特殊字符 | 原始分类值含空格(如'Mobile Phone') | df['category'] = df['category'].str.replace(' ', '_') | all(' ' not in col for col in df.columns) |
LabelEncoder在推理时报ValueError: y contains previously unseen labels | 训练时未保存classes_,推理时遇到新类别 | 训练后np.save('le_classes.npy', le.classes_),推理时le.classes_ = np.load('le_classes.npy') | len(le.classes_)与训练时一致 |
目标编码出现inf值 | 某类别下目标变量全为0,分母为0 | df['target_enc'] = df.groupby('cat')['y'].transform('mean').fillna(0) | df['target_enc'].isin([np.inf, -np.inf]).sum()为0 |
5.3 不均衡处理类问题速查表
| 问题现象 | 根本原因 | 解决方案 | 验证方法 |
|---|---|---|---|
| SMOTE生成样本后模型过拟合 | 合成样本过于接近原始样本,未增加泛化能力 | 改用SMOTE(sampling_strategy='minority', k_neighbors=3)减少邻居数 | 计算合成样本与最近原始样本的平均距离,应>0.1 |
scale_pos_weight设置后AUC不升反降 | 权重过大导致模型过度关注少数类,牺牲多数类精度 | 用网格搜索:scale_pos_weight=[10, 20, 50, 100],选验证集F1最高者 | classification_report(y_val, y_pred)中各类F1均衡 |
| 分层抽样后验证集分布偏差大 | stratify参数未对齐目标变量(如用user_id而非label) | stratify=df['label']必须是1D数组,且长度与df一致 | len(stratify) == len(df) |
5.4 实操心得:五个让我少熬十次夜的技巧
技巧1:预处理脚本必须带版本号
所有.py文件开头加__version__ = "1.2.3",每次修改更新。某项目因团队成员用不同版本脚本,导致特征不一致,排查3天。现在用git tag绑定脚本版本与模型版本。技巧2:缺失值填充必须留痕
不用df.fillna(0),而用df['amount'].fillna(df['amount'].median(), inplace=True),并在注释写明# median=128.5, calculated on 2023-01-01 data。这样半年后回看,知道填充依据。技巧3:分箱边界必须持久化
bins = [0, 100, 500, 1000]不能写死在代码里,而要存为JSON:import json with open('ltv_bins.json', 'w') as f: json.dump({'ltv_bins': bins, 'ltv_labels': labels}, f)推理时
json.load()读取,确保线上线下一致。技巧4:时间特征必须标注时区
所有pd.to_datetime()后立即.dt.tz_localize('UTC'),即使数据无时区信息。某项目因未标注,海外用户订单时间错位8小时,导致“夜间下单”特征全错。技巧5:过采样必须限定在训练集
每次train_test_split后,只对train_df做SMOTE,val_df和test_df保持原始分布。用assert len(train_df[train_df['label']==1]) > 0验证,避免采样失败无声崩溃。
6. 最后分享一个真实案例:如何用预处理把模型AUC从0.72干到0.85
某在线教育平台想预测用户续费率,原始数据含200万学员的课程学习记录。初始Pipeline用默认Z-score+One-Hot,AUC仅0.72。我们按本文方法逐项优化:
- 时间处理:发现“最后学习时间距今”字段未做周期性编码,23点和0点被当作远距离,加入
sin/cos后AUC+0.03; - 分箱优化:将“累计学习时长”从等频分5档改为按业务断点
[0,10,50,200,1000],因运营策略明确:0-10小时需推送入门课,10-50小时推进阶课,AUC+0.04;