第3章 卡方分箱原理:基于卡方检验的自适应分箱方法

卡方分箱这个名字,听起来挺唬人。但我告诉你它到底在干嘛——说白了,就是让数据自己决定哪些相邻的区间可以合并,而不是靠你拍脑袋定边界。我当年做信用评分卡时第一次接触它,当时就一个感觉:这玩意儿比手动分箱靠谱太多了。

核心思想一句话:两个相邻区间,如果它们的目标变量分布没有显著差异,就合并。有没有差异?卡方检验说了算。

3.1 卡方检验在分箱里到底怎么用

卡方检验本是用来判断两个分类变量是否独立的。放到分箱场景里,我们关心的是:相邻两个箱,它们的正负样本占比是否真的不同。

举个具体例子。假设我们正在处理年龄特征,目标是「是否逾期」。现在有两个相邻的年龄区间:20-25 岁和 26-30 岁。如果这两个区间的逾期率差不多,那把它们合成一个箱,损失的信息很少。反过来,如果逾期率差异很大,硬要合在一起反而会模糊掉特征和目标的真实关系。

卡方统计量的计算我简化一下:

χ² = Σ (观测频数 - 期望频数)² / 期望频数

观测频数就是每个箱里的实际正负样本数。期望频数是假设两个箱分布一致时,理论上应该有多少。卡方值越小,说明两个箱越「像」,越该合并。

3.2 卡方分箱的完整流程

我个人习惯把卡方分箱理解成一个自底向上的归并过程。下面这张图可以帮你把整个链路刻在脑子里:

卡方分箱流程图 输入特征与目标变量 每个值初始化为独立箱 计算相邻箱卡方值 合并卡方最小的相邻箱 达到终止 条件? 输出最优分箱方案 是 否 终止条件选项: ① 达到最大箱数 ② 卡方值全部大于阈值

流程其实就五个步骤,你按照这个节奏来就不会乱:

  1. 排序初始化——把特征值从小到大排好,每个值单独当做一个箱。
  2. 算卡方——逐一计算相邻两个箱的卡方统计量,得到一个卡方值列表。
  3. 找最小——从列表里挑出最小的那个卡方值,它对应的两个箱差异最小。
  4. 合并——把那两个箱合二为一,箱数减一。
  5. 判停——检查是否达到了你设的最大箱数,或者所有卡方值都超过了阈值。没达到就回到步骤2继续。

这个逻辑有点像聚类里的凝聚层次聚类,但合并的依据换成了卡方检验。

3.3 Python 代码实现

我带你把上面的流程翻译成可运行的代码。这里实现一个 ChiMerge 类,保持和 sklearn 风格一致,有 fit 和 transform。

import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency

class ChiMerge:
    """卡方分箱实现,自底向上合并相邻箱"""

    def __init__(self, max_bins=5, chi_threshold=0.95, min_samples=30):
        self.max_bins = max_bins
        self.chi_threshold = chi_threshold
        self.min_samples = min_samples
        self.split_points = None

    def _chi_square(self, c1, c2):
        """计算两个箱的卡方统计量"""
        table = np.array([c1, c2])
        # 如果某行或某列全为0,返回无穷大(不合并)
        if table.shape[1] < 2 or np.any(table.sum(axis=1) == 0):
            return float('inf')
        chi2, p, _, _ = chi2_contingency(table, correction=False)
        return chi2

    def fit(self, X, y):
        """训练分箱,找到最优切分点"""
        df = pd.DataFrame({'X': X, 'y': y})
        df = df.sort_values('X').reset_index(drop=True)

        # 初始化:每个值独立成箱
        bins = []
        for val in df['X'].unique():
            mask = df['X'] == val
            pos = mask & (df['y'] == 1)
            neg = mask & (df['y'] == 0)
            bins.append({
                'min': val, 'max': val,
                'pos': pos.sum(), 'neg': neg.sum(),
                'count': mask.sum()
            })

        # 边界检查:箱内样本数不能小于min_samples
        # 迭代合并
        while len(bins) > self.max_bins:
            chi_values = []
            for i in range(len(bins) - 1):
                c1 = [bins[i]['pos'], bins[i]['neg']]
                c2 = [bins[i + 1]['pos'], bins[i + 1]['neg']]
                chi2 = self._chi_square(c1, c2)
                chi_values.append(chi2)

            min_idx = np.argmin(chi_values)

            # 如果最小卡方值对应的p值大于阈值,说明差异不显著,必须合并
            # 这里用卡方值本身做判断,也可以转为p值
            chi2_min = chi_values[min_idx]
            # 计算p值
            _, p, _, _ = chi2_contingency(
                np.array([[bins[min_idx]['pos'], bins[min_idx]['neg']],
                          [bins[min_idx + 1]['pos'], bins[min_idx + 1]['neg']]]),
                correction=False
            )

            # 当p值大于(1 - chi_threshold)时合并,或箱数仍超过max_bins
            if p > (1 - self.chi_threshold) or len(bins) > self.max_bins:
                # 合并相邻箱
                bins[min_idx]['max'] = bins[min_idx + 1]['max']
                bins[min_idx]['pos'] += bins[min_idx + 1]['pos']
                bins[min_idx]['neg'] += bins[min_idx + 1]['neg']
                bins[min_idx]['count'] += bins[min_idx + 1]['count']
                bins.pop(min_idx + 1)
            else:
                # 卡方值已经全部显著,停止合并
                break

        # 提取切分点
        self.split_points = [b['min'] for b in bins] + [bins[-1]['max']]
        self.split_points = sorted(set(self.split_points))
        return self

    def transform(self, X):
        """应用分箱,返回箱编号"""
        if self.split_points is None:
            raise ValueError("请先调用 fit 方法")
        return pd.cut(X, bins=self.split_points, labels=False, include_lowest=True)

    def fit_transform(self, X, y):
        self.fit(X, y)
        return self.transform(X)
经验之谈:实际项目中,我通常不会让卡方分箱直接输出最终的箱编号。我会先用它找到切分点,然后手动检查每个箱的 bad rate 是否单调。如果不单调,适当调整切分位置,效果会更好。

3.4 关键参数与调优

卡方分箱的参数不多,但每个都很「要命」。我整理了一张表,方便你对照调参:

参数 作用 典型范围 我的建议
max_bins 控制最终箱数的上限 3 ~ 10 先设 5,跑完看单调性再调
chi_threshold 卡方检验的显著性门槛,值越大合并越激进 0.85 ~ 0.99 我个人习惯从 0.95 起步
min_samples 每个箱最少样本数,防止统计失效 20 ~ 100 看数据量,数据少就设 20

调优节奏我一般是这么走的:

  • 先用 max_bins=10、chi_threshold=0.95 跑一轮,看合并后实际得到几个箱。
  • 如果箱数过多(比如 8 个以上),我拉高 chi_threshold 到 0.98 或 0.99,让合并更积极。
  • 如果箱数太少(比如只剩 2 个),我降低 chi_threshold 到 0.90,或者提高 max_bins 的上限。
  • 最后检查每个箱的样本量——任何一个箱的样本数少于 30,卡方检验的结果就不可信了。
千万别踩的坑:卡方分箱不是箱数越少越好。我见过有人为了凑 3 个箱把阈值设到 0.999,结果两个分布明显不同的区间被硬凑在一起,模型上线后特征贡献度掉了一半。分箱要保留「有意义的差异」,而不是一味追求简洁。

3.5 实战避坑指南

做卡方分箱这几年,我踩过不少坑。挑几个典型的跟你聊聊:

第一个坑:忽视样本量不足。 我曾经在一个信贷项目中处理「居住年限」特征,数据集中在 1-5 年,后面 15-20 年的样本寥寥无几。卡方检验在样本少的箱上算出来的 p 值很不稳定,动不动就认为差异显著,导致该合并的没合并。后来我强制每个箱至少 50 个样本,才把结果稳住。

第二个坑:没检查单调性。 卡方分箱只管分布差异,不管 bad rate 是否单调。但你想想看,如果分完箱后 bad rate 忽上忽下,模型学到的就是噪音而不是趋势。我建议你在分箱完成后,手动算一下每个箱的坏样本率,画个折线图看一眼。不单调就微调切分点,或者跟业务规则结合一下。

第三个坑:跟缺失值较劲。 卡方分箱不能直接处理缺失值。我记得第一次用的时候,没注意数据里有 NaN,结果分箱结果全乱了套。现在我的习惯是:分箱前先用 -999 或单独类别把缺失值填好,等分箱完毕后,再单独决定缺失值算哪个箱。

一句话总结:卡方分箱是个好工具,但它只管「统计差异」,不管「业务合理」。把它的输出当作候选方案,而不是最终答案,再加一道人工校验的关卡,你的特征工程会稳得多。

嗯,如果你正打算把卡方分箱用到自己的项目里,建议你先拿一个特征跑通流程,看看切分点是否符合直觉。代码有了,参数表也有了,剩下的就是动手试。