数值特征处理:标准化、归一化、离散化
开始讲数值特征之前,我先问你一个问题:你给模型喂过身高、收入、点击率这种数据吗?它们的量纲可能差几个数量级。如果不做处理,模型会把数值大的特征当成更重要的特征——其实只是单位没选对而已。
我早期做一个推荐系统项目,跑了三天发现模型完全不收敛。排查半天,用户年龄(0-100)和消费金额(几千到几十万)直接拼在一起喂给神经网络,梯度更新被金额特征带偏了。从那以后,数值特征处理成了我建模流程里雷打不动的第一步。
这一节我们讲三个最常用的数值特征处理方法:标准化、归一化和离散化。它们在底层原理和适用场景上各有不同,但目标都是让模型更稳定、更高效地学习。
标准化:把数据拉回标准正态
标准化的核心公式你肯定见过:z = (x - μ) / σ。结果变成均值为0、标准差为1的分布。它不改变数据的形状,只改变位置和尺度。
什么时候用?我个人习惯是:当后续要用线性模型、SVM、PCA或者神经网络时,标准化几乎是必选项。因为这些方法依赖距离或梯度,数值范围不同会导致某些特征支配学习过程。
代码示例:使用 scikit-learn 的 StandardScaler
from sklearn.preprocessing import StandardScaler
import numpy as np
# 模拟特征矩阵:行=样本,列=特征
X = np.array([[1.0, 200.0],
[2.0, 500.0],
[3.0, 800.0],
[4.0, 1100.0]])
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
print("均值:", scaler.mean_) # [2.5, 650.0]
print("标准差:", scaler.scale_) # [1.118, 335.41]
print("标准化后:\n", X_scaled)
跑完这段,你会发现变换后的第一列和第二列的量级完全一致了。我再提一点:fit_transform 用在训练集上,验证集和测试集只能用 transform,不能重新 fit。我见过有人把整个数据集拿去 fit,那验证集的分布信息就泄露了。
避坑指南: 我曾经在一个时序项目里对全部历史数据做了标准化,结果验证集用了未来信息的均值和方差。模型上线后表现暴跌10个点。对时序数据,正确的做法是滑动窗口滚动标准化。
归一化:缩放到固定区间
归一化的经典做法是 Min-Max 缩放:x' = (x - min) / (max - min),结果落在 [0,1] 之间。也有均值归一化,把数据搬到 [-1,1] 范围。
归一化不改变数据分布的形状,但会把异常值压扁。如果数据有极端离群点,Min-Max 会让大多数正常数据挤在很窄的区间里,模型几乎学不到区分度。
我推荐在图像像素处理、或者需要特征值有物理意义上下界时用归一化。 比如神经网络输出层用 sigmoid,输入归一化到 [0,1] 能加速收敛。
代码示例:MinMaxScaler
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler(feature_range=(0, 1))
X_norm = scaler.fit_transform(X)
print("归一化后:\n", X_norm)
# 第一列: [0, 0.333, 0.667, 1.0]
# 第二列: [0, 0.333, 0.667, 1.0]
看着结果,两种特征完美对齐了。但要注意:如果测试集出现比训练集更大的值,会被 cap 到 1 或超出范围,需要预先处理边界。
离散化(分箱):把连续变离散
离散化就是把连续数值切分成几个区间,每个区间变成一个类别。说白了,就是把“年龄=25岁”变成“年龄在20-30岁这个箱子里”。
我遇到过做信用评分模型需求,原始收入分布严重右偏,模型总学不好。后来用分箱变成“低收入、中低收入、中等收入、高收入”4个等级,AUC 直接涨了3个点。为什么?因为连续值里面的非线性关系被分箱后,模型更容易捕捉到分段效应。
| 分箱方法 | 原理 | 适用场景 |
|---|---|---|
| 等宽分箱 | 将值域等分成固定宽度的区间 | 数据分布均匀时简单有效 |
| 等频分箱 | 每个箱子包含相同数量的样本 | 偏态分布、长尾分布数据 |
| 基于聚类分箱 | 用k-means聚类后分配箱号 | 有自然簇状结构的数据 |
| 最优分箱 | 基于目标变量进行卡方或信息增益分割 | 风控评分卡等需要强可解释性的场景 |
我的经验: 分箱数目别太多,通常5~10个就够了。太多箱跟直接用连续值没区别,还增加了稀疏性。另外分箱后一定要做 one-hot 编码或 label encoding,别直接把箱号当连续变量喂给模型,除非箱号本身有顺序意义。
代码实现:自己动手做分箱
下面我用 pandas 演示等宽和等频分箱,再对比一下效果。
import pandas as pd
import numpy as np
# 生成偏态数据:收入
np.random.seed(42)
income = np.random.lognormal(mean=3, sigma=1.0, size=1000)
df = pd.DataFrame({'income': income})
# 等宽分箱:4个箱
df['equal_width_bin'] = pd.cut(df['income'], bins=4, labels=['低', '中低', '中高', '高'])
# 等频分箱:每个箱约250个样本
df['equal_freq_bin'] = pd.qcut(df['income'], q=4, labels=['Q1', 'Q2', 'Q3', 'Q4'])
print(df.head())
print("\n等宽分箱样本数:\n", df['equal_width_bin'].value_counts().sort_index())
print("\n等频分箱样本数:\n", df['equal_freq_bin'].value_counts().sort_index())
输出会显示等宽分箱下,第一个箱(低)可能只有几十个样本,最后一个箱(高)可能有几百个样本,因为数据右偏。而等频分箱每个箱严格250个左右,更均衡。具体选哪个,看你的下游任务——如果不希望某个箱样本太少导致过拟合,优先等频。
GPU 优化的小贴士
最后聊一下 GPU 上的考虑。标准化和归一化都是逐元素操作,GPU 可以并行处理,计算开销几乎可以忽略。但分箱涉及排序或聚类,可能会触发 CPU 上的预处理,尤其是等频分箱需要对全数据排序。我建议在数据加载阶段(比如 PyTorch Dataset 的 __init__ 里)提前算好分箱边界,然后在训练时只做查表映射。
GPU 友好的实现方式: 把分箱边界作为张量上传到 GPU,然后用 torch.bucketize 进行快速分箱映射。
import torch
# 假设你在 GPU 上计算
boundaries = torch.tensor([0.0, 100.0, 500.0, 2000.0, 1e10], device='cuda')
data = torch.tensor([30.0, 250.0, 1500.0, 8000.0], device='cuda')
bins = torch.bucketize(data, boundaries)
print(bins) # tensor([1, 2, 3, 4], device='cuda:0')
这样整个分箱过程都在 GPU 上完成,不需要 CPU-GPU 数据传输,速度能快一个数量级。