【分箱进阶篇】分箱的工程细节:从训练到部署的完整模式

基础篇参考:【分箱基础篇】pandas 分箱双子星:pd.cut 与 pd.qcut

​ 我们在基础篇讲了 pd.cutpd.qcut 各自怎么用。但在实际项目里,分箱不是调一次函数就完事的。通常来说,训练集上算出来的切分点要保存下来,测试集和线上推断要复用同一套切分点,还要处理各种边界情况。

​ 这篇聚焦分箱落地时的工程问题:怎么 fit、怎么 apply、怎么兜底、怎么验证。

准备工作:生成模拟数据

​ 本篇所有示例共用同一份模拟数据,先跑这段:

python 复制代码
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split

np.random.seed(42)
n = 5000
df = pd.DataFrame({
    '月收入':   np.random.exponential(8000, n).clip(1000, 80000),
    '负债率':   np.random.beta(2, 5, n),
    '逾期次数': np.random.poisson(0.5, n).clip(0, 10).astype(int),
    '账龄':     np.random.randint(1, 120, n),
    '年龄':     np.random.randint(20, 65, n),
})

# 模拟违约标签:收入低、负债高、逾期多 → 违约概率更高
log_odds = -3 - 0.0001*df['月收入'] + 2.0*df['负债率'] + 0.5*df['逾期次数'] - 0.005*df['账龄']
df['label'] = (np.random.rand(n) < 1/(1+np.exp(-log_odds))).astype(int)

train, test = train_test_split(df, test_size=0.2, random_state=42, stratify=df['label'])
print(f"训练集 {len(train)} 条,测试集 {len(test)} 条,坏样本率 {df['label'].mean():.2%}")

输出:

bash 复制代码
训练集 4000 条,测试集 1000 条,坏样本率 4.38%

​ 后续代码中出现的 traintest 均来自这里。

一、fit / apply 分离模式

​ 分箱和模型训练一样,存在训练与推断不一致的风险。如果在测试集上重新调用 qcut,因为切分点可能会不同,这样在训练集和测试集上相同的值可能落进不同的箱,这就是数据泄漏的一种形式。

1.1 将分箱拆分为拟合和应用

​ 通常,在实践过程中,会把分箱拆成两步:

​ 首先,拟合切分点。fit_bins 负责在训练集上用 qcut 算出切分点,只返回 edges 数组,不返回分箱结果:

python 复制代码
def fit_bins(series, n_bins=5):
    """在训练集上拟合切分点"""
    _, edges = pd.qcut(series, q=n_bins, retbins=True, duplicates='drop')
    return edges

apply_bins 负责用已有的 edges 对任意数据分箱。注意它内部调用的是 pd.cut 而不是 qcut,因为切分点已经确定了,不需要再从数据中计算:

python 复制代码
def apply_bins(series, edges):
    """用已保存的切分点对任意数据分箱"""
    return pd.cut(series, bins=edges, include_lowest=True)

1.2 使用

​ 训练时调 fit_bins 拿到切分点,之后训练集、测试集、线上数据全部走 apply_bins

python 复制代码
# 训练阶段:拟合
edges = fit_bins(train['月收入'], n_bins=5)

# 训练集分箱
train['月收入_bin'] = apply_bins(train['月收入'], edges)

# 测试集 / 线上推断:复用同一套 edges
test['月收入_bin'] = apply_bins(test['月收入'], edges)

​ 这个模式的核心是 qcut 只调用一次,后续全部用 cutedges 是一个 numpy 数组,可以序列化保存(pickle / JSON / 配置文件),和模型权重一起管理。

fit_bins 对应 sklearn 的 fitapply_bins 对应 transform。分箱本质上也是一个有状态的转换器,状态就是那组 edges。

二、边界值兜底

2.1 问题:极端值变 NaN

​ 训练集的值域是有限的。线上数据完全可能出现训练时没见过的极端值,例如收入特别高的客户、负债率为零的新用户。如果这些值落在 edges 范围之外,pd.cut 会返回 NaN,下游特征全部失效。

2.2 解法:首尾替换为无穷

​ 只需要在 fit_bins 里加两行,把 qcut 算出的第一个和最后一个切分点替换成 ±inf,让区间向两端无限延伸:

python 复制代码
def fit_bins(series, n_bins=5):
    _, edges = pd.qcut(series, q=n_bins, retbins=True, duplicates='drop')
    edges[0]  = -np.inf
    edges[-1] =  np.inf
    return edges

​ 这样不管来什么值,都一定能落进某个箱里。

python 复制代码
edges = fit_bins(train['月收入'], n_bins=5)
bash 复制代码
[          -inf  1776.89448627  4075.8209369   7400.24411354
 12748.68735451            inf]

​ 对于月收入,我们设置的分箱数为5, 得到了6个边界点。

​ 验证一下效果,故意传入一个远低于训练集最小值的 500 和一个远高于最大值的 200000:

python 复制代码
test_series = pd.Series([500, 200000])
test_bins_res = apply_bins(test_series, edges)
test_bins_df = pd.DataFrame({"data": test_series, "bins": test_bins_res})
print(f"test bins = {test_bins_df}")
复制代码
test bins =      data              bins
0     500  (-inf, 1776.894]
1  200000  (12748.687, inf]

​ 可以看到极小值进第一个箱,极大值进最后一个箱。不会再有 NaN出现。

2.3 注意事项

​ 有一个细节值得注意:替换 ±inf 后,第一个箱和最后一个箱变成了兜底箱,它们的样本量在线上可能和训练时不一样。如果业务上对极端值的处理有特殊要求(比如超高收入客户需要单独审批流程),应该在分箱之前就做截断或特殊标记,而不是依赖兜底箱。

三、重复边界的处理策略

​ 基础篇中提到过 duplicates='drop' 能绕过重复切分点的报错。但"绕过"不等于"解决",箱数会减少,等频的约束被打破。实际遇到这个问题时,有几种处理方式。

3.1 先看分布再决定

​ 遇到 duplicates 报错时,第一反应不是加 duplicates='drop',而是先看看数据长什么样:

python 复制代码
s = pd.Series([0, 0, 0, 0, 0, 1, 2, 3, 5, 10])
sorted_res = s.value_counts().sort_index()
print(sorted_res)
bash 复制代码
0     5
1     1
2     1
3     1
5     1
10    1

​ 10 个值里 5 个是 0,等频分 5 箱需要找 20%、40%、60%、80% 四个分位数作为切分点,其中 20% 分位和 40% 分位都落在 0 上,产生重复的切分点,qcut 直接报 ValueError。看清分布之后,再选下面某种策略。

3.2 策略一:drop + 接受更少的箱

​ 最省事的方式,就是让 pandas 自动合并重复的切分点,实际箱数会少于你请求的数量:

python 复制代码
_, edges = pd.qcut(s, q=5, retbins=True, duplicates='drop')
# 实际只得到 2~3 个箱

​ 适合探索阶段快速看个大概,不适合直接用于建模。

3.3 策略二:把高频值单独成箱

​ 思路是把导致重复的那个值拎出来给它一个专属箱,剩下的值再正常分箱。

  • 单独拎出来0所在的信息,剩下的再分箱

    python 复制代码
    mask_zero = (s == 0)
    _, edges_rest = pd.qcut(s[~mask_zero], q=3, retbins=True)
    print("====剩下的边界====")
    print(edges_rest)
    bash 复制代码
    ====剩下的边界====
    [ 1.          2.33333333  4.33333333 10.        ]
  • 手工拼接:在 edges_rest 前面插入 -inf0,让 0 独占 (-inf, 0] 这个箱。

    python 复制代码
    edges = np.concatenate([[-np.inf, 0], edges_rest[1:]])
    edges[-1] = np.inf
    print("=====拼接边界=======")
    print(edges)
    python 复制代码
    =====拼接边界=======
    [      -inf 0.         2.33333333 4.33333333        inf]

​ 注意最终箱数是 1(0 的专属箱)+ 3(剩余值的等频箱)= 4 个,不是 q=3 指定的 3 个。q 控制的只是"去掉高频值之后"那部分数据的分箱数,加上手工拆出来的专属箱,总箱数会多出来。

​ 风控场景常见,比如"逾期次数 = 0"本身就是一个强特征,值得单独成箱。

3.4 策略三:换用等距或自定义分箱

​ 如果等频分箱在这个特征上表现不好,换用 pd.cut 或业务自定义切分点可能更合适。不必执着于一种分箱方式。

四、自定义分箱点

​ 等频和等距都是数据驱动的自动方案。实际业务中,分箱点经常来自领域知识

4.1 按业务含义切分

​ 年龄是最典型的例子,每个年龄段在风控中的含义不同,切分点来自业务经验而非数据分布:

python 复制代码
age_bins = [0, 18, 25, 35, 45, 55, 65, np.inf]
age_labels = ['未成年', '18-25', '26-35', '36-45', '46-55', '56-65', '65+']

df['年龄段'] = pd.cut(df['年龄'], bins=age_bins, labels=age_labels,
                      right=False, include_lowest=True)

​ 负债率也类似,风控经验上 20%、40% 等是常见的风险分水岭:

python 复制代码
debt_bins = [-np.inf, 0.2, 0.4, 0.6, 0.8, np.inf]
df['负债率段'] = pd.cut(df['负债率'], bins=debt_bins, include_lowest=True)

​ 自定义分箱的好处是可解释性强,业务人员能直接理解"负债率 40%~60%"是什么意思,不需要解释"第 3 分位到第 4 分位"。缺点是需要领域经验,切分点选不好会导致某些箱样本量太少。

4.2 折中:自动探索 + 手工微调

​ 一种折中做法是:先用 qcut 自动分箱看分布,再根据业务含义手工微调切分点。

五、分箱结果的验证

​ 分箱完成后,需要验证结果的合理性,避免把有问题的分箱带入下游建模。以下三项检查覆盖了最常见的问题。

5.1 各箱样本量

​ 最基本的检查,看每个箱里有多少样本:

python 复制代码
df['月收入_bin'].value_counts().sort_index()
复制代码
(-inf, 3200.0]       812
(3200.0, 5800.0]     798
(5800.0, 9500.0]     803
(9500.0, 16000.0]    791
(16000.0, inf]       796

​ 等频分箱的各箱应该大致均匀。如果某个箱样本量特别少(比如不到总量的 5 % 5\% 5%),考虑合并相邻箱。

5.2 是否存在空箱

​ 训练集和测试集的分布可能有差异,某个箱在测试集里可能一个样本都没有:

python 复制代码
# 检查测试集是否有箱为空
test_counts = test['月收入_bin'].value_counts()
if (test_counts == 0).any():
    print("警告:测试集存在空箱")

​ 测试集某个箱为空不一定是问题(可能只是样本量小),但如果训练集某个箱为空就需要处理了,空箱算不出 WOE,会在下游引发错误。

5.3 可视化验证

​ 数字看完看图。最有信息量的图是柱线混合图,柱子表示各箱样本量,折线表示坏样本率:

python 复制代码
import matplotlib.pyplot as plt

bin_stats = (
    train.groupby('月收入_bin', observed=True)['label']
    .agg(['mean', 'count'])
    .rename(columns={'mean': 'bad_rate', 'count': 'n'})
)

groupby 按分箱聚合,mean 就是坏样本率(因为 label 是 0/1),count 是样本量。

bash 复制代码
月收入_bin    bad_rate    n                          
(-inf, 1776.894]        0.06875  800
(1776.894, 4075.821]    0.05250  800
(4075.821, 7400.244]    0.05375  800
(7400.244, 12748.687]   0.03250  800
(12748.687, inf]        0.01125  800

​ 画图用双 Y 轴:左轴画柱子表示样本量,右轴画折线表示坏样本率。两个指标量纲差异大,共享一个 Y 轴会导致其中一个被压扁看不清:

python 复制代码
fig, ax1 = plt.subplots(figsize=(10, 5))
ax1.bar(range(len(bin_stats)), bin_stats['n'], alpha=0.3, label='样本量')

ax2 = ax1.twinx()  # 创建共享 X 轴的第二个 Y 轴
ax2.plot(range(len(bin_stats)), bin_stats['bad_rate'], 'ro-', label='坏样本率')

​ X 轴的刻度默认是 0, 1, 2... 这样的整数索引,需要替换成分箱区间标签。标签较长,旋转 45° 避免重叠:

python 复制代码
ax1.set_xticks(range(len(bin_stats)))
ax1.set_xticklabels(bin_stats.index.astype(str), rotation=45, ha='right')
plt.tight_layout()
plt.show()

​ 重点看折线的走势:理想情况下,坏样本率应该在各箱之间呈单调趋势(随收入增加而递减,或随逾期次数增加而递增)。如果出现非单调的拐点,通常意味着需要合并相邻箱或重新调整切分点,这就是WOE 单调性约束。另外需要说明的是,非单调拐点是一个信号,不是一个错误。它提示你这两个相邻箱的风险差异可能不够显著,合并后模型会更稳定。

六、多特征批量分箱

​ 实际项目中特征不止一个。把前面的 fit_bins / apply_bins 包一层循环,对所有特征统一处理:

python 复制代码
def fit_all_bins(df, features, n_bins=5):
    """对多个特征批量拟合分箱"""
    return {feat: fit_bins(df[feat], n_bins) for feat in features}

def apply_all_bins(df, features, bin_edges):
    """对多个特征批量应用分箱"""
    result = df.copy()
    for feat in features:
        result[f'{feat}_bin'] = apply_bins(df[feat], bin_edges[feat])
    return result

​ 用法很直观,训练时 fit 一次,之后对训练集和测试集各 apply 一次:

python 复制代码
features = ['月收入', '负债率', '逾期次数', '账龄', '年龄']

bin_edges = fit_all_bins(train, features)
train_binned = apply_all_bins(train, features, bin_edges)
test_binned  = apply_all_bins(test, features, bin_edges)

bin_edges 是一个字典,键为特征名,值为 edges 数组。整个字典可以一次性序列化保存,部署时加载复用。

七、edges 的持久化

bin_edges 需要和模型一起保存,否则线上没法分箱。

7.1 方案一:pickle

​ 最简单的方式,但 numpy 版本敏感:

python 复制代码
import pickle

with open('bin_edges.pkl', 'wb') as f:
    pickle.dump(bin_edges, f)

7.2 方案二:JSON

​ 跨语言通用(Java/Go 线上服务也能读),但有一个麻烦:JSON 规范只支持有限数值,±infNaN 都无法直接表示。Python 的 json.dumps(float('inf')) 会直接抛 ValueError

​ 这里需要约定一个映射规则。常见做法是把 ±inf 映射成 None(Python)→ null(JSON)。注意这三者的对应关系:

Python JSON 含义
None null 空值
float('inf') 无对应,需自行约定 正无穷
float('nan') 无对应,需自行约定 非数值

​ 我们选择 null 来表示 ±inf,是因为 edges 数组中只有首尾两个位置可能是无穷,反序列化时根据位置(首位→-inf,末位→+inf)就能无歧义地还原。

(1) 序列化实现
python 复制代码
import json

def edges_to_json(bin_edges):
    result = {}
    for feat, edges in bin_edges.items():
        result[feat] = [
            None if np.isinf(e) else float(e)
            for e in edges
        ]
    return result

​ 序列化后的 JSON 大概长这样:{"月收入": [null, 3200.0, 5800.0, 9500.0, 16000.0, null]}

(2) 反序列化实现

​ 反序列化时把首尾的 null 还原成 ±inf:

python 复制代码
def edges_from_json(data):
    result = {}
    for feat, edges in data.items():
        arr = []
        for i, e in enumerate(edges):
            if e is None:
                arr.append(-np.inf if i == 0 else np.inf)
            else:
                arr.append(e)
        result[feat] = np.array(arr)
    return result

7.3 如何选择

​ 选哪个取决于部署环境。如果线上服务也是 Python(比如用 Flask/FastAPI 包一层),pickle 最省心;如果线上是 Java 微服务,JSON 或配置中心更合适。

八、总结

​ 这篇讲的是分箱的"工程骨架":

  • fit / apply 分离qcut 只在训练集上调一次,后续全用 cut + 保存的 edges
  • ±inf 兜底:替换首尾边界,确保任何值都有箱可落
  • 重复边界:先看分布,再选策略(drop / 单独成箱 / 换方式)
  • 自定义分箱 :业务知识优先,qcut 做初始探索
  • 验证三件事:样本量、空箱、坏样本率单调性
  • 持久化:edges 和模型一起保存、一起部署
相关推荐
七夜zippoe2 个月前
特征工程完整流程:从数据清洗到自动化特征生成的艺术
运维·自动化·特征工程·特征选择·featuretools
deephub2 个月前
机器学习特征工程:分类变量的数值化处理方法
python·机器学习·特征工程·分类变量
deephub2 个月前
使用 tsfresh 和 AutoML 进行时间序列特征工程
人工智能·python·机器学习·特征工程·时间序列
DreamLife☼3 个月前
Node-RED:制造业:预测性维护系统——用 Node-RED 让设备“开口说话”
特征工程·node-red预测性维护·振动传感器分析·lstm异常检测·维修工单自动化·设备健康指数·降低非计划停机
deephub3 个月前
机器学习时间特征处理:循环编码(Cyclical Encoding)与其在预测模型中的应用
人工智能·python·机器学习·特征工程·时间序列
其美杰布-富贵-李3 个月前
数据清理与特征工程完整指南
笔记·深度学习·特征工程·训练·数据清理
饕餮怪程序猿4 个月前
寻找数据中的“真相”:零基础入门关键特征筛选方法
人工智能·特征工程
青云交5 个月前
Java 大视界 -- Java 大数据机器学习模型在电商用户流失预测与留存策略制定中的应用
随机森林·机器学习·特征工程·java 大数据·spark mllib·电商用户流失·留存策略
数据与后端架构提升之路5 个月前
在自动驾驶数据闭环中的特征工程应用(上)
机器学习·自动驾驶·特征工程