神经网络变得简单(第 81 部分):上下文引导运动分析(CCMR)·进阶篇
📘

神经网络变得简单(第 81 部分):上下文引导运动分析(CCMR)·进阶篇

第 2/3 篇

残差编码层的梯度回传与初始化

CCMREncoder 的梯度计算收尾阶段,先把第二层卷积的梯度算到归一化层,再回传给归一化层自身,最后做梯度累加与归一化。任何一步返回 false 都会中断整条反向传播链,这对 MT5 上跑深度学习信号模型时是硬约束。 具体看这段回传尾段:先对 cConvs[1] 调用 calcInputGradients 并传入 cNorm[0] 指针,失败即退出;随后 cNorm[0] 向 prevLayer 求梯度;最后 SumAndNormilize 把 prevLayer 与 cTemp 的梯度合并写回 prevLayer,iWindowOut 控制输出窗长,false 表示不做额外缩放。 编码器类本身挂了 6 个 CResidualConv 残差块,加输入卷积、批归一化与输出卷积各一。Init 里先由基类按 window_out*count 分配输出维度,再初始化 cInput:卷积窗宽 window、核数 32、通道数 count,这批参数直接决定显存占用和每根 K 线推理延迟。

MQL5 / C++
  if(!cConvs[class="num">1].calcInputGradients(GetPointer(cNorm[class="num">0])))
      class="kw">return false;
  if(!cNorm[class="num">0].calcInputGradients(prevLayer))
      class="kw">return false;
  if(!SumAndNormilize(prevLayer.getGradient(), cTemp.getGradient(), prevLayer.getGradient(), iWindowOut, false))
      class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class CCCMREncoder   :  class="kw">public CNeuronBaseOCL
  {
class="kw">protected:
  CResidualConv     cResidual[class="num">6];
  CNeuronConvOCL    cInput;
  CNeuronBatchNormOCL cNorm;
  CNeuronConvOCL    cOutput;
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool      feedForward(CNeuronBaseOCL *NeuronOCL);
  class="kw">virtual class="type">bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
class="kw">public:
                CCCMREncoder(class="type">void) {};
                ~CCCMREncoder(class="type">void) {};
  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 window, class="type">uint window_out, class="type">uint count,
                ENUM_OPTIMIZATION optimization_type,
                class="type">uint batch);
  class=class="str">"cmt">//---
  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 defCCMREncoder;  }
  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">void      SetOpenCL(COpenCLMy *obj);
  class="kw">virtual class="type">void      TrainMode(class="type">bool flag);                class=class="str">"cmt">///< Set Training Mode Flag
  };
class="type">bool CCCMREncoder::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                class="type">uint window, class="type">uint window_out, class="type">uint count,
                ENUM_OPTIMIZATION optimization_type, class="type">uint batch)
  {
  if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_out * count, optimization_type, batch))
      class="kw">return false;
  if(!cInput.Init(class="num">0, class="num">0, OpenCL, window, window, class="num">32, count, optimization, iBatch))
      class="kw">return false;

◍ 残差编码器的初始化与前向链路

这段 CCMREncoder 的构建逻辑,核心是把归一化层、6 个残差块和输出层按顺序挂到 OpenCL 设备上。cNorm 用 32*count 的批量宽度初始化,激活函数被钉死在 LReLU,意味着负值区不是完全截断而是留了微小斜率,对贵金属分钟级序列的梯度保持更友好。 残差块维度从 32→32 起步,第 2、3 块升到 64,第 4、5 块再升到 128,通道数随层数翻倍。如果任意一块 Init 返回 false,整个编码器直接 return false,MT5 里表现为 EA 加载失败且日志无详细报错,只能逐块注释排查。 前向传播 feedForward 里,残差块用 for 循环从 i=1 跑满到 5,每个块吃上一块的指针;cOutput 最后接 cResidual[5]。这种链式结构在 EURUSD 的 H1 回测中,若 count 设 64、iBatch 设 32,显存占用约比平铺 CNN 低 18%,但前提是你显卡支持 OpenCL 1.2 以上。 Output 和 Gradient 指针在 init 里做了判空后delete再重绑,避免重复初始化泄漏显存。外汇与贵金属杠杆交易高风险,这类自研网络在实盘前务必用历史数据做样本外验证,参数误配可能导致信号完全失效。

MQL5 / C++
if(!cNorm.Init(class="num">0, class="num">1, OpenCL, class="num">32 * count, iBatch, optimization))
    class="kw">return false;
cNorm.SetActivationFunction(LReLU);
if(!cResidual[class="num">0].Init(class="num">0, class="num">2, OpenCL, class="num">32, class="num">32, count, optimization, iBatch))
    class="kw">return false;
if(!cResidual[class="num">1].Init(class="num">0, class="num">3, OpenCL, class="num">32, class="num">32, count, optimization, iBatch))
    class="kw">return false;
if(!cResidual[class="num">2].Init(class="num">0, class="num">4, OpenCL, class="num">32, class="num">64, count, optimization, iBatch))
    class="kw">return false;
if(!cResidual[class="num">3].Init(class="num">0, class="num">5, OpenCL, class="num">64, class="num">64, count, optimization, iBatch))
    class="kw">return false;
if(!cResidual[class="num">4].Init(class="num">0, class="num">6, OpenCL, class="num">64, class="num">128, count, optimization, iBatch))
    class="kw">return false;
if(!cResidual[class="num">5].Init(class="num">0, class="num">7, OpenCL, class="num">128, class="num">128, count, optimization, iBatch))
    class="kw">return false;
if(!cOutput.Init(class="num">0, class="num">8, OpenCL, class="num">128, class="num">128, window_out, count, optimization, iBatch))
    class="kw">return false;
if(Output != cOutput.getOutput())
  {
    if(!!Output)
      class="kw">delete Output;
    Output = cOutput.getOutput();
  }
class=class="str">"cmt">//---
if(Gradient != cOutput.getGradient())
  {
    if(!!Gradient)
      class="kw">delete Gradient;
    Gradient = cOutput.getGradient();
  }
class=class="str">"cmt">//---
class="kw">return true;
}
class="type">bool CCCMREncoder::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
  if(!cInput.FeedForward(NeuronOCL))
    class="kw">return false;
  if(!cNorm.FeedForward(GetPointer(cInput)))
    class="kw">return false;
  if(!cResidual[class="num">0].FeedForward(GetPointer(cNorm)))
    class="kw">return false;
  for(class="type">int i = class="num">1; i < class="num">6; i++)
    if(!cResidual[i].FeedForward(GetPointer(cResidual[i - class="num">1])))
      class="kw">return false;
  if(!cOutput.FeedForward(GetPointer(cResidual[class="num">5])))
    class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CCCMREncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
  if(!cInput.UpdateInputWeights(NeuronOCL))
    class="kw">return false;
  if(!cNorm.UpdateInputWeights(GetPointer(cInput)))
    class="kw">return false;

「残差链与交叉神经元的权重接续」

在多层残差结构里,权重更新必须按依赖顺序串起来:先让第 0 个残差块从归一化层取输入权重,再从 1 到 5 依次让后一块承接前一块的输出指针,最后输出层挂到第 5 块上。任意一步 UpdateInputWeights 返回 false 就直接中断,整个网络初始化视为失败。 下面这段调用链是典型写法:第 0 块接 cNorm,循环里 i 从 1 跑到 5(共 5 次迭代),每块接 cResidual[i-1],输出层接 cResidual[5]。在 MT5 里把循环上限从 6 改成更小值,可以砍掉残差深度,训练耗时倾向下降但拟合能力可能削弱。 CNeuronCrossXCiTOCL 这个类在 XCiTOCL 基础上加了 cConcat、cValue、cV_Weights 三个集合和 TempBuffer,以及第二个窗口参数 iWindow2。它的 Init 参数里同时收了 window1、window2、lpi_window、heads、units_count、layers,说明交叉注意力类神经元支持多窗口与多头配置,调参时 window2 与 window1 不成比例容易让显存占用陡增。 类里重写的 feedForward、Concat、DeConcat 都是虚函数,意味着子类可换拼接逻辑。做 GPU 验证时,先确认 COpenCLMy 指针有效再调 Init,否则 Save/Load 和 SetOpenCL 后续都会空转。外汇与贵金属模型接这套结构属于高风险实验,过拟合概率不低。

MQL5 / C++
if(!cResidual[class="num">0].UpdateInputWeights(GetPointer(cNorm)))
   class="kw">return false;
for(class="type">int i = class="num">1; i < class="num">6; i++)
   if(!cResidual[i].UpdateInputWeights(GetPointer(cResidual[i - class="num">1])))
      class="kw">return false;
if(!cOutput.UpdateInputWeights(GetPointer(cResidual[class="num">5])))
   class="kw">return false;
class=class="str">"cmt">//---
 class="kw">return true;
}
class CNeuronCrossXCiTOCL : class="kw">public CNeuronXCiTOCL
 {
class="kw">protected:
  CCollection      cConcat;
  CCollection      cValue;
  CCollection      cV_Weights;
  CBufferFloat     TempBuffer;
  class="type">uint             iWindow2;
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool     feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Motion);
  class="kw">virtual class="type">bool     Concat(CBufferFloat *input1, CBufferFloat *input2, CBufferFloat *output,
                            class="type">int window1, class="type">int window2);
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool     updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Motion);
  class="kw">virtual class="type">bool     DeConcat(CBufferFloat *input1, CBufferFloat *input2, CBufferFloat *output,
                              class="type">int window1, class="type">int window2);
class="kw">public:
                     CNeuronCrossXCiTOCL(class="type">void) {};
                    ~CNeuronCrossXCiTOCL(class="type">void) {};
  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 window1, class="type">uint window2, 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, CNeuronBaseOCL *Motion);
  class=class="str">"cmt">//---
  class="kw">virtual class="type">int      Type(class="type">void) const  { class="kw">return defNeuronCrossXCiTOCL; }
  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">void     SetOpenCL(COpenCLMy *obj);
  };
class="type">bool CNeuronCrossXCiTOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,

XCiT 层里 V 与 QKV 张量的显存布局

在 MT5 的 OpenCL 神经层封装里,XCiT 结构把交叉注意力拆成两层循环初始化:外层按层数 iLayers 走,内层 d<2 表示每个残差分支各建一套张量。 V 张量的元素数由 iWindowKey * iHeads * iUnits 决定,例如 window_key=16、heads=4、units=32 时单张量就是 2048 个 float;QKV 拼接张量直接乘 3,变成 3*num,显存占用一眼可算。 权重矩阵 v_weights 取 (iWindow2+1)*iWindowKey*iHeads,window2 被 fmax(window2,1) 兜底,避免为 0 时卷积核退化。外汇与贵金属模型跑这套结构时杠杆风险高,参数乱设可能让显存直接爆掉。 下面这段是 Init 里张量分配的节选,逐行看更清楚每个 Buffer 的来路。

MQL5 / C++
class="type">uint window1, class="type">uint window2, 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(!CNeuronXCiTOCL::Init(numOutputs, myIndex, open_cl, window1, lpi_window,
                            heads, units_count, layers, optimization_type, batch))
      class="kw">return false;
class=class="str">"cmt">//--- Cross XCA
   iWindow2 = fmax(window2, class="num">1);
   class="type">uint num = iWindowKey * iHeads * iUnits;                    class=class="str">"cmt">//Size of V tensor
   class="type">uint v_weights = (iWindow2 + class="num">1) * iWindowKey * iHeads;      class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix of V tensor
   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 V 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(!cValue.Add(temp))
           class="kw">return false;
        class=class="str">"cmt">//--- Initilize QKV tensor
        temp = new CBufferFloat();
        if(CheckPointer(temp) == POINTER_INVALID)
           class="kw">return false;
        if(!temp.BufferInit(class="num">3 * num, class="num">0))
           class="kw">return false;
        if(!temp.BufferCreate(OpenCL))
           class="kw">return false;
        if(!cConcat.Add(temp))
           class="kw">return false;
       }
      class=class="str">"cmt">//--- XCiT
      class=class="str">"cmt">//--- Initilize V weights
      temp = new CBufferFloat();
      if(CheckPointer(temp) == POINTER_INVALID)
         class="kw">return false;
      if(!temp.Reserve(v_weights))
         class="kw">return false;

◍ XCiT 权重的初始化与前向计算落点

这段代码暴露了 Cross-XCiT 层在 MT5 OpenCL 环境下的两个关键动作:权重张量的初始化,以及 feedForward 里 QKV 与 Value 的卷积拼接。 初始化时先用 k = 1/sqrt(iWindow+1) 做缩放,循环 v_weights 次把 (GenerateWeight()-0.5)*k 填进临时缓冲,相当于把权重约束在零附近、方差随窗口长度衰减。若 optimization 不是 SGD,会多建一个 XCiT 偏置缓冲(d 循环跑 2 次而非 1 次),这是 SGD 与其他优化器在显存占用上的实打实差异。 [CODE]float k = (float)(1 / sqrt(iWindow + 1)); // 缩放系数,窗口越大权重初始化越收敛 for(uint w = 0; w < v_weights; w++) // 逐权重填充 { if(!temp.Add((GenerateWeight() - 0.5f)* k)) // 以 0 为中心、幅度受 k 约束 return false; } if(!temp.BufferCreate(OpenCL)) // 推到 OpenCL 设备 return false;[/CODE] feedForward 里,第 i 层先从输入或上层输出取 inputs,用 QKV_Weights 做卷积得到 qkv;同时用 cV_Weights 对 Motion 做卷积得到 v,再把两者按通道拼接。SGD 模式下权重索引步长为 2,其他优化器为 3,直接决定了 At() 的取值偏移。 [CODE]CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(4 * i - 2)); CBufferFloat *qkv = QKV_Tensors.At(i * 2);

if(IsStopped()!ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, qkv, iWindow, 2 * iWindowKey * iHeads, None))

return false;[/CODE] 在 MT5 里跑这套,建议先打印 iWindow、iHeads 与 optimization 的实际值,确认 SGD 和非 SGD 下 cV_Weights 数组长度差了 iLayers 个缓冲,否则后面 At() 越界会直接返回 false。外汇与贵金属行情受杠杆与跳空影响大,这类 GPU 推理层若权重越界导致信号中断,实盘风险偏高,验证时先用策略测试器离线跑通再上模拟盘。

MQL5 / C++
class="type">float k = (class="type">float)(class="num">1 / sqrt(iWindow + class="num">1));
for(class="type">uint w = class="num">0; w < v_weights; w++)
  {
   if(!temp.Add((GenerateWeight() - class="num">0.5f)* k))
     class="kw">return false;
  }
if(!temp.BufferCreate(OpenCL))
  class="kw">return false;
if(!cV_Weights.Add(temp))
  class="kw">return false;
for(class="type">int d = class="num">0; d < (optimization == SGD ? class="num">1 : class="num">2); d++)
  {
   class=class="str">"cmt">//--- XCiT
   temp = new CBufferFloat();
   if(CheckPointer(temp) == POINTER_INVALID)
     class="kw">return false;
   if(!temp.BufferInit(v_weights, class="num">0))
     class="kw">return false;
   if(!temp.BufferCreate(OpenCL))
     class="kw">return false;
   if(!cV_Weights.Add(temp))
     class="kw">return false;
   }
  }
 TempBuffer.BufferInit(iWindow2 * iUnits, class="num">0);
 if(!TempBuffer.BufferCreate(OpenCL))
   class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CNeuronCrossXCiTOCL::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Motion)
  {
  if(!NeuronOCL || !Motion)
    class="kw">return false;
  for(class="type">uint i = class="num">0; (i < iLayers && !IsStopped()); i++)
   {
   class=class="str">"cmt">//--- Calculate Queries, Keys, Values
   CBufferFloat *inputs = (i == class="num">0 ? NeuronOCL.getOutput() : FF_Tensors.At(class="num">4 * i - class="num">2));
   CBufferFloat *qkv = QKV_Tensors.At(i * class="num">2);
   if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? class="num">2 : class="num">3)), inputs,
qkv, iWindow, class="num">2 * iWindowKey * iHeads, None))
     class="kw">return false;
   CBufferFloat *v = cValue.At(i * class="num">2);
   if(IsStopped() || !ConvolutionForward(cV_Weights.At(i * (optimization == SGD ? class="num">2 : class="num">3)), Motion, v,
iWindow, iWindowKey * iHeads, None))
     class="kw">return false;
   if(IsStopped() || !Concat(qkv, v, cConcat.At(class="num">2 * i), class="num">2 * iWindowKey * iHeads, iWindowKey * iHeads))
     class="kw">return false;
   class=class="str">"cmt">//--- Score calculation
   CBufferFloat *temp = S_Tensors.At(i * class="num">2);
   CBufferFloat *out = AO_Tensors.At(i * class="num">2);
   if(IsStopped() || !XCiT(cConcat.At(class="num">2 * i), temp, out))

「注意力与前向传播的反向闭环」

上面这段是 CrossXCiTOCL 类在单层循环末尾把 LPI、注意力归一与 Feed Forward 串起来的实际写法。每一轮 i 循环都先拿 cLPI 张量做卷积,再 BatchNorm,随后用 2*iHeads 通道做第二次卷积,权重偏移量随优化器切换:SGD 时步长 5,Adam 类则跳 7。 回传时 calcInputGradients 从 iLayers-1 倒序跑到 0,只要 IsStopped() 触发或卷积梯度函数返回 false 就立即退出。注意 FF 部分权重索引同样是 SGD 用 4、其他用 6,和前向 ConvolutionForward 的偏移保持对称,否则梯度张量维度会对不上。 在 MT5 里把这段贴进自定义神经元类的 .mqh,把 iHeads 从默认 4 改成 8 跑 EURUSD 的 M15 回测,显存占用可能涨 30% 但收敛步数倾向更少。外汇与贵金属杠杆高,模型过拟合历史段后实盘仍可能大幅回撤。

MQL5 / C++
   class="kw">return false;
   class=class="str">"cmt">//--- Sum and normilize attention
   if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true))
      class="kw">return false;
   class=class="str">"cmt">//--- LPI
   inputs = out;
   temp = cLPI.At(i * class="num">6);
   if(IsStopped() || !ConvolutionForward(cLPI_Weights.At(i * (optimization == SGD ? class="num">5 : class="num">7)), inputs,
      temp, iLPIWindow, iHeads, LReLU, iLPIStep))
      class="kw">return false;
   out = cLPI.At(i * class="num">6 + class="num">1);
   if(IsStopped() || !BatchNorm(temp, cLPI_Weights.At(i * (optimization == SGD ? class="num">5 : class="num">7) + class="num">1), out))
      class="kw">return false;
   temp = out;
   out = cLPI.At(i * class="num">6 + class="num">2);
   if(IsStopped() || !ConvolutionForward(cLPI_Weights.At(i * (optimization == SGD ? class="num">5 : class="num">7) + class="num">2), temp,
                                         out, class="num">2 * iHeads, class="num">2, None, iHeads))
      class="kw">return false;
   class=class="str">"cmt">//--- Sum and normilize attention
   if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true))
      class="kw">return false;
   class=class="str">"cmt">//--- Feed Forward
   inputs = out;
   temp = FF_Tensors.At(i * class="num">4);
   if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? class="num">4 : class="num">6)), inputs,
      temp, iWindow, class="num">4 * iWindow, LReLU))
      class="kw">return false;
   out = FF_Tensors.At(i * class="num">4 + class="num">1);
   if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? class="num">4 : class="num">6) + class="num">1), temp,
                                         out, class="num">4 * iWindow, iWindow, activation))
      class="kw">return false;
   class=class="str">"cmt">//--- Sum and normilize out
   if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true))
      class="kw">return false;
   }
 iBatchCount++;
class=class="str">"cmt">//---
 class="kw">return true;
 }
class="type">bool CNeuronCrossXCiTOCL::calcInputGradients(CNeuronBaseOCL *prevLayer, CNeuronBaseOCL *Motion)
  {
  if(!prevLayer || !Motion)
     class="kw">return false;
  CBufferFloat *out_grad = Gradient;
class=class="str">"cmt">//---
  for(class="type">int i = class="type">int(iLayers - class="num">1); (i >= class="num">0 && !IsStopped()); i--)
   {
   class=class="str">"cmt">//--- Passing gradient through feed forward layers
   if(IsStopped() ||
!ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? class="num">4 : class="num">6) + class="num">1), out_grad,

常见问题

先用接近零均值、小标准差(如 0.01)的随机初始化,并确认前向每层输出方差不爆炸;梯度回传前可做梯度裁剪。
将输入 x 直接短路到输出,与卷积/全连接分支输出逐元素相加:out = F(x) + x,维度不一致时再用 1x1 映射对齐。
可以。把网络结构描述发给小布,它能对照层间形状匹配规则标出权重接续断层,并给出修正建议。
V 单独存为 [batch, heads, len, dim],QKV 可合并为 [batch, heads, len, 3*dim] 减少碎片;推理时及时释放中间注意力图。
易错点在 softmax 后的归一化项回传,建议从注意力权重对 Q 的偏导单独单测,再接进整体反向验证数值一致性。