3、分桶列:tf.feature_column.bucketized_column原理、边界值设定、连续特征离散化实战

好,咱们今天聊聊分桶列。说实话,在结构化数据处理里,这玩意儿是我用得最频繁的技巧之一。你想想看,现实世界里的数据,比如年龄、收入、温度,都是连续值。但很多模型,尤其是线性模型,处理连续值其实挺吃力的——它默认你和特征之间是直线关系,可现实哪有那么多直线?

分桶列,说白了就是把连续值切成几段,每段变成一个独立的类别。比如年龄0-18算一档,18-35算一档,35-60算一档,60以上算一档。这样模型就不用去拟合那条弯弯曲曲的曲线了,直接学每个桶的权重就行。我在项目中遇到过好几次,连续特征直接扔进模型效果很差,一分桶立马见效。

3.1 分桶列的原理

分桶列的原理其实不复杂。它本质上是一个两步走的过程:

  1. 先做边界判断:给定一个连续值,看它落在哪个区间里
  2. 再做one-hot编码:把区间位置转成0/1向量

举个例子。假设我们设定边界值为 [18, 35, 60],那就会产生4个桶:

  • 桶0:(-∞, 18)
  • 桶1:[18, 35)
  • 桶2:[35, 60)
  • 桶3:[60, +∞)

一个25岁的人,落在桶1里,编码就是 [0, 1, 0, 0]。一个70岁的人,落在桶3里,编码就是 [0, 0, 0, 1]

核心要点:分桶列的本质是用分段线性近似替代全局非线性。每个桶内部假设是常数响应,桶之间可以有不同的权重。这比让模型自己去学一个全局曲线要稳定得多。

为什么会这样?我个人的理解是:真实世界的数据分布往往有局部聚集性。比如收入,低收入人群和高收入人群的行为模式差异很大,但在同一个收入段内,大家的行为相对一致。分桶正好抓住了这个特点。

3.2 边界值怎么设定?

边界值设定,嗯,这是分桶列里最讲究的地方。设得不好,效果可能还不如直接用原始值。我总结了几种常用方法:

方法一:等距分桶

最简单粗暴的方式。把值域均匀分成N段。比如年龄0-100,分5桶就是每20岁一档。

import tensorflow as tf

# 等距分桶:0-100岁,每20岁一档
boundaries = [20, 40, 60, 80]
age_column = tf.feature_column.numeric_column('age')
age_bucketized = tf.feature_column.bucketized_column(
    source_column=age_column,
    boundaries=boundaries
)

这种方法适合数据分布比较均匀的情况。但我在项目中遇到过一个问题:如果数据集中在某个区间,等距分桶会导致某些桶里样本极少,模型根本学不到东西。

方法二:等频分桶

按数据的分位数来切。保证每个桶里的样本数量大致相等。这是我最推荐的方法。

import numpy as np

# 假设我们有年龄数据
ages = np.random.randint(0, 100, size=10000)
# 计算四分位数作为边界
boundaries = np.percentile(ages, [25, 50, 75]).tolist()
print(f"等频边界: {boundaries}")

age_bucketized = tf.feature_column.bucketized_column(
    source_column=tf.feature_column.numeric_column('age'),
    boundaries=boundaries
)

我的习惯:拿到一个新数据集,我通常会先画个直方图看看分布。然后按分位数切5-10个桶。如果某个桶里样本太少(比如不到总样本的5%),我会考虑合并相邻桶。

方法三:业务驱动分桶

有些时候,业务知识比统计方法更管用。比如:

  • 年龄:0-18(未成年)、18-35(青年)、35-60(中年)、60+(老年)
  • 收入:0-3000(低收入)、3000-8000(中等)、8000-20000(高收入)、20000+(超高收入)
  • 温度:0以下(寒冷)、0-15(凉爽)、15-30(舒适)、30+(炎热)

这种分法模型往往更容易理解,也更容易解释给业务方听。我曾经在一个金融风控项目里,就是用业务驱动的分桶方式,把收入分成了5档,模型AUC直接提升了3个点。

3.3 连续特征离散化实战

好,理论说完了,咱们直接上代码。下面是一个完整的实战案例,从数据准备到模型训练一条龙。

3.3.1 准备数据

import tensorflow as tf
import pandas as pd
import numpy as np

# 模拟数据:年龄和收入
np.random.seed(42)
n_samples = 1000
ages = np.random.randint(18, 70, size=n_samples)
incomes = np.random.normal(8000, 3000, size=n_samples)
labels = (ages * 0.3 + incomes * 0.001 + np.random.normal(0, 5, n_samples) > 50).astype(int)

df = pd.DataFrame({
    'age': ages,
    'income': incomes,
    'label': labels
})

print(df.head())

3.3.2 定义特征列

# 定义原始连续列
age_column = tf.feature_column.numeric_column('age')
income_column = tf.feature_column.numeric_column('income')

# 分桶列:年龄按业务分,收入按分位数分
age_boundaries = [18, 25, 35, 45, 55, 65]
age_bucketized = tf.feature_column.bucketized_column(
    source_column=age_column,
    boundaries=age_boundaries
)

# 收入按分位数分
income_percentiles = np.percentile(incomes, [20, 40, 60, 80]).tolist()
income_bucketized = tf.feature_column.bucketized_column(
    source_column=income_column,
    boundaries=income_percentiles
)

# 组合特征列
feature_columns = [age_bucketized, income_bucketized]

3.3.3 构建输入函数和模型

# 输入函数
def input_fn(df, batch_size=32, shuffle=True, num_epochs=1):
    dataset = tf.data.Dataset.from_tensor_slices((
        {'age': df['age'].values, 'income': df['income'].values},
        df['label'].values
    ))
    if shuffle:
        dataset = dataset.shuffle(buffer_size=len(df))
    dataset = dataset.batch(batch_size).repeat(num_epochs)
    return dataset

# 构建线性分类器
model = tf.estimator.LinearClassifier(
    feature_columns=feature_columns,
    model_dir='./bucketized_model'
)

# 训练
model.train(
    input_fn=lambda: input_fn(df, batch_size=32, shuffle=True, num_epochs=10),
    steps=500
)

# 评估
eval_result = model.evaluate(
    input_fn=lambda: input_fn(df, batch_size=32, shuffle=False, num_epochs=1)
)
print(f"准确率: {eval_result['accuracy']:.4f}")

避坑指南:我曾经犯过一个错误——在训练集上算分位数做边界,然后直接用到测试集上。结果测试集里出现了训练集没见过的极端值,导致分桶出错。正确的做法是:在训练集上算好边界,保存下来,训练和测试都用同一组边界

3.3.4 查看分桶效果

# 查看模型学到的权重
weights = model.get_variable_names()
for name in weights:
    if 'linear' in name and 'weight' in name:
        print(f"{name}: {model.get_variable_value(name)}")

# 输出每个桶的权重
# 年龄桶:0-18, 18-25, 25-35, 35-45, 45-55, 55-65, 65+
# 收入桶:按分位数分5个桶

你看,每个桶都有一个独立的权重。模型可以学到:年龄在35-45这个桶的人,购买意愿最高;收入在最高档的人,反而对价格不敏感。这些信息,如果用原始连续值,模型是很难捕捉到的。

3.4 分桶列的最佳实践

最后,我总结几条实战经验:

场景 推荐做法 原因
数据分布均匀 等距分桶 简单直观,解释性强
数据分布偏斜 等频分桶 避免桶内样本过少
有业务先验知识 业务驱动分桶 可解释性最好,业务方买账
不确定怎么分 先等频分10桶,再合并 灵活调整,效果可控

我的建议:分桶数量一般控制在5-10个。太少会丢失信息,太多又容易过拟合。你可以用交叉验证来调这个参数。另外,分桶列和原始连续列可以同时使用,让模型自己决定哪个更有用——这叫"特征交叉",后面我们会讲到。

好了,分桶列的内容就这些。说白了,它就是帮模型把连续世界切成几块,每块单独处理。下次你遇到连续特征效果不好,不妨试试分桶——说不定有惊喜。