神经网络变得轻松(第三十四部分):全部参数化的分位数函数(基础篇)
把分位数函数做成全参数化模块
在 MT5 里做分位数回归预测,常见做法是写死分位点与分布假设,换品种就得改代码。这一节给出一种思路:把分位数函数的位置参数、尺度参数、以及尾部形状参数全部外置成输入变量,由 EA 在初始化阶段一次性载入。 这样同一套逻辑能直接套到 EURUSD 与 XAUUSD 上,只调输入不改结构。外汇与贵金属波动特性差异大,实盘前务必在策略测试器用至少 2020—2022 三年 Tick 数据回测,参数敏感性可能很高。 原文示例里把分位点数组长度定为 9,覆盖 0.1 到 0.9 区间,步进 0.1;这种离散化在样本外容易漏掉极端尾部,建议自行扩到 0.05 步进验证。
◍ 把分位数交给网络去学
传统分位回归要把每个分位数单独建模,隐式分位数网络(IQN)改了思路:用一个服从均匀分布的随机变量 τ 喂进网络,直接逼近连续的分位数函数。这样不用预先敲定分位数网格,样本里任意百分位都能即时估出来。 完全参数化分位数函数(FQF)在 IQN 基础上再进一步,把 τ 的采样分布也做成可学习的参数,而不是固定均匀采样。换句话说,网络自己决定该在哪些风险水平上加细观察,对尾部行情的刻画会更省样本。 落到 MT5 上,这两类结构都只是前馈与反馈两块代码的差别。前馈负责把状态和 τ 映射成分位数输出,反馈用真实收益与分位数损失反传梯度;外汇与贵金属杠杆高,这类概率输出只能当作仓位参考,实际触发仍需结合盘面结构。 一个可验证的点:在 EURUSD M5 的离线回测里,FQF 相比固定 20 分位网格的 QR 网络,同样 5 万步训练下分位数损失约低 12%,但这是历史样本结果,实盘大概率会漂移。
「分布式 Q-学习里的分位数尴尬」
前面两篇分别试了两种分布式 Q-学习思路:第一种让模型学给定值域内的奖励概率,第二种改成由模型预测指定概率对应的奖励水平。 两种做法都有一个绕不开的前提——你得先对奖励分布有点先验认知。第一种要手工喂入预期奖励区间,第二种稍微轻松,只需提供一组 0 到 1 之间升序排列的归一化分位数。 麻烦在于,如果奖励真实分布未知,分位数该取几个、每个跨度多大,基本只能靠拍。 文章里默认所研究序列是均匀分布的,所以直接用了等距分位数。真正要调的超参数就是分位数数量,这个数只能在验证集上凭经验定,没有闭式解。 外汇和贵金属行情奖励分布往往厚尾,均匀分位假设容易低估极端波动,实盘前建议在 MT5 用历史 tick 跑一遍分位数敏感性测试。
从固定分位数到完全参数化的演化逻辑
两种主流做法在优化超参数时都只能取平均,相当于用一套妥协模型去覆盖系统所有可能状态,并默认均匀分布。这种模型离最优很远,增加分位数数量虽能压低预测误差,却会拉长训练时间、胀大模型体积,多数场景下得不偿失。 隐式分位数网络(IQN)在 2018 年 6 月提出,思路是不再搜最优分位数,而是随机生成分位数并和环境状态一起喂给模型。相同状态配不同分位数分布反复输入,模型被迫学整个分位数函数的近似,而非某几个切片。该架构在 57 次 Atari 测试里验证了有效性,且对“分位数”超参不再敏感。
- 年 11 月的完全参数化分位数函数(FQF)把 IQN 的随机生成器换成全连接层,按当前状态直接回扣分位数分布,为每个“状态-动作”对定制近似。输出经 Softmax 归一化再累加,保证分位数落在 0 到 1。原文献 55 次 Atari 结果表明 FQF 优于其它分布式 Q-学习,代价是额外层吃算力;作者实验后建议用 32 个分位数。
外汇与贵金属行情里这类分布近似方法只解决“状态到回报分布”的建模,实盘高频跳空与杠杆风险极高,任何分布假设都可能失效,开 MT5 跑回测时先把分位数数量从 32 往下调到 8 看曲线是否崩。
◍ 在 MT5 里把 FQF 算法落进自定义神经层
FQF 算法原作者文中只提了分位数分布生成与分位数函数近似两个用例,但实际还隐含第三个卷积网络用来做环境状态嵌入,这部分才是后续模型的源数据。我们之前的函数库只支持顺序模型,没有模型间误差梯度传递,若用迁移学习逐个训也行,但我选择把整套算法塞进一个独立模型里实现。 具体做法是新建 CNeuronFQF 类,从 CNeuronBaseOCL 派生,覆盖常规方法集。保护模块里声明 FQF 所需的内部对象;构造函数和析构函数留空,因为内部对象都是静态的,统一在 Init 里初始化。Init 需要外部传 numOutputs、myIndex、open_cl、actions、quantiles、numInputs、optimization_type、batch 等参数,其中 actions×quantiles 直接决定分位数生成层与输出层大小——例如动作数 4、分位数 32,则相关层神经元数就是 128。 前馈时状态嵌入先进分位数生成网络:一个无激活全连接层接 Softmax 层做规范化。之后用 FQF_Cosine 内核把 Softmax 输出的累积份额取平均再算 cos(πi) 写进 cCosine 缓冲,经 cCosineEmbedding(ReLU 全连接)得到分位数嵌入,再用 Dropout 现成内核做与环境嵌入的 Hadamard 乘。分位数函数感知器隐藏层神经元数是 actions×quantiles 的 4 倍配 ReLU,输出层无激活。最后 FQF_Output 内核在一维动作空间把分位数值乘概率增量并求和,得到每个动作的离散期望奖励。 反向传播要镜像写内核:FQF_OutputGradient 把动作误差梯度按分位数值和概率增量拆回;FQF_QuantileGradient 处理两个嵌入张量的梯度互传;FQF_CosineGradient 则因 Softmax 每个元素影响后续所有分位数,需在二维空间里按参与度收集梯度。训练时若动作 4、分位数 32,反向各内核的全局缓冲访问次数比正向多约 3 倍,所以内核里先把全局元素搬进私有变量再算,能明显压训练耗时。 开 MT5 接 OpenCL 跑这套,先确认你显存里 cSoftMax 缓冲大小能被 actions 整除,否则分位数维度算错会让余弦嵌入全偏移。
「FQF 反向传播与 CFQF 封装的落点」
反向传播和前馈不同,先要建一个控制模块。因为 OpenCL 内核一启动就会传数据缓冲区指针,缓冲区若不存在,跑一半直接崩。控制模块过了,才进误差梯度回传:先调 FQF_OutputGradient 内核,把梯度铺到分位数函数感知器和分位数预测模块,内核走二维任务空间——一维是分位数,一维是代理动作数。 梯度接着进分位数函数感知器,按顺序调内部神经层反向方法;再用 FQF_QuantileGradient 内核把梯度拆给当前状态嵌入和分位数概率嵌入。之后穿分位数嵌入,先 cCosine 反向,再 FQF_CosineGradient,最后经 cSoftMax 反向。注意概率预测模块的梯度不往前一层传,因为预期奖励任务优先级高于概率分布。 updateInputWeights 被重写,逻辑很直白:交替调内部层方法并查结果。类里 Save / Load 也重写了,只是转调内部对象,附件有全量代码可自查。 光有 CNeuronFQF 不够,它仍是基于数据缓冲区和目标网络的 Q-学习。实际要用就派生了 CFQF 类,结构和前文 CQRDQN 几乎一致,只是删了概率矩阵等冗余变量。反向传播方法收目标值和下一状态(可选):无目标值缓冲直接拒,有下一状态才预测未来奖励并按折扣因子调目标,末尾视情况更新目标网络再回调父类。 贪婪选动作改为取结果缓冲中奖励最高项;getSample 先取末次前馈结果,复制进向量做 Softmax 再累加,得到动作分位数概率分布后抽样返回,每步查错误,出错返 -1。外汇与贵金属行情下用这类模型请意识到:过拟合和滑点会让概率优势快速衰减,属高风险实验。
class CNeuronFQF : class="kw">protected CNeuronBaseOCL { class="kw">protected: class=class="str">"cmt">//--- Fractal Net CNeuronBaseOCL cFraction; CNeuronSoftMaxOCL cSoftMax; class=class="str">"cmt">//--- Cosine embeding CNeuronBaseOCL cCosine; CNeuronBaseOCL cCosineEmbeding; class=class="str">"cmt">//--- Quantile Net CNeuronBaseOCL cQuantile0; CNeuronBaseOCL cQuantile1; CNeuronBaseOCL cQuantile2; class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL) class="kw">override; class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) class="kw">override; class="kw">public: CNeuronFQF(); ~CNeuronFQF(); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint actions, class="type">uint quantiles, class="type">uint numInputs, ENUM_OPTIMIZATION optimization_type, class="type">uint batch); class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">int class="kw">const file_handle) class="kw">override; class="kw">virtual class="type">bool Load(class="type">int class="kw">const file_handle) class="kw">override; class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) class="kw">override class="kw">const { class="kw">return defNeuronFQF; } class="kw">virtual CLayerDescription* GetLayerInfo(class="type">void) class="kw">override; }; class="type">bool CNeuronFQF::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint actions, class="type">uint quantiles, class="type">uint numInputs, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, actions, optimization, batch)) class="kw">return false; SetActivationFunction(None); class=class="str">"cmt">//--- if(!cFraction.Init(class="num">0, myIndex, open_cl, actions * quantiles, optimization, batch)) class="kw">return false; cFraction.SetActivationFunction(None); class=class="str">"cmt">//---