多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

HMM-LSTM股票趋势分析:四种模型搭建与工程避坑指南

HMM-LSTM股票趋势分析:四种模型搭建与工程避坑指南 简介基于HMM-LSTM的股票市场趋势分析Python项目集成HMM、GMM-HMM、LSTM与XGB-HMM四种模型面向金融量化初学者、算法研究人员及有一定Python基础的投资者用于探索股票行情中的状态转换与趋势预测。压缩包共61个文件以Python脚本为核心28个py完整覆盖数据清洗、特征构造、模型训练、概率估计、集成预测和结果绘图等环节同时包含26个pyc编译文件、4张实验效果图、1份PDF论文以及Markdown格式的项目说明文档。包体仅2.54MB目录结构按数据处理、模型训练、预测评估等模块归档便于按需查阅和二次开发。目前已有327人学习下载具有较好的实践参考价值。通过源码与说明文档读者可系统掌握HMM对市场隐含状态的建模方式理解LSTM、XGBoost等模型如何与HMM融合用于趋势分析并可直接运行脚本复现实验、调整参数。1. 为什么用HMM-LSTM做股票趋势分析先看模型分工和一个反直觉结论做了一段时间量化选股的人多半会遇到一个尴尬场面LSTM明明在回测里很能拟合历史K线一上实盘就开始表演追高杀跌。问题不在LSTM本身而在于股票价格不是一个平稳序列牛熊切换、政策跳空、板块轮动会让窗口内的模式忽然失效。这时如果把HMM-LSTM组合当成一个“时序状态机序列学习器”来用让HMM先把行情切成可解释的状态再把状态交给LSTM做趋势判别效果往往比单独堆LSTM网络要稳得多。这套方案要落地也不复杂把四个模型按从简单到融合的顺序实现一遍就能看清每种结构各自补了什么短板。这篇笔记就按这个标题讲透四种模型怎么搭、滑窗数据和标签怎么设计、参数怎么设、坑在哪。2. 四个模型的输入与标签设计趋势分类任务怎么定义才能让HMM和LSTM都派上用场2.1 HMM在金融时间序列里到底起什么作用HMM隐马尔可夫模型的核心假设是你看到的观测序列比如对数收益率背后存在一个离散的隐状态序列每个状态的观测服从某个分布。放到股价场景里那个隐状态就是你无法直接观察到的“当前行情模式”趋势上涨、区间震荡、急跌释放。HMM本身不是用来预测价格的它做的是两件事一是对整段训练序列做状态估计二是在新数据到来时给出当前处于哪个状态的概率。仔细理解这个分工很重要。LSTM擅长对固定窗口内的非线性关系做映射但它不擅长回答“当前窗口属于哪种行情模式”这种全局问题HMM恰好相反它对整个序列做的是全局的概率推断状态切换点是一步步按转移矩阵算出来的。把两者串起来等于先让HMM把行情的历史阶段切好块再让LSTM在每个块的语境里去学局部规律这样比直接拿裸特征喂LSTM多了一层先验结构。这里有一个容易踩的坑HMM的观测不要用收盘价或开盘价本身要用收益率或对数收益率序列。原因很简单HMM的观测假设是条件独立的但价格序列本身带有强自相关和非平稳趋势直接建模会把趋势当状态划分结果非常难看。常见做法是用日对数收益率也可以把成交量的差分或换手率作为多维观测一起放进GaussianHMM。2.2 趋势分类的标签定义与切分方式要同时喂给HMM与LSTM学习得先把“趋势”定义成一个有监督分类问题。常规做法是取未来N日累计收益率作为标签来源比如未来5日累计涨跌超过某个阈值记为正类低于负阈值记为负类中间算震荡。阈值建议用历史收益率的滚动分位数来定比如过去250日里5日前瞻收益率的20%与80%分位这样标签分布不会严重失衡。标签定义还牵涉到滑窗构造方式。每个训练样本是一个长度为window的历史窗口标签是窗口结束后下一个时间点的未来N日方向。这里有个特别需要注意的细节所有特征归一化只能用训练集的统计量如果对整个数据集做全局标准化等于把未来信息泄露给了LSTM回测精度会虚高一截。另外验证集不能用随机切分必须按时间顺序切否则窗口前后会串数据。第一次做这类项目的人最容易在这两个点上翻车后面避坑章节里细说。3. 四种模型逐档搭建从单LSTM基线到HMM-LSTM融合的Python实现3.1 模型总览四种结构分别改了哪一环为了把HMM-LSTM的作用讲清楚四个模型的设计是逐层叠加的模型结构描述核心差异模型1单LSTM基线只用量价特征窗口不做HMM模型2HMM状态特征 LSTM把HMM识别出的状态ID作为额外特征拼进LSTM输入模型3HMM状态概率加权融合HMM输出各状态概率多个LSTM子模型按概率加权投票模型4HMM-LSTM堆叠集成把模型2和模型3的输出特征堆叠起来交给第二层分类器这个顺序对应一条清晰的演进路径先看纯LSTM能学到什么再加上状态信息看提升多少然后换成软化的概率融合最后用堆叠把前两者的优点合并。下面的代码按这个顺序逐个给出核心实现环境依赖是python 3.8以上、torch、hmmlearn、numpy、pandas。3.2 模型1与模型2的LSTM结构实现模型1是最朴素的LSTM分类器输入维度是(batch, window, feature_dim)输出三分类概率。模型2只在输入特征上多拼了一个HMM状态ID。这里的关键是HMM状态ID是一个离散整数不能直接当作连续特征喂进去我一般会把状态ID归一化到0到1之间或者做embedding后再拼进LSTM输入向量。import torch import torch.nn as nn class LSTMTrendClassifier(nn.Module): def __init__(self, feature_dim, hidden_dim64, num_layers2, num_classes3, dropout0.3): super().__init__() self.lstm nn.LSTM( input_sizefeature_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.classifier nn.Sequential( nn.Linear(hidden_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, num_classes) ) def forward(self, x): # x shape: (batch, window, feature_dim) out, _ self.lstm(x) # 取最后一个时间步的隐状态 last out[:, -1, :] return self.classifier(last)上面这段就是模型1的整体结构。模型2只需要在构造训练集时把HMM的状态ID拼到每个时间步的特征末尾feature_dim从原来的raw_feature_num变成raw_feature_num 1即可网络结构完全不用改方便对比加状态前后的效果差异。LSTM层数的选择上两层是收益与训练难度的平衡点一层表达能力有限三层在小样本数据上很容易过拟合。import numpy as np from hmmlearn.hmm import GaussianHMM # 用对数收益率拟合三状态HMM def fit_hmm(log_returns, n_states3): model GaussianHMM( n_componentsn_states, covariance_typediag, n_iter200, random_state42 ) model.fit(log_returns.reshape(-1, 1)) states model.predict(log_returns.reshape(-1, 1)) return model, states # 构造模型2输入原始特征并拼接HMM状态归一化值 def build_model2_windows(features, states, window60): # features: (total_timesteps, feature_dim) # states: (total_timesteps,) norm_states states.astype(np.float32) / 3.0 augmented np.concatenate([features, norm_states.reshape(-1, 1)], axis1) X, y [], [] for i in range(window, len(augmented)): X.append(augmented[i - window:i]) y.append(label_seq[i]) # label_seq为预先算好的未来N日类别 return np.array(X), np.array(y)参数说明n_states3对应涨、跌、震荡三种行情可以先用2到4个状态跑几轮对比但我个人经验是三状态最稳定状态太少吞掉细节状态太多转移矩阵稀疏covariance_typediag表示各状态内部特征独立建模full协方差在金融数据上容易过拟合n_iter200是EM迭代上限如果收敛早会提前停。注意HMM是对整段历史做无监督拟合它不需要标签所以你的标签序列只用在LSTM训练里别把HMM也放进有监督流程中。3.3 模型3HMM状态概率加权的多子模型融合模型3的思路是与其把离散状态ID硬塞给LSTM当一个普通特征不如让HMM输出“当前窗口处于每个状态的概率”然后用这个概率作为权重把按不同状态语料训练出的LSTM子模型预测结果加权。每个子模型用同一网络结构但训练时只使用该状态窗口区间内的样本。预测时先对最近一个窗口做HMM滤波得到概率向量再对各子模型的softmax输出做加权平均。class ProbWeightedEnsemble: def __init__(self, n_states, feature_dim, hidden_dim64): self.n_states n_states self.sub_models [ LSTMTrendClassifier(feature_dim) for _ in range(n_states) ] self.hmm_model None def train_one_state(self, state_idx, X_train, y_train, epochs30): # 每个子模型只训练该状态对应的样本 model self.sub_models[state_idx] # 这里省略标准PyTorch训练循环注意按batch shuffle self._run_train_loop(model, X_train, y_train, epochs) return model def predict_proba(self, X_window, hmm_state_proba): # X_window shape: (1, window, feature_dim) preds np.zeros((self.n_states, 3)) for i, model in enumerate(self.sub_models): with torch.no_grad(): preds[i] torch.softmax(model(X_window), dim1).numpy() # 以HMM状态概率为权重加权求和 return hmm_state_proba preds这里有个关键问题需要特别说明HMM状态在每次训练后编号是漂移的上一次训练编号0表示“上涨”下一次可能编号1才是“上涨”。如果你按状态ID去切样本状态语义一旦漂移模型3的子模型分工就全乱了。我一般会在每次HMM训练结束后做一次状态对齐做法是统计每个状态下观测均值的符号把均值最大的状态重映射为上涨状态最小重映射为下跌状态中间那个归为震荡。这段逻辑你可以在拿到状态序列后自己实现属于商业项目里不会写在源码注释里但必须做的工程步骤。3.4 模型4特征级堆叠集成模型4不另起网络而是把模型2的LSTM隐层特征和模型3的加权概率拼在一起作为第二层分类器的输入。第二层用逻辑回归或者随机森林都行随机森林对特征尺度不敏感且不容易过拟合。这里有三个特征来源模型2最后一个LSTM时间步的64维向量、模型3的三维加权概率、以及原始特征的最后10个时间步的扁平化切片。把这些拼成一个向量喂给sklearn的RandomForestClassifier。from sklearn.ensemble import RandomForestClassifier def build_model4_training_set(model2, ensemble3, X_windows, hmm_probas): features [] with torch.no_grad(): # 取模型2的LSTM隐层输出作为特征 lstm_feat model2.get_last_hidden(X_windows) # (n, 64) for i in range(len(X_windows)): proba3 ensemble3.predict_proba(X_windows[i:i1], hmm_probas[i]) tail_raw X_windows[i, -10:, :10].flatten() # 原始特征尾部切片 features.append(np.concatenate([lstm_feat[i], proba3, tail_raw])) return np.array(features) meta_model RandomForestClassifier( n_estimators200, max_depth8, min_samples_leaf20, random_state42 )堆叠模型的目的不是追求复杂度而是弥补模型2和模型3各自的盲区模型2把状态当成硬编码特征丢失了状态置信度模型3只用了概率加权浪费了LSTM隐层学到的时序表示。用随机森林做第二层等于让模型自己学“什么时候该信谁”。随机森林的参数里max_depth8是为了让每棵树浅一点防止第二层去记忆第一层的噪声min_samples_leaf20保证叶子节点有足够多样本支撑。这组参数是我在沪深300日线数据上调出来的起点换其他市场不必照抄但方向是对的。4. 模型训练与评估流程数据切分、特征工程和参数调优的完整落地步骤4.1 数据准备与特征计算先把K线数据读进来计算必要的基础特征。不要一上来就堆几十个指标特征多了LSTM会过度拟合噪声。我常用的特征是5日收益率、20日均线偏离度、成交量对数变化率、以及RSI(14)加一个日对数收益率给HMM用。总共五个原始特征维度后续模型2拼接HMM状态后变成六维。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(dropTrue) df[log_ret] np.log(df[close]).diff() df[ret_5d] df[close].pct_change(5) df[ma20] df[close].rolling(20).mean() df[ma20_dev] (df[close] - df[ma20]) / df[ma20] df[volume_log] np.log(df[volume] 1).diff() df[rsi_14] compute_rsi(df[close], 14) # 自行实现或引talib feature_cols [log_ret, ret_5d, ma20_dev, volume_log, rsi_14]注意log_ret和volume_log第一行会产生NaN建议直接丢弃前几行别用均值填充。HMM对NaN是零容忍的hmmlearn会直接报错。特征计算完后用dropna()统一清掉空值再开始滑窗构造。4.2 构造滑窗数据集与标签标签定义用未来5日累计收益率的滚动分位法这个设计直接影响模型上限。如果用固定阈值比如正负1%牛市里正样本会暴涨模型学成“永远看多”。滚动分位法让三类样本比例相对稳定模型必须真正学习特征模式而不是学类别的先验分布。# 未来5日收益率 df[fut_ret_5d] df[close].shift(-5) / df[close] - 1 # 用过去250日分位定义三分类标签 def make_labels(fut_ret, window250): labels np.zeros(len(fut_ret)) for i in range(window, len(fut_ret) - 5): hist fut_ret[i - window:i] lower np.percentile(hist, 30) upper np.percentile(hist, 70) if fut_ret[i] upper: labels[i] 2 elif fut_ret[i] lower: labels[i] 0 else: labels[i] 1 return labels df[label] make_labels(df[fut_ret_5d].values)分位数选30%和70%而不是20%和80%是刻意让中间震荡类少一点。原因是LSTM对三分类的决策边界本身就有偏好中间类样本太多会让模型倾向于输出中性预测实盘里这种预测没有操作价值。滑窗函数按时间顺序滑动窗口长度默认60天。标签序列要与特征序列严格对齐窗口数据取t-window1到t标签取t5那一天的label值。这也是数据泄漏最容易被忽略的地方计算标签时用了未来数据但样本内特征和标签的时间对齐不能错位。4.3 训练流程、早停与模型保存训练上的做法是训练集取前80%时间区间验证集取最后20%。早停监控验证集loss持续10个epoch不下降就停。别看整体准确率要看每类的F1和混淆矩阵类别不平衡会让准确率虚高。def train_model(model, X_train, y_train, X_val, y_val, epochs60, lr1e-3, patience10): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() best_loss float(inf) wait 0 for epoch in range(epochs): model.train() # 这里按batch_size64做mini-batch训练省略打散代码 for X_batch, y_batch in make_batches(X_train, y_train, batch_size64): optimizer.zero_grad() out model(torch.tensor(X_batch, dtypetorch.float32)) loss criterion(out, torch.tensor(y_batch, dtypetorch.long)) loss.backward() optimizer.step() model.eval() val_loss evaluate_loss(model, X_val, y_val) if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), f{model_name}.pt) wait 0 else: wait 1 if wait patience: break早停的patience10对日线数据比较合适训练集样本量通常在2000到5000条再多等容易让模型记住验证集噪声。学习率统一用1e-3Adam优化器如果验证集loss震荡厉害降到5e-4。另外每个epoch的batch shuffle是必须的但shuffle时只能打散样本顺序不能打散时间跨度也就是说窗口样本内部的时间顺序永远保持从旧到新。5. 避坑记录HMM-LSTM项目里最常见的5个翻车点与排查方法5.1 数据与特征相关的坑HMM报错、标签泄漏、特征泄漏第一条坑是HMM直接喂原始价格序列结果全乱。现象状态划分出来的三段看不出任何行情结构涨跌状态交替频繁得像个随机序列。原因价格序列不满足HMM观测的条件独立假设而且量纲极大高斯分布拟合出来方差也被带偏。解决一律改用对数收益率或标准化后的收益率作为观测HMM只负责看收益率的波动结构不要让它直接看价格水平。第二条坑是全局标准化导致未来函数泄露。现象训练过程验证集准确率高达90%换成滚动前推验证立刻掉到55%。原因特征的均值和标准差是用全量数据算的等于模型在训练时偷看到了未来区间的统计信息。解决特征标准化只fit在训练集上验证集和测试集用同一套mean和std做transform。这个细节在量化项目里是血泪级教训回测好看全是假象。第三条坑是滑窗构造时索引错位。现象模型训练loss正常但预测结果总是滞后一天。原因构造窗口时用了i-window:i作为特征但标签取的是i当天的标签而标签本身是未来5日收益率等于特征和标签之间实际相差了5天。解决建议先把所有特征和标签都对齐到同一个时间索引上再滑窗取i-window1到i标签取i5那行的值。每个样本里的窗口是完整的标签指向固定未来时点不要拿错位后数据将就训练。5.2 模型训练与状态漂移相关的坑第四条坑是HMM状态编号每次运行都在变。现象模型3第一次训练完预测准确率不错第二次重新训练结果完全不同子模型的预测语义发生翻转。原因HMM的EM算法初始化是随机的状态编号没有固定含义。解决训练完HMM后立即按每个状态观测均值的大小做语义对齐上涨、下跌、震荡三个状态分别映射固定ID映射代码要放在HMM训练函数里不能放在预测时临时处理。第五条坑是类别不平衡让模型学会躺平。现象验证准确率看着有70%但上涨类样本F1只有0.1模型几乎把所有样本都预测成震荡类。原因震荡类占比最高交叉熵损失函数优化方向就是偏向多数类。解决给CrossEntropyLoss设class_weight权重按训练集样本数量的倒数归一化另外可以在训练时对少数类做上采样复制上涨样本参与batch训练。这里优先调类别权重上采样容易让模型记住少数类噪声。第六个补充注意点LSTM对输入特征的尺度极其敏感。现象拼入HMM状态ID后模型反而变差。原因状态ID是离散整数取值范围与连续特征不匹配模型被迫花容量去学习缩放关系。解决要么把状态ID除以状态总数做线性归一化要么做一个embedding层把这个整数映射成低维向量再拼。我惯用前者简单直接效果不差。6. 用前推验证证明模型不是靠运气一个更接近实盘的评估技巧实盘与回测最本质的区别是实盘每一步决策都只能用当时已经发生的数据。我前面提到的训练集与验证集切分其实就是一种静态前推但更严谨的做法是滚动前推验证。每轮只用过去固定长度的数据训练然后预测接下来一段时间的趋势推进到下一轮时把新数据并入训练集重新训练一次。这样做能直接看出四个模型在时间维度上的稳定性。def walk_forward(df, features, labels, train_len1800, test_len250, step250): records [] start 0 while start train_len test_len len(df): train_end start train_len test_end train_end test_len X_train build_windows(features[start:train_end]) y_train labels[start window:train_end] X_test build_windows(features[train_end:test_end]) y_test labels[train_end window:test_end] # 对当前折重新fit HMM并训练四个模型 hmm fit_hmm(df[log_ret].values[start:train_end]) acc, f1, confusion evaluate_all_models(hmm, X_train, y_train, X_test, y_test) records.append((df[date].iloc[train_end], acc, f1, confusion)) start step return pd.DataFrame(records)前推验证里有一个关键参数容易被人忽略step与train_len的比例。如果step250但train_len1800意味着每轮有1550天数据是重叠的模型训练近乎重复浪费算力不说还会高估稳定性。我一般让step大于等于test_len让每轮的测试区间不重叠。读结果时别只看平均准确率要看每轮三个类别的F1标准差。如果模型1到模型3的平均准确率差不多但模型3的F1标准差明显更小说明HMM状态信息确实带来了稳定性提升而不是单纯拟合得更好。最后一件事是检查模型预测的方向性是否有连贯性。前推验证里如果某一轮HMM状态概率输出几乎全部集中在某个状态上说明那段时间行情模式比较单一LSTM子模型缺少其他状态的训练样本这轮预测结果参考价值有限。我自己的教训是不要在某一轮表现特别好时急着激动要连续观察跨五轮以上的平均表现。股票趋势分析这件事本质上是在不确定里找概率优势希望这套方案能帮你在评估模型时少走一点弯路。本文还有配套的精品资源点击获取
返回列表