简介:面向近红外光谱(NIR)数据回归分析场景的深度学习模型代码包,适合化学、生物医学、食品及农业等领域需要利用光谱数据预测组分含量的研究人员与学生使用。该压缩包共9个文件,含8个Python脚本和1个Markdown说明文档,包体仅26KB,结构紧凑。Python脚本实现了ConvNet、SpectFormer、VitNet、DeepVit等多种主流神经网络及迁移学习版本,覆盖从传统卷积到Transformer、视觉Transformer的典型架构,可用于光谱特征自动提取与属性回归预测;Markdown文档配合代码介绍模型结构、数据预处理及实验设置思路。针对NIR光谱高维、复杂的特点,脚本内置了标准化、降噪、交叉验证等关键处理环节,并给出正则化、早停等过拟合控制策略,帮助用户在训练中保持模型稳定。已有117人学习下载,兼具入门参考与对比研究价值。运行这些脚本,可快速对比不同深度模型在同一回归任务上的精度与收敛特性,理解深度学习在光谱建模中的实际应用方式,并在此基础上扩展自己的实验方案。资源体积小巧,却覆盖了从基础CNN到前沿视觉Transformer的典型路径,适合作为光谱深度学习实战的起步模板。
1. 基于深度学习的近红外光谱数据回归分析模型:这套代码包里到底装了什么
在近红外光谱这种变量多、样本少、信噪比还带着基线漂移的数据上,回归分析模型的构建一直是门手艺活。传统PLSR在新样本上一测就掉点,而基于深度学习的近红外光谱数据回归分析模型把这事从“调主成分个数”换成了“调网络结构和正则化”,效果上限更高,但坑也更多。这个zip里装的不是魔法,而是一套从光谱预处理、样本划分到模型训练、评估的完整工程。我拿到这类包,第一件事不是跑训练,而是先看数据加载和预处理函数,因为那儿决定了一套代码能不能换到自己的光谱数据上直接用。适合谁?手上有近红外光谱和对应化学测定值、想脱离手动调参的从业者,或者刚看完深度学习理论想找真实回归场景练手的同学。
2. 从吸光度到输入张量:光谱预处理的流水线与样本划分
2.1 为什么近红外的深度学习必须先过预处理这一关
近红外光谱记录的是分子含氢基团(O-H、C-H、N-H)的倍频与合频吸收,信号本身宽峰重叠严重,又很容易被样品颗粒度、温湿度和仪器状态干扰。同一个样品在不同装样条件下测出来的谱线,吸光度整体抬升或者斜率发生偏移是常事。模型再强,也扛不住输入分布天天变。深度学习方法在光谱回归上有优势,靠的是非线性拟合能力,不是对脏数据的容忍度。所以预处理这一步,把物理干扰去掉,让模型把精力放在化学信息的提取上,这是整个回归分析建模里性价比最高的一段。
常见做法是:先做散射校正(SNV或MSC)补偿颗粒度和光程差异,再做平滑降低随机噪声,需要时用一阶或二阶导数分离重叠峰。顺序上我习惯先做散射校正再平滑,或者组合使用,别一上来就上导数,因为导数会把噪声放大,信噪比差的波段直接没法看。预处理做完后,还要做一次可视化体检:把训练集样本的均值谱和标准差谱画出来,高方差波段往往就是水汽吸收或仪器噪声区,可以在建模时考虑加权或截断。
2.2 平滑、SNV与MSC:三条最常用的光谱处理管线
三个函数是这套代码里的常客。Savitzky-Golay平滑用滑动窗口做局部多项式拟合,比简单移动平均更能保留峰形;SNV对每条光谱独立做标准化,按行减均值除以标准差,适合消除加性散射;MSC则需要一个参考谱(通常用训练集平均谱),对每条光谱做一元线性回归后校正。
import numpy as np from scipy.signal import savgol_filter def preprocess_spectra(X, mode='snv', window=11, polyorder=2): """ X: (n_samples, n_wavelengths) 原始吸光度矩阵 mode: 'snv' / 'msc' / 'smooth' """ if mode == 'smooth': return np.apply_along_axis( lambda row: savgol_filter(row, window_length=window, polyorder=polyorder, deriv=0), 1, X) if mode == 'snv': mean = X.mean(axis=1, keepdims=True) std = X.std(axis=1, keepdims=True) return (X - mean) / (std + 1e-12) if mode == 'msc': ref = X.mean(axis=0) X_msc = np.zeros_like(X) for i, x in enumerate(X): coef = np.polyfit(ref, x, 1) X_msc[i] = (x - coef[1]) / coef[0] return X_msc raise ValueError(f"unknown mode: {mode}")这里有个细节:SNV的std加了一个1e-12的极小量,防止吸光度全零的样本除出NaN;MSC用np.polyfit拟合一条回归线,拟合的是样本光谱对参考光谱的斜率和截距,校正后所有样本的散射背景被拉齐到同一水平。window取9到15、polyorder取2到3是近红外数据里比较稳的起点,波数点越密窗口可以越大,但窗口超过峰宽的1/3就会把真实吸收峰削平。导数处理时把savgol_filter的deriv参数改成1或2即可,但我建议先对比一阶导和原始谱的验证集误差再决定用不用。
预处理完了,输入形状是(n_samples, n_features),n_features就是波数点数,一般几百到几千。这个二维矩阵在送入卷积网络之前还要加一个通道维度,变成(n_samples, 1, n_features),这部分放到模型搭建章节再说。
2.3 SPXY与K折:样本怎么切,结果才不虚高
训练集和测试集怎么切,是近红外回归分析里最容易被低估的一步。光谱数据往往带着明显的来源分组:同一批次采样、同一台仪器、同一批化学测定,样本之间并不独立。如果随机切分,测试集里很可能混着跟训练集几乎重复的样本,验证出来的R²能到0.99,现场一测却只有0.8。用SPXY算法(sample set partitioning based on joint x-y distances)按光谱空间和浓度空间的联合距离选点,能保证训练集覆盖整个浓度范围,测试集也不会落在训练集的空区里。
def spxy_split(X, y, train_ratio=0.7): X = np.asarray(X, dtype=float) y = np.asarray(y, dtype=float).reshape(-1, 1) n = X.shape[0] # 光谱距离矩阵 dxx = np.sqrt(((X[:, None, :] - X[None, :, :]) ** 2).sum(-1)) # 浓度距离矩阵 dyy = np.abs(y - y.T) dxx /= dxx.max() dyy /= dyy.max() dxy = dxx + dyy # 选择距离最远的两个样本作为起点 i, j = np.unravel_index(np.argmax(dxy), dxy.shape) train_idx = [int(i), int(j)] rest = list(set(range(n)) - set(train_idx)) while len(train_idx) < int(n * train_ratio): min_d = dxy[np.ix_(rest, train_idx)].min(axis=1) k = rest[int(np.argmax(min_d))] train_idx.append(k) rest.remove(k) return np.array(train_idx), np.array(rest)这段代码的贪心逻辑是:每次从未选样本里挑出一个,它离当前训练集中任意一个样本的最小距离最大,也就是把最“陌生”的样本优先补进训练集。这样训练集的浓度范围被尽量撑开,测试集则贴近真实使用场景(总会有训练集没覆盖到的角落)。注意这个版本要预先把dxy矩阵算满,样本到两三千条还吃得消,上万的样本集就得分块计算,否则内存先炸。切完数据后,最好再打印一下训练集和测试集的浓度均值与标准差,确认两组分布没有系统性偏移;如果测试集浓度范围比训练集还宽,那模型在这批数据上表现再差都合理。
SPXY只负责一次划分,而K折交叉验证负责评估稳定性。我一般会在训练集内部做5折重复3次,报告R²的均值和标准差;标准差大于0.03就说明模型对样本选择太敏感,需要在正则化上再收紧。
3. 回归分析模型搭建:用PyTorch实现一个1D-CNN并调好关键参数
3.1 选型:MLP足够,但1D-CNN更适合光谱序列
近红外光谱回归的深度模型选择,跟图像分类那种“直接上ResNet”完全不是一个逻辑。光谱样本通常只有几百条,深度网络极易过拟合,所以模型规模要小,正则化要重。MLP(全连接网络)是最简单且够用的起点:输入几百个波数点,中间两到三层隐藏层,输出层一个神经元。它的缺点是每个波数点被当作独立特征,忽略了相邻波数点的局部相关性。
1D-CNN用一维卷积在波数轴上滑动,每次覆盖一小段光谱窗口,天然把邻近波段的吸收特征组合起来,参数量也远小于同样宽度的全连接层。对包含宽峰和肩峰的光谱,1D-CNN在中小样本下通常比MLP高出0.02到0.05的R²,并且对输入噪声的鲁棒性更好。Transformer和LSTM也能做序列建模,但近红外光谱是静态谱图,长度几百到上千点,序列位置信息没有文本或语音那么关键,结构复杂度带来的收益往往被过拟合抵消。所以这套代码里,1D-CNN是性价比最高的选择。
3.2 搭建NIR-1DCNN:PyTorch代码与每一层的参数含义
模型结构按“卷积-批归一化-激活-池化”的单元堆叠,最后接全连接输出回归值。输入形状是(batch, 1, n_wavelengths),第一个维度是通道数,对单条光谱来说是1。
import torch.nn as nn class NIR1DCNN(nn.Module): def __init__(self, n_features, n_outputs=1): super().__init__() self.features = nn.Sequential( nn.Conv1d(1, 32, kernel_size=5, padding=2), nn.BatchNorm1d(32), nn.ReLU(inplace=True), nn.MaxPool1d(2), nn.Conv1d(32, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(inplace=True), ) self.regressor = nn.Sequential( nn.Flatten(), nn.Dropout(0.3), nn.Linear(self._flatten_size(n_features), 64), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(64, n_outputs), ) def _flatten_size(self, n_features): # 计算经过池化后的特征长度 dummy = torch.zeros(1, 1, n_features) return self.features(dummy).numel() def forward(self, x): return self.regressor(self.features(x))前两个卷积块用kernel_size=5,窗口宽度对应5个波数点,适合捕捉中等宽度的吸收峰;第三个块换成kernel_size=3,细化局部特征。pool=2会把序列长度减半两次。BatchNorm在这里不是用来加速收敛,而是稳定每层输入的分布,让不同批次的基线漂移不至于在深层被放大。回归头前面加Dropout(0.3)是光谱小样本下最有效的正则化手段,比单纯增大weight_decay更直接。n_features传入后,最后一个卷积块的输出长度要算清楚,代码里的_flatten_size用一次前向来算,避免手算错维度。
这个结构只适合作为起点。如果你的光谱分辨率高、波数点数超过2000,可以在第三层后面再接一个池化;如果样本只有一两百条,建议把通道数从32-64-128降成16-32-64,否则卷积核数量一多照样过拟合。
3.3 训练配置:学习率、batch size、weight decay与早停
训练配置直接决定回归模型能不能收敛。近红外光谱数据量小,batch size不宜太大,我用16或32;学习率从1e-3起步,配合ReduceLROnPlateau在验证loss停滞时自动降一半。weight_decay对应L2正则化,PyTorch里写AdamW时直接传weight_decay参数就行,这部分代码对应很多人在找的“深度学习里L2正则化怎么加”的标准答案。
import torch from torch import nn def train_model(model, train_loader, val_loader, epochs=300, lr=1e-3, weight_decay=1e-4, patience=20): optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=15) loss_fn = nn.MSELoss() best_rmse = float('inf') wait = 0 for epoch in range(epochs): model.train() for xb, yb in train_loader: optimizer.zero_grad() pred = model(xb).squeeze(-1) loss = loss_fn(pred, yb) loss.backward() optimizer.step() # 验证 model.eval() val_pred, val_true = [], [] with torch.no_grad(): for xb, yb in val_loader: val_pred.append(model(xb).squeeze(-1)) val_true.append(yb) val_pred = torch.cat(val_pred).numpy() val_true = torch.cat(val_true).numpy() rmse = float(np.sqrt(((val_pred - val_true) ** 2).mean())) scheduler.step(rmse) # 早停 if rmse < best_rmse: best_rmse = rmse torch.save(model.state_dict(), 'best_nir_model.pt') wait = 0 else: wait += 1 if wait >= patience: break return best_rmse训练逻辑里有个容易被忽略的点:每个epoch都要把模型切回train模式再切回eval模式,因为Dropout和BatchNorm在两种模式下的行为不同。保存模型时只保存state_dict而不是整个模型对象,避免把训练器状态和优化器状态一起打包成几百MB的冗余文件。早停的patience=20意味着验证集RMSE连续20轮不下降就停,这对小数据训练很关键,否则模型会在最后阶段把验证集也背下来,回归精度反而变差。环境配置上,PyTorch 2.x配合Python 3.8到3.11都行,Windows和Linux都能跑这套代码,几百条光谱用CPU训练也就几分钟到十几分钟,没必要一上来就配GPU集群。
4. 回归模型评估与验证:R²、RMSE、RPD三个数决定能不能用
4.1 回归指标的计算代码与判据
模型训完,先算三个数:决定系数R²、均方根误差RMSE、相对分析误差RPD。R²反映模型解释的方差比例,RMSE反映预测值与化学测定值的平均偏差,RPD是样本标准差除以RMSE,在近红外领域是最常用的模型有效性分档指标。
def regression_metrics(y_true, y_pred): y_true = np.asarray(y_true, dtype=float) y_pred = np.asarray(y_pred, dtype=float) ss_res = ((y_true - y_pred) ** 2).sum() ss_tot = ((y_true - y_true.mean()) ** 2).sum() r2 = 1 - ss_res / (ss_tot + 1e-12) rmse = float(np.sqrt(((y_true - y_pred) ** 2).mean())) rpd = float(y_true.std(ddof=1) / (rmse + 1e-12)) mae = float(np.abs(y_true - y_pred).mean()) return {'R2': r2, 'RMSE': rmse, 'RPD': rpd, 'MAE': mae}判据按近红外行业的经验习惯:RPD大于3,模型可以用于定量检测;RPD在2.5到3之间,可用于粗略筛查;低于2说明模型不具备实际分析价值,回去查预处理和样本代表性。这里必须注意RPD的陷阱:如果测试集的浓度范围很窄(比如纯度都在99%以上),样本标准差本身就小,RPD会被拉低,但RMSE可能仍然很小。所以看指标要两个一起看,RMSE还要跟化学测定方法本身的重复性对比——如果参考方法的标准偏差是0.1,模型RMSE是0.15,已经接近方法上限,再堆模型结构也不会质变。
4.2 内外两条验证线:K折交叉和独立样本集
一个容易让人误判的地方:只做一次随机测试集划分,得到的指标波动可能大到让两个模型无法比较。我的做法是训练集内做K折交叉验证,用交叉验证指标来调超参数;最后用一批完全没有参与训练的独立样本(最好来自不同批次或不同时间采集)做外部验证,这个外部验证的R²才是给领导或客户看的数。
内部交叉验证看的是稳定性:5折里每一折的R²如果差异很大,说明训练集内部存在强分组效应,这时候要用SPXY重新划分,或者改用分层采样保证每个折里的浓度分布相似。外部验证看的是泛化:外部验证R²比内部交叉验证低0.1以上,几乎可以断定模型学到了训练集的采样批次特征而非化学特征。这个现象在近红外里非常常见,因为同一批样品的温度和湿度是相近的,模型很容易把环境信息当化学信息学进去。
4.3 从指标走查模型:过拟合、欠拟合与偏差长什么样
指标组合能直接暴露模型状态。训练集R²高达0.99、验证集只有0.85,过拟合,对策是加大Dropout和weight_decay、缩小网络宽度、增加光谱噪声增强。训练集和验证集R²都在0.7附近,欠拟合,说明网络容量不够或预处理过度(比如窗口调太大把吸收峰磨平了),适当加深网络、把平滑窗口调小。训练集R²尚可、验证集R²忽高忽低,样本划分有问题,用SPXY重切。还有一个很容易被忽略的检查项:预测残差与参考浓度的关系图。把预测值减真值画在纵轴、真值画在横轴,如果残差随浓度呈喇叭形变大,说明高浓度区模型误差失控,需要考虑对浓度做log变换,或者对高浓度样本加权训练。
5. 避坑指南:近红外光谱与深度学习回归的5个翻车场景
5.1 样本量小还硬上深度网络:过拟合不是玄学,是必然
现象:光谱只有150条,网络用三层卷积加两层全连接,训练loss降得很快,验证R²始终在0.7上下挣扎。原因:网络参数量远大于样本信息量,模型把训练集的噪声当成信号背了下来。解决:先把网络宽度减半,Dropout提到0.3,weight_decay调到1e-3量级;再加一条数据增强——对光谱加均值为0、标准差为原始谱标准差1%的高斯噪声,等价于扩大样本量。增强要在预处理之后做,而且每个epoch重新生成噪声,否则等于没加。
5.2 按时间顺序切训练集和测试集:批次效应让验证失真
现象:把某个月采集的样本做训练,下个月的样本做测试,外部验证R²比预期的0.9低到0.75。原因:光谱仪状态、环境温湿度是随时间漂移的,按时间切分等于把仪器漂移当成测试难度,这跟工业现场使用场景一致,但不能用来判断模型本身好坏。解决:先用SPXY把全量样本切成训练和测试,保证浓度覆盖;再在训练集内部按时间顺序做一次“时间验证”,只用于评估模型在现场是否会因为漂移掉点,两个指标分开报告。
5.3 换一台光谱仪就翻车:仪器间光谱差异没处理
现象:模型在A仪器上R²=0.97,搬到B仪器上对同批样品预测值整体偏高。原因:不同仪器分辨率、波数校准和检测器响应存在差异,导致同一化学样品的光谱有系统性偏移。解决:最稳妥的是把B仪器的一部分光谱加入训练集,做模型迁移校准(常见做法是加一两个B仪器的梯度修正层重新微调);轻量方案是用MSC做散射校正后,再对波数轴做局部对齐,多数光谱仪厂商软件支持波数校正,先校正再建模。
5.4 预处理参数没保存:部署时模型和训练时对不上
现象:训练脚本里预处理、建模、预测都在同一个notebook中跑通,部署时单独写了个推理脚本,预测结果完全不对。原因:推理脚本里重新定义预处理流程,SNV的均值和标准差、MSC的参考谱都是在新样本上重新计算的,而训练时用的是训练集的统计量,两者不是同一个变换。解决:把预处理器固定下来,训练完把SNV的mean、std和MSC的ref存成npz文件,推理时一律加载保存的统计量,不能用新样本现算。
5.5 全波段无脑送入模型:水汽和仪器噪声也被学了
现象:模型外部验证RMSE稳定,但残差谱显示某些波段(如1900nm附近的水汽吸收带)贡献了不成比例的误差。原因:光谱两端和强水汽吸收区信噪比低,这些伪特征被网络当成有效信号。解决:建模前画出训练集光谱标准差谱,把标准差异常高的波段截掉或加权降低;或者在数据加载时直接按波数索引切片,只保留信噪比好的区间(比如1000到1800nm之间)。不要等模型训完再回头看,预处理阶段就做波段筛选,能省很多调参时间。
6. 上生产前的最后一步:把预处理器和模型打包成同一个推理接口
训练时所有变换和模型是在一起的,部署时也必须是。我习惯把预处理统计量、波段索引和模型权重打包成一个predict函数,新样本进来只进这一个口。
def load_assets(pkg_path): assets = np.load(pkg_path / 'preprocess_assets.npz', allow_pickle=True) model = NIR1DCNN(n_features=assets['wavelength_idx'].size) model.load_state_dict(torch.load(pkg_path / 'best_nir_model.pt')) return model, assets def predict_spectrum(model, assets, raw_spectrum): x = raw_spectrum[assets['wavelength_idx']] x = (x - assets['mean']) / (assets['std'] + 1e-12) x = torch.tensor(x, dtype=torch.float32).reshape(1, 1, -1) with torch.no_grad(): return model(x).item()load_assets把训练阶段保存的波段索引、SNV统计量、模型参数一次加载;predict_spectrum对新样本先做索引切片,再套用训练集的统计量做标准化,最后过模型输出浓度预测值。这个接口的好处是:波段选择、预处理、模型三个决策被固定成一份资产,谁也不会在部署时悄悄改掉其中一环。部署后我还习惯定期用标准样品做预测自检,把预测值和化学测定值的差画成控制图,连续3个点偏离超过2倍RMSE就提醒做模型校准或重新训练。
项目做多了会发现,近红外光谱加深度学习回归,真正决定成败的不是网络结构有多新,而是对样本、预处理和验证流程的把控。少在模型结构上追新,多在数据划分和部署一致性上较真,这套流程才真正从zip变成能用的生产力。希望帮到你。
本文还有配套的精品资源,点击获取