简介:这份资源面向具备一定Python编程基础的科研人员、数据科学家与工程师,聚焦时序数据分类预测这一典型难题,给出基于GASF-CNN的完整项目实例。它通过格拉姆角场算法将一维时序信号转换为二维图像,再交由卷积神经网络提取特征,从而降低对人工特征工程的依赖,提升分类准确率与模型泛化能力,并兼顾可解释性输出。压缩包共1个docx文件,约73KB,内容涵盖项目背景、目标与意义、七项挑战及对应解决方案、六项创新点,以及金融市场预测、医疗数据分析、气象与环境监测等应用领域,并附完整程序、GUI设计与代码详解。目录结构按背景、目标、挑战、创新、应用逐层展开,便于按模块查阅与动手复现。目前已有66人学习,适合希望系统掌握GASF与CNN结合方法、并需要可运行代码与界面参考的读者。
1. 从一维振动信号到二维图像:GASF-CNN 到底在解决什么分类难题
手里只有一列传感器采样点,却想让卷积神经网络像看图一样把它认出来,这是很多做设备故障诊断、生理信号识别、工况分类的工程师都会撞上的墙。一维时序直接喂 CNN 不是不行,但卷积核在单行数据上滑动,感受野受限,纹理特征几乎提不出来,模型很容易退化成多层感知机。格拉姆角场(Gramian Angular Summation Field,GASF)给出的思路是:先把一维序列归一化到 [-1,1],再把它映射成极坐标,用两两采样点夹角的余弦和构造出一张对称的二维矩阵,于是时间序列的时序相关性被编码成了图像里的纹理。GASF-CNN 就是拿这张图当输入,用卷积神经网络做分类预测。它适合谁?适合手头有中等规模一维信号、标签干净、想用现成 CNN 结构快速拿到比 SVM、随机森林更高准确率的从业者。这篇笔记把 GASF 的数学、CNN 的搭建、GUI 封装和踩坑点一次讲透,代码可以直接抄。
2. GASF 编码原理与 CNN 选型:为什么不是直接上 1D-CNN
2.1 格拉姆角场的数学推导与坐标映射
GASF 的核心动作只有三步,但每一步的参数都会影响最终图像的可分性。第一步是归一化,把原始序列 $x = {x_1, x_2, ..., x_n}$ 缩放到 $[-1, 1]$,常见做法是 min-max:
$$\tilde{x}_i = \frac{x_i - \min(x)}{\max(x) - \min(x)} \times 2 - 1$$
第二步是极坐标编码,把归一化后的值当作余弦,时间戳当作角度:
$$\phi_i = \arccos(\tilde{x}_i), \quad r_i = \frac{t_i}{N}$$
其中 $t_i$ 是时间步,$N$ 是归一化常数。第三步用角度和构造 GASF 矩阵:
$$GASF_{ij} = \cos(\phi_i + \phi_j) = \tilde{x}_i \cdot \tilde{x}_j - \sqrt{1-\tilde{x}_i^2} \cdot \sqrt{1-\tilde{x}_j^2}$$
这个式子展开后就是两个采样点的内积减去它们正弦分量的乘积。矩阵对角线是 $\cos(2\phi_i)$,保留了每个点自身的幅值信息;非对角线元素反映的是两个时间点之间的相关性。序列越长,矩阵越大,一张 $N \times N$ 的图就诞生了。为什么用 GASF 而不是 GADF?GASF 对幅值变化更敏感,适合区分能量差异明显的工况;GADF 用角度差,对相位偏移更敏感。做故障分类时,冲击成分的幅值差异往往是关键,所以 GASF 更常用。
2.2 为什么把一维信号转成图像再喂 CNN
直接上 1D-CNN 的问题在于,一维卷积核只能沿时间轴滑动,提取的是局部波形片段,对长程依赖和全局形态的建模能力弱。转成 GASF 图像后,原本相隔很远的时间点变成了图像上不同位置的像素,二维卷积核可以同时捕捉局部纹理和全局结构。更关键的是,GASF 矩阵的对称性和纹理模式与原始信号的周期性、突变点直接对应,CNN 在 ImageNet 上预训练出来的边缘、角点检测能力可以迁移过来。常见做法是:序列长度控制在 64 到 256 之间,太短则矩阵信息量不足,太长则图像尺寸爆炸,训练显存吃不消。我一般会把原始信号先做滑动窗口切分,每个窗口 128 个点,重叠率 50%,这样既扩充了样本,又保证了 GASF 图像尺寸可控。
2.3 CNN 主干网络的结构选择与参数设定
GASF 图像是单通道灰度图,尺寸通常是 $128 \times 128$ 或 $64 \times 64$。主干网络不需要太深,四层卷积加两层全连接足够。第一层卷积核 $3 \times 3$,通道数 32,后面每层翻倍到 64、128、256,每层后接 BatchNorm 和 ReLU,再跟一个 $2 \times 2$ 最大池化。全连接层前用全局平均池化替代 Flatten,能显著减少参数量,降低过拟合风险。学习率初始设 1e-3,用 Adam 优化器,配合余弦退火调度。Batch size 根据显存来,128 的图用 32 或 64 都行。损失函数用交叉熵,如果类别不平衡就加类别权重。下面这段代码是 GASF 编码和 CNN 模型定义的完整实现:
import numpy as np import torch import torch.nn as nn import torch.nn.functional as F def gasf_encode(series, image_size=128): """ 将一维序列编码为 GASF 图像 series: 一维 numpy 数组,长度需 >= image_size image_size: 输出图像边长 """ # 截断或重采样到 image_size 长度 if len(series) > image_size: idx = np.linspace(0, len(series) - 1, image_size).astype(int) series = series[idx] elif len(series) < image_size: series = np.interp( np.linspace(0, len(series) - 1, image_size), np.arange(len(series)), series ) # min-max 归一化到 [-1, 1] s_min, s_max = series.min(), series.max() if s_max - s_min < 1e-8: norm = np.zeros_like(series) else: norm = 2 * (series - s_min) / (s_max - s_min) - 1 norm = np.clip(norm, -1, 1) # 极坐标角度 phi = np.arccos(norm) # 构造 GASF 矩阵 cos_sum = np.cos(phi[:, None] + phi[None, :]) return cos_sum.astype(np.float32) class GASFCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2) ) self.conv2 = nn.Sequential( nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2) ) self.conv3 = nn.Sequential( nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2) ) self.conv4 = nn.Sequential( nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.fc = nn.Linear(256, num_classes) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) x = self.conv4(x) x = x.view(x.size(0), -1) return self.fc(x)gasf_encode里做了长度对齐,用线性插值把任意长度序列拉到image_size,这样不同采样率的信号都能统一处理。归一化时加了极小值保护,防止常数序列导致除零。np.arccos输入必须严格在 [-1,1],所以前面 clip 了一次。CNN 部分用AdaptiveAvgPool2d(1)把特征图压成 1×1,再接全连接,参数量比 Flatten 少一个数量级。num_classes根据你的标签数改,比如轴承故障四分类就设 4。
3. 从原始信号到训练集:数据预处理与 GASF 图像生成流水线
3.1 滑动窗口切分与样本增强
原始信号往往是一条长序列,直接整段做 GASF 会得到一张巨大的图,而且样本量只有一个。标准做法是滑动窗口切分。窗口长度取 128,步长取 64,重叠 50%。这样一条 10000 点的信号能切出约 150 个样本。如果样本还是不够,可以加高斯噪声、幅值缩放、时间偏移做增强。注意,增强要在 GASF 编码之前做,因为编码后的图像做几何变换会破坏格拉姆矩阵的数学结构。下面这段代码把切分和编码串起来:
def make_dataset(signal, label, window=128, step=64): """ 滑动窗口切分并生成 GASF 图像数据集 返回: images (N, 1, H, W), labels (N,) """ images, labels = [], [] for start in range(0, len(signal) - window + 1, step): seg = signal[start:start + window] img = gasf_encode(seg, image_size=window) images.append(img) labels.append(label) images = np.stack(images)[:, None, :, :] # 加通道维 labels = np.array(labels, dtype=np.int64) return images, labelswindow和step是核心参数。窗口太短,GASF 矩阵分辨率低,纹理模糊;窗口太长,单样本计算量和显存占用都上去了。我一般先用 128 试,如果准确率不够再试 256。step控制样本重叠度,步长越小样本越多,但相邻样本高度相关,验证集划分时要按时间顺序切,不能随机打乱,否则数据泄漏会让验证准确率虚高。
3.2 训练集、验证集、测试集的正确划分方式
时序数据的划分是个容易翻车的地方。很多人习惯用train_test_split随机划分,结果同一段信号切出来的窗口被分到训练集和验证集,模型相当于在验证集上见过训练数据,准确率虚高十几个点。正确做法是按时间顺序切:前 70% 做训练,中间 15% 做验证,最后 15% 做测试。如果有多条独立信号,可以按信号文件划分,同一文件的窗口只进一个集合。下面是一个按比例切分的示例:
def split_by_time(images, labels, ratios=(0.7, 0.15, 0.15)): n = len(images) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) train = (images[:n_train], labels[:n_train]) val = (images[n_train:n_train + n_val], labels[n_train:n_train + n_val]) test = (images[n_train + n_val:], labels[n_train + n_val:]) return train, val, test这个函数假设images已经按时间顺序排列。如果你的数据来自多个文件,先把每个文件切好的窗口按文件顺序拼接,再调用这个函数。验证集的作用是调超参和早停,测试集只在最后跑一次,不要反复用测试集调参。
3.3 用 PyTorch Dataset 封装与 DataLoader 配置
数据量大了以后,把图像全放内存不现实。用Dataset封装,__getitem__里做 GASF 编码,DataLoader开多进程加载。这样内存占用低,还能在线做增强。下面是一个完整的 Dataset 实现:
from torch.utils.data import Dataset, DataLoader class GASFDataset(Dataset): def __init__(self, signals, labels, window=128, step=64, transform=None): self.samples = [] self.transform = transform for sig, lab in zip(signals, labels): for start in range(0, len(sig) - window + 1, step): self.samples.append((sig[start:start + window], lab)) def __len__(self): return len(self.samples) def __getitem__(self, idx): seg, lab = self.samples[idx] img = gasf_encode(seg, image_size=len(seg)) if self.transform: img = self.transform(img) return torch.from_numpy(img).unsqueeze(0), lab train_loader = DataLoader( GASFDataset(train_signals, train_labels), batch_size=64, shuffle=True, num_workers=4, pin_memory=True )num_workers设成 CPU 核数的一半左右,太多反而因为进程切换拖慢速度。pin_memory=True在 GPU 训练时能加速数据传输。shuffle=True只在训练集用,验证集和测试集必须shuffle=False,保证评估结果可复现。
4. 训练、评估与 GUI 封装:把模型变成能用的工具
4.1 训练循环与关键超参数配置
训练循环本身不复杂,但有几个参数直接决定成败。学习率用 1e-3 起步,配合CosineAnnealingLR,每 10 个 epoch 降一次。早停 patience 设 15,验证损失连续 15 轮不降就停。梯度裁剪阈值设 1.0,防止 GASF 图像里少数极端值导致梯度爆炸。下面是一个完整的训练函数:
def train_model(model, train_loader, val_loader, epochs=100, lr=1e-3, patience=15): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) criterion = nn.CrossEntropyLoss() best_val_loss = float('inf') wait = 0 for epoch in range(epochs): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() # 验证 model.eval() val_loss, correct, total = 0, 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) out = model(imgs) val_loss += criterion(out, labels).item() * imgs.size(0) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) val_loss /= total acc = correct / total print(f"Epoch {epoch+1}: val_loss={val_loss:.4f}, val_acc={acc:.4f}") if val_loss < best_val_loss: best_val_loss = val_loss wait = 0 torch.save(model.state_dict(), 'best_gasf_cnn.pth') else: wait += 1 if wait >= patience: print("Early stopping") break return modelweight_decay=1e-4是 L2 正则,防止过拟合。clip_grad_norm_放在backward之后、step之前。保存模型时只存state_dict,加载时先实例化模型再load_state_dict。验证准确率比训练准确率低 5 个点以内算正常,差太多就是过拟合了。
4.2 混淆矩阵与分类报告:评估不能只看准确率
准确率在类别不平衡时会骗人。比如九成样本是正常工况,模型全预测正常也能拿 90% 准确率。必须看混淆矩阵和每类的 precision、recall、F1。下面这段代码用 sklearn 输出完整报告:
from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt def evaluate(model, test_loader, class_names): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) preds = model(imgs).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names=class_names)) cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt='d', xticklabels=class_names, yticklabels=class_names) plt.ylabel('True'); plt.xlabel('Predicted') plt.savefig('confusion_matrix.png', dpi=150, bbox_inches='tight')classification_report里的macro avg和weighted avg要一起看。macro avg对每类一视同仁,weighted avg按样本数加权。如果两者差距大,说明小类别表现差。混淆矩阵能看出哪两类容易混,比如轴承内圈故障和外圈故障在 GASF 图上纹理接近,可能需要加注意力机制或者换 GADF 试试。
4.3 用 Tkinter 搭一个能选文件、能预测的 GUI
模型训好了,总不能每次都让人跑命令行。用 Tkinter 搭个简单界面:选信号文件、选模型权重、点预测、显示结果。下面是一个最小可用的 GUI:
import tkinter as tk from tkinter import filedialog, messagebox import numpy as np import torch class GASFApp: def __init__(self, root, model, class_names): self.root = root self.model = model self.class_names = class_names root.title("GASF-CNN 分类预测") tk.Button(root, text="选择信号文件", command=self.load_file).pack(pady=10) self.label = tk.Label(root, text="未选择文件", wraplength=400) self.label.pack(pady=5) tk.Button(root, text="预测", command=self.predict).pack(pady=10) self.result = tk.Label(root, text="", font=("Arial", 14)) self.result.pack(pady=10) self.signal = None def load_file(self): path = filedialog.askopenfilename(filetypes=[("Numpy", "*.npy"), ("CSV", "*.csv")]) if not path: return if path.endswith('.npy'): self.signal = np.load(path).flatten() else: self.signal = np.loadtxt(path, delimiter=',').flatten() self.label.config(text=f"已加载: {path}, 长度 {len(self.signal)}") def predict(self): if self.signal is None: messagebox.showwarning("提示", "请先选择信号文件") return img = gasf_encode(self.signal, image_size=128) tensor = torch.from_numpy(img).unsqueeze(0).unsqueeze(0) self.model.eval() with torch.no_grad(): out = self.model(tensor) prob = torch.softmax(out, dim=1) pred = out.argmax(1).item() self.result.config( text=f"预测类别: {self.class_names[pred]}, 置信度: {prob[0][pred]:.4f}" ) if __name__ == '__main__': model = GASFCNN(num_classes=4) model.load_state_dict(torch.load('best_gasf_cnn.pth', map_location='cpu')) root = tk.Tk() app = GASFApp(root, model, ['正常', '内圈故障', '外圈故障', '滚动体故障']) root.mainloop()gasf_encode里对输入长度做了插值,所以 GUI 里选任意长度的信号都能跑。map_location='cpu'保证没有 GPU 的机器也能加载。预测时加softmax拿置信度,低于 0.6 的结果建议人工复核。这个 GUI 够用,想做得好看点可以换 PyQt,但核心逻辑一样。
5. 避坑与排查:GASF-CNN 落地时最容易翻车的五个地方
5.1 现象:训练准确率 99%,测试准确率 60%
原因几乎都是数据泄漏。滑动窗口切分后,相邻窗口重叠 50%,如果随机划分数据集,训练集和测试集里会有大量高度相似的样本。模型记住了这些样本,测试时遇到没见过的就露馅。解决办法是按时间顺序切分,或者按信号文件切分,确保同一段信号的所有窗口只出现在一个集合里。验证集和测试集也要分开,不能拿测试集调参。
5.2 现象:GASF 图像全灰,看不出纹理
原因是归一化前信号幅值范围太小,或者序列里有直流分量。min-max 归一化对异常值敏感,一个尖峰就能把其他点压到接近零。解决办法是先做去均值,再用鲁棒归一化,比如用中位数和四分位距代替最小最大值。另外,如果信号本身是常数或者接近常数,GASF 矩阵会退化成全 1 或全 -1,这种样本要提前剔除。
5.3 现象:训练 loss 震荡剧烈,不收敛
常见原因是学习率太大或者 batch size 太小。GASF 图像像素值在 [-1,1],和常规图像 [0,255] 不同,如果直接套用 ImageNet 的训练配置,学习率 0.1 起步,梯度会爆炸。解决办法是把学习率降到 1e-3 甚至 1e-4,加梯度裁剪,batch size 至少 32。另外检查一下输入有没有 NaN,np.arccos输入超出 [-1,1] 会产生 NaN,前面 clip 那一步不能省。
5.4 现象:GPU 显存不够,报 CUDA out of memory
GASF 图像尺寸是 $N \times N$,序列长度 256 就是 256×256 的图,一个 batch 64 张就是 64×1×256×256,显存占用不小。解决办法有三个:一是减小窗口长度到 128 或 64;二是减小 batch size;三是用混合精度训练,torch.cuda.amp能省一半显存。如果还不行,把模型通道数减半,或者用梯度累积模拟大 batch。
5.5 现象:GUI 打包成 exe 后闪退
用 PyInstaller 打包 Tkinter 程序时,如果模型权重文件没一起打包,或者路径写的是相对路径,exe 换台机器就找不到文件。解决办法是用sys._MEIPASS获取临时解压目录,把权重文件用--add-data参数打包进去。另外,PyTorch 打包后体积很大,可以用--exclude-module排除不用的库,或者改用 ONNX Runtime 推理,体积能小很多。
6. 进阶技巧:用 Grad-CAM 看 CNN 到底学到了什么
模型训完准确率不错,但心里没底,不知道它是真学到了故障特征还是记住了噪声。Grad-CAM 能把最后一层卷积的梯度加权回特征图,生成热力图,告诉你模型做决策时关注 GASF 图像的哪个区域。下面这段代码实现 Grad-CAM:
class GradCAM: def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.gradients = None self.activations = None target_layer.register_forward_hook(self.save_activation) target_layer.register_full_backward_hook(self.save_gradient) def save_activation(self, module, input, output): self.activations = output.detach() def save_gradient(self, module, grad_input, grad_output): self.gradients = grad_output[0].detach() def generate(self, input_tensor, class_idx=None): self.model.eval() output = self.model(input_tensor) if class_idx is None: class_idx = output.argmax(1).item() self.model.zero_grad() output[0, class_idx].backward() weights = self.gradients.mean(dim=(2, 3), keepdim=True) cam = (weights * self.activations).sum(dim=1, keepdim=True) cam = F.relu(cam) cam = F.interpolate(cam, size=input_tensor.shape[2:], mode='bilinear', align_corners=False) cam = cam.squeeze().cpu().numpy() cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return cam, class_idxtarget_layer一般选最后一个卷积层,比如model.conv4[0]。register_full_backward_hook在 PyTorch 新版本里替代了register_backward_hook,别用错了。生成的热力图叠加到原 GASF 图像上,红色区域就是模型关注的地方。如果热力图集中在图像边缘或者对角线以外的区域,说明模型可能学到了伪相关,需要检查数据预处理有没有问题。我一般会挑几个预测错误的样本看热力图,往往能发现标注错误或者信号采集时的干扰。这个技巧帮我省了很多盲目调参的时间,希望帮到你。
本文还有配套的精品资源,点击获取