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。复盘后发现三个问题:

  1. 时间对齐错误:历史拒付率包含了未来 90 天信息,造成标签泄漏;
  2. 缺失值用均值填充,未增加缺失指示特征,丢失了“未采集”这一信号;
  3. 高基数商户ID直接 One-Hot,维度爆炸且稀疏。

修正后,采用扩展窗口计算历史拒付率,缺失值增加指示特征并用中位数填充,商户ID改用频率编码加目标编码(折内计算),金额用 Robust Scaling。离线 AUC 降至 0.84,但上线后 AUC 稳定在 0.82,模型泛化能力显著提升。

四、误区与复盘

  • 误区一:先标准化再划分训练测试集。正确做法是先划分,再在训练集上 fit,测试集 transform。
  • 误区二:把所有缺失都填充为 0。对金额类特征,0 可能是有效值,会造成语义混淆。
  • 误区三:异常值一律删除。拒付场景中,极端大额交易往往是高风险信号,应保留并做稳健缩放。
  • 误区四:目标编码在全量数据上计算。必须使用 K 折或时间序列交叉验证,否则泄漏不可避免。
  • 误区五:忽略时间对齐,用随机划分处理时间序列数据。拒付预测必须按时间划分训练集和测试集。

复盘清单:① 特征统计窗口是否严格早于预测时点?② 缺失指示特征是否保留?③ 异常值处理是否区分了业务无效与统计极端?④ 类别编码是否在高基数场景下做了折内计算?⑤ 标准化是否只在训练集上 fit?

完成以上五步后,数据才具备进入特征工程和模型训练的条件。预处理的每一个决策都应在代码中留下可复现的记录,并在时间维度上反复验证,确保离线评估与线上表现一致。