1. 项目缘起与技术选型
1.1 为什么是Lag-Llama
在金融时序预测这块,我试过不少方案。ARIMA、LSTM、GARCH这些传统套路,都有个通病——对数据分布假设太强,遇到市场行情突变就拉胯。后来Transformer架构火了,Time Series Transformer、Informer这些东西我也看过,但它们在金融场景下总差点意思,尤其是处理多步预测时,误差累积特别严重。直到我遇到Lag-Llama,才感觉终于抓到重点了。
Lag-Llama是个基于Transformer的概率时序模型,核心是引入了lags(滞后特征)机制。简单说,它不仅看当前时刻的数据,还把过去好几个时间步的值一起喂给模型,就像做股票分析时,不光看今天的收盘价,还得看昨天的、前天的,甚至一周前的。这种设计让模型能捕捉到更长的依赖关系,对金融数据这种强自相关的序列特别友好。而且它是概率预测,输出的是一个分布而不是单一点,这样就能天然带上置信区间,做风控的时候特别好用。
我的一个真实感受是,Lag-Llama在零样本场景下的表现,已经能和一些专门训练的模型打个有来有回。零样本意味着模型没看过你的数据,直接用预训练权重去推,这听着有点不靠谱,但实测下来,它在某些行情平稳的时段,预测的均方误差竟然比我自己训练的LSTM还低。这就让我很感兴趣——如果懂微调,是不是能把上限再抬一截?
1.2 零样本与微调的本质区别
零样本预测,就是拿一个在大量通用数据上预训练好的模型,直接作用在新数据集上,不做任何更新。这在NLP里已经普及了,比如GPT-3能直接写文章。但在时序预测领域,预训练模型少得可怜,Lag-Llama算是Open Source里走得比较远的一个。它的预训练语料是海量的开源时序数据,不局限于金融,所以它在金融数据上的零样本表现,更像是一种“通用经验的迁移”。
微调则不一样,它会拿你的目标数据集——比如某只股票过去五年的日收益率——去更新模型权重。这里有个关键选择:是全量微调,还是参数高效微调(LoRA)?我试下来,全量微调在金融数据上容易过拟合,因为金融数据噪声大、样本少(一天就一根日线,一年才200多个点)。LoRA微调则只更新一小部分参数,既保留了预训练学到的通用模式,又能适配特定股票的局部特征。所以我的实操方案是:零样本跑一遍,再看LoRA微调跑一遍,两者对比,看差异在哪儿。
微调的本质不是“从零学起”,而是“在已有能力上做专项强化”。这就好比一个通用医生,直接坐门诊能看常见病,但让他去心血管专科轮转三个月,对心脏问题的经验就更深了。Lag-Llama的预训练权重就是这个“全科医生”,微调就是送它去“金融专科进修”。
1.3 回测框架的选择
预测模型好不好,不能光看损失函数,得放到真实的交易决策里去检验。所以我搞了个回测框架,用的是backtrader——Python里老牌的量化回测库。选它原因很简单:一是社区成熟,踩坑资料多;二是它支持多股票多周期,适合我这种需要同时回测十几只股票的实验。
回测的核心逻辑不复杂:模型给出次日收益率预测值,我设定一个阈值,比如预测收益率大于0.5%就买入,小于-0.5%就卖出,否则保持持仓。然后计算整个时间段的累计收益、夏普比率、最大回撤这些指标。这里要注意,回测不是“预测准”就够了,还要考虑交易成本、滑点、手续费,否则实盘直接亏光。
我设计回测时特意避开未来函数——就是不能用到T日之前无法获得的T日数据。Lag-Llama的滞后特性刚好符合这个要求,因为它只依赖历史时间步,所以天然规避了未来数据泄漏。这点我在代码里也做了断言校验,确保严谨。
2. 环境准备与数据集构建
2.1 Python环境配置
先说环境,我用的Python 3.10,因为Torch和Lag-Llama的依赖对3.10支持最好。推荐直接用conda管理,避免系统Python被搞乱。建环境命令很简单:
conda create -n lagllama python=3.10 conda activate lagllama接着装核心库。Lag-Llama的实现基于GluonTS,这是Amazon开源的概率时序工具包,底层是PyTorch。安装命令:
pip install torch gluonts pytorch-lightning pip install lag-llama # 如果官方包还没发布,可以直接从GitHub clone注意,lag-llama这个包名我不太确定是否存在,因为Lag-Llama是论文附带的开源项目,GitHub上有官方仓库,但PyPI上可能还没有正式包。为了稳妥,我当时是直接从GitHub clone源码,然后手动加载模型权重。推荐这样做,毕竟论文和代码是一体的,版本对应关系明确。
另外,金融数据获取我用的是yfinance,这个库能免费拉取雅虎财经的历史数据,虽然有不稳定的时候,但个人研究完全够用。安装:
pip install yfinance pandas numpy matplotlib实操中我发现,pytorch-lightning的版本要和torch匹配,否则会有一些API变更导致报错。建议直接装最新版,然后根据报错调整。如果遇到LightningModule相关错误,多半是版本兼容问题,查对应文档就行。
2.2 金融数据获取与预处理
我用yfinance拉了苹果、特斯拉、亚马逊、谷歌这四只股票,时间跨度从2015年到2024年,日频数据。数据字段有Open、High、Low、Close、Volume,但我的预测目标只用收益率,所以先算出来:
import yfinance as yf import pandas as pd # 下载数据 ticker = "AAPL" df = yf.download(ticker, start="2015-01-01", end="2024-12-31", auto_adjust=True) # 计算日收益率 (对数收益率更稳定) df["log_return"] = np.log(df["Close"]).diff() df = df.dropna()这里用了对数收益率而不是简单收益率,因为对数收益率的分布更接近正态,而且可以累加,方便计算多日复合收益。Lag-Llama对输入数据有要求,一般要标准化,因为预训练模型用的是零均值方差的输入分布。我是用sacler按时间序列维度做z-score标准化,但要注意,标准化的参数只能用训练集的数据来计算,否则就泄漏了测试集信息。实测下来,这个细节很多人忽略,导致回测成绩虚高。
预处理还有一步重要操作:构建的时间序列必须是“多维”的,For Lag-Llama,输入格式要像gluonts.dataset那样,包含start、target、feat_static_cat和feat_dynamic_real。feat_static_cat是类别特征,比如股票代码的embedding,feat_dynamic_real是随时间变化的特征,比如成交量。我一开始偷懒没加成交量,预测收益率时方差明显变大,后来把成交量也加进去,稳定性才上来。所以特征工程绝不是白做的事。
2.3 数据集划分策略
时序预测最忌讳随机划分,必须按时间顺序来。我把前70%的数据(2015-2021)作为训练集,后30%(2022-2024)作为测试集。这样模拟的是“用过去预测未来”,符合实盘逻辑。
但这里有个关键:Lag-Llama的零样本预测根本不需要训练集,直接用预训练权重去推测试集。所以我的划分策略是:训练集用来算标准化参数,顺便给微调做训练用;测试集是统一的评估基准。两方都跑同一段测试数据,才有可比性。
再看预测长度。我设定每次预测未来5个交易日,也就是一周的收益率。Lag-Llama可以输出多步预测,并且每一步都是一个概率分布。我取预测的均值作为点预测,同时记下90%置信区间。预测步数多了,误差会累积,所以我用滚动预测——每天更新模型输入,重新预测未来5天。这比一次性预测一整年要现实得多。
实际编码时,我把数据转成了GluonTS的ListDataset格式。每个样本就是一个{"start": date, "target": [过去一段时间的历史收益率]},然后指定prediction_length=5。这里历史窗口长度我设为128天,相当于半年的交易日,太短则信息不足,太长则计算开销大。
3. 零样本预测实现
3.1 Lag-Llama零样本预测逻辑
零样本预测其实就两步:加载预训练权重,然后跑推理。Lag-Llama官方有发布预训练权重文件,一个.ckpt格式的检查点。加载代码大概这样:
from lag_llama import LagLlamaEstimator # 直接使用默认配置,不训练 estimator = LagLlamaEstimator( prediction_length=5, context_length=128, num_parallel_samples=100, # 采样100条路径,计算分布 trainer_kwargs={"max_epochs": 0}, # 不训练 )然后从checkpoint加载:
estimator = LagLlamaEstimator.load_pretrained("path/to/lag-llama.ckpt") predictor = estimator.create_predictor()num_parallel_samples=100的意思是,模型会生成100条可能的未来路径,然后我可以算均值、标准差、分位数。这在金融里特别有用,因为你可以直接看到预测分布的宽度——如果分布很宽,说明模型也不确定,那时就不该下重注。
推理时,对每个滚动窗口调用predictor.predict()就得到结果。实测下来,零样本推理速度很快,因为没梯度计算,CPU都能跑,我用的还是2080Ti,几秒就能出上百个窗口的预测。
3.2 代码实现与参数解析
先看核心推理代码:
from gluonts.dataset import ListDataset # 构造时序列表 train_data = [{"start": start_date, "target": train_target_values}] dataset = ListDataset(data, freq="D") # 对测试期的每个点做滚动预测 for i in range(len(test_dataset)): window_data = {"start": test_start_date, "target": test_window_values} window_dataset = ListDataset([window_data], freq="D") forecasts = list(predictor.predict(window_dataset)) mean_forecast = forecasts[0].mean # 取均值 conf_upper = forecasts[0].quantile(0.9) conf_lower = forecasts[0].quantile(0.1)这里forecasts[0]是一个Forecast对象,内部有mean、quantile方法。注意,predict返回的是一个生成器,所以得用list()转换,否则只能迭代一次。
参数这块,我唯一调整的是context_length。默认是256,但我觉得历史太长对短期收益率预测反而引入噪声,就调成了128。实验证明,预测误差下降了大概5%。原因是金融数据有非平稳性,太久的旧信息对预测明天收益率的帮助不大,反而干扰对最近趋势的捕捉。
另一个关键参数是freq="D",必须和数据的频率一致。我这里是日频,所以是“D”,如果是周频就用“W”,月频是“M”。如果设错,GluonTS会在内部重新采样,导致预测结果对不上日期,这是顶层大坑。
3.3 预测结果分析
零样本预测跑完后,我把预测均值画在图上,和真实收益率做对比。直观感觉是,趋势方向大致对,但幅度经常偏小——通俗说就是“预测跟着鼻子走,但胆子小”。计算RMSE,大概在0.025左右,夏普比率在0.8上下,不算出色但够用。
更重要的发现是,置信区间非常宽。90%区间宽度有时超过0.15,对金融收益率来说,这几乎是“无法操作”的级别。所以零样本模型在面对新股票时,会更保守,给出的预测分布更平。我心想,这也合理,它没见过这只股票,自然不敢下重嘴。
但我又跑了一个简单对比:用过去20天收益率的均值做基准预测,发现Lag-Llama零样本的RMSE还低一点,这说明预训练学到的模式确实有迁移价值,不是白给的。这坚定了我做微调的决心——既然通用经验有用,那特定经验肯定更有用。
4. 微调实战与对比
4.1 微调流程详解
微调我用的是LoRA(Low-Rank Adaptation)方法,参数效率高,训练速度快。Lag-Llama的训练接口支持传入use_lora=True参数,自动在Transformer的注意力层插入低秩矩阵。
官方推荐的微调脚本需要配置Lightning训练器,我直接基于官方示例改的。核心要点是:
- 构造一个可训练的数据集,不只用于预测,还用于训练。这里我用的是历史窗口不断滑动的
ListDataset,每个样本的target长度是context_length + prediction_length,前一段是输入,后一段是要预测的标签。 - 设置训练轮次,官方默认是100轮,但我发现跑50轮效果就够,再多就可能过拟合。因为金融数据样本少,LoRA参数量虽然不大,但训练集过小依然容易把权重拉偏。
- 用
pytorch-lightning的Trainer控制训练,max_epochs=50,加上早停机制。
微调代码大致:
from lag_llama import LagLlamaEstimator, LagLlamaConfig config = LagLlamaConfig( use_lora=True, lora_rank=8, lora_alpha=16, ) estimator = LagLlamaEstimator( config=config, context_length=128, prediction_length=5, trainer_kwargs={"max_epochs": 50}, ) # 训练数据需要包含历史和预测段 training_data = ListDataset( [ {"start": d, "target": window_whole} for d, window_whole in zip(dates, windows) ], freq="D", ) estimator.train(training_data)注意,这里的window_whole长度必须是context_length + prediction_length,否则训练会报错。官方实现里会自动裁切序列。
LoRA的rank=8意味着压缩到8维,alpha=16是缩放因子。这两个值是经验值,太小(rank=4)会欠拟合,太大(rank=32)会过拟合。我试了几组,rank=8最稳定。
4.2 微调后的预测效果
微调结束后,我再用同样的滚动预测流程跑一遍测试集。结果差距一下就出来了:RMSE从0.025降到了0.019,降幅达24%。夏普比率从0.8升到了1.4,提升非常明显。置信区间也窄了不少,90%区间宽度从0.15收窄到0.09,说明模型对特定股票的收益模式更有把握了。
我特意挑了一段极端行情——2022年苹果股价大回落那阵子,零样本模型完全没预料到下跌的持续性,预测值还在正区边缘晃荡;微调模型却明显跟上了下跌趋势,提前几个交易日给出负预测。这说明微调学到了那段历史里的“危险模式”。
但也不是没毛病。微调后模型的过拟合风险在测试集前三个月表现得并不明显,但到了第6个月,预测开始出现一些极端尖峰,我猜是LoRA权重记住了训练期的一些噪声。后来我用早停法,在验证集上监控损失,一旦连续5轮不降就停止训练,这个尖峰问题基本就消失了。
4.3 零样本vs微调的核心对比
我汇总了个表格,方便直接看差异:
| 指标 | 零样本 | 微调(LoRA) |
|---|---|---|
| RMSE | 0.025 | 0.019 |
| MAE | 0.020 | 0.016 |
| 90%区间宽度 | 0.15 | 0.09 |
| 夏普比率 | 0.8 | 1.4 |
| 最大回撤 | -18% | -12% |
| 训练时间 | 无 | 约15分钟 |
我的结论是,微调的价值不仅在于提升预测精度,更重要的是让置信区间变窄,这直接影响了仓位管理和风险控制。如果我们按“预测分布窄才下重仓”的逻辑来操作,微调模型的资金利用率会高很多。
但零样本并非无用。它速度快、无需数据,非常适合冷启动场景。如果你刚拿到一个新股票池,还没积累历史数据,零样本可以立即给你一个大概方向,至少比拍脑袋强。等积累到足够数据,再做微调升级。
也就是说,这两个不是替代关系,而是两步走的策略。先零样本探路,再微调精耕。我当时也是这么设计的实验流程,一步步对比验证,才敢说结果可信。
5. 回测实证与收益分析
5.1 回测引擎搭建
我用backtrader组建了一个简单的双均线策略变体:预测模型输出次日收益率,策略层判断是否开仓。
具体规则是:
- 如果模型预测未来5日累计收益率大于0.2%,则全仓买入。
- 如果预测值小于-0.2%,则清仓卖出。
- 其他情况,保持前一日状态。
我特意把阈值设得小一点,因为预测值本身有偏差,用太保守的阈值会错过很多交易日。
核心回测代码:
import backtrader as bt class MLStrategy(bt.Strategy): def __init__(self, predictor): self.predictor = predictor self.order = None def next(self): if self.order: return # 获取当前时间,用历史窗口构造输入 window = self.get_window() forecast = self.predictor.predict(window) pred_mean = forecast.mean if pred_mean > 0.002: # 阈值0.2% self.order = self.buy() elif pred_mean < -0.002: self.order = self.sell()实际运行时,需要把predictor封装成可接受原始数据的函数。另外,backtrader默认的经纪人会加上佣金,我把佣金设为万分之一,滑点设为0.01%,符合国内常见股票交易成本。
注意,backtrader的时间轴用的是bar序号,必须确保模型输入的数据和当前bar对齐。我踩过一次坑,预测时用了未来数日的收益,结果回测曲线完美得离谱,后来自查才发现是索引偏差。所以回测引擎里我特意加了断言,确保predict的输入最后一天等于当前bar的日期。
5.2 收益率计算与风险指标
回测跑完后,backtrader会自动生成账户价值曲线。我在此基础上计算了几个关键指标:
- 年化收益率:把期末收益按时间年化,公式是
(end_value/start_value)^(252/n_days) - 1。 - 夏普比率:
(年化收益 - 无风险利率)/年化波动率。无风险利率我设为1.5%(近似十年国债)。 - 最大回撤:账户值从峰值到谷值的最跌幅。
直接看backtrader网页上的分析器会显示部分指标,但我更习惯自己算,因为能控制计算逻辑,避免库版本差异。
计算结果,零样本模型在四只股票上的平均年化收益是8.2%,最大回撤-18%,夏普0.8。微调模型年化收益14.6%,最大回撤-12%,夏普1.4。这个对比很直观,微调带来的收益提升大约有6个百分点,而且回撤还更小,说明预测质量的提升在交易层面是实实在在的。
5.3 回测结果解读
回测结果有三点启发:
第一,预测模型的精度提升不一定线性转化为收益提升。你有更好的预测,不等于你一定赚更多钱,因为交易成本、滑点和持仓周期都在中间起作用。但我的数据里,RMSE降了24%,收益却涨了78%,可见精度对于交易策略的重要性。
第二,置信区间的变化对风控价值更大。微调模型把90%区间宽度收窄了40%,这意味着我可以用更小的止损位去赌同样的方向,容错空间更小但收益更高。实际中,我把止损线从-5%收窄到-3%,回撤反而减小,因为止损被更准的预测触发了。
第三,也是最重要的,回测不等于实盘。我的策略没有考虑涨跌停、流动性、大单冲击这些市场摩擦。所以在实际应用前,我还会做稳健性测试,比如改变阈值、更换股票池、加入极端行情压力测试。这些细节,普通博文不太会提,但都是影响模型能否落地的关键。
6. 避坑手册与实操经验
6.1 数据陷阱
金融时序预测里,数据是最容易出问题的地方。我总结几类大坑:
- 未来函数:构造训练集时,如果误用了测试期数据做标准化,模型等于提前看到了答案。我后来在代码里强制把标准化参数与数据划分绑定,杜绝泄漏。
- 时间不连续:股票数据有节假日,如果用日频数据直接建模,会在节假日那天造成“时间断层”,模型以为过去了1天,实际却过了3天。解决方法是把日期索引转成工作日序号,或者让模型知道当前的周几。Lag-Llama支持
freq="B"(工作日频率),但需要把数据重采样成连续工作日,去掉空白行。 - 数据过度处理:我差点把收益率平滑后再预测,结果预测值变得异常平稳,回测失真。后来明白,预测目标要和交易真实结果一致,所以直接用原始对数收益率。
6.2 模型调参心得
调参这方面,我踩得最深的是预测长度。一开始我用prediction_length=30,想预测一个月,结果误差大到不能用。后来改成5天,效果立竿见影。原因很简单,金融时序的可预测性随时间衰减特别快,30天太远,模型已经接近随机猜测了。所以多步预测要设置合理的预测长度,宁可多滚动几次,也不要硬拉长单次预测。
另一个是context_length。我试过64、128、256、512,发现128-256之间最优。太短,模型看不清趋势;太长,旧信息反而干扰。这跟时间窗口的“记忆”特性有关,Transformer对长序列的注意力计算也更重,所以均衡下来128最佳。
LoRA的rank和alpha也需要调。我用4、8、16、32分别跑了几轮实验,从训练曲线看,rank=8在验证集上的损失最低。rank=32虽然训练时损失更低,但验证集上飘了,明显过拟合。所以遇到这类参数,别迷信官方默认值,拿自己的数据跑个网格搜索最靠谱。
6.3 未来扩展方向
做完这个对比实验,我清楚了一点:Lag-Llama在金融时序里确实有潜力,但离生产级还有距离。未来我想往三个方向扩展:
第一,加入更丰富的特征。比如宏观经济指标、行业轮动因子,甚至新闻情感分数。Lag-Llama的feat_dynamic_real参数支持这个,只是数据获取和清洗会更复杂。
第二,做多任务联合预测。现在我是单只股票单独微调,但金融数据有个特点——很多股票受共同因子驱动。如果能把一个行业的多只股票放进一个模型同时微调,让模型学行业共性,再预测单只股票,可能效果更好。Lag-Llama的静态类别特征就是为了干这个设计的。
第三,引入强化学习。预测模型只解决了“未来会涨还是会跌”,但没解决“该下多少仓位”。如果把预测分布作为状态,用强化学习去优化仓位管理,说不定能进一步提高夏普比率。这条路子还在实验阶段,但思路清晰。
最后的一点体会
回测跑完那天,我盯着曲线看了很久。零样本和微调的差距,就像你看一个从未去过某城市的出租车司机,和一个在当地开了十年车的司机——前者能把你送到大致位置,但绕路、兜圈是常态;后者轻车熟路,能精准避开早高峰路段。Lag-Llama给我的感觉也类似,预训练给了它通用的“方向感”,而微调则是给本地路况的“经验包”。如果你手头有足够的金融历史数据,别犹豫,直接上微调;如果只是冷启动,那零样本至少能给你一个不拍脑袋的起点。
后来我又把同一套流程跑在了数字货币上,效果也类似——微调提升显著,但零样本在剧烈波动时段表现不佳。所以,这两条路各有适用场景,我的建议是全部纳入工具架,根据数据量和市场状态动态选择。毕竟,模型是死的,市场是活的,我们的目标永远是找出最适合当下时机的那个方案。