神经网络变得简单(第 87 部分):时间序列补片化(基础篇)
📘

神经网络变得简单(第 87 部分):时间序列补片化(基础篇)

第 1/3 篇

把行情切成补片喂给神经网络

传统 MLP 直接把整条时间序列拉平成一维向量,序列一长,参数爆炸且丢掉了局部时间结构。把序列按窗口切成若干 patch(补片),再分别嵌入,是缓解这一问题的低成本做法。 在 MT5 里可以用 iMA 或 CopyBuffer 取一段收盘价,按 patch_size 滑动截取。比如取最近 240 根 H1 收盘价,patch_size=12,就能得到 20 个补片,每个补片保留 12 根 K 的局部走势。 外汇与贵金属杠杆高、跳空频繁,patch 边界若落在重大数据发布前后,局部统计特征会失真,回测前应先剔除异常段。

「补片变换器为什么更适合看盘数据」

时间序列预测的核心,是搞清每个时间步之间的依赖关系。但单个 tick 或单根 K 线本身没有语义,真正有用的是局部片段里藏着的形态。传统做法把每个时间步当成一个 token 喂给模型,点级别的信息太碎,复杂语义抓不到。 PatchTST 的思路是把序列切成补片(patch),把相邻时间步聚成子序列级 token。这样一来,局部性被强化,模型能学到点级别给不了的语义关系。论文里原话是「一个时间序列值得 64 个词」,本质就是用补片代替时间点当输入单元。 多变量行情(比如多币种、多周期叠加)是个多通道信号。PatchTST 验证了「通道独立」比「通道混合」在变换器上更靠谱——每个 token 只装一个通道的数据,不提前搅在一起。这点和之前卷积、线性模型的结论一致,但它是头回在纯 transformer 架构上跑通。 三个实打实的优点:补片化把时间和空间复杂度压下来了,大样本更扛造;回溯窗口能拉更长还不爆显存,预测质量有改善空间;除了预报数值,它还能抽抽象表示,拿去做异常检测或分类都行。外汇和贵金属波动受事件驱动,用这类模型前先认清高风险,回测不过就是过拟合的漂亮壳子。

◍ PatchTST 怎么把多变量行情拆开喂给模型

PatchTST 面向的是多元时间序列:系统每一刻的状态由一组参数向量描述,各时间步的向量维度一致。按参数个数,可把整体多元序列拆成几条独立的单变量序列,每条单独走后面的补片与编码流程。 进模型前先归一化,这点不能省。即便 PatchTST 标榜通道独立的单变量分析,所有通道仍共享同一套训练参数,所以不同品种、不同维度的数据必须落在可比区间,否则训练抖动会明显放大。 补片化(patching)是核心一步:按固定大小把单变量序列切段,步长可等于补片大小(不重叠),也可小于它(重叠)。不重叠时补片数最少——缩减倍数是步长的倍数,意味着用同等显存能塞下更长的输入窗口;重叠则保留更多局部语义依赖,适合短输入窗口的精细刻画。 每条单变量序列用同一组补片参数切完,接可训练位置编码,送进若干 Vanilla Transformer 编码器层。编码器只在单变量内部抓依赖,但跨序列共享权重。输出表示串联后丢给“决策头”:换头就能做异常检测、分类或不同周期预测,不必重训主干。 预测分支在输出端做逆归一化,回贴从输入提取的统计特征。外汇与贵金属波动具有高杠杆高风险,这套结构只是特征提取框架,实盘信号强弱仍取决于你喂的历史窗口与决策头标定。

在 OpenCL 里手搓补片嵌入

PatchTST 的思路是把多元序列拆成独立单变量再做补片,但标准卷积层只能吃一维张量,没法在多元张量里隔离通道。为了省掉数据复制,我直接在 OpenCL 端把补片化和嵌入合并成一个内核,只做一次投影。 前馈内核 PatchCreate 跑在 3 维任务空间:补片数 × 嵌入向量位置 × 变量标识。输入张量按「时间步状态行、参数列」排布,取块时步长等于变量数,也就是沿列移动实现单变量补片。乘权重后遇 NaN 直接置 0,再走激活函数写回。 反向传播分两步。PatchHiddenGradient 在 2 维空间把输出梯度按权重回传,外循环收重叠补片的多窗口影响,内循环收单补片嵌入全元素影响,最后乘激活导数写前层梯度。PatchUpdateWeightsAdam 则用 Adam 在 3 维空间更新权重,第一二阶动量缓存在 weights_m / weights_v,且只在调整值非 0 时才写全局缓冲,剔掉无谓的全局访问。 主程序侧用 CNeuronPatching 类(继承 CNeuronBaseOCL)封装调用。feedForward 排 3 维内核,calcInputGradients 排 2 维,updateInputWeights 排「补片大小+贝叶斯乖离 × 嵌入大小 × 通道数」的 3 维。类里缓冲全声明为静态,构造析构留空,训练动量也在 Init 里建好。 实测我用非重叠补片、窗口与步长都等于 3;位置编码放在输入级而非嵌入级,配合 40% 的 Dropout 遮掩。补片嵌入分两阶段:先减半尺寸嵌入,再经卷积层撑回原尺寸,随后直接进 10 层 Conformer 模块——这比原版 Vanilla 变换器重,但实验显示独立通道内依赖抽取更稳。外汇与贵金属行情高波动,这套仅作编码器替换,策略回测仍需自跑 MT5 验证。

MQL5 / C++
__kernel class="type">void PatchCreate(__global class="type">float *inputs,
                               __global class="type">float *weights,
                               __global class="type">float *outputs,
                               const class="type">int total_in,
                               const class="type">int patch_size,
                               const class="type">int stride,
                               const class="type">int use_act)
{
   class="type">int p = get_global_id(class="num">0);
   class="type">int e = get_global_id(class="num">1);
   class="type">int v = get_global_id(class="num">2);
   class="type">int P = get_global_size(class="num">0);
   class="type">int E = get_global_size(class="num">1);
   class="type">int V = get_global_size(class="num">2);
   class="type">int in_off = (p * stride + class="num">0) * V + v;
   class="type">int w_off = e * patch_size;
   class="type">float sum = class="num">0.0f;
   for(class="type">int i=class="num">0; i<patch_size; i++)
     {
      class="type">int idx = in_off + i * V;
      if(idx < total_in)
         sum += inputs[idx] * weights[w_off + i];
      else
         sum += class="num">0.0f * weights[w_off + i];
     }
   if(isnan(sum)) sum = class="num">0.0f;
   if(use_act == class="num">1) sum = sum / (class="num">1.0f + fabs(sum));
   outputs[(p * E + e) * V + v] = sum;
}

「OpenCL 隐藏层的前向与梯度内核」

在 MT5 用 OpenCL 跑神经网络时,隐藏层计算被拆成两个内核:前向传播的 PatchHidden 与反向的 PatchHiddenGradient。前者按全局 ID 定位样本 i、输出窗口 w、变量 v,把权重偏置和滑窗输入做点积,再套激活函数写回 outputs。 PatchHidden 里 shift_weights 用 (window_in+1)*(v*window_out+w) 寻址,说明每个「变量×输出节点」都独占一组含偏置的权重。若 res 出现 NaN 直接归零,避免显存脏数据污染后续批次。 激活分支里 case 1 用 clamp(res,-20,20) 再套 sigmoid,防止 exp 溢出;case 2 是带 0.01 系数的 Leaky 式截断,负区不全杀。这些细节直接决定贵金属跨周期预测时梯度是否爆掉,外汇高频训练尤需留意高风险。 下面这段是 PatchHidden 前向内核的原文,可逐行对照在 MetaEditor 建 .cl 文件验证: __global float *outputs, int inputs_total, int window_in, int step, int activation ) { const int i = get_global_id(0); const int w = get_global_id(1); const int v = get_global_id(2); const int window_out = get_global_size(1); const int variables = get_global_size(2); const int shift_in = i * step * variables + v; const int shift_out = (i * variables + v) * window_out + w; const int shift_weights = (window_in + 1) * (v * window_out + w); float res = weights[shift_weights + window_in]; for(int p = 0; p < window_in; p++) if((shift_in + p * variables) < inputs_total) res += inputs[shift_in + p * variables] * weights[shift_weights + p]; if(isnan(res)) res = 0; switch(activation) { case 0: res = tanh(res); break; case 1: res = 1 / (1 + exp(-clamp(res, -20.0f, 20.0f))); break; case 2: if(res < 0) res *= 0.01f; break; default: break; } //--- outputs[shift_out] = res; } __kernel void PatchHiddenGradient(__global float *inputs, __global float *inputs_gr, __global float *weights, __global float *outputs_gr, int window_in, int step, int window_out,

MQL5 / C++
__global class="type">float *outputs,
 class="type">int inputs_total,
 class="type">int window_in,
 class="type">int step,
 class="type">int activation
 )
{
 const class="type">int i = get_global_id(class="num">0);
 const class="type">int w = get_global_id(class="num">1);
 const class="type">int v = get_global_id(class="num">2);
 const class="type">int window_out = get_global_size(class="num">1);
 const class="type">int variables = get_global_size(class="num">2);
 const class="type">int shift_in = i * step * variables + v;
 const class="type">int shift_out = (i * variables + v) * window_out + w;
 const class="type">int shift_weights = (window_in + class="num">1) * (v * window_out + w);
 class="type">float res = weights[shift_weights + window_in];
 for(class="type">int p = class="num">0; p < window_in; p++)
  if((shift_in + p * variables) < inputs_total)
   res += inputs[shift_in + p * variables] * weights[shift_weights + p];
 if(isnan(res))
  res = class="num">0;
 class="kw">switch(activation)
  {
   case class="num">0:
    res = tanh(res);
    break;
   case class="num">1:
    res = class="num">1 / (class="num">1 + exp(-clamp(res, -class="num">20.0f, class="num">20.0f)));
    break;
   case class="num">2:
    if(res < class="num">0)
     res *= class="num">0.01f;
    break;
   class="kw">default:
    break;
  }
class=class="str">"cmt">//---
 outputs[shift_out] = res;
}
__kernel class="type">void PatchHiddenGradient(__global class="type">float *inputs,
 __global class="type">float *inputs_gr,
 __global class="type">float *weights,
 __global class="type">float *outputs_gr,
 class="type">int window_in,
 class="type">int step,
 class="type">int window_out,

◍ 反向传播里的梯度与激活分支

这段 OpenCL 内核承接前面的前向计算,负责把输出梯度沿权重回传,算出每个输入样本的梯度。核心循环先用 get_global_id 定位样本 i 与变量 v,再按步长 step 在窗口内累加 outputs_gr 与 weights 的乘积,得到未激活前的 grad。 梯度算完先做了 NaN 防护:isnan(grad) 时直接置 0,避免单点脏数据把整张梯度图带崩。随后按 activation 参数分流——case 0 是 Tanh 类,用 1 - pow(inp,2) 近似导数,边界处塞了 0.99999999f 防除零;case 1 对应 Sigmoid,导数写成 inp*(1-inp),在 0 和 1 处退化为 1e-8f;case 2 是带 0.01 泄漏的 ReLU,负区梯度直接乘 0.01f。 最后一行 inputs_gr[i * variables + v] = grad 把结果写回全局缓冲,供下一层反向使用。在 MT5 里跑这类内核时,把 activation 从 0 切到 2 观察黄金 1 分钟序列的权重更新幅度,可能明显更耐极端影线扰动,但外汇与贵金属杠杆高,回测顺滑不等于实盘稳健。

MQL5 / C++
class="type">int outputs_total,
              class="type">int activation
               )
  {
  const class="type">int i = get_global_id(class="num">0);
  const class="type">int v = get_global_id(class="num">1);
  const class="type">int variables = get_global_size(class="num">1);
  const class="type">int w_start = i % step;
  const class="type">int r_start = max((i - window_in + step) / step, class="num">0);
  class="type">int total = (window_in - w_start + step - class="num">1) / step;
  total = min((i + step) / step, total);
  class="type">float grad = class="num">0;
  for(class="type">int p = class="num">0; p < total; p ++)
    {
      class="type">int row = r_start + p;
      if(row >= outputs_total)
        break;
      for(class="type">int wo = class="num">0; wo < window_out; wo++)
        {
          class="type">int shift_g = (row * variables + v) * window_out + wo;
          class="type">int shift_w = v * (window_in + class="num">1) * window_out + w_start + (total - p - class="num">1) * step + wo * (window_in + class="num">1);
          grad += outputs_gr[shift_g] * weights[shift_w];
        }
    }
  class="type">float inp = inputs[i * variables + v];
  if(isnan(grad))
      grad = class="num">0;
class=class="str">"cmt">//---
  class="kw">switch(activation)
    {
      case class="num">0:
        grad = clamp(grad + inp, -class="num">1.0f, class="num">1.0f) - inp;
        grad = grad * (class="num">1 - pow(inp == class="num">1 || inp == -class="num">1 ? class="num">0.99999999f : inp, class="num">2));
        break;
      case class="num">1:
        grad = clamp(grad + inp, class="num">0.0f, class="num">1.0f) - inp;
        grad = grad * (inp == class="num">0 || inp == class="num">1 ? class="num">0.00000001f : (inp * (class="num">1 - inp)));
        break;
      case class="num">2:
        if(inp < class="num">0)
            grad *= class="num">0.01f;
        break;
      class="kw">default:
        break;
    }
  inputs_gr[i * variables + v] = grad;
  }
__kernel class="type">void PatchUpdateWeightsAdam(__global class="type">float *weights,
                                     __global const class="type">float *outputs_gr,
                                     __global const class="type">float *inputs,

常见问题

按固定窗口长度(如 16 根 K 线)不重叠切片,每片保留开高低收和成交量;切完先标准化再入模,避免跳空造成伪信号。
补片变换器并行处理各变量补片,注意力能跨品种抓领先滞后关系;LSTM 串行易忘远端依赖,对 30 分钟以上周期噪声更敏感。
可以。小布盯盘的 AIGC 已内置补片化诊断,打开对应品种页即可看到模型高亮的风险补片与置信区间。
先降补片批量大小(batch_patch)到 64 以下,再把嵌入维度从 128 砍到 64;多数老显卡能跑通前向。
梯度符号反了会让验证集 MSE 在 20 轮内翻倍;用数值梯度对一遍前 3 层即可定位分支写错位置。