ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GASF-CNN分类预测:格拉姆角场将一维信号转图像,再用CNN精准分类

GASF-CNN分类预测:格拉姆角场将一维信号转图像,再用CNN精准分类 简介面向时序数据分类预测需求的Python项目实例基于GASF格拉姆角场将时序数据编码为图像再结合CNN进行特征提取与分类。适合有一定编程基础的科研人员、数据科学家和工程师也适用于金融市场预测、医疗数据分析、智能交通等场景。压缩包内含一份docx完整技术文档共1个文件压缩包仅73KB但内容涵盖数据预处理、模型构建与训练、性能评估、GUI界面设计等全流程并配有完整程序代码和逐段代码详解文档目录按项目背景、目标、挑战、创新与应用领域逐层展开便于按需查阅。文档还专门分析了高维时序数据、GASF图像质量控制、CNN过拟合等挑战及应对方案并给出多领域应用扩展方向。目前已有66人学习若想快速上手GASF-CNN并理解其落地细节这份实例可提供从理论到实践的直接参考。1. GASF-CNN分类预测一维信号先变成图再用卷积神经网络去分类手里有一批传感器信号、电力负荷曲线或者行情切片想分个类很多人第一反应是直接上一维卷积神经网络。但一维CNN的卷积核只能看到序列里相邻一小段跨时间步的相位依赖很难直接学出来。GASF-CNN的做法刚好相反先用格拉姆角场算法把每条一维序列编码成一张二维图像再交给普通的二维卷积神经网络做分类。这个思路最反直觉的地方在于它没有引入更复杂的网络而是靠一种更贴近信号本质的图形化表示让CNN自己把相位关系找出来。适合做设备故障诊断、电力负荷识别、金融行情涨跌分类这类任务的从业者。下文给出完整的Python实现、CNN模型搭建、Tkinter GUI设计和代码详解照着能跑通跑完能改成自己的数据。2. 格拉姆角场原理与Python实现把一维序列变成二维图像2.1 GASF的数学含义为什么要绕到极坐标GASF全称是Gramian Angular Field核心思想是给一维时间序列换一种坐标系。原始序列是一条横轴时间、纵轴数值的折线直接把它当矩阵喂给CNN时间步之间只有简单的左右位置关系。格拉姆角场先把每个数值映射成一个角度再把角度两两组合成一个矩阵这样一来任意两个时间步之间的关系就变成了矩阵里一个具体的元素。具体分三步。第一步把原始序列归一化到 [-1, 1] 区间归一化公式是 x_i ((x_i - max(x)) (x_i - min(x))) / (max(x) - min(x))这么做是为了让下一步的 arccos 运算有定义域。第二步把归一化后的数值看成余弦值求反余弦得到角度 θ_i arccos(x_i)同时把时间步的位置编码成半径也就是第 i 个点落在极坐标里离圆心距离为 i/N 的位置。第三步定义矩阵第 i 行第 j 列的元素为 cos(θ_i θ_j)。为什么用 cos(θ_i θ_j) 而不是直接用 x_i 和 x_j 相乘因为 cos(θ_i θ_j) 展开后等于 cos(θ_i)cos(θ_j) - sin(θ_i)sin(θ_j)它同时保留了数值本身的余弦信息和角度差带来的相位信息。两个时间步的相位差越大这个元素的值就越敏感正好把一维序列里隔了多远、相位对不对这类信息显式写进了二维矩阵。另一个常用变体GADF用的是 sin(θ_i - θ_j)对差异更敏感但在做分类时GASF的对称矩阵和余弦形式通常更稳定。这一套编码对数据本身没有太多假设不要求序列平稳也不要求等间隔采样所以用起来很省心。缺点也明显矩阵大小是序列长度的平方窗口稍微长一点计算量和内存就涨得很快这个坑后面专门说。2.2 最小GASF转换代码向量化实现与参数说明这里给出一个可以直接跑的最小实现。核心是把双重for循环换成矩阵运算不然遇到批量数据会慢到怀疑人生。import numpy as np def normalize_to_neg11(x): # 将一维序列缩放/归一化到 [-1, 1] # 公式((x - max) (x - min)) / (max - min) x_min x.min() x_max x.max() # 防止除零序列完全恒定时分母为0 if x_max - x_min 1e-9: return np.zeros_like(x) return ((x - x_max) (x - x_min)) / (x_max - x_min) def gasf_transform(series): # 输入 series: 一维numpy数组长度N # 输出 gasf: N x N 的二维矩阵 x normalize_to_neg11(series) # arccos要求输入在[-1,1]前面已保证 theta np.arccos(x) # 用广播构造 cos(theta_i)*cos(theta_j) - sin(theta_i)*sin(theta_j) cos_t np.cos(theta).reshape(-1, 1) # 列向量 sin_t np.sin(theta).reshape(-1, 1) # 矩阵乘法就是外层积得到NxN gasf cos_t cos_t.T - sin_t sin_t.T return gasf逻辑说明normalize函数把任意量纲的序列压到 [-1, 1]arccos才能求出有效角度。cos_t和sin_t通过reshape(-1, 1)变成列向量列向量与自身的转置做矩阵乘法直接得到所有两两组合的乘积避免了for循环。计算出来的gasf矩阵元素范围理论上在 [-1, 1] 之间可以直接当单通道图像喂给CNN。参数说明series长度N直接决定输出矩阵大小N64时输出64×64N128时输出128×128。实际使用时建议把series长度固定如果原始序列太长就截断太短就做零填充。另外如果希望保留原始幅度信息normalize之后可以乘一个缩放系数再传给下一个环节但大多数分类任务下不需要这么做。2.3 窗口长度与归一化方式两个必调参数窗口长度是GASF-CNN最关键的参数它同时影响信息完整度、计算开销和模型效果。常见的做法是先用一个经验值跑通再按验证集结果调整。下面这个表是我常用的参考范围。窗口长度输出矩阵大小适用场景备注1616×16快速验证、极短序列信息偏少容易欠拟合3232×32短序列分类计算快起步推荐6464×64中等长度序列通用首选信息量足内存可控128128×128低频信号、周期较长的序列单样本128×12816384个元素256及以上256×256不推荐O(n²)计算训练和推理都吃力归一化方面GASF要求数值落在 [-1, 1] 区间所以最稳的是最小-最大归一化但我们用的其实是 (x - max) (x - min) 这种对称形式它把最小值映射到 -1、最大值映射到 1。不要用z-score标准化直接做因为标准化后的值可能超出 [-1, 1]arccos会返回nan整个矩阵直接废掉。如果必须用z-score外面得再套一层tanh或clip。注意做滑动窗口采样时相邻窗口高度重叠生成的GASF图之间相关性非常强。后面划分训练集和验证集时不能随机打散这一点在第四章详细说明。还要提醒一下GASF转换后得到的矩阵是单通道灰度图如果项目里要求三通道输入可以把矩阵归一化后复制三份或者把GASF和GADF上下拼接成双通道再填零补成三通道。多数情况下单通道就够用了没必要增加计算量。3. CNN模型搭建与训练从GASF图像到分类标签3.1 网络结构设计小尺寸GASF图不需要深网络GASF图的特点是单通道、尺寸小通常集中在32×32到128×128之间。这种图不需要搬ResNet、EfficientNet这类大网络来跑一方面参数量太大几十条样本一训练就过拟合另一方面GASF图里的纹理和相位模式相对简单两层卷积加一层全连接就能提取得很充分。我常用的结构是输入(1, 64, 64) → 第一层卷积Conv2d(1→16, 3×3, padding1) BatchNorm ReLU MaxPool2d(2) → 第二层卷积Conv2d(16→32, 3×3, padding1) BatchNorm ReLU MaxPool2d(2) → Flatten → 全连接64 → Dropout(0.3) → 全连接分类数。两次池化后特征图边长变成64/416展平后是32×16×168192维接64个神经元的全连接层参数量适中。import torch.nn as nn class GASFCNN(nn.Module): def __init__(self, in_channels1, num_classes3, img_size64): super().__init__() self.features nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 64 - 32 nn.Conv2d(16, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32 - 16 ) # 展平后维度32通道 * (16) * (16) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * (img_size // 4) * (img_size // 4), 64), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(64, num_classes), ) def forward(self, x): # x shape: (batch, 1, img_size, img_size) x self.features(x) x self.classifier(x) return x逻辑说明padding1保证卷积不改变特征图尺寸尺寸缩小全靠MaxPool2d的步长。BatchNorm放在卷积和激活之间对小批量数据能明显稳定训练。Dropout放在最后一层全连接前减少过拟合。参数说明img_size必须能被4整除否则两次池化后尺寸对不上。如果窗口长度取32或128对应的img_size改成32或128就行代码里全连接层的输入维度会自动跟着算。要改变网络宽度调整第一层卷积的16和第二层的32即可。3.2 训练脚本数据划分、早停与模型保存训练数据和验证数据的划分对GASF-CNN格外重要。因为滑动窗口相邻样本高度相关如果把整个数据集随机打乱再切分验证集里会出现训练集样本的近亲导致验证准确率虚高线上效果却很差。我一般按时间顺序切分拿序列前70%生成训练集后30%生成验证集。import torch import numpy as np from torch.utils.data import DataLoader, TensorDataset import torch.nn as nn def build_samples(raw_signal, win_len, step1): # 用滑动窗口把长序列切成样本step为步长 samples [] for start in range(0, len(raw_signal) - win_len 1, step): samples.append(raw_signal[start:start win_len]) return np.array(samples) def train_model(model, X_train, y_train, X_val, y_val, epochs60, batch_size32, lr1e-3, patience10): # X_train/X_val: (N, win_len) 原始序列在函数内部转成GASF后训练 # 转成torch张量 train_ds TensorDataset(torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.long)) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue) val_ds TensorDataset(torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.long)) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.5) best_acc 0.0 bad_epochs 0 for epoch in range(epochs): model.train() running_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() out model(xb.unsqueeze(1)) # 加通道维变成 (B,1,H,W) loss criterion(out, yb) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() # 验证集评估 model.eval() correct, total 0, 0 with torch.no_grad(): for xb, yb in val_loader: out model(xb.unsqueeze(1)) pred torch.argmax(out, dim1) correct (pred yb).sum().item() total yb.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), best_gasf_cnn.pt) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}, best_acc{best_acc:.4f}) break return best_acc逻辑说明训练时通过unsqueeze(1)给数据补上通道维因为模型输入要求四维张量。验证集评估用torch.no_grad()关闭梯度省内存也避免反向传播。模型只在验证集准确率创新高时保存这是防止过拟合的后悔药后面训练崩了还能回到最佳状态。参数说明shuffleTrue只对训练集生效验证集保持顺序。StepLR每20个epoch把学习率乘0.5让模型在后期稳步收敛。patience10表示连续10个epoch验证集没有进步就提前结束避免无意义的等待。如果样本量很大batch_size可以调到64或128学习率对应调大一点。3.3 评估指标准确率之外还要看混淆矩阵训练完只盯着准确率远远不够尤其是故障诊断、信号分类这类任务类别不平衡很常见准确率会被多数类带偏。我一般会打印classification_report输出每个类别的精确率、召回率和F1值再看混淆矩阵确认哪些类别之间互相混淆。from sklearn.metrics import classification_report, confusion_matrix def evaluate_model(model, X_val, y_val): model.eval() preds [] with torch.no_grad(): for i in range(0, len(X_val), 64): xb torch.tensor(X_val[i:i64], dtypetorch.float32).unsqueeze(1) out model(xb) preds.extend(torch.argmax(out, dim1).numpy()) print(classification_report(y_val, preds)) print(confusion_matrix(y_val, preds)) return preds逻辑说明按批次推理避免一次把整个验证集塞进显存。classification_report会给出每个类别的精确率、召回率、F1以及整体macro avg比单独看准确率可靠得多。混淆矩阵则能直观看出哪两个类别最容易分不清比如一类波形和另一类波形在相位上有重叠模型就会频繁误判。如果发现某两个类别互混严重先回去看原始序列多半是这两类信号本身差异太小。常见的处理手段是缩小窗口长度让局部相位特征更突出或者增加训练样本把这两个类的样本用更小的滑动步长过采样一批。4. GASF-CNN训练与部署避坑5个常见问题排查4.1 模型loss不降、准确率像随机猜测现象训练了二三十个epochloss在高位震荡验证集准确率始终在类别数的倒数附近徘徊比如三分类一直卡在0.33左右。原因最常见的是输入张量的形状或者数值范围不对。有人忘了做归一化就把原始序列直接拿去算arccos结果全是nan也有人把GASF矩阵直接喂给模型但没有加通道维模型拿到的是(B, H, W)三维修卷积层直接报错或者任务无法收敛。解决训练前打印一两条样本的形状和数值范围确认x.min()和x.max()落在[-1, 1]确认张量shape是(B, 1, H, W)。如果输出了nan回到gasf_transform里检查分母是否接近0再加一行np.isnan(gasf).sum()的断言有nan就提前停下来定位。4.2 随机划分造成数据泄露验证集准确率虚高现象训练代码里用了train_test_split(X, y, random_state42)随机划分验证集准确率冲到0.95但部署到真实场景一测只有0.6。原因滑动窗口切出来的相邻样本高度相似随机划分时训练集和验证集里会出现来自同一段原始序列的窗口。这些近亲样本让模型在验证时偷看了答案测试环境里数据分布一变效果立刻露馅。解决按时间顺序切分训练集用原始序列前70%验证集用剩余30%。更严格的做法是保证验证集和训练集的窗口之间至少留出一个窗口长度的间隔比如窗口长度64训练集最后一个样本结束于位置N验证集第一个样本从N64开始。血的教训是任何涉及时间序列的切分都不要用默认随机划分。4.3 GUI预测与训练结果不一致现象训练时准确率不错模型保存也正常但GUI加载模型后预测出来的标签和训练脚本里跑出来的不一致甚至同一段数据换了输入方式结果就变。原因GUI里重新写了一套预处理逻辑没用训练时保存的归一化参数。训练时可能用了序列自带的min/max做归一化而GUI里又单独用当前输入序列的min/max做了一次两边尺度对不上。模型在训练时学习到的输入分布被破坏输出自然就乱了。解决训练完成后把每类样本的归一化参数固定下来保存成npy或json文件GUI加载后只用这一份参数。更省事的做法是把gasf_transform封装成一个公共函数训练和GUI都调用同一个模块避免两处逻辑分叉。这一点在第五章会给出具体做法。4.4 类别不平衡模型总预测多数类现象三个类别样本比例是20:5:1训练结束后少数类准确率几乎为0所有样本都被判成多数类但准确率数字看起来还挺高。原因CrossEntropyLoss默认情况下每个类别权重相同模型只要学会永远输出多数类就能拿到很低的loss梯度也主要在多数类上更新少数类根本没有话语权。解决给CrossEntropyLoss传class_weight参数权重按类别样本数量的倒数设定比如class_weight 1.0 / np.bincount(y_train).astype(float)。或者对少数类做数据增强以更小的滑动步长对少数类原始序列过采样让样本比例尽量接近。权重法最快但注意学习率和batch_size也要适当调小否则训练初期波动很大。4.5 窗口太长矩阵计算慢且内存爆现象窗口长度设成512甚至1024数据预处理好几分钟训练时内存一路飙升最后OOM被系统杀掉。原因GASF矩阵是O(n²)的窗口512时单条样本就是512×512262144个浮点数10000条样本就是十亿级元素float64的情况下直接占8GB内存必然扛不住。解决窗口长度控制在32到128之间这是精度和开销的平衡点。同时把numpy数组转为float32GASF矩阵数值范围本来就在[-1,1]float32精度完全够用内存直接减半。预处理时一段一段计算不要一次性把所有样本的GASF都堆进内存算完一批就转成tensor存进磁盘或者直接喂给训练进程。5. 用Tkinter把GASF-CNN包成GUI预测工具5.1 交互流程与界面布局模型训练好之后给同行或者业务方用命令行肯定不友好。Tkinter是Python自带的GUI库不需要额外安装第三方框架轻量且适合工具型界面。这个预测工具的交互流程设计成四步选择待预测的序列文件输入窗口长度和归一化参数点击预测按钮界面展示GASF图像和分类结果。界面布局从上到下依次是文件选择区、参数设置区、图像显示区、结果输出区。文件选择区放一个选择CSV文件按钮和一个路径显示框参数设置区放窗口长度输入框、类别标签下拉框图像显示区用matplotlib的FigureCanvasTkAgg嵌入画布预测时把当前样本的GASF图画出来结果输出区用Label显示预测类别和各类别概率。import tkinter as tk from tkinter import filedialog, messagebox import numpy as np import torch from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg # 假设gasf_transform和GASFCNN已经在公共模块里定义 from common import gasf_transform, GASFCNN class GASFApp: def __init__(self, master): self.master master master.title(GASF-CNN 分类预测工具) master.geometry(680x720) # 选择文件按钮 tk.Button(master, text选择CSV文件, commandself.load_file).pack(pady8) self.path_label tk.Label(master, text未选择文件, fggray) self.path_label.pack() # 窗口长度输入 tk.Label(master, text窗口长度).pack() self.win_entry tk.Entry(master, width10) self.win_entry.insert(0, 64) self.win_entry.pack() # 预测按钮 tk.Button(master, text开始预测, commandself.predict).pack(pady10) # matplotlib画布用于显示GASF图像 self.figure Figure(figsize(5, 4), dpi100) self.axis self.figure.add_subplot(111) self.canvas FigureCanvasTkAgg(self.figure, mastermaster) self.canvas.get_tk_widget().pack() # 结果显示 self.result_label tk.Label(master, text等待预测..., font(Arial, 12)) self.result_label.pack(pady10)逻辑说明窗口长度从Entry里读取预测按钮触发predict方法。matplotlib画布用FigureCanvasTkAgg嵌入Tkinter窗口这是Tkinter和matplotlib集成的标准姿势。result_label显示最终预测结果。5.2 核心代码文件读取、预测与结果展示predict方法要做的就是把CSV里的一维序列读进来按窗口长度切分转成GASF图喂给模型推理再把结果画到画布上。这里有一个常见坑是GUI主线程里跑模型会卡住界面数据量大时窗口会假死所以推理要放到单独的线程里执行。def predict(self): # 在子线程中执行预测避免阻塞GUI事件循环 import threading t threading.Thread(targetself._predict_worker, daemonTrue) t.start() def _predict_worker(self): try: file_path self.path_label.cget(text) if file_path 未选择文件: messagebox.showwarning(提示, 请先选择CSV文件) return win_len int(self.win_entry.get()) signal np.loadtxt(file_path, delimiter,) # 假设CSV里只有一列数值 if len(signal) win_len: messagebox.showerror(错误, f序列长度小于窗口长度{win_len}) return # 取最后一个完整窗口作为当前待预测样本 sample signal[-win_len:] gasf gasf_transform(sample) # 归一化到0-1之间再显示matplotlib画图更清楚 display_img (gasf - gasf.min()) / (gasf.max() - gasf.min() 1e-9) self.axis.clear() self.axis.imshow(display_img, cmapviridis) self.canvas.draw() model_path best_gasf_cnn.pt # 每次预测都重新加载模型省内存如果频繁预测可改为常驻内存 model GASFCNN(num_classes3, img_sizewin_len) model.load_state_dict(torch.load(model_path, map_locationcpu)) model.eval() x torch.tensor(gasf, dtypetorch.float32).unsqueeze(0).unsqueeze(0) with torch.no_grad(): logits model(x) probs torch.softmax(logits, dim1).squeeze(0).numpy() label int(np.argmax(probs)) self.result_label.config(textf预测类别: {label} 概率: {probs.max():.4f}) except Exception as e: messagebox.showerror(预测失败, str(e))逻辑说明loadtxt按逗号分隔读取CSV如果业务数据是别的分隔符改成delimiter\t即可。取最后一个完整窗口当样本是因为新来的线上数据往往是最新的一段。torch.unsqueeze两次分别补batch维和通道维和训练时保持一致。softmax把logits转成概率argmax得到类别。参数说明map_locationcpu保证没有GPU的机器也能加载模型。每次预测都重新加载模型会慢适合工具型GUI如果想更快把model初始化放进__init__里加载一次。窗口长度win_len必须和训练时的img_size一致否则模型全连接层维度对不上会直接报错。5.3 运行环境和打包注意torch体积与界面卡死打包GUI工具让同事用最常见的是用PyInstaller。但torch的坑比较大打出来的exe动辄几百MB而且PyInstaller经常漏掉torch的依赖运行时报找不到模块。我的做法是先在虚拟环境里装一个纯CPU版本的torchCPU版体积小很多再用PyInstaller打包时加上--collect-all torch参数。运行环境方面机器上需要安装numpy、matplotlib、torch和scikit-learn其中torch是唯一比较重的依赖。如果只是给别人用推荐把模型导出成ONNX然后用onnxruntime做推理这样能绕开torch的体积问题整个GUI工具的依赖会轻很多。不过ONNX导出GASF-CNN这类小模型时要注意torch的某些算子在ONNX转换时可能不完全兼容特别是涉及到动态尺寸时建议把输入固定成(1,1,64,64)再导出。界面假死是个高频问题尤其是文件较大时GASF转换加模型推理可能耗时好几秒如果直接在回调函数里跑窗口会卡到失控。上面代码里用threading.Thread起子线程主线程只负责刷新UI就是标准解法。要注意子线程里不要直接操作Tkinter之外的界面控件所以结果通过self.result_label.config更新而不是创建新的弹窗。6. 用混淆矩阵和Grad-CAM确认模型学到了相位特征模型训练完、GUI也能跑了还得回答一个问题模型是真的学到了序列的相位模式还是靠某种偶然的分布特征在分类。我的验证套路是两条一是交叉验证加混淆矩阵统计稳定性二是用Grad-CAM看模型到底在GASF图的哪些区域做决策。Grad-CAM的做法是取最后一个卷积层的输出特征图用类别对应的梯度做加权得到一张热力图。GASF图上的热力区域对应回原始序列其实就是模型认为最重要的时间片段。如果热力图集中在某个特定相位区域说明模型确实在按相位特征分类如果热力图散得到处都是大概率模型欠拟合或者数据本身没有可分的相位信息。代码上给GASFCNN写一个hook注册进最后一个卷积层推理时同时拿到梯度计算加权平均后再归一化成热力图。这个工程值不值得做取决于项目阶段如果只是快速验证GASF-CNN能不能用混淆矩阵和分类报告就足够了如果要往生产上推Grad-CAM能给业务方一个模型看的是哪里的解释对建立信任很有帮助尤其医疗信号和金融场景里这个动作几乎是标配。我自己早期做过一个电力负荷分类项目当时省了这一步只看准确率0.93就准备提交结果结果上测试环境发现模型把负荷的幅值大小当成主要特征换了季节的数据后准确率掉到0.7。后来补上混淆矩阵才发现冬季和夏季样本在相位特征上确实重叠光加数据不解决根本问题最后还是把窗口长度从128调到了64让模型更关注局部相位才稳住效果。现在我的习惯是任何GASF-CNN项目第一步跑通流程第二步立刻打印混淆矩阵第三步至少看一眼Grad-CAM热力图再决定要不要调参而不是直接改网络结构。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进