我第一次训练机器学习模型的时候,信心爆棚。
花了三天选因子。量价因子、基本面因子、技术面因子,整整选了12个。
市盈率、市净率、换手率、日成交量、20日波动率、RSI、MACD柱......
每个都有理论支撑。每个跑单因子IC的时候都显著。
我把它们一股脑扔进模型。线性回归。
跑完回测一看------
夏普0.3。
亏了。
我以为是模型不行。换了XGBoost。
夏普0.2。
又亏了。
我以为是因子不行。一个一个剔除,换新的。折腾了一周。
夏普还是0.3。
导师路过,瞟了一眼我的代码。
"你归一化了没有?"
"归什么?"
"特征缩放。你把市盈率0到80的数值,和成交量几亿到几十亿的数值,直接一起喂给模型了?"
我点头。
"那模型当然学不到东西。"
他拉过来键盘,加了四行代码。
// python
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
四行。
重新跑。
夏普------1.2。
我盯着屏幕,嘴巴张了五分钟没合上。
四行代码。从0.3到1.2。
我浪费了一整周,问题出在------
特征没有做归一化。

WHY:为什么模型这么"以貌取数"?
一个生活中的比喻
想象你要给两个同学做综合排名。
同学A:语文考了85分(满分100)。
同学B:数学考了9分(满分10)。
如果你直接比原始分数------
85 vs 9。
85远大于9。同学A碾压同学B。
但这公平吗?
不公平。
同学B的9分是满分,换算成百分制是90分,比同学A的85分还高。
问题出在------
两个分数的"尺度"不一样。
一个是百分制。一个是十分制。
直接比大小,毫无意义。
模型也是这个逻辑。
模型不认识你的数据。它不知道"市盈率30"代表高估值,也不知道"日成交量5亿"代表放量。
它只认识数字。
当你的特征A范围是0到80,特征B范围是1亿到50亿------
模型在计算梯度的时候,特征B的数值比特征A大了九个数量级。
梯度被特征B完全主导。
特征A的信息------不管它多有预测力------被模型直接忽略了。
这就好比------
你跟一个体重200斤的人比体重。
你跟一个身高1米9的人比身高。
然后你说:我们来算"综合身体素质"。
体重200斤直接碾压一切。身高1米9也碾压。
但你真正关心的------心肺功能、反应速度、柔韧性------
全被忽略了。
因为它们和体重、身高不在一个量级。
模型也一样。
不做特征缩放,就是在让大数值的特征欺负小數值的特征。
所以------特征缩放,本质上是在给模型建一个"公平考场"。
让每个特征都在同一个尺度上竞争。
模型才能公平地评估每一个因子的预测力。
WHAT:三种主流的"公平考场"
特征缩放,主流三种方法。
每种方法解决不同的问题。
一个一个来。
第一种:Min-Max归一化------把数据"压"到0到1之间
最直观。
把每个特征的最小值映射到0,最大值映射到1,其他值按比例排在中间。
就像考试成绩归一化------
全班最低分映射成0。最高分映射成1。其他同学按比例排。
公式也简单:
Xscaled = (X - X min) / (Xmax - X min)
// python
def min_max_normalize(values):
"""Min-Max归一化:将数据缩放到0, 1区间"""
min_val = values.min()
max_val = values.max()
return (values - min_val) / (max_val - min_val)
示例
pe_ratio = pd.Series(5, 10, 20, 35, 80) # 市盈率:5到80
volume = pd.Series(1e8, 5e8, 2e9, 1e10) # 成交量:1亿到100亿
pe_norm = min_max_normalize(pe_ratio)
vol_norm = min_max_normalize(volume)
print("市盈率归一化后:", pe_norm.values)
print("成交量归一化后:", vol_norm.values)
两组数据现在都在0到1之间,模型公平对待
输出:
市盈率归一化后: 0. 0.067 0.2 0.4 1.
成交量归一化后: 0. 0.04 0.19 1.
两组数据现在都在0到1之间。模型可以公平对待了。
但------有一个大坑。
Min-Max对异常值极其敏感。
假设你的成交量里有一个值:500亿。
X_max 直接变成500亿。
其他正常的1亿到10亿------全部被压缩到0附近。
0.002、0.004、0.02......
挤在一起。模型完全分不清差异。
一个异常值,毁了所有正常数据的区分度。
所以Min-Max适合------数据分布稳定、没有极端异常值的场景。
比如:技术指标(RSI、MACD),它们的值域本身就有天然边界。
第二种:Z-score标准化------用"标准差"做尺子
Min-Max的问题是太在意极值。
Z-score换了个思路------不看你在全局的绝对位置,看你离平均值有几个标准差。
打个比方:
全班身高,平均值170cm,标准差5cm。
你身高180cm------比平均值高2个标准差------标准化后你的值是+2。
你身高165cm------比平均值低1个标准差------标准化后你的值是-1。
你身高170cm------正好是平均值------标准化后你的值是0。
这样,不管原始数据的尺度是厘米还是毫米,标准化之后都是"离平均值几个标准差"。
公式:
Xscaled = (X - X mean) / X_std
// python
def z_score_standardize(values):
"""Z-score标准化:均值为0,标准差为1"""
mean_val = values.mean()
std_val = values.std()
return (values - mean_val) / std_val
示例
pe_std = z_score_standardize(pe_ratio)
vol_std = z_score_standardize(volume)
print("市盈率标准化后:", pe_std.values.round(3))
print("成交量标准化后:", vol_std.values.round(3))
输出:
市盈率标准化后: -1.092 -0.79 -0.189 0.711 1.369
成交量标准化后: -0.816 -0.665 -0.254 1.735
均值变成0,标准差变成1。
和Min-Max比------Z-score的好处是:不依赖极值 。
一个异常值会让均值和标准差稍微偏移一点,但不会像Min-Max那样把正常数据全部压到0附近。
适用场景:
大多数机器学习模型------神经网络、SVM、KNN、逻辑回归------Z-score是默认选择。
如果不确定用哪个------先用Z-score。
第三种:RobustScaler------异常值?直接无视
金融数据有个特点------异常值特别多。
市场崩盘那天,成交量是平时的十倍。
黑天鹅事件,波动率飙到天上去。
财务造假公司的市盈率是负数或者几百倍。
Z-score虽然比Min-Max稳健,但均值和标准差还是会被异常值拉偏。
怎么办?
用中位数代替均值。用四分位距(IQR)代替标准差。
中位数不受极端值影响。10个人排队,不管首富多有钱,中位数永远是第5个人的收入。
四分位距也不受极端值影响------它只看中间50%的数据范围。
// python
from sklearn.preprocessing import RobustScaler
scaler = RobustScaler()
假设有极端异常值
volume_with_outlier = pd.Series(1e8, 2e8, 3e8, 5e8, 500e8) # 最后一个500亿是异常值
X = volume_with_outlier.values.reshape(-1, 1)
vol_robust = scaler.fit_transform(X)
print("原始值(亿):", volume_with_outlier.values / 1e8)
print("RobustScaler后:", vol_robust.flatten().round(3))
500亿的异常值被压到合理范围,正常数据之间的区分度保留得很好。
三种方法怎么选?
|--------------|------------|----------|-------------------|
| 方法 | 核心逻辑 | 抗异常值 | 适用场景 |
| Min-Max | 压到0,1 | 差 | 值域有天然边界(RSI、MACD) |
| Z-score | 均值为0,标准差为1 | 中等 | 大多数ML模型(默认选择) |
| RobustScaler | 中位数+IQR | 强 | 有明显异常值的金融数据 |
量化场景的经验法则:
价格类特征(开盘价、收盘价)→ Z-score
成交量类特征 → RobustScaler(量经常有极端值)
技术指标(RSI、MACD)→ Min-Max(值域本身有界)
基本面特征(市盈率、市净率)→ RobustScaler(有极端异常值)
HOW:完整的归一化流水线
实战中不是选一种方法就够了。
你有12个特征。每个特征尺度不同。每个特征的数据分布不同。
你需要的是一个完整的流水线------
不同特征用不同的缩放方法。
// python
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler, RobustScaler, MinMaxScaler
模拟因子数据
np.random.seed(42)
n = 200
df = pd.DataFrame({
'pe_ratio': np.random.uniform(5, 80, n), # 市盈率:有界
'volume': np.random.lognormal(20, 1.5, n), # 成交量:对数分布,有极端值
'rsi': np.random.uniform(0, 100, n), # RSI:天然0-100
'volatility': np.random.exponential(0.02, n), # 波动率:右偏分布
})
定义每个特征对应的缩放方法
scalers = {
'pe_ratio': RobustScaler(), # 基本面,有异常值
'volume': RobustScaler(), # 成交量,有极端值
'rsi': MinMaxScaler(), # RSI,天然有界
'volatility': StandardScaler(), # 波动率,右偏但用Z-score够用
}
逐列缩放
df_scaled = pd.DataFrame()
for col, scaler in scalers.items():
df_scaledcol = scaler.fit_transform(df\[col])
print("缩放前各特征范围:")
print(df.describe().loc\['min','max'].round(2))
print("\n缩放后各特征范围:")
print(df_scaled.describe().loc\['min','max'].round(2))
这就是一个完整的归一化流水线。
每个特征用最适合自己的缩放方法。
缩放之后------所有特征都在合理的尺度上。模型可以公平地学习每一个因子。
几个坑,必须踩了才知道
坑一:未来数据泄露
这是最常见的错误,也是最致命的错误。
// python
❌ 错误写法
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # 用全部数据(包括测试集)做归一化
X_train, X_test = train_test_split(X_scaled)
✅ 正确写法
scaler = StandardScaler()
X_train, X_test = train_test_split(X) # 先分割
X_train_scaled = scaler.fit_transform(X_train) # 只用训练集fit
X_test_scaled = scaler.transform(X_test) # 测试集只用transform
为什么?
因为fit的时候会计算均值和标准差。
如果你用全部数据fit------测试集的信息就泄露到了训练集。
模型在训练时"偷看"了答案。
回测可能很好。实盘一定很烂。
坑二:树模型不需要归一化
XGBoost、LightGBM、RandomForest------基于树的模型------完全不需要特征缩放。
因为树模型是按分位数切分的。它不关心数值的绝对大小,只关心大小排序。
"成交量大于中位数"------不管中位数是1亿还是10亿,树模型都能正确切分。
所以:
用神经网络/SVM/KNN → 必须归一化
用XGBoost/LightGBM/随机森林 → 不需要归一化
我当年用XGBoost也做了归一化------虽然没坏处,但也没好处。白白浪费了时间。
坑三:交叉验证时的数据泄露
如果你做交叉验证------不要在全部数据上做归一化再分折。
应该在每一折内,只对训练部分fit,然后transform验证部分。
用Pipeline可以自动处理:
// python
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_score
pipe = Pipeline( ('scaler', StandardScaler()), ('model', LinearRegression()) )
scores = cross_val_score(pipe, X, y, cv=5)
Pipeline会自动保证每一折的归一化只用训练数据。不会泄露。
坑四:实盘推理时要用训练集的scaler
训练时fit了一个scaler。实盘推理时------必须用同一个scaler。
不能重新fit。
如果你实盘时用当天的数据重新fit------同一个特征值,在不同日期会得到不同的缩放结果。
模型的输入就不稳定了。
今天预测准、明天预测不准------可能不是策略变了,而是你的scaler变了。
// python
import joblib
训练时保存scaler
joblib.dump(scaler, 'feature_scaler.pkl')
实盘时加载同一个scaler
scaler = joblib.load('feature_scaler.pkl')
X_live_scaled = scaler.transform(X_live) # transform,不是fit_transform
回到那个故事
导师加了四行代码。
夏普从0.3变成了1.2。
不是因为策略不好。
是因为特征的尺度差异太大,模型根本学不到东西。
那个下午,我学到了一条量化新手最容易忽略、也最容易犯的错------
不是你的模型不行。是你的数据没处理好。
很多人花大量时间调模型参数、选因子、换架构。
但忽略了最基础的一步------把数据缩放到合理的尺度。
这就像盖房子------
地基不平。你买再贵的砖、再好的水泥,房子也是歪的。
归一化,就是把地基找平。
平了之后,你才能在上面试各种结构、各种材料。
下一节------
特征选好了,也归一化了。但12个特征真的都需要吗?
有些特征可能是冗余的。有些可能根本没信息量。
下一篇,我们聊特征选择------怎么从12个特征里,筛出真正有用的那几个。
OVER。