Qlib量化框架实战:模型训练、回测与结果分析全解
2026/9/23 15:46:27 网站建设 项目流程

Qlib这套框架我从去年开始断断续续用了快一年,前两篇笔记把Data Layer和Operator Layer聊得差不多了,这篇把剩下的主要组件一次性说清楚。如果你正准备用Qlib搭一套自己的量化研究流程,或者已经把数据准备那步跑通、现在卡在“模型怎么接、回测怎么跑、结果怎么分析”这个环节,那这篇文章应该能帮你把整条链路的最后几块拼图补上。

Part Ⅲ的定位很明确:讲透Model、Workflow、Backtest、Analysis这几个核心组件各自负责什么,它们之间怎么协作,以及我在实际使用中踩过、后来摸清楚的几个坑。官方文档里很多概念散落在不同页面,对新手不够友好,这篇笔记换个讲法,按一个实验从数据到结果的生命周期来走。

1. 组件地图:先搞清楚谁在谁后面

1.1 我习惯的分层方式

Qlib官方文档把MAIN COMPONENTS分成了Data Layer、Operator Layer、Model Layer、Workflow Layer、Backtest Layer和Analysis Layer。但对我来说,文档里的分层更适合已经熟悉框架的人回顾,刚开始上手时按“一个实验跑起来之后发生了什么”来理解会更快。

阶段核心组件主要任务
数据接入DataServer、DataHandler把原始行情/财务数据按统一格式接入,划分日历和标的池
特征工程Operator、ExpressionEngine在数据上生成因子,比如Alpha158、Alpha360
模型构建Model、Trainer定义模型结构,完成训练和预测
实验编排Task、Workflow把数据集、模型、记录器串成一条可复现的流水线
模拟交易Executor、Strategy、Portfolio根据预测信号生成订单,模拟成交并更新仓位
结果分析Analysis评估预测质量、回测收益、风险指标

前两篇笔记已经覆盖了表格前两行,这篇从模型构建开始往后走。你会发现后面这些组件几乎都是围绕一个叫Workflow的东西转的,而Workflow本身并不高深,它其实就是一套约定好的配置结构和调度逻辑。

1.2 为什么Part Ⅲ这样切分

我见过不少新手卡在一个地方:用qrun跑官方example能出结果,但一换模型、一改策略就抓瞎。原因在于Qlib把很多步骤都用缺省配置帮你做了,你不清楚每一步背后调了谁的类、传了什么参数。

所以Part Ⅲ在内容上专门做了两个取舍:一是把模型和训练过程挑出来单独讲,因为自定义模型是大多数人迈向实战的第一步;二是把回测和分析放一起讲,因为它们的很多概念是贯通的,比如预测信号怎么变成订单、订单又如何影响最终收益归因。

这样切还有个现实原因:Qlib的Data Layer和Operator Layer相对稳定,一旦你理解了字段、日历、因子表达式,后面很少改动;而Model、Workflow、Backtest这三个环节是你做实验时反复修改的地方,值得花更多篇幅去拆解。

2. Model组件:一套接口适配所有算法

2.1 Qlib里的“模型”到底是个什么东西

在Qlib中,一个模型并不是某个具体的算法,而是对外暴露统一接口的类。它必须至少实现两个方法:fit用于训练,predict用于预测。你用什么算法都行,LightGBM、MLP、GRU、Transformer,甚至是你自己写的回归模型,只要把这两个方法按约定实现,就能接入Qlib的整套工作流。

这个设计很关键。它意味着你的实验对比脚本可以写得很干净:数据集不变,只改yaml配置里的model.class,其他代码一行不用动,就能比较不同模型的IC、Rank IC、回测收益。如果Qlib把每个模型都做成独立的调用方式,想拍平对比就得写一堆胶水代码,实验效率会低很多。

我最早用Qlib时犯过一个错,以为只有内置那几种模型能用。后来翻了源码才发现,官方在qlib/contrib/model下面提供了线性回归、LightGBM、MLP、LSTM、GRU、Transformer、再加一部分强化学习算法的baseline,基本覆盖了主流路子。但更实用的能力是,你可以把自己的模型塞进去。

2.2 同构训练接口:从LightGBM到GRU为什么能无缝切换

要理解这个“无缝切换”,看一个简化版的自定义模型模板就明白了:

from qlib.model.base import Model from qlib.data.dataset import DatasetH class ToyModel(Model): def __init__(self, learning_rate=0.01): self.learning_rate = learning_rate def fit(self, dataset: DatasetH, num_workers=0, **kwargs): # 拿到训练集的特征和标签 train_df = dataset.prepare( "train", col_set=["feature", "label"], data_key=DataHandler.DK_I ) feature = train_df["feature"] label = train_df["label"] # 在这里做你的训练逻辑 ... def predict(self, dataset: DatasetH, segment="test", **kwargs): # 拿到某个时间段的特征 test_df = dataset.prepare(segment, col_set="feature", data_key=DataHandler.DK_I) # 在这里生成预测 pred = pd.Series(...) return pred

dataset.prepare解决了数据供给问题,fitpredict解决了训练和推理问题。Qlib内部在做实验调度时,只认这两个方法名,不会管你内部是树模型还是神经网络。

这就是为什么在yaml里把model.classLGBModel换成GRUModel,然后把对应参数改一下,整个workflow就能跑通。得益于这种接口同构,Qlib把“模型选型”这种本该很重的工程问题,压缩成了一个配置问题。

2.3 模型注册与自定义模型:照着BaseModel写就行

除了直接在代码里实例化模型,Qlib更推荐的方式是把模型注册到框架的模型注册表里,这样yaml配置才能按名字引用。注册方式非常简单,就是用框架自带的装饰器给模型类打标。

from qlib.model.base import Model from qlib.utils import code_to_obj @code_to_obj class ToyModel(Model): ...

注册之后,你在workflow配置里这样写:

model: class: ToyModel module_path: my_models.toy_model kwargs: learning_rate: 0.001

Qlib的小型工具init_instance_by_config会按照module_path定位模块、按class找类名、再把kwargs作为参数实例化。这整套机制学起来有点绕,但实际用起来很顺手——它让实验配置可读性大大提高,也方便了参数网格搜索。

这里提个忠告:自定义模型时,第一版不要追求花哨。先用最简单的线性回归或均值预测跑通接口,确认fit和predict的数据形状、索引都对,再往上叠加复杂度。很多人一上来就接Transformer,结果数据对齐错了都没发现,后面全是白费。

3. 工作流组件:Task、Trainer、Record如何串起一次实验

3.1 qrun与一个典型的workflow配置

Qlib里跑实验最常用的入口是qrun命令,它接收一个yaml配置文件,配置文件描述了一个完整的Task。Task是Qlib对“一次实验”的形式化定义,它把数据集、模型、训练参数、记录器打包在一起,让实验具备可复现性。

我一般在examples/benchmarks/LightGBM这类官方目录的基础上改配置,下面是一个去掉次要字段的精简版:

experiment_name: lgbm_alpha158 model: class: LGBModel module_path: qlib.contrib.model.gbdt kwargs: loss: mse learning_rate: 0.05 num_leaves: 64 early_stopping_rounds: 50 dataset: class: DatasetH module_path: qlib.data.dataset kwargs: handler: class: Alpha158 module_path: qlib.contrib.data.handler kwargs: start_time: 2008-01-01 end_time: 2020-12-31 fit_start_time: 2008-01-01 fit_end_time: 2014-12-31 segments: train: - 2008-01-01 - 2014-12-31 valid: - 2015-01-01 - 2016-12-31 test: - 2017-01-01 - 2020-12-31 task: fit: true predict: true record: - class: SignalRecord module_path: qlib.workflow.record_temp kwargs: model_name: lgbm_alpha158 - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: true ana_ic: true - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: strategy: class: TopkDropoutStrategy module_path: qlib.contrib.strategy kwargs: topk: 50 n_drop: 5

乍一看字段很多,但拆开其实就四块:model定义算法和超参,dataset定义特征处理和切分,task定义生命周期动作,record定义训练后要记录哪些产物。Qlib拿到这个配置后,会按顺序执行:初始化模型、初始化数据集,然后进入训练器和记录器的流程。

3.2 Dataset的处理链与缓存机制

很多人把Dataset简单理解成“特征数据”,其实它更像一条处理链。Qlib里的Dataset由Handler负责生成原始特征,比如Alpha158会把基础行情数据扩展成158维因子,然后Dataset再按segments切出train、valid、test三段时间区间。

初次运行时,Dataset的处理结果会被持久化到缓存文件里。这样第二次跑同样配置就不用重新计算因子了,能省下大量时间。但也正是这个缓存,坑了不少人。你修改了因子表达式或者换了Handler参数,如果不清理缓存,跑出来的结果还是旧的。

我习惯在改完特征配置后,先删掉对应数据集目录下的缓存文件再跑实验。如果用的是notebook环境,重启kernel也是个保险操作。等到确认特征逻辑稳定了,再依赖缓存提速也不迟。

Dataset这块的另一个经验是:segments的时间段要和Handler的fit窗口配合好。常见错误是Handler用全区间数据做标准化,然后你拿前一段数据做训练,这会造成标签泄漏。Qlib为此设计了fit_start_timefit_end_time,专门控制因子处理器的拟合区间,正是用来避免这种问题的。

3.3 Trainer训练器:训练、预测、记录各司其职

Trainer是Workflow里很容易被忽略的组件。它本身不实现算法,但负责把“训练动作”和“记录动作”编排起来。fit阶段,Trainer调用模型的fit方法,并传入Dataset;predict阶段,Trainer调用模型的predict方法,得到预测信号;在predict之后,它还会触发SignalRecord、SigAnaRecord这些记录器去保存结果。

之所以多个Record类,是因为一个实验通常需要多个产物:预测信号要存下来,IC分析要单独算,回测结果还要根据组合策略再跑一遍。Qlib把记录器设计成列表,允许你同时挂多个,跑完一个Task后,所有结果都会按实验名归档到mlruns目录下。

如果你是自己用脚本写实验循环,要注意Trainer默认会把每次实验存成独立运行记录。同一个配置跑多次,会自动生成新的run id,不会互相覆盖。刚开始可能觉得多此一举,但当你做参数对比时就会感谢这个设计,因为它保证了每个实验的结果都有迹可循。

4. 回测组件:从预测信号到真实交易模拟

4.1 回测里的几个关键角色

预测信号本身不能直接说明策略盈亏,必须放进一个模拟交易环境里跑一圈。Qlib的回测组件不像有些框架那样把“买什么、卖什么”写死在代码里,而是拆成几个互相配合的角色。

  • Strategy:负责根据预测信号和当前持仓,决定要交易哪些标的、按什么权重调仓。比如TopkDropoutStrategy会一直持有得分最高的Topk只股票,每隔N天调仓,把掉出前Topk的剔掉,把新进入的加进来。
  • Executor:负责把策略生成的order执行掉。它会模拟成交,考虑滑点、交易成本、涨跌停约束。
  • AccountPosition:负责记录现金、持仓、交易流水,相当于账本。
  • RiskManager:负责组合风控,比如控制单一股票的持仓上限。

我在实际跑实验时,最常用的是TopkDropoutStrategy加默认的SimulatorExecutor。它简单、直观、回测结果容易解释,适合做baseline。后续如果要贴近实盘,可以自己写Strategy,把过滤停牌、处理涨跌停的逻辑加进去。

下面是一段常见的回测配置片段,可以看到策略和执行器都是可以任意替换的:

portfolio: class: SignalPortfolio module_path: qlib.workflow.portfolio kwargs: strategy: class: TopkDropoutStrategy module_path: qlib.contrib.strategy kwargs: topk: 20 n_drop: 5 executor: class: SimulatorExecutor module_path: qlib.backtest.executor kwargs: time_per_step: day

time_per_step: day表示按日级调仓。Qlib回测支持day、week、month等不同频率,选哪一种要跟你的策略逻辑匹配。如果你做的是中低频价量因子,日频够了;如果信号本身就来自日线,硬去做小时级回测反而会引入大量不真实的噪声。

4.2 回测中常见的坑:停牌、涨跌停和成本估计

回测看起来简单,但真实细节非常多。最容易翻车的是标的池里混入了停牌股或一字板股票。默认执行器会基于行情快照模拟成交,但如果你没对标的池做可交易性过滤,回测结果会很乐观,因为你认为能买到的价格实际上排队买不到。

我吃过一次不小的亏:某次回测年化收益率多出十几个点,最后排查发现是一批停牌复牌后一字涨停的股票被我按一字板价格成交了。此后我在回测前都会先过滤掉当天不可交易的标的,或者至少在解读结果时标注“未考虑涨跌停成交限制”这个假设。

另一个容易被忽略的问题是交易成本。Qlib默认支持在Executor里配置交易成本,如果你用的是官方示例配置,成本参数往往很保守。做策略对比时,成本参数最好固定,否则你很难区分收益差异是策略带来的还是成本假设不同带来的。

回测还有个细节是换手率。组合调仓卖出旧票买入新票会产生成本,换手率越高,成本拖累越明显。所以分析回测结果时,我会同时看两个东西:总收益和换手率。一个策略收益高但换手率奇高,实盘跑起来很可能被成本吃光收益。

5. 分析组件:用IC和收益曲线判断模型好坏

5.1 结果分析器都有哪些花样

训练完模型、跑完回测,Qlib会把预测信号、组合权重、账户净值都存下来。接下来就要靠分析组件把这些原始产物翻译成人类能看懂的指标。

其中SignalRecord保存的预测值,对应两类最基础的衡量指标:IC和Rank IC。IC是预测值与未来收益的相关系数,Rank IC则是用排名计算的相关系数。IC越高,说明预测因子的线性预测能力越强。Rank IC不受异常值影响,更稳健。正常有效的日频价量因子,Rank IC大概在0.03到0.06之间,超过0.1已经算很优秀了。

SigAnaRecord会额外输出ICIR(IC的均值除以标准差),这个指标衡量因子预测能力的稳定性。两个因子IC均值相同,ICIR更高的那个更可信,因为它的预测效果更稳定。

PortAnaRecord则输出回测层面的指标:累计收益、年化收益、最大回撤、夏普比率、换手率等。这些指标在回测报告里都有,但其中最大回撤最值得关注。很多模型在测试集上平均表现不错,但遇到极端行情回撤会异常大,说明模型的风控能力不足。

5.2 从回测报告反推模型选型

我一般在一次实验结束后,会先看三个维度:IC和Rank IC判断预测质量,累计收益和回撤判断策略表现,换手率判断可交易性。如果三者里有明显短板,再决定是调模型还是调策略。

如果IC不错、但回测收益差,问题很可能出在策略层。比如调仓频率不合适、Topk设置太大、交易成本假设不合理。如果IC和收益都不行,问题往往在特征层或模型层,优先检查因子逻辑是否正确、训练数据是否泄漏。

Qlib的分析组件还支持做多组实验的横向对比。我在做模型选型时,会在同一份数据集上分别跑LightGBM、GRU、Transformer,然后用同一个TopkDropout策略回测,最后把所有指标拉到一张表里看。这个流程很机械,但正是因为Workflow把实验步骤标准化了,才能这样批量对比。

指标用处主观参考
IC判断预测与未来收益的相关性绝对值长期看是否稳定大于0.02
Rank IC剔除异常值影响后的信号强度比IC更稳,波动更小
ICIR信号预测能力的稳定性越大越好,通常大于0.3
年化收益直观的投资回报需要结合回撤看
最大回撤极端风险水平越小越稳
换手率成本敏感度越高实盘越难兑现

6. Part Ⅲ踩坑随笔:几个容易被文档绕晕的细节

6.1 自定义模型注册后不生效,先想想是不是缓存没清

有一次我改完模型结构重新跑实验,结果输出的指标跟之前一模一样,怎么自查都没发现代码问题。后来才反应过来,模型实例被Qlib在进程内缓存了,notebook环境里没重启kernel,旧的模型对象一直在被复用。

解决办法不复杂:写完模型代码,需要重新加载的模块最好手动reload,或者干脆重启kernel,让它重新走一遍模块导入流程。如果你在脚本环境里跑,那就重新启动进程,别省那几秒。

这件事给我的教训是:Qlib的注册机制虽然方便,但它会掩盖“代码更新没生效”的问题。我后来自定义模型时,都会先在fit方法里打一条日志,确认跑的是新代码。

6.2 qrun跑workflow时,路径和日志信息一定确认好

qrun在哪个目录下执行,决定了相对路径的基准。如果配置文件里用了相对路径,而你又恰好换了执行目录,数据集索引和缓存路径就会对不上,各种找不到文件的报错接踵而来。

我现在的习惯是:每个实验项目开一个独立目录,yaml配置里尽量用绝对路径,或者统一在项目根目录下执行qrun。日志方面,Qlib会把实验细节打到mlruns对应run的目录下,跑完先看日志尾部,基本能定位90%的问题。

另外一点,不要忽略experiment_namerun_id。很多人实验跑多了以后,翻回去找不到哪次是哪次。我给实验命名时会带上日期和模型简称,方便后续回溯。

6.3 训练段和回测段时间切分要严格对齐

预测和回测的实验设计里,最容易出错的是时间切分。训练集用的是2008到2014,验证集是2015到2016,回测你当然会觉得应该跑三年测试集,但实际一写配置,可能segment写错、或者Handler的start_time包含了未来数据。

为了避免这种问题,我会在跑回测前先单独打印一次数据集的segments确认范围,确认测试集结束日期和回测日期一致再动手。这种“先看一眼再跑”的习惯,帮我挡下了不止一次数据泄漏事故。

Qlib的灵活性很高,但这份灵活性也需要你对自己的实验设计有清晰边界。每次跑实验前,我都会在心里过一遍:模型看到的数据、策略交易的标的、回测统计的时间段,这三者之间有没有不该存在的重叠。


按我个人的使用体验来说,Qlib的学习曲线不算平缓,但一旦你把Model、Workflow、Backtest、Analysis这几个组件的协作关系捋顺了,后面做实验的效率会成倍提升。这篇文章提到的坑,基本都是文档里不太会写、但实际操作中大概率会遇到的细节,希望能给你省下一些排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询