4. 数据预处理实战
在支付拒付(Chargeback)风险预测项目中,数据预处理往往决定了模型效果的上限。拒付数据来自交易流水、商户信息、持卡人行为、争议案件等多个系统,天然存在缺失、异常、时间错位、类别不均衡和量纲差异等问题。本章围绕五个关键环节展开:缺失值处理、异常值识别、时间对齐、类别编码与特征标准化,并结合支付场景给出可落地的工程做法。
一、核心原理:为什么拒付场景的预处理更敏感
拒付风险预测本质上是一个高度不平衡的二分类问题,正样本(发生拒付)通常只占交易量的千分之几到百分之几。任何预处理失误都会被不平衡放大:缺失值填充不当会引入虚假信号,异常值未处理会扭曲标准化,时间对齐错误会造成标签泄漏,类别编码不合理会让高基数特征淹没模型。
因此,预处理的目标不是“把数据洗干净”,而是在保留业务语义的前提下,让特征对模型可解释、可学习、可复现。以下五个环节需要按顺序推进,但实际工程中常需迭代回退。
处理顺序建议:先做时间对齐(确定样本与标签的时间边界),再做缺失值与异常值(在正确时间窗口内统计),最后做类别编码与标准化(避免用全量数据统计导致泄漏)。
二、方法流程:五步预处理流水线
1. 缺失值处理
拒付数据中,缺失通常分三类:随机缺失(如持卡人年龄偶尔未采集)、系统性缺失(如某些商户未开通3DS验证,该字段整列缺失)、业务性缺失(如拒付原因代码在交易时点尚不存在)。处理策略应区分对待:
- 缺失率 > 80% 且无业务含义的字段,直接删除;
- 缺失率中等且与目标相关的,增加“是否缺失”指示特征,再用中位数/众数填充;
- 时间序列类特征(如近7天拒付率)用前向填充,避免未来信息;
- 类别特征缺失可单独编码为“UNKNOWN”,保留信息。
import pandas as pd
import numpy as np
# 示例:对拒付数据做缺失值处理
df['is_age_missing'] = df['cardholder_age'].isna().astype(int)
df['cardholder_age'] = df['cardholder_age'].fillna(df['cardholder_age'].median())
# 类别特征缺失单独编码
df['mcc_code'] = df['mcc_code'].fillna('UNKNOWN')
# 时间序列特征前向填充
df = df.sort_values(['merchant_id', 'txn_time'])
df['merchant_7d_cb_rate'] = df.groupby('merchant_id')['merchant_7d_cb_rate'].ffill()
2. 异常值识别
支付金额、交易频次、拒付金额等字段常出现极端值。异常值不等于错误值:大额交易可能是真实的高风险信号。建议采用业务规则 + 统计方法双轨制:
- 业务规则:金额为负、时间戳早于商户注册时间、拒付金额大于交易金额等,直接标记为无效;
- 统计方法:对金额取对数后用 IQR 或分位数截断(Winsorize),保留极端但合理的样本;
- 对树模型,异常值影响较小,可保留;对逻辑回归、神经网络,必须处理。
# 分位数截断示例
lower = df['txn_amount'].quantile(0.01)
upper = df['txn_amount'].quantile(0.99)
df['txn_amount_winsor'] = df['txn_amount'].clip(lower, upper)
# 业务规则标记无效交易
df['invalid_txn'] = (
(df['txn_amount'] <= 0) |
(df['txn_time'] < df['merchant_register_time']) |
(df['chargeback_amount'] > df['txn_amount'])
).astype(int)
3. 时间对齐
这是拒付预测中最容易出错的一步。拒付发生在交易之后,通常有 30~120 天的争议窗口。如果特征统计窗口包含了标签发生之后的信息,就会造成标签泄漏。正确做法是:以“预测时点”为基准,所有特征只能使用该时点之前的数据。
常见错误:用全量数据计算商户历史拒付率,再喂给模型预测早期交易。这会让模型“偷看”未来,离线 AUC 虚高,上线后效果骤降。
# 以交易时间为基准,只统计交易发生前的历史特征
df = df.sort_values(['merchant_id', 'txn_time'])
df['merchant_hist_cb_rate'] = (
df.groupby('merchant_id')['is_chargeback']
.transform(lambda s: s.shift(1).expanding().mean())
)
4. 类别编码
拒付数据中的类别特征包括商户类别码(MCC)、卡组织、国家、拒付原因代码等。编码方式选择取决于基数与模型类型:
| 编码方式 | 适用场景 | 优点 | 风险 |
|---|---|---|---|
| One-Hot | 低基数(<15类) | 无顺序假设 | 维度膨胀 |
| Ordinal | 有序类别 | 简单 | 引入虚假顺序 |
| Target Encoding | 高基数(如商户ID) | 压缩维度 | 易泄漏,需交叉验证 |
| Frequency | 高基数且频次有含义 | 稳定 | 丢失类别差异 |
# 高基数类别使用频率编码 + 目标编码(带平滑)
freq_map = df['merchant_id'].value_counts(normalize=True)
df['merchant_freq'] = df['merchant_id'].map(freq_map)
# 目标编码需在训练折内计算,此处仅示意
target_mean = df.groupby('mcc_code')['is_chargeback'].mean()
df['mcc_target_enc'] = df['mcc_code'].map(target_mean)
5. 特征标准化
金额、频次、时间间隔等数值特征量纲差异大。对线性模型和神经网络,标准化是必须的;对树模型,标准化不影响分裂点,但统一量纲便于监控和解释。常用方法:
- Z-Score:适合近似正态分布的特征;
- Min-Max:适合有明确边界的特征,但对异常值敏感;
- Robust Scaling:用中位数和 IQR,适合含异常值的支付金额。
from sklearn.preprocessing import RobustScaler, StandardScaler
num_cols = ['txn_amount_winsor', 'merchant_hist_cb_rate', 'txn_count_7d']
scaler = RobustScaler()
df[num_cols] = scaler.fit_transform(df[num_cols])
工程建议:将所有预处理步骤封装为 sklearn Pipeline,确保训练集和测试集使用相同的统计量,避免数据泄漏,也便于线上推理复用。
三、案例场景:某跨境支付机构的拒付模型预处理
某跨境支付机构拥有 200 万笔交易,拒付率约 0.8%。原始数据中,商户行业类别缺失 12%,交易金额存在负值和极端值,且商户历史拒付率直接用全量计算导致离线 AUC 达 0.92,上线后仅 0.71。复盘后发现三个问题:
- 时间对齐错误:历史拒付率包含了未来 90 天信息,造成标签泄漏;
- 缺失值用均值填充,未增加缺失指示特征,丢失了“未采集”这一信号;
- 高基数商户ID直接 One-Hot,维度爆炸且稀疏。
修正后,采用扩展窗口计算历史拒付率,缺失值增加指示特征并用中位数填充,商户ID改用频率编码加目标编码(折内计算),金额用 Robust Scaling。离线 AUC 降至 0.84,但上线后 AUC 稳定在 0.82,模型泛化能力显著提升。
四、误区与复盘
- 误区一:先标准化再划分训练测试集。正确做法是先划分,再在训练集上 fit,测试集 transform。
- 误区二:把所有缺失都填充为 0。对金额类特征,0 可能是有效值,会造成语义混淆。
- 误区三:异常值一律删除。拒付场景中,极端大额交易往往是高风险信号,应保留并做稳健缩放。
- 误区四:目标编码在全量数据上计算。必须使用 K 折或时间序列交叉验证,否则泄漏不可避免。
- 误区五:忽略时间对齐,用随机划分处理时间序列数据。拒付预测必须按时间划分训练集和测试集。
复盘清单:① 特征统计窗口是否严格早于预测时点?② 缺失指示特征是否保留?③ 异常值处理是否区分了业务无效与统计极端?④ 类别编码是否在高基数场景下做了折内计算?⑤ 标准化是否只在训练集上 fit?
完成以上五步后,数据才具备进入特征工程和模型训练的条件。预处理的每一个决策都应在代码中留下可复现的记录,并在时间维度上反复验证,确保离线评估与线上表现一致。