最近看到群里有人讨论:“股市真的能预测吗?我看好多人都说预测股票是伪命题。”
我听完这句话,脑子里第一个反应是:不是不能预测,是你喂给模型的东西就不对。
你想啊,模型又不是什么算命先生,它不可能通灵。它能预测,是因为你让它“看到”了一些有规律的东西。换句话说:
不是模型预测能力有问题,而是你让它“看”的那些特征,本身就没信息量。
有些人直接拿开高低收、换手率、成交额这些通用特征就塞进模型,生怕数据不够多。但问题是,这些变量在某些场景下几乎不带“信号”,只带“噪音”。
说得狠一点,你塞进去的数据,模型看了半天,最后得出一个结论:“啊对对对,我也不知道这股票该涨还是跌。”
所以今天,我们不谈模型,不谈策略,我们就聊一件事:怎么把“有用的信息”变成“模型能理解的特征”。
也就是——特征工程,干就完了。
模型不是你爹,自己得喂饭
你看那些做高频的,对吧,搞 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)
实战小贴士: 分箱之后加个交互特征(下面讲),威力翻倍。
四、 特征缩放
你有没有见过这种表:
| 股票 | 市值(亿) | 换手率 |
|---|---|---|
| A | 8763 | 0.03 |
| B | 236 | 0.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["收盘价"]
意思是:“最近股价涨得猛的+波动大的,我特别关注”
实战策略里,这种变量一旦好用,会极度好用。
说点真心话
如果你现在还觉得模型是重点,那你可能还没入门。
但如果你开始觉得“我是不是该多造点有意思的变量”,恭喜你,已经开始靠近真正的量化核心了。
别问怎么找到“好特征”——
多看,多做,多感受,多造。
我是花姐,一个比你早走几年弯路的量化码农。如果你觉得这篇文章有点料,记得点个“关注”或者“在看”给我打个信号。我就知道该继续写下去了。