终于把机器学习中的特征工程搞懂了!!

317 阅读10分钟

最近看到群里有人讨论:“股市真的能预测吗?我看好多人都说预测股票是伪命题。”

我听完这句话,脑子里第一个反应是:不是不能预测,是你喂给模型的东西就不对。

你想啊,模型又不是什么算命先生,它不可能通灵。它能预测,是因为你让它“看到”了一些有规律的东西。换句话说:
不是模型预测能力有问题,而是你让它“看”的那些特征,本身就没信息量。

有些人直接拿开高低收、换手率、成交额这些通用特征就塞进模型,生怕数据不够多。但问题是,这些变量在某些场景下几乎不带“信号”,只带“噪音”。

说得狠一点,你塞进去的数据,模型看了半天,最后得出一个结论:“啊对对对,我也不知道这股票该涨还是跌。”

所以今天,我们不谈模型,不谈策略,我们就聊一件事:怎么把“有用的信息”变成“模型能理解的特征”。

也就是——特征工程,干就完了。

模型不是你爹,自己得喂饭

你看那些做高频的,对吧,搞 tick 数据,甚至 Level-2、盘口异动,搞得花里胡哨,其实本质上就一个目标:提炼有用信号。

但咱们大多数人用的是日线、周线、财报数据,这种情况下,如果你不把特征琢磨透,模型十有八九学到的是噪音。

我自己踩过最深的坑之一就是:“信息量和特征个数没半毛钱关系。”

曾经我一口气提了300多个因子,兴高采烈地跑模型。回测一看,全绿。那种绿,像刚割完的草坪一样整齐。模型根本没学到任何信号,因为大部分特征就是在数据表里显眼但没卵用的“看客”

特征该怎么构造?花姐这回整点“真家伙”

刚刚讲的那点基础的特征处理只是热身。下面这几种处理方式,是我平时自己实战中高频使用的武器

你不一定全用,但你一定得知道它们存在。

一、把时间想清楚,再决定“你该看哪里”

这句话听着有点悬,我解释一下。

我们很多时候在用财报数据,比如净利润营业收入。这些变量是滞后的,季报年报披露出来的时间和报告期不一致,你拿错了时间,预测就变成偷看答案之后的事后诸葛

比如你训练集用的是2023年3月31号的股票数据,你不能用2022年四季度财报——因为那时候财报可能还没发。

正确姿势? 做一个“数据可得时间表”,比如每个财务指标都附加一个发布时间,用这个字段做 mask。

你可以这么处理:

df["is_available"] = df["date"] >= df["fa_time"]

然后再根据这个is_available来判断是不是该用这个数据。 这一点看着细,但一不注意就是“未来函数”。


二、跨时间的统计值,才是“趋势”的本体

我以前总以为静态的值比如“ROE=15%”就够了,直到后来我开始做跨期变化量,发现模型精度直接上了一个台阶。

比如我们不直接用毛利率,而是看它同比变化:

df["毛利率变化"] = df["毛利率"] - df["毛利率"].shift(4)

你甚至可以继续拓展,做出“斜率”这种特征:

import numpy as np
from sklearn.linear_model import LinearRegression

def get_slope(series):
    X = np.arange(len(series)).reshape(-1, 1)
    y = series.values.reshape(-1, 1)
    return LinearRegression().fit(X, y).coef_[0][0]

df["pe_斜率"] = df["pe"].rolling(5).apply(get_slope, raw=False)

这不是乱搞,是让模型知道这个公司估值在“爬坡”还是“下坡”


三、市场行为类特征 > 财务特征

我个人的体感是,大多数短期价格的变化,其实和市场行为有关,而不是基本面。

举个例子,某只股票刚刚被北向资金爆买一周,是不是意味着它短期内会有热度?

于是我构造了这么一个特征:

df["北向买入5日占比"] = df["北向买入金额"].rolling(5).sum() / df["总成交额"].rolling(5).sum()

然后再做个分位:

df["北向热度"] = df["北向买入5日占比"].rank(pct=True)

你猜猜效果?模型明显喜欢这种能反映“情绪面”的变量。


四、用“结构型变量”重新组织信息

比如我们经常看财报,其实很多指标之间是有结构关系的。 营业收入 - 营业成本 = 毛利 毛利率的提升,其实很多时候跟成本控制更相关,而不是卖得多。

那我有一次就突发奇想:我想知道一家公司“靠什么在赚钱”。

于是我造了个变量:

df["cost_ratio"] = df["营业成本"] / df["营业收入"]

再和行业平均对比:

df["cost_alpha"] = df["cost_ratio"] - df.groupby("行业")["cost_ratio"].transform("mean")

这个cost_alpha就变成了一个结构残差变量。它告诉我这个公司是不是“成本结构优于同行”。

这种特征,很多时候一塞进去,模型立马就有反应了。


五、用模型自己造特征?可以,但别太信

很多人觉得特征工程太麻烦,直接上PCA、AutoEncoder一顿压缩搞定。

我也玩过,比如:

from sklearn.decomposition import PCA
pca = PCA(n_components=5)
df_pca = pca.fit_transform(df[numeric_cols])

结果呢?看着高大上,解释性全无。而且压缩后丢了很多非线性关系,模型反而跑不动了。

所以我一般不太推荐“纯自动”的特征构造。除非你真的已经知道你构造的是“冗余维度”或者“强共线”。


聊完特征该怎么构造,接下来我们讲讲 常用的特征工程方法

一、编码分类变量

我们经常遇到这种数据:

股票代码行业板块
000001银行金融
600519白酒消费

你直接把“银行”“白酒”塞进模型,模型一脸懵逼。它又不是 GPT,不认汉字,只认数字。 所以你得把它“翻译”一下。

有两种主流方式:

1. Label Encoding(整数编码)

import pandas as pd

data = {
    "股票代码": ["000001", "600519", "000858", "600036", "601318"],
    "行业": ["银行", "白酒", "白酒", "银行", "保险"],
    "板块": ["金融", "消费", "消费", "金融", "金融"]
}

df = pd.DataFrame(data)
df["hangye_code"] = df["行业"].astype("category").cat.codes
df["板块_code"] = df["板块"].astype("category").cat.codes
print(df)
     股票代码  行业  板块  hangye_code  板块_code
0  000001  银行  金融            2        1
1  600519  白酒  消费            1        0
2  000858  白酒  消费            1        0
3  600036  银行  金融            2        1
4  601318  保险  金融            0        1

效果是:“银行”=2,“白酒”=1…… 优点:简单 缺点:模型会误以为这些行业有大小、有顺序,容易出事。线性模型尤其不友好。

2. One-Hot Encoding(独热编码)

df = pd.get_dummies(df, columns=["行业", "板块"])
   股票代码  行业_保险  行业_白酒  行业_银行  板块_消费  板块_金融
0  000001  False  False   True  False   True
1  600519  False   True  False   True  False
2  000858  False   True  False   True  False
3  600036  False  False   True  False   True
4  601318   True  False  False  False   True

变成多个0/1列:你是就1,不是就0。

模型最爱这个。 但弊端是:特征维度爆炸,尤其是那种“有上百种风格板块”的列。

实际操作中,我会结合使用,比如:

  • 频率高的行业做 One-Hot
  • 低频类合并成“其他”再编码
  • Tree 模型直接用 Label Encoding 也能扛得住(它能“看穿”这些数值)

二、 缺失值处理

股市数据不全是干净的,有的指标压根没填,有的公司数据就那样烂掉了。

如果你直接用:

model.fit(df)

它就崩了,报错一堆。

所以我们要给模型打个补丁,让它知道“这里我不知道,但我不是瞎填的”。

几种实用方式:

1. 直接填常数(0 或 -1)

适合那种 “缺就表示没发生”的数据 比如:股东增持金额,如果没填,通常代表“没人增持”。

df["zengchi"] = df["zengchi"].fillna(0)

2. 均值 / 中位数填充

df["pb"] = df["pb"].fillna(df["pb"].mean())

看似合理,但小心被异常值影响。

3. 加一列“缺失标志”,让模型自己判断缺失有无信号

df["is_null_pb"] = df["pb"].isnull().astype(int)

4. 删除大法

删除含有缺失值的样本或特征 比如我们在做均线指标之后,前面的数据会是空值,我们会采用df.dropna()删掉空值。

注意:很多时候,“数据缺失” 本身就是一种 “信号”


三、特征分箱

分箱的好处,是让模型“按段学习”。 就像你教小孩数学不会直接上微积分,而是先分阶段讲:个位数、十位数……

举个例子,波动率vol,太低没人玩,太高可能超跌——那你就这样切:

df["vol_bin"] = pd.qcut(df["vol"], 5, labels=False)

也可以用pd.cut按固定值切。

我还喜欢搞自定义分箱,比如市值这种,一眼就能看出“超大盘”“中盘”“小盘”。

bins = [0, 1e9, 1e10, 5e10, 1e11, np.inf]
labels = [1, 2, 3, 4, 5]
df["size_level"] = pd.cut(df["市值"], bins=bins, labels=labels)

实战小贴士: 分箱之后加个交互特征(下面讲),威力翻倍。


四、 特征缩放

你有没有见过这种表:

股票市值(亿)换手率
A87630.03
B2360.17

市值这个量级太夸张了,直接影响模型权重。

所以我们要“标准化”或“归一化”:

from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
df["市值_std"] = scaler.fit_transform(df[["市值"]])

或者:

df["换手_norm"] = (df["换手率"] - df["换手率"].min()) / (df["换手率"].max() - df["换手率"].min())

注意:Tree 模型(比如 XGBoost)一般不需要缩放,但神经网络、线性模型非常需要!


五、创建交互特征

这是花姐我最爱的操作之一。 很多人只想到“构造新特征”,但不懂“变量之间的关系才最有信息量”。

比如:

df["pe_roe_ratio"] = df["pe"] / (df["roe"] + 1e-6)

又比如:

df["is_value_growth"] = ((df["pe"] < 15) & (df["roe"] > 12)).astype(int)

这种布尔交互变量非常有意思——它“浓缩”了人类对选股的直觉,然后硬塞进模型里

再高阶点,还可以搞交叉项(特征乘积):

df["vol_price"] = df["vol"] * df["收盘价"]

意思是:“最近股价涨得猛的+波动大的,我特别关注”

实战策略里,这种变量一旦好用,会极度好用

说点真心话

如果你现在还觉得模型是重点,那你可能还没入门。
但如果你开始觉得“我是不是该多造点有意思的变量”,恭喜你,已经开始靠近真正的量化核心了。
别问怎么找到“好特征”——
多看,多做,多感受,多造。
我是花姐,一个比你早走几年弯路的量化码农。如果你觉得这篇文章有点料,记得点个“关注”或者“在看”给我打个信号。我就知道该继续写下去了。