神经网络变得简单(第 77 部分):交叉协方差变换器(XCiT)(基础篇)
📘

神经网络变得简单(第 77 部分):交叉协方差变换器(XCiT)(基础篇)

第 1/3 篇

「用交叉协方差给 Transformer 瘦身的思路」

标准 Transformer 的自注意力在序列长度 N 上复杂度是 O(N²),行情序列一长,MT5 里跑起来显存和时延都顶不住。XCiT(Cross-Covariance Transformer)把注意力算在特征维度 C 上,复杂度降为 O(C²),对固定特征数的技术指标向量更友好。 原文给出的实现里,交叉协方差矩阵由 token 在特征轴做归一化后相乘得到,再经可学习缩放与非线性映射回注各 token。这样能在不依赖序列长度平方开销的前提下,保留跨标的协动信息——对外汇多货币对联立建模可能有用,但贵金属与外汇杠杆高,回测过拟合风险也高。 要在 MT5 验证,直接把下面片段接进你的特征提取管线,看 64 根 K 线、32 维特征下耗时是否明显低于标准注意力。

◍ 把自关注掰成线性复杂度

标准自关注给序列里每个令牌都两两算注意力,全局互动是有了,但计算量和显存占用随令牌数走的是平方曲线。序列一长,MT5 里跑这类模型基本就卡死,更别说实时盯盘用的长 K 线窗口。 XCiT 那篇论文把自关注做了个转置:不在令牌维度上算,改到特征通道上走,用主键和查询的交叉协方差矩阵来建模互动。出来的交叉协方差关注度(XCA)复杂度变成随令牌数线性增长,长序列处理效率直接拉开差距。 原实验是在图像分类、对象检测、实例分段几个视觉基准上验证的,精度接近传统变换器,扩展性类比卷积架构。外汇和贵金属行情序列本质也是高维长序列,拿这套思路替换掉平方复杂度的注意力层,回测窗口才可能拉到千根以上——这类品种杠杆高、跳空频繁,实盘前务必在 MT5 用历史数据跑通再谈。

交叉协方差注意力怎么绕开二次方爆炸

经典自关注沿着令牌维度算,复杂度是 O(N^2 * d),内存 O(hN^2 + Nd),N 一大就崩。XCiT 把操作挪到特征维度:用 Query、Key、Value 先算交叉协方差矩阵,再得出关注度权重,复杂度降到 O(Nd^2/h),和令牌数 N 呈线性关系,内存也只剩 O(d^2/h + Nd)。 作者对 Q、K 做 L2 归一化,让每列单位范数,协方差元素被锁在 [−1, 1]。代价是自由度少了、表达力下降,所以又引入可训练温度 τ,在 SoftMax 前缩放内积,控制分布是更尖还是更平。 特征被拆成 h 个头,各自算 XCA,d_k = d_q = d_v = d/h。实证上这种对角分块更好优化,聚合值复杂度再除以 h。当 N 很大、d 相对小的时候,XCiT 的伸缩性明显优于常规 Transformer。 XCA 模块里补丁之间只通过共享统计间接通信,所以作者在后面接了 LPI(局部补丁互动):两层卷积夹一个 BN,激活用 GELU,参数和带宽开销都可忽略。再往后接逐点卷积 FFN(隐藏层 4d),补上跨特征互动。协方差模块大小固定,不随输入分辨率变,这大概就是 XCiT 换分辨率不拉胯的原因。外汇/贵金属量化里若拿它做特征提取,注意过拟合高风险,建议先在 MT5 用小规模 N 验证线性伸缩。

「在 MT5 里把 XCiT 落成可跑的神经网络层」

把交叉协方差变换器搬进 MQL5,核心动作是新建一个神经层类 CNeuronXCiTOCL,父类沿用 CNeuronMLMHAttentionOCL。父类里现成的多头注意力工具能省掉大量重复代码,但 XCiT 特有的 LPI 局部补丁模块必须自己补缓冲区:cLPI 存结果与梯度,cLPI_Weights 存权重和动量;外加三个常量 iLPIWindow、iLPIStep、iBatchCount 控制卷积窗口与批量归一化的操作数。 类里所有新增对象都声明为静态,构造函数留空,真正的初始化全塞进 Init 方法。Init 里先按「单元素描述向量大小 ÷ 注意力头数」重算内部维度——这是 XCiT 原作者的设定,顺带省掉了降维层。随后循环建缓冲区:过渡结果、梯度、QKV 串联缓冲、XCA 系数缓冲、LPI 两层卷积加批归一缓冲、FeedForward 缓冲。注意第二层只存指针不新建,避免和前面的结果缓冲重复。 前向验算必须去 OpenCL 端写 XCA 内核。内核以 3 维任务空间启动(元素维度 × 序列长度 × 注意力头数),前两个维度并进局部工作组。流程是:先按作者建议对 Q、K 做向量归一(除以模长平方根),再算 Q·K 取指数求和得到依赖系数并归一,最后乘 V 写回结果缓冲。主程序侧 CNeuronXCiTOCL::feedForward 跑循环调这些内核,并在每层把注意力输出加回原始数据再做批归一,然后走 LPI 和 FFN。 反向传播同样要在 OpenCL 写 XCiTInsideGradients 内核,接收 qkv、qkv_g、scores、gradient 四个指针,先求 V 的梯度,再沿 SoftMax 导数调 Query 和 Key 的梯度。主程序用 calcInputGradients 逆序循环分发梯度,updateInputWeights 里按层更新 QKV 矩阵、LPI 两层卷积加批归一、以及 FFN 参数。 模型架构上,原文作者没给固定结构,实测可直接把旧模型里的 CNeuronMLMHAttentionOCL 换成 CNeuronXCiTOCL。测试时用 4 个注意力头,descr.window_out 参数被借去当第一层 LPI 窗口大小,批量大小也指定给 LPI 的批归一。保持其它层不变,能较干净地看出只换一层的影响。外汇与贵金属行情高波动,此类模型仅作技术验证,实盘前务必在 MT5 策略测试器跑历史回测。

MQL5 / C++
class CNeuronXCiTOCL  : class="kw">public CNeuronMLMHAttentionOCL
  {
class="kw">protected:
  class=class="str">"cmt">//---
  class="type">uint                iLPIWindow;
  class="type">uint                iLPIStep;
  class="type">uint                iBatchCount;
  class=class="str">"cmt">//---
  CCollection         cLPI;

◍ XCiT 神经元的类骨架与初始化入口

在 MT5 的 OpenCL 神经网络扩展里,CNeuronXCiTOCL 承担了交叉协方差图像 Transformer 的单体封装。它内部用 CCollection cLPI_Weights 管理局部感知权重,并暴露 feedForward、XCiT、BatchNorm 三套虚函数,分别对应前向推理、注意力打分与归一化。 反向传播一侧,updateInputWeights 与 XCiTInsideGradients、BatchNormInsideGradient 成对出现,说明梯度回传时权重和激活是分开计算的。这种切分在调参时很关键:若某段行情特征不更新,优先查 InsideGradient 系是否返回 false。 Init 函数的入参直接决定了张量形状——window * units_count 作为 CNeuronBaseOCL::Init 的第四参,意味着输入维度不是单看窗口长度,而是窗口乘以单元数。heads 与 layers 虽未在此段直接参与基类构造,但会后续影响多头拆分深度。 下面这段是类声明与 Init 开头,可直接贴进 .mqh 对照编译。注意若 CNeuronBaseOCL::Init 返回 false,整个神经元直接失效,不会往下走。

MQL5 / C++
  CCollection        cLPI_Weights;
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool        feedForward(CNeuronBaseOCL *NeuronOCL);
  class="kw">virtual class="type">bool        XCiT(CBufferFloat *qkv, CBufferFloat *score, CBufferFloat *out);
  class="kw">virtual class="type">bool        BatchNorm(CBufferFloat *inputs, CBufferFloat *options, CBufferFloat *out);
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool        updateInputWeights(CNeuronBaseOCL *NeuronOCL);
  class="kw">virtual class="type">bool        XCiTInsideGradients(CBufferFloat *qkv, CBufferFloat *qkvg,
                                           CBufferFloat *score, CBufferFloat *aog);
  class="kw">virtual class="type">bool        BatchNormInsideGradient(CBufferFloat *inputs, CBufferFloat *inputs_g,
                                 CBufferFloat *options, CBufferFloat *out,
                                 CBufferFloat *out_g, ENUM_ACTIVATION activation);
  class="kw">virtual class="type">bool        BatchNormUpdateWeights(CBufferFloat *options, CBufferFloat *out_g);
class="kw">public:
                      CNeuronXCiTOCL(class="type">void) {};
                     ~CNeuronXCiTOCL(class="type">void) {};
  class="kw">virtual class="type">bool        Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                           class="type">uint window, class="type">uint lpi_window, class="type">uint heads,
                           class="type">uint units_count, class="type">uint layers,
                           ENUM_OPTIMIZATION optimization_type,
                           class="type">uint batch);
  class="kw">virtual class="type">bool        calcInputGradients(CNeuronBaseOCL *prevLayer);
  class=class="str">"cmt">//---
  class="kw">virtual class="type">int         Type(class="type">void) const   { class="kw">return defNeuronXCiTOCL; }
  class=class="str">"cmt">//--- methods for working with files
  class="kw">virtual class="type">bool        Save(class="type">int const file_handle);
  class="kw">virtual class="type">bool        Load(class="type">int const file_handle);
  class="kw">virtual CLayerDescription* GetLayerInfo(class="type">void);
  class="kw">virtual class="type">bool        WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau);
  class="kw">virtual class="type">void        SetOpenCL(COpenCLMy *obj);
};
class="type">bool CNeuronXCiTOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                          class="type">uint window, class="type">uint lpi_window, class="type">uint heads, class="type">uint units_count,
                          class="type">uint layers, ENUM_OPTIMIZATION optimization_type, class="type">uint batch)
{
if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type,
batch))
   class="kw">return false;

张量显存占用的参数化估算

把窗口、头数、层数这些用户输入先做一次下限保护,避免传进 0 或负数把后续矩阵尺寸算崩。iWindowKey 用 (window + iHeads - 1) / iHeads 做向上取整,意味着当 window=64、heads=8 时,iWindowKey 得到 8,而不是浮点除法后的 7.875。 XCA 部分里 QKV 张量尺寸是 3 * iWindowKey * iHeads * iUnits,权重矩阵是 3 * (iWindow + 1) * iWindowKey * iHeads;以 window=64、heads=8、units=10 为例,QKV 张量约 3*8*8*10=1920 个 float,权重约 3*65*8*8=12480 个 float。Score 张量固定为 iWindowKey 的平方乘头数,上面的例子就是 8*8*8=512。 LPI 与 FF 两块按各自公式展开:LPI1 权重仅 (iLPIWindow+1)*iHeads,FF 两层分别是 4*(iWindow+1)*iWindow 与 (4*iWindow+1)*iWindow。循环里按 iLayers 层数、每层两次 d 循环去 new CBufferFloat 并走 BufferInit + BufferCreate,任何一步指针无效或初始化失败直接 return false,实盘跑前最好先打印 num / scores 这些量确认显存不会爆。 外汇与贵金属杠杆高、滑点跳空频繁,这类基于 GPU 缓冲的推理模块若在高波动时段卡显存,可能错过信号或重复建仓,建议先在模拟盘用上面参数跑通再上真仓。

MQL5 / C++
  iWindow = fmax(window, class="num">1);
  iUnits = fmax(units_count, class="num">1);
  iHeads = fmax(fmin(heads, iWindow), class="num">1);
  iWindowKey = fmax((window + iHeads - class="num">1) / iHeads, class="num">1);
  iLayers = fmax(layers, class="num">1);
  iLPIWindow = fmax(lpi_window, class="num">1);
  iLPIStep = class="num">1;
class=class="str">"cmt">//--- XCA
  class="type">uint num = class="num">3 * iWindowKey * iHeads * iUnits;           class=class="str">"cmt">// Size of QKV tensor
  class="type">uint qkv_weights = class="num">3 * (iWindow + class="num">1) * iWindowKey * iHeads; class=class="str">"cmt">// Size of weights&class="macro">#x27; matrix of
class=class="str">"cmt">// QKV tensor
  class="type">uint scores = iWindowKey * iWindowKey * iHeads;        class=class="str">"cmt">// Size of Score tensor
  class="type">uint out = iWindow * iUnits;                           class=class="str">"cmt">// Size of output tensor
class=class="str">"cmt">//--- LPI
  class="type">uint lpi1_num = iWindow * iHeads * iUnits;             class=class="str">"cmt">// Size of LPI1 tensor
  class="type">uint lpi1_weights = (iLPIWindow + class="num">1) * iHeads;         class=class="str">"cmt">// Size of weights&class="macro">#x27; matrix of
class=class="str">"cmt">// LPI1 tensor
  class="type">uint lpi2_weights = (iHeads + class="num">1) * class="num">2;                 class=class="str">"cmt">// Size of weights&class="macro">#x27; matrix of
class=class="str">"cmt">// LPI2 tensor
class=class="str">"cmt">//--- FF
  class="type">uint ff_1 = class="num">4 * (iWindow + class="num">1) * iWindow;              class=class="str">"cmt">// Size of weights&class="macro">#x27; matrix class="num">1-st
class=class="str">"cmt">// feed forward layer
  class="type">uint ff_2 = (class="num">4 * iWindow + class="num">1) * iWindow;              class=class="str">"cmt">// Size of weights&class="macro">#x27; matrix class="num">2-nd
class=class="str">"cmt">// feed forward layer
  for(class="type">uint i = class="num">0; i < iLayers; i++)
    {
      CBufferFloat *temp = NULL;
      for(class="type">int d = class="num">0; d < class="num">2; d++)
      {
        class=class="str">"cmt">//--- XCiT
        class=class="str">"cmt">//--- Initilize QKV tensor
        temp = new CBufferFloat();
        if(CheckPointer(temp) == POINTER_INVALID)
          class="kw">return false;
        if(!temp.BufferInit(num, class="num">0))
          class="kw">return false;
        if(!temp.BufferCreate(OpenCL))
          class="kw">return false;
        if(!QKV_Tensors.Add(temp))
          class="kw">return false;
        class=class="str">"cmt">//--- Initialize scores
        temp = new CBufferFloat();
        if(CheckPointer(temp) == POINTER_INVALID)
          class="kw">return false;
        if(!temp.BufferInit(scores, class="num">0))
          class="kw">return false;
      }
    }

常见问题

可以把自注意力换成交叉协方差注意力(XCiT),它把复杂度从序列平方降到线性,普通笔记本也能跑中等长度序列。
普通自注意力算 token 两两相似度,XCiT 改算特征通道间的协方差再归一化,用一次矩阵乘替代二次注意力图,数学上等价但省显存。
小布已内置这类轻量神经网络诊断,打开对应贵金属或外汇品种页就能看到 XCiT 特征层的偏离提示,不用自己搭环境。
交叉协方差层对缩放敏感,建议用正交初始化加小常数偏置,学习率比标准 Transformer 调低半档,否则梯度易发散。
按 批次×序列长×特征维×4字节 算主权重,XCiT 额外缓存协方差矩阵约 特征维²,提前代入公式能避开中途崩溃。