神经网络变得简单(第 87 部分):时间序列补片化(基础篇)
把行情切成补片喂给神经网络
传统 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 验证。
__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,
__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 分钟序列的权重更新幅度,可能明显更耐极端影线扰动,但外汇与贵金属杠杆高,回测顺滑不等于实盘稳健。
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,