简介:这是一份面向计算机类专业学生毕业设计或课程作业的完整工程资源,聚焦基于深度学习的近红外光谱数据回归分析,解决光谱数据预处理、模型构建与回归预测这一完整流程问题。项目以Python为主要实现语言,并引入C++用于数据预处理或计算密集环节以提升效率,适合有一定深度学习基础、希望上手真实回归任务的学习者。资源共9个文件,以8个Python源码文件为主,涵盖ConvNet、DeepVit、SpectFormer等不同深度模型实现,并配有1个Markdown说明文档,便于理解代码结构、运行逻辑与复现实验;压缩包整体仅27KB,轻量易用。该资源已有233人浏览学习,学习热度可观。通过源码可掌握近红外光谱数据的特征工程方法、深度学习回归模型的搭建与训练流程,以及基于TensorFlow或PyTorch的模型验证与调参思路,对完成毕设或课程设计具有直接的参考和复用价值。
1. 近红外光谱回归建模,为什么最后选择了深度学习
近红外光谱数据的回归分析,是化学计量学和工业在线检测里最常出现的一类任务:输入一条覆盖几百到几千个波长点的吸光度曲线,输出水分、蛋白质、辛烷值这类连续指标。做这种题目,绝大多数教材会先教偏最小二乘(PLS)回归,因为光谱矩阵共线性重,线性方法配合预处理往往已经能用。但真把一批复杂样品拿到手,谱峰重叠、颗粒散射、温湿度漂移叠加在一起时,PLS 能挖出来的信息就到头了。于是基于深度学习的近红外光谱数据回归分析模型,这几年顺理成章成了毕设和课程作业的热门选题。它做的事情很直接:让一维卷积或全连接网络自己去光谱里找特征,绕开人工设计特征和线性假设。这篇文章按做这类题目最顺的路径展开——先把光谱数据洗干净,再定网络结构,最后给出可直接改的训练代码和几个答辩时能多拿分的验证细节。
2. 近红外光谱数据预处理:清洗、平滑与增强
2.1 光谱数据在回归任务里的真实形态
拿到手的近红外光谱数据,常见格式是 CSV 或文本表格:每一行是一个样本,前面若干列是波长点的吸光度值,最后一列是标签。波长点数量从几百到几千不等,但样本量常常只有几百条。这是整个建模过程里第一个要正视的矛盾——特征维度远大于样本量,而且相邻波长点高度相关,直接丢给全连接网络,参数空间大,过拟合几乎是必然的。
另一个容易被忽视的问题是数据来源。实验室里的光谱经常按样品顺序连续采集,同一个样品可能测了 2 到 3 条谱,如果直接随机划分数据集,同一样品的重复谱会被同时塞进训练集和验证集,验证指标会比真实水平好看很多,这就是数据泄露。处理这类数据的第一原则是先看谱图文件里有没有样品编号、采集批次这类列,划分必须按样品而不是按行做。
2.2 预处理方法对比与参数选择
近红外光谱的预处理方法很成熟,目的无非是消除基线漂移、颗粒散射和高频噪声。下面这几种是我处理光谱回归数据时最常用的,按优先级排:
| 方法 | 解决什么问题 | 常用参数或公式 | 注意事项 |
|---|---|---|---|
| 均值中心化 | 基线整体平移 | X - X.mean(axis=0) | 通常最先做,配合其他方法使用 |
| SNV 标准正态变量变换 | 颗粒散射导致的样本间幅值差异 | 每行减去自身均值再除自身标准差 | 逐样本独立计算,不跨样本统计 |
| SG 平滑 | 高频随机噪声 | window_length=11, polyorder=2 | 窗口过大会抹掉窄吸收峰 |
| 一阶导数 | 消除常值基线,凸显峰形变化 | np.gradient(X, axis=1) | 会放大噪声,一般放在平滑之后 |
| MSC 多元散射校正 | 加性乘性散射效应 | 以平均光谱为参考谱做回归校正 | 适合颗粒较大的固体粉末光谱 |
我一般把 SNV 和 SG 平滑组合成默认管线,因为近红外光谱里颗粒散射和仪器噪声最常同时出现。SG 平滑的窗口长度要跟着光谱分辨率走,波长间隔在 1nm 左右时 11 到 15 个点很安全;如果谱峰本身很尖锐,窗口别超过 9。一阶导数能明显拉开重叠峰,但会让噪声变大,用在深度学习模型上时,数据量不足反而容易把模型带偏。
2.3 一条可复用的预处理管线
下面这段代码可以直接作为模型输入的预处理模块,支持原始模式、SNV、SG 平滑和导数的组合:
import numpy as np from scipy.signal import savgol_filter def snv(x: np.ndarray) -> np.ndarray: """标准正态变量变换:逐样本去均值、除标准差,消除散射带来的幅值差异。""" return (x - x.mean()) / (x.std() + 1e-8) def preprocess_spectra(X: np.ndarray, mode: str = "snv+sg", window: int = 11, polyorder: int = 2) -> np.ndarray: X = X.astype(np.float64) if "snv" in mode: X = np.apply_along_axis(snv, axis=1, arr=X) if "sg" in mode: X = np.apply_along_axis( lambda row: savgol_filter(row, window_length=window, polyorder=polyorder), axis=1, arr=X ) if "deriv" in mode: X = np.gradient(X, axis=1) return Xapply_along_axis让每个样本独立走一遍处理,避免跨样本统计量泄漏到验证集。snv末尾加1e-8是为了防止全零光谱导致除零。这里有个决定模型上限的细节:预处理参数只能在训练集上确定。比如你要做均值中心化,就只计算训练集的光谱均值,验证集和测试集减同一个均值,而不是各自减自己的均值。对 SNV 这类逐样本方法影响不大,但对任何跨样本统计方法来说,这是红线。
2.4 数据集划分与光谱增强
划分代码我会直接写成按组分:
from sklearn.model_selection import GroupShuffleSplit groups = df["sample_id"].values # 同一个样品重复测的光谱编号相同 split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(split.split(X, y, groups=groups))GroupShuffleSplit的核心作用是保证同一样品的所有重复光谱全部落在同一侧。如果数据里没有样品编号,就把连续采集的一段光谱当成一个组,用谱图索引的区间来划分。
光谱增强对深度学习模型很有用,尤其是样本量不足 500 时。我常用的增强是给训练集加高斯噪声和随机缩放:
rng = np.random.default_rng(0) noise = rng.normal(0, 1e-3 * X_train.std(), X_train.shape) X_train_aug = np.concatenate([X_train, X_train + noise], axis=0) y_train_aug = np.concatenate([y_train, y_train], axis=0)噪声方差取训练集整体标准差的千分之一左右,太小没效果,太大会把真实吸收峰盖掉。增强只作用于训练集,验证集和测试集保持原始状态,否则验证损失会被“增强过”的数据污染,失去对比意义。
3. 回归网络选型与训练:从 MLP 基线到 1D-CNN
3.1 先跑通 MLP 基线,确认标签可学习
很多人一上来就上卷积,但我的习惯是先搭一个简单的多层感知机(MLP)做基线。MLP 不是用来拿最好结果的,它回答两个问题:预处理的谱图信息是否真的和标签相关,以及整个训练管线有没有跑通。如果 MLP 在验证集上的 R² 连 0.5 都到不了,后面的复杂模型大概率也不会太好,问题往往出在标签质量或数据划分上。
MLP 结构不需要复杂,按下面配置就够:
| 层 | 输出维度 | 参数说明 |
|---|---|---|
| Linear | 512 | 把整条光谱展平后压到隐藏层 |
| BatchNorm1d | 512 | 对 batch 内样本做归一化,稳定训练 |
| ReLU + Dropout(0.3) | 512 | Dropout 防过拟合 |
| Linear | 128 | 继续压缩特征 |
| ReLU + Dropout(0.3) | 128 | 第二个隐藏层 |
| Linear | 1 | 回归输出层,不加激活函数 |
MLP 的参数量和波长点数直接挂钩,1000 个波长点时第一层就有几十万个参数,几百条样本几乎必然过拟合。所以 MLP 的隐藏层不要贪宽,512 已经偏大,加 Dropout 是必须的。输出层绝对不能用 Sigmoid 或 ReLU,回归任务要求输出没有上限,直接裸线性层。
3.2 1D-CNN 为什么是近红外光谱最合适的结构
一维卷积在光谱数据上有个天然优势:卷积核沿波长方向滑动,每个核只看相邻的几个波长点,这正好对应近红外光谱里吸收峰的特征——一个吸收带通常跨越几个到几十个纳米,局部波形比全局平均值更有信息量。卷积既能提取局部形状特征,又把参数数量压了下来,这是 MLP 做不到的。
近红外光谱回归里我用得最多的一组结构如下:
import torch.nn as nn class SpectralCNN(nn.Module): def __init__(self, n_wavelengths: int = 1024): super().__init__() self.features = nn.Sequential( nn.Conv1d(1, 32, kernel_size=7, stride=1, padding=3), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, kernel_size=5, stride=1, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=5, stride=1, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 1), ) def forward(self, x): return self.head(self.features(x))第一个卷积核大小取 7,对应 7 个相邻波长点,覆盖一个窄吸收峰的宽度。padding 设为 3,保证卷积后波长维长度不变。每个卷积块后接 BatchNorm 和 MaxPool,池化步长为 2,把波长维逐步减半,相当于不断放大感受野。最后一层用AdaptiveAvgPool1d(1)而不是直接 Flatten,好处是最终特征图的尺寸和输入波长点数量无关,换一批不同分辨率的光谱数据也能跑。唯一需要同步调整的只有n_wavelengths,但由于全局平均池化的存在,这个参数实际上只影响第一层输入维度,网络主体不需要改。
3.3 回归任务的损失函数与训练超参
回归模型最常用的损失函数是均方误差 MSE,它对大误差样本惩罚更重,梯度方向也稳定。如果标签里有离群值,MSE 会被离群样本牵着走,这时可以改用nn.SmoothL1Loss,它在误差较大时梯度为常数,不会因为单个坏点导致训练震荡。近红外光谱数据一般比较干净,默认 MSE 就够用。
训练配置我一般这样给:
| 超参数 | 建议值 | 说明 |
|---|---|---|
| batch_size | 16 或 32 | 光谱样本通常只有几百条,太大容易收敛到差的局部解 |
| 初始学习率 | 1e-3 | Adam 配合 1e-3 起步,验证损失停滞再降低 |
| 学习率调度 | ReduceLROnPlateau(factor=0.5, patience=10) | 验证损失连续 10 个 epoch 不降就降一半 |
| weight_decay | 1e-5 到 1e-4 | 相当于 L2 正则,能有效压低验证误差 |
| 早停 | patience=25 | 记录验证 RMSE 最优的模型权重,停止后再恢复 |
激活函数方面,隐藏层用 ReLU 是最稳的选择,近红外光谱本身是连续的平滑信号,不存在 ReLU 死亡导致梯度消失的问题。想再激进一点可以用 SiLU,有时能提零点几个百分点的 R²,但换来的是训练更敏感。评价回归模型不能只看 loss,必须回到原始量纲看 RMSE 和 R²,这两个指标才是毕设报告里最有说服力的数字。
4. 用 PyTorch 跑通基于深度学习的近红外光谱回归模型
4.1 数据装载与标签标准化
训练模型前,我先把光谱数据转成 PyTorch 的 TensorDataset,并给输入加一个通道维。近红外光谱数据本质上是一条一维信号,PyTorch 的 Conv1d 期望输入形状是(batch, channels, length),所以要在中间加一维。
标签标准化这一步容易被忽略,但影响很大。水分含量、蛋白质含量这类标签的量纲和数值范围五花八门,如果标签均值是 50、方差是 200,MSE 的初始值会非常大,模型前几个 epoch 都在补偿标签偏移量。常见做法是对训练集标签做 z-score 标准化,保存均值和标准差,预测完再还原:
import torch import numpy as np from torch.utils.data import TensorDataset, DataLoader y_mean = y_train.mean() y_std = y_train.std() y_train_norm = (y_train - y_mean) / y_std train_ds = TensorDataset( torch.tensor(X_train, dtype=torch.float32).unsqueeze(1), torch.tensor(y_train_norm, dtype=torch.float32).unsqueeze(1) ) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)unsqueeze(1)把输入从(B, L)变成(B, 1, L),这个 1 就是卷积层的通道数,对应单条光谱。标签也做了unsqueeze(1),让输出的形状和 MSE 计算的要求一致。shuffle=True每个 epoch 都打乱样本顺序,避免模型学到采集顺序带来的伪模式。
4.2 训练循环、早停与学习率调度
训练循环里我习惯把验证集评估和早停写在一起,每轮 epoch 先训练再验证,验证 RMSE 下降才保留当前权重:
import torch.nn as nn from torch.optim.lr_scheduler import ReduceLROnPlateau model = SpectralCNN() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=10) criterion = nn.MSELoss() X_val_t = torch.tensor(X_val, dtype=torch.float32).unsqueeze(1) y_val_t = torch.tensor(y_val, dtype=torch.float32).unsqueeze(1) best_val_rmse = float("inf") best_state = None patience = 25 wait = 0 for epoch in range(300): model.train() train_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() optimizer.step() train_loss += loss.item() * xb.size(0) model.eval() with torch.no_grad(): val_pred = model(X_val_t) val_rmse = torch.sqrt(criterion(val_pred, y_val_t)).item() scheduler.step(val_rmse) if val_rmse < best_val_rmse - 1e-5: best_val_rmse = val_rmse best_state = {k: v.clone() for k, v in model.state_dict().items()} wait = 0 else: wait += 1 if wait >= patience: break if epoch % 10 == 0: print(f"epoch {epoch:3d} | train_loss {train_loss/len(train_loader.dataset):.4f} | val_rmse {val_rmse:.4f}") model.load_state_dict(best_state)mode="min"对应验证 RMSE 越小越好,调度器检测到验证值 10 个 epoch 不下降就把学习率乘 0.5。patience=25是验证 RMSE 不再创新高时允许等待的最大轮数,等到 25 轮直接跳出循环,然后用best_state恢复历史最优权重。注意这里验证 RMSE 是在标准化后的标签上算的,最终报告指标要还原到原始量纲再计算,否则 RMSE 数值小得离谱,答辩时容易说不清。
4.3 评价指标计算与结果验证
模型训练完,第一件事是看预测值和真实值的散点图与回归指标。我固定计算三个指标:
from sklearn.metrics import r2_score, mean_absolute_error model.eval() with torch.no_grad(): pred_norm = model(X_val_t).numpy().ravel() pred = pred_norm * y_std + y_mean y_true = y_val rmse = float(np.sqrt(np.mean((y_true - pred) ** 2))) r2 = r2_score(y_true, pred) mae = mean_absolute_error(y_true, pred) print(f"RMSE {rmse:.3f} | R2 {r2:.4f} | MAE {mae:.3f}")R² 反映模型的解释力,正常应该在 0.9 以上才算模型基本可用;RMSE 的单位和标签一致,直接代表平均预测偏差,比如水分含量预测 RMSE 为 0.5%,意味平均误差在半个百分点左右。画出验证集散点图时,点应该紧贴 y=x 对角线,如果在低值区或高值区系统性偏离,说明该区间样本量不足或者模型对该区间不敏感。
4.4 三个最常见的训练故障
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| loss 为 NaN | 学习率过大或输入光谱含有 NaN/Inf | 预处理后检查np.isnan(X).sum(),学习率调到 1e-4 |
| 验证 RMSE 远高于训练 RMSE | 过拟合,参数太多样本太少 | 加大 Dropout 到 0.5,weight_decay 提到 1e-3,缩小全连接层宽度 |
| 训练 loss 不下降 | 标签未标准化,网络太深难收敛 | 确认标签做了 z-score,先跑 MLP 基线验证数据本身可学习 |
遇到 loss 不降,不要把精力放在换模型上。先回到预处理和数据划分,看标签分布是否异常,看训练集和验证集的光谱范围是否重叠。很多时候问题根本不在结构上,而在数据本身。
5. 答辩多拿分的细节:波长注意力与过拟合排查
5.1 用一维类 Grad-CAM 定位重要波长区间
深度学习模型被诟病最多的就是不可解释。近红外光谱数据自带波长坐标,可以让模型输出对输入光谱求梯度,看哪些波长点对预测结果贡献最大。类 Grad-CAM 的手法在一维信号上同样成立:
model.eval() x = torch.tensor(X_val[0], dtype=torch.float32).unsqueeze(0).unsqueeze(0) x.requires_grad_(True) pred = model(x).sum() pred.backward() importance = x.grad.abs().squeeze().numpy() # importance 与原始波长坐标对齐 # 如果使用的是标准化后的光谱,先记录原始光谱的波长轴sum()是把回归输出聚合成标量,才能触发反向传播。梯度绝对值越大,说明该波长点对预测输出的影响越大。把 importance 画成曲线叠在平均光谱下面,高贡献区间如果落在水分子 O-H 吸收带或蛋白质 N-H 吸收带附近,就可以在答辩时直接说“模型学到的是化学意义上的特征,不只是在背数据”。如果输入层梯度噪声太大,改为对最后一个卷积层输出的特征图做梯度加权平均,结果更稳定。
5.2 可学习的波长注意力权重
比输入层梯度更直观的做法是在网络里加一个可学习的波段权重向量,初始化全 1,训练完直接看哪些波段的权重被放大:
class SpectralAttention(nn.Module): def __init__(self, n_wavelengths: int): super().__init__() self.mask = nn.Parameter(torch.ones(1, 1, n_wavelengths)) def forward(self, x): return x * torch.sigmoid(self.mask)nn.Parameter会被优化器自动更新,sigmoid把权重限制在 0 到 1 之间,训练结束后查看self.mask的分布,就能知道模型保留了哪些区间。注意初始化不能全 0,否则一开始所有波长都被抑制,模型学不出来。
5.3 过拟合排查的实操顺序
最后整理一个排查过拟合的固定流程,照着做能省一半时间。第一步看验证集 RMSE 和训练集 RMSE 的差距,差距在 20% 以内属正常,超过 50% 就先把 Dropout 提到 0.5。第二步看权重衰减,weight_decay 从 1e-5 逐步加到 1e-3,每次只改一个参数看验证集表现。第三步减少网络容量,把卷积层通道数减半或全连接层压缩到 32,光谱任务对宽度不敏感,对深度更不敏感。第四步检查增强强度,噪声加到原光谱标准差的千分之一以上时,训练误差会抬高但不该抬高验证误差。记录每次实验的 train_rmse、val_rmse、R² 到 CSV 里做对照,比凭感觉调参数可靠得多。
本文还有配套的精品资源,点击获取