简介:面向连续光谱分析的数值型卷积神经网络(CNN)Python源码包,专为光谱数据处理、特征提取与模式识别任务设计,适合具备一定深度学习基础、希望快速搭建可复现光谱分析模型的研究者或开发者。压缩包共19个文件,约110.74MB,其中7个py脚本覆盖模型搭建、训练测试与特征可视化,5个csv文件提供实测光谱数据集与结果表,另有txt日志、pkl模型权重、png混淆矩阵及md说明文档,目录结构清晰,可直接运行或二次开发。模型实现中集成了CBAM、SplitAttention、SE等注意力模块,能够自适应增强光谱关键波段,配合去噪、归一化等预处理策略,在高维含噪光谱数据上展现出较强的特征提取能力与分类准确率。另提供带标签的预测结果与可视化结果,便于快速评估模型效果并对比不同注意力机制的影响。已有97人浏览学习,适合用于复现实验、深入理解CNN参数优化过程,或作为进一步改进光谱分类模型的起点。
1. 连续光谱的数值型CNN:Python源码包到底值不值得拆
连续光谱数据(拉曼、红外、紫外-可见光)落到计算机里,其实就是一串按波长顺序排开的强度数值。肉眼看着差不多,但不同物质在某个峰位上差那么零点几个强度单位,就足以区分。这套源码包就是干这个的:直接对光谱数值做一维卷积,配合注意力机制做分类识别。我拿到手之后最意外的结论是——train.py 谁都能跑通,但真正值钱的是 bestmodel.pkl、lastmodel.pkl 和 checkpoint 这三个模型文件,它们本身就是一条完整的避坑路径。这套东西适合两类人:一类是化学计量学背景、想用深度学习但不知道光谱怎么喂进网络的研究者;另一类是已经跑通分类、但被基线漂移和准确率虚高折磨的工程师。它没有图形界面,全部是脚本加 CSV 数据,胜负手全在数据管线和训练恢复的逻辑里。
2. 数据管线先立住:SR_mean / SR_median / SR_mode 三套 CSV 各自的定位
2.1 光谱数据进网络之前的形状处理与归一化
光谱仪输出的原始数据,横坐标是波数或波长,纵坐标是强度,本质是一个一维向量。连续光谱经过采样后,通常有几百到几千个离散点。这份工程包里以 CSV 组织数据,常见做法是:每一行代表一条光谱,前面的列是按波长排序的强度值,最后一列是类别标签。绝大多数新手上来就把整个 CSV 丢给 pandas 读成 DataFrame,然后直接转 tensor,结果就是模型训练 loss 降不下去,问题大多出在数据形状和数值尺度上。
先把形状理清。一维卷积要求输入是(batch_size, channels, sequence_length),channels 一般是 1,sequence_length 就是光谱长度。也就是一条光谱最终要变成(1, 2600)这样的张量,而不是(2600, 1)。这个顺序反了,卷积核扫的方向就从波长轴变成了通道轴,整个特征提取逻辑全乱。
归一化也直接影响收敛。我的习惯是,先用均值方差标准化把每条光谱压到零均值单位方差附近,而不是做 MinMax 归一化。原因是光谱强度受仪器增益、激光功率影响很大,同一物质在不同条件下测出来的绝对强度差两三倍,但形状一致。减均值除标准差之后,绝对强度的影响就被消掉了,CNN 学的是峰形而非强度绝对值。MinMax 在光谱场景下容易被个别异常强峰拉偏,整套数据被压扁到很小的取值区间。
2.2 Dataset 类:全样本种子策略与 train/test 划分
工程包里有一个spectral4_allseed_mean_all.csv,这个命名很有意思。“allseed”代表在多组随机种子下做过多次划分,然后把结果平均,也就是说这份 CSV 本身已经带上了评估稳健性的思路。你在自己的数据上复现时,只跑一次随机划分就下结论是不太信得过的,至少要在三种 seed 下各跑一遍,把指标取平均才有说服力。我拿到工程包后先做的事,就是把 Dataset 类重建出来,保证数据读取、增强和划分逻辑可控。
# 常见做法:CSV 前 n 列是波长点强度值,最后一列是label import pandas as pd import numpy as np import torch from torch.utils.data import Dataset class SpectralDataset(Dataset): def __init__(self, csv_path, label_col=-1, augment=False): df = pd.read_csv(csv_path) self.data = df.iloc[:, :label_col].values.astype(np.float32) self.labels = df.iloc[:, label_col].values.astype(np.int64) self.augment = augment def __len__(self): return len(self.labels) def __getitem__(self, idx): x = self.data[idx] if self.augment: # 加一点高斯噪声模拟测量波动,量级要远小于真实信号 noise = np.random.normal(0, 1e-4, size=x.shape) x = x + noise return torch.tensor(x).unsqueeze(0), self.labels[idx]这段代码有两个容易被忽略的点。一是astype(np.float32),默认读进来的 CSV 是 float64,double 一下占两倍显存,两百条光谱感觉不出来,两万条就明显了。二是unsqueeze(0),这就是前面说的把(sequence_length,)变成(1, sequence_length),补上 channel 维度。噪声增强里的量级1e-4是个参考值,要和你的信号强度匹配,如果你的谱峰强度在 0.1 这个量级,噪声加 1e-4 就是千分之一,加多了反而把峰淹了。
2.3 数据增强与无效波段裁剪的取舍
光谱数据增强和图像不一样,不适合随机裁剪、旋转。光谱的物理意义是波长轴对应特定化学键振动,裁剪等于破坏物理信息。这套工程包里比较实用的增强手段就三类:加高斯噪声模拟测量误差、轻微基线漂移(给整条曲线加一个随机斜率)、强度缩放。但要注意增强只对训练集做,测试集必须保持原始状态,否则你评估的就不是模型的泛化能力,而是模型对噪声的容忍度。
无效波段裁剪值得花时间做。光谱首尾常常是噪声区,尤其是红外光谱,两端还可能被水汽峰占据。我看过很多人把全谱段直接喂进去,模型参数量翻一倍,准确率反而掉两个点。我的处理方式是:先用Feature_visualisation.py里的可视化脚本画出各类平均光谱,观察哪些波段完全没有区分度,直接切片裁掉,把维度降下来再进网络。这个裁剪的波段区间要记下来,测试和部署时用同一套区间,不要训练裁了测试没裁,维度对不上就是一堆报错。
3. 网络结构拆解:1D-CNN 主干与 SE / CBAM / SplitAttention 的取舍
3.1 MyModel 主干设计:卷积核大小、池化与全连接
这个工程包里MyModel.py是主干网络,配套SE.py、CBAM.py、SplitAttention.py三个注意力模块文件。理解主干设计对后续改网络至关重要。数值型 CNN 处理连续光谱的主干结构,常见做法是两层到三层一维卷积加池化,最后接全局池化和全连接层。卷积核大小是这里最关键的超参数:核越小,看到的局部波段越窄,越能捕捉单个尖锐峰;核越大,视野越宽,越能把握基线漂移和宽峰形态。我的经验值是第一层用 5、第二层用 3,步长 1,padding 设为核长一半,保证卷积后长度不变,池化后再降采样。这是光谱分类里一个比较稳妥的起点。
# 一维CNN主干示意,适配 (N, C, L) 输入 import torch.nn as nn class SpectralCNN(nn.Module): def __init__(self, in_channels=1, seq_len=2600, num_classes=6): super().__init__() self.features = nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size=5, stride=1, padding=2), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2), nn.Conv1d(64, 128, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2) ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这版结构有几个刻意的选择。第一层 64 个卷积核、第二层 128 个,通道数翻倍是常规做法,因为池化把空间分辨率砍半,用通道数补偿信息容量。AdaptiveAvgPool1d(1)把每个通道压成一个数,它的好处是无论输入光谱长度是多少,池化后的特征维度恒为 128,全连接层不用改。这对处理不同批次光谱长度不一致的情况很有用。需要注意池化层会把长度从 2600 降到 650,如果连续卷积层数加得太多,到最后特征图只剩几十个点,此时卷积核的局部感受野已经没有物理意义。所以光谱数据里,两层卷积加池化是甜点区,再深未必更好。
3.2 SE、CBAM、SplitAttention 三种注意力机制的适用边界
SE.py实现的是 Squeeze-and-Excitation,也就是通道注意力。它把每个通道整体压成一个标量,学到一组通道权重,然后乘回原特征图。对光谱来说,这相当于告诉网络“这一层 64 个卷积核里,哪些核提取到的峰形信息更重要”。SE 结构轻、参数少,适合数据量只有几千条的光谱工程,属于不会出错的兜底选择。
CBAM.py在通道注意力之上又加了空间注意力。空间注意力在图像里是关注“图片的哪个区域重要”,对应到光谱就是“哪个波长区间重要”。这一点在光谱里其实比图像更直接:拉曼光谱里指纹区的峰、官能团区的峰,物理意义就是波长位置本身。如果数据里存在明显的峰位偏移(比如温度变化导致的峰位漂移),CBAM 的空间注意力能缓解这个问题。代价是参数多一些,训练时间也长一点,但绝对值得跑一轮对比。
SplitAttention.py是另一种思路,它先把通道分成若干组,每组内部先算一个注意力权重,再在组间做归一化加权融合。它比 SE 和 CBAM 都重,特征表达能力更强,但数据需求量也更大。我用下来的经验是:训练数据不足三千条时,SplitAttention 很容易在验证集上抖动,最后效果反而不如 SE。只有当数据量破万,或者做的是精细分类(比如区分十几种相近物因),才有必要试它。
| 模块 | 注意力类型 | 参数开销 | 数据量要求 | 适用场景 |
|---|---|---|---|---|
| SE | 通道 | 小 | 低 | 通用基线,稳定兜底 |
| CBAM | 通道 + 空间 | 中 | 中 | 峰位偏移、基线漂移明显 |
| SplitAttention | 分组通道 | 较大 | 高 | 大样本、精细分类 |
3.3 把注意力模块接到主干里的标准写法
接入注意力模块的写法,直接影响到底有的效果能不能发挥出来。常见错误是直接把 SE 放在网络的输入端或输出端,两端都不是好位置。注意力应当作用在卷积层输出的特征图上,因为那才是“通道语义”最丰富的地方。我会把 SE 接在每个卷积块之后、池化之前,也就是先卷积提取特征,再用注意力去重标定特征通道的权重,然后才池化降采样。
# 接法示意:在卷积块后插入SE模块 from se import SE1D class SpectralCNNWithSE(nn.Module): def __init__(self, in_channels=1, num_classes=6): super().__init__() self.conv1 = nn.Conv1d(in_channels, 64, kernel_size=5, padding=2) self.se1 = SE1D(channels=64, reduction=16) self.pool1 = nn.MaxPool1d(2) self.conv2 = nn.Conv1d(64, 128, kernel_size=3, padding=1) self.se2 = SE1D(channels=128, reduction=16) self.pool2 = nn.MaxPool1d(2) self.head = nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.pool1(self.se1(nn.functional.relu(self.conv1(x)))) x = self.pool2(self.se2(nn.functional.relu(self.conv2(x)))) return self.head(x)reduction=16是 SE 模块里最常用的压缩比:通道数 64 时,中间瓶颈层的维度就是 4,这个数字太小(比如 reduction=4)会把注意力信息压没,太大又起不到降参作用。如果你把内核从 64 改成 128,reduction 保持 16,瓶颈维度变成 8,依然合理。我每次换了网络宽度都会重新确认这个中间维度不是 0,否则全连接层会直接报维度错误。三层注意力模块之间还有个原则:通道数变化大的层之前接注意力收益高,通道数已经一样的层再叠加注意力属于浪费参数量。
4. 训练参数与 checkpoint 的正确打开方式:bestmodel 和 lastmodel 别搞混
4.1 train.py 里值得改和打死不该动的参数
train.py 是整个工程包的操作核心,但它的参数不是每个都值得动。初始化学习率是最敏感的一个值,光谱数据经过标准化后,损失面相对平滑,学习率设在 1e-3 附近通常能稳定收敛;如果发现 loss 前几轮就炸成 NaN,基本都是学习率太大,降到 3e-4 重来。批次大小默认用 32,光谱样本量本来就不大,批次再大容易让梯度方向过于平滑,Bath Size 小反而带了点正则化味道。优化器用 Adam 就够,我个人习惯加一点 weight decay(1e-4),对抑制光谱里的高频噪声过拟合挺有用。
数据量小的时候,训练轮数不能只看模型是否收敛,还要看是否开始过拟合。工程包里给了一百轮以上的默认配置,在小样本上跑到第 60 轮左右验证集准确率开始回落是常见现象。所以训练脚本里建议把学习率计划换成 CosineAnnealingLR,它能让学习率在后期平滑降到底,比固定学习率跑到底的最终效果更稳。以下是我通常调整控制流的方式:
# 常见排练:学习率、损失函数、类别权重三件套 import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model = SpectralCNN(num_classes=6) optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss(weight=class_weights) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5)class_weights是这段里最容易被跳过的参数。光谱数据天然存在类别不平衡:某类物质测了八百条,另一类只有一百条,交叉熵损失会把多数类的梯度放大,导致少数类几乎学不到。常规做法是统计每类样本数量的倒数,归一化后传给CrossEntropyLoss的weight参数。这个权重不会让少数类准确率飙升,但能让模型从“全预测成多数类”变成“至少尝试区分少数类”。
4.2 checkpoint、bestmodel.pkl、lastmodel.pkl 的加载与恢复
这个工程包同时给了三个模型文件,很多第一次接触的人会问:用哪个来预测?答案是 bestmodel.pkl。checkpoint是训练中间状态存档,bestmodel.pkl是验证集最优权重,lastmodel.pkl是训练结束后最后一轮的权重。三者的用途完全不同,混用就会翻车。
checkpoint 是整个训练过程的后悔药,里面通常存了模型权重、优化器状态、当前轮数、学习率调度器状态。它的唯一正确用途是中断后恢复训练。如果你用 checkpoint 做预测,得到的结果大概率比 bestmodel 差,因为它是某次保存时刻的快照,不是验证集上表现最好的版本。bestmodel.pkl 是每个 epoch 结束时对比验证集指标,保存最优的那次权重,这是部署推理的正确入口。lastmodel.pkl 用于实验对比:你想确认训练到最后一轮模型是否还在稳定提升,拿它和 best 对比就能看出来。
# 断点续训:只能走checkpoint这条路 import torch def resume_training(model, optimizer, scheduler, ckpt_path): ckpt = torch.load(ckpt_path, map_location="cuda") model.load_state_dict(ckpt["model_state_dict"]) optimizer.load_state_dict(ckpt["optimizer_state_dict"]) scheduler.load_state_dict(ckpt["scheduler_state_dict"]) start_epoch = ckpt["epoch"] + 1 return model, optimizer, scheduler, start_epoch这段代码最强调的是一个细节:如果恢复时漏了scheduler.load_state_dict,学习率就会从初始值重新开始,而余弦退火的周期已经走到一半,学习率忽高忽低,loss 曲线会突然出现阶梯状波动,看起来像玄学,其实就是调度器没有跟着恢复。我见过有人在这个坑里耗了两天,最后加一行代码解决。另一个容易忽略的恢复技巧是确认epoch从哪开始,文件里存的是已完成的轮数,所以要加 1,否则会把已有轮次再重训一遍。
4.3 从 train_info.txt 和 output.txt 定位训练异常
训练结束后,工程包里会产出 train_info.txt 和 output.txt 两个文本文件,还有一张 Confusion Matrix.png。不是所有人都习惯先看日志再动手,但这恰恰是最快的排错路径。output.txt 是控制台输出的原始重定向,包含每个 epoch 的 loss 值、时间戳和 LR 值;train_info.txt 是整理后的训练指标汇总,看起来更像一个结构化的进度表。
我最先看的是 loss 曲线的形态。正常的曲线是前二十轮快速下降,随后缓慢趋平。如果 loss 在某个 epoch 突然跳高又回落,通常有三个来源:一是学习率调度器在那个节点做了跳变;二是训练集里混入了异常样本(比如一条空白光谱被标上了类别);三是数据加载器做了增强,而噪声量级设置过大。如果验证集准确率一直不动,但训练集准确率一路拉满,说明模型把训练集背下来了,优先检查输出层前的全连接层宽度和 dropout 是否缺位。如果 output.txt 里出现了 NaN,不要再讨论模型架构,先降到学习率,或者检查数据是否存在 log0 的来源。
日志和模型文件是配套的。每次训练完,把 bestmodel 对应的 epoch 号回填到 train_info.txt 里,和验证集指标对应上。这样以后你看到它是第 87 轮选出的最优,就知道训练到 100 轮时模型其实已经在后面十几轮轻微过拟合了。
5. 四个高频避坑:数据泄漏、类别不平衡、基线漂移与随机种子
5.1 数据泄漏:全数据集归一化会让准确率虚高
现象:训练集准确率 99.5%,测试集准确率 99.2%,看起来完美。换了一批从同一型号光谱仪新采集的数据,准确率直接掉到 72%。第一反应怀疑模型过拟合,但训练曲线很正常,最后发现是预处理泄漏。
原因:这个工程包里 CSV 是全量数据,如果你的归一化代码在划分训练测试集之前就执行了,均值和方差就是全数据集计算出来的,测试集的信息已经混进了训练过程。模型在训练时间接“见过”测试集的分布特性,测试准确率虚高,部署到新数据就原形毕露。
解决:严格按“先划分、再 fit、再 transform”的顺序处理。在训练集上计算均值方差,测试集只做 transform,不做 fit。这个预处理器要单独存储,推理新光谱时用同一个均值方差参数。从那以后我每次跑光谱工程都强制检查:scaler 是在 split 之前还是之后 fit 的,这一行顺序决定了指标可不可信。
5.2 类别不平衡:混淆矩阵比总准确率诚实得多
现象:总准确率 92%,看着很好,但打开 Confusion Matrix.png 发现,样本量最小的两类准确率只有 20% 左右,几乎全被分到多数类。多数类样本量是少数类的十倍以上,交叉熵损失整体上就被多数类主导。
原因:光谱实验数据经常是按物质类型采集的,某些类别容易出峰、测得多,某些类别工艺难度大、只攒了几十条。模型学到“全部预测成多数类”就能获得很高的整体准确率,但实际业务场景里,少数类往往才是真正要找的目标物。
解决:用class_weight给少数类加权,监控指标从 accuracy 换到 macro-F1,并且把混淆矩阵当成验收必看项。只看总准确率就是骗自己,模型上线后漏检的正是少数类样本。
5.3 基线漂移与 SG 平滑:预处理不是可选项,是必修课
现象:同一物质同一批次,连续测十条光谱,峰位一致但基线高低起伏明显。模型训练时准确率尚可,换一台光谱仪再测,准确率骤降,因为模型的“分类依据”很大一部分学了基线的高低,而不是峰的形状。
原因:光谱仪的漂移、荧光背景、样品厚度差异,都会在原始曲线上叠加一个缓慢变化的基线。CNN 虽然能自动提取特征,但如果输入里去噪和基线校正不足,卷积层在浅层捕获到的最大响应往往是基线变化,而不是拉曼峰或吸收峰的差异。
解决:对原始曲线先做 Savitzky-Golay 平滑去小毛刺,再做一阶导数或基线校正,最后才进归一化。SG 平滑的 window 建议取 7 到 11,polyorder 取 2 或 3。window 太大会把窄峰磨平,太小又去不掉毛刺。这也是工程包里同时提供SR_mode.csv、SR_mean.csv、SR_median.csv三份数据的原因,同一批原始光谱按不同聚合方式处理,SR_median对离群点最稳,SR_mean噪声抑制最均衡,SR_mode保留的峰形最锐利但噪声也最大。调参时三份数据各跑一遍,选验证集表现最稳的那个作为上线版本,不要只赌一份。
5.4 随机种子与确定性:省掉“为什么我复现不了”的后悔药
现象:同一个代码同一个数据集,跑三次得到三个不同结果,准确率在 89% 到 93% 之间浮动。这看起来像是玄学,其实是深受欢迎的“初代炼丹”。固定了torch.manual_seed还是不行,因为还有两层随机源没锁住。
原因:数据加载器拆数据时的随机索引、numpy 的随机噪声增强、以及 cuDNN 的自动调优,每一个都会引入随机性。只固定 PyTorch 的种子,对另外两层起不到约束作用。
解决:写一个统一函数把这几个随机源全部固定,包括 Python 内置 random、NumPy 和 cuDNN。只要设置torch.backends.cudnn.deterministic = True,卷积算法都改成确定式实现,训练速度会稍微下降,但换来的是完全可复现的实验结果。做消融实验时如果不锁种子,三个注意力模块之间的差异会被随机噪声淹没,根本分不清是模块效果好还是运气好。
6. 特征可视化与推理验证:把“效果较好”变成能拿出手的证据
6.1 Feature_visualisation.py 里到底生成了什么
阅读代码前先看输出。Feature_visualisation.py 和 report.py 组合在一起,产出三类图形:训练过程的 loss/acc 曲线、测试集预测结果的混淆矩阵、高维特征空间的降维分布图。前两类是训练报告常规内容,真正有意思的是第三类。网络在全连接层之前输出的特征向量是 128 维的,人眼看不见,常规做法是先用 PCA 把维度压到 30,再用 t-SNE 投影到二维平面,perplexity 通常取 30 到 50。这张图里每个点代表一条光谱,点的颜色代表真实类别。如果同色点聚成清晰团簇,异色点明显分开,说明模型学到的特征确实有区分度,而不只是 loss 数字好看。观察时重点看有没有“不同类别被压在同一片区域”的情况,如果有,回到数据管线检查类别不平衡和基线校正,通常比盲目加深网络有效得多。
6.2 用 Test 目录做离线推理并核对混淆矩阵
Test 目录和 test.csv 是独立的,意味着工程包把训练和推理拆成了两个阶段,这是光谱工程里正确的姿势。推理脚本要保证一次完成三件事:加载预处理器参数、加载 bestmodel.pkl、输出测试指标和混淆矩阵。预处理器参数必须是最初在训练集上 fit 的那份,不能加载后重新 fit,否则数据分布又变了。
# 推理流程:预处理参数、模型权重、测试数据三者顺序不能乱 import joblib def predict_from_csv(test_csv, scaler_path, model_path): scaler = joblib.load(scaler_path) # 训练时保存的归一化参数 model = SpectralCNN() model.load_state_dict(torch.load(model_path, map_location="cpu")) model.eval() df = pd.read_csv(test_csv) x = scaler.transform(df.values.astype(np.float32)) x = torch.tensor(x).unsqueeze(1) with torch.no_grad(): logits = model(x) preds = logits.argmax(dim=1).numpy() return preds这段代码里最容易被忽略的是model.eval()。eval 模式会关闭 dropout 和 batch norm 的统计更新,在训练模式下跑推理,结果会有随机性。正常使用下,一个 epoch 十秒到几十秒不等,CPU 上也可以用,只是慢一些,建议推理阶段放 GPU 或者至少保证输入不是超大 batch。脚本跑完,输出 test_info.txt 和 Confusion Matrix.png,这里面的准确率、精确率、召回率才是值得写进报告的数字。
我自己的习惯是拿到任何光谱工程,都强制按这个顺序走一遍:读 CSV 确认列结构,固定随机种子,只对训练集 fit 归一化参数,用 bestmodel 出推理指标,最后看一眼混淆矩阵里少数类的位置。这五步走完,模型的真实水平基本藏不住了。这套源码包的价值不在于某个结构多新鲜,而在于它把模型、注意力、训练恢复、可视化这几个环节装配成了一个能闭环的工程底座——快改两行就能换数据上自己的光谱分类任务。希望帮到你。
本文还有配套的精品资源,点击获取