1. 从"预测股价"这个念头说起:为什么用神经网络逼近收盘均价
很多人第一次接触TensorFlow,脑子里冒出来的第一个"实战项目"就是预测股票价格。这个念头太自然了——手头有历史数据,价格是数字,神经网络擅长拟合数字,那不就正好?我当初也是这么想的,于是就有了这个"用神经网络逼近股票收盘均价"的练习。但真正动手之后才发现,这件事的坑远比想象中多,而它作为TensorFlow入门练习的价值,恰恰不在于"能不能预测准",而在于它逼着你去理解回归问题、数据预处理、网络结构设计和过拟合判断这几件最基础的事。
先把定位说清楚:这篇内容不是教你炒股,也不是承诺任何预测能力。它是一个TensorFlow基础操作的补充练习,目标是用一个真实感较强的数据集(股票日线数据),把前面学过的张量操作、变量、梯度下降、损失函数这些零散知识点串成一个完整流程。适合已经看过TensorFlow基础教程、能跑通线性回归Demo,但一到自己找数据、自己搭网络就不知道从哪下手的人。如果你连tf.Variable和tf.GradientTape都还没用过,建议先回去补那部分,否则这篇会让你更晕。
我选择"收盘均价"而不是"收盘价",这里有个小细节值得说。单日收盘价噪声极大,受消息面、情绪面影响,用神经网络去拟合单点几乎必然过拟合。而"均价"(比如5日均线、10日均线)是平滑后的结果,趋势性更强,噪声更小,网络更容易学到"趋势"而不是"随机波动"。这个选择本身就是一次特征工程的决策,也是我想通过这个练习传达的第一个经验:输入和目标的选择,比网络结构更能决定结果。
下面我会按照真实的操作顺序,从数据准备一路讲到模型评估,中间穿插我自己踩过的坑和反复调整的过程。代码基于TensorFlow 2.x,用Keras高层API搭网络,但关键部分会用底层梯度下降再实现一遍,确保你理解每一步在干什么。
2. 数据准备:从原始行情到可训练张量的完整链路
2.1 数据获取与字段选择
真实行情数据通常包含开盘、最高、最低、收盘、成交量、成交额等字段。做收盘均价逼近,我只需要两列:日期和收盘价。日期用来排序和划分训练/测试集,收盘价用来构造特征和目标。
数据来源这里不展开,任何公开的日线数据都可以。假设你已经拿到一个CSV文件,结构大致如下:
date,open,high,low,close,volume 2020-01-02,10.52,10.88,10.41,10.75,1234567 2020-01-03,10.78,10.95,10.60,10.63,987654 ...读进来之后第一件事是按日期升序排列。很多数据源默认是倒序(最新在前),如果不排序直接切分,训练集里会混入未来数据,造成"未来函数"泄漏,模型评估结果会虚高得离谱。我第一版就犯了这个错,训练损失低到0.001,还高兴了半天,后来发现是数据顺序问题。
import pandas as pd import numpy as np df = pd.read_csv('stock_daily.csv', parse_dates=['date']) df = df.sort_values('date').reset_index(drop=True) close = df['close'].values.astype(np.float32) print(close.shape, close[:5])2.2 构造"均价"目标:滑动窗口的两种写法
"收盘均价"我定义为未来N日的收盘均值,N取5。也就是说,用今天及之前的信息,去逼近未来5天的平均收盘价。这个定义决定了任务的性质:它是一个多步前瞻的回归,而不是简单的下一日预测。
构造目标有两种写法。第一种用pandas的rolling:
N = 5 target = pd.Series(close).rolling(window=N).mean().shift(-N).valuesshift(-N)把均值往前挪,使得第t个样本的目标是t+1到t+N的均值。第二种用numpy循环,更直观但慢:
target = np.full_like(close, np.nan) for i in range(len(close) - N): target[i] = close[i+1:i+1+N].mean()两种写法结果一致,但要注意末尾N个样本的目标是NaN,必须剔除。同时,如果你还想用历史价格作为特征,那特征侧也会有窗口,需要对齐。我建议统一用pandas处理,索引对齐不容易出错。
2.3 归一化:为什么不能直接喂原始价格
股票价格量纲在几十到几百之间,如果直接输入网络,梯度会非常大,学习率稍微大一点就发散。更麻烦的是,不同股票价格区间不同,同一个网络换一只股票就完全失效。所以归一化是必须的,而且要用训练集的统计量去归一化测试集,不能全量归一化。
我常用的是Min-Max归一化到[0,1]:
train_size = int(len(close) * 0.8) train_close = close[:train_size] test_close = close[train_size:] c_min, c_max = train_close.min(), train_close.max() close_norm = (close - c_min) / (c_max - c_min)这里有个容易忽略的点:目标值也要用同一套参数归一化。因为目标是从收盘价算出来的,量纲一致,用同一个c_min和c_max即可。预测完之后再反归一化回原始价格,才能和真实值比较。
注意:归一化参数必须保存下来。线上推理时如果重新算min/max,结果会完全错乱。我一般把
c_min、c_max存成json,和模型权重放一起。
2.4 特征工程:只用历史收盘价够不够
只用历史收盘价作为特征,网络能学到的信息非常有限。我试过加入以下特征,效果有明显提升:
- 历史收益率:
(close[t] - close[t-1]) / close[t-1],比原始价格更平稳 - 移动平均线:5日、10日、20日均线,作为趋势特征
- 波动率:过去N日收益率的标准差
- 成交量变化率:成交量的对数差分
但特征不是越多越好。我加了一堆技术指标之后,训练集损失降得很低,测试集反而变差,典型的过拟合。最后保留的是过去10日收盘价 + 5日均线 + 10日波动率这三类,维度控制在合理范围。
特征矩阵的构造用滑动窗口:
def make_features(series, window=10): X = [] for i in range(len(series) - window): X.append(series[i:i+window]) return np.array(X, dtype=np.float32)这样每个样本是一个长度为10的序列,配合后面的全连接网络或一维卷积都行。
3. 网络结构设计:全连接、RNN还是一维卷积
3.1 三种结构的适用场景对比
拿到序列型数据,第一反应可能是上RNN。但在这个任务里,我实际对比了三种结构,结论可能和直觉不同:
| 结构 | 参数量 | 训练速度 | 测试集表现 | 适用场景 |
|---|---|---|---|---|
| 全连接(Flatten) | 中等 | 快 | 一般 | 序列短、特征已充分 |
| 一维卷积 | 少 | 快 | 较好 | 局部模式明显 |
| LSTM | 多 | 慢 | 好但易过拟合 | 长序列、依赖复杂 |
我的数据窗口只有10,序列很短,LSTM的优势发挥不出来,反而因为参数量大,在小数据集上过拟合严重。最后我选的是一维卷积 + 全连接的组合:先用卷积提取局部趋势,再用全连接做回归。
3.2 一维卷积层的参数选择
一维卷积的核心参数是卷积核大小和卷积核数量。核大小决定感受野,核数量决定特征通道数。
对于长度为10的序列,核大小取3比较合适,能覆盖3天的局部模式。核数量从16开始试,32、64都试过,32在验证集上最好。步长用1,padding用'same'保持长度不变,方便后面接池化或直接展平。
import tensorflow as tf from tensorflow.keras import layers, models def build_model(input_len): model = models.Sequential([ layers.Input(shape=(input_len, 1)), layers.Conv1D(filters=32, kernel_size=3, padding='same', activation='relu'), layers.MaxPooling1D(pool_size=2), layers.Conv1D(filters=16, kernel_size=3, padding='same', activation='relu'), layers.GlobalAveragePooling1D(), layers.Dense(32, activation='relu'), layers.Dropout(0.2), layers.Dense(1) ]) return model这里用GlobalAveragePooling1D代替Flatten,参数量更少,抗过拟合更好。Dropout放在全连接之后,比例0.2,再大就欠拟合了。
3.3 输出层为什么不用激活函数
回归任务的输出层不加激活函数,直接线性输出。如果加了sigmoid,输出被限制在[0,1],反归一化后价格范围受限,遇到极端行情就完全预测不了。我第一版加了sigmoid,结果所有预测值都挤在中间,完全跟不上趋势。
损失函数用MSE(均方误差),优化器用Adam,学习率从1e-3开始。如果损失震荡,降到1e-4;如果下降太慢,升到3e-3。这个任务上1e-3比较稳。
3.4 用底层梯度下降再实现一遍
Keras的model.fit把训练过程封装了,但作为基础练习,我强烈建议用tf.GradientTape手写一遍训练循环,理解每一步:
model = build_model(input_len=10) optimizer = tf.keras.optimizers.Adam(1e-3) loss_fn = tf.keras.losses.MeanSquaredError() @tf.function def train_step(x_batch, y_batch): with tf.GradientTape() as tape: preds = model(x_batch, training=True) loss = loss_fn(y_batch, preds) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss手写循环的好处是你能随时打印梯度范数,判断是否梯度消失或爆炸。我实测下来,卷积层的梯度范数在1e-2到1e-1之间比较健康,如果小于1e-4,说明前面层没学到东西,需要检查初始化或学习率。
4. 训练过程中的坑:过拟合、数据泄漏与评估陷阱
4.1 训练集损失下降但验证集上升:过拟合的识别
这是最经典的信号。我第一版训练到第20个epoch时,训练损失0.0008,验证损失0.0035,差距4倍多。这时候继续训练只会让验证损失更差。
应对手段有三个,按优先级排序:
- 早停(EarlyStopping):监控验证损失,patience设5,连续5轮不下降就停
- Dropout:在全连接层加0.2到0.5的Dropout
- L2正则化:对卷积核和全连接权重加1e-4的L2惩罚
我最后三个都用上了,验证损失从0.0035降到0.0021,提升明显。
callbacks = [ tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3) ]ReduceLROnPlateau在验证损失停滞时自动降学习率,比手动调省事很多。
4.2 数据泄漏:最容易犯又最难发现的错
数据泄漏有三种常见形式,我在这个项目里全踩过:
第一种是归一化泄漏。前面说过,用全量数据的min/max归一化,测试集信息就泄漏到训练里了。正确做法是只用训练集的统计量。
第二种是窗口重叠泄漏。构造特征时,如果训练集和测试集之间有重叠的滑动窗口,测试集的部分信息会出现在训练集中。解决办法是在切分点留一个gap,gap长度等于窗口长度。
第三种是目标泄漏。我的目标是未来5日均值,如果特征里不小心包含了未来信息(比如用了shift(-1)的特征),那就是直接作弊。检查方法是:把特征矩阵和目标矩阵按时间对齐,确保第t个样本的特征只用到t及之前的数据。
我排查泄漏的方法很简单:故意把测试集打乱,如果模型表现依然很好,说明有泄漏。正常情况打乱后表现应该大幅下降。
4.3 评估指标:MSE之外还要看什么
MSE是训练用的损失,但评估时只看MSE会误导。价格量纲下,MSE=0.002听起来很小,但反归一化后可能是几块钱的误差。我建议同时看三个指标:
- MAE(平均绝对误差):直观反映平均误差多少钱
- MAPE(平均绝对百分比误差):反映相对误差,跨股票可比
- 方向准确率:预测涨跌方向正确的比例,对交易场景更有意义
def evaluate(y_true, y_pred): mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100 direction = np.mean(np.sign(np.diff(y_true)) == np.sign(np.diff(y_pred))) return mae, mape, direction我实测下来,MAE在0.5元左右,MAPE约3%,方向准确率55%左右。方向准确率只比随机猜(50%)高一点,说明这个任务的预测能力确实有限,不要抱不切实际的期望。
4.4 一个反直觉的发现:简单模型有时更好
我花了很多时间调卷积网络,后来试了一个只有两层全连接的简单模型,测试集MAE居然只差了0.05元。这说明在这个数据量和特征维度下,模型复杂度带来的收益已经很小了,瓶颈在数据本身的信息量。
这个发现让我重新思考:与其堆网络层数,不如花时间做特征工程和数据清洗。后来我加了波动率特征,MAE直接降了0.1元,比调网络结构有效得多。
5. 完整训练脚本与关键参数速查
5.1 端到端脚本结构
把前面的步骤串起来,一个完整的训练脚本包含以下部分:
# 1. 数据读取与排序 # 2. 构造目标(未来5日均值) # 3. 构造特征(过去10日收盘价 + 均线 + 波动率) # 4. 划分训练/验证/测试集(留gap) # 5. 归一化(只用训练集统计量) # 6. 构建模型 # 7. 编译与训练(带早停和降学习率) # 8. 评估与反归一化 # 9. 保存模型和归一化参数每一步的代码前面都给过,这里重点说划分比例和gap设置。我用的比例是训练70%、验证15%、测试15%。gap长度等于特征窗口(10)+ 目标窗口(5)= 15,确保三个集合之间没有信息重叠。
5.2 关键参数速查表
| 参数 | 取值 | 说明 |
|---|---|---|
| 特征窗口 | 10 | 过去10日 |
| 目标窗口 | 5 | 未来5日均值 |
| 卷积核大小 | 3 | 覆盖3日局部模式 |
| 卷积核数量 | 32/16 | 两层递减 |
| Dropout | 0.2 | 全连接层后 |
| 学习率 | 1e-3 | Adam默认 |
| Batch size | 32 | 小数据集不宜太大 |
| 最大epoch | 100 | 配合早停 |
| EarlyStopping patience | 5 | 验证损失监控 |
这些参数不是最优解,但在我测试的数据集上是稳定的起点。你可以在此基础上微调,但每次只调一个参数,否则无法判断哪个改动起了作用。
5.3 模型保存与加载的注意事项
保存模型用model.save('model.h5'),但归一化参数必须单独保存。我一般存一个json:
import json params = {'c_min': float(c_min), 'c_max': float(c_max), 'window': 10, 'target_window': 5} with open('norm_params.json', 'w') as f: json.dump(params, f)加载时先读参数,再读模型,推理时用同一套参数归一化和反归一化。漏掉这一步,线上结果会和训练时完全对不上。
6. 这个练习真正教会我的几件事
做完这个项目,我对TensorFlow基础操作的理解上了一个台阶,但更重要的是几个认知上的转变。
第一,数据质量决定上限,模型只是逼近上限的工具。我花在数据清洗、特征构造、泄漏排查上的时间,远超调网络结构的时间。一开始我不信,觉得网络够深就能学到一切,后来发现数据里没有的信息,网络再深也变不出来。
第二,评估指标要贴合业务含义。MSE降到0.001不代表模型有用,如果方向准确率只有50%,那它对交易决策毫无价值。选指标之前先问自己:这个任务真正关心什么?
第三,简单方案先跑通,再考虑复杂化。我第一版直接用LSTM,调了两天没调好。后来换成全连接,半小时跑通,有了baseline之后再逐步加卷积、加特征,每一步都有对比。这个顺序不能反。
第四,过拟合不是敌人,是信号。训练损失远低于验证损失,说明模型容量过剩,这时候加正则化或减参数就对了。如果两者都高,那是欠拟合,要加容量或加特征。区分这两种情况,比盲目调参重要得多。
最后分享一个我常用的小技巧:在训练循环里每隔几个epoch打印一次预测值和真实值的对比片段,不要只看损失数字。数字下降但预测曲线完全跟不上趋势的情况太常见了,肉眼看一下能发现很多指标看不出的问题。这个习惯帮我省了大量调参时间。