简介:这是一份基于MATLAB的光谱数据预处理工具包,面向光谱分析领域的研究人员、学生及工程技术人员,解决原始光谱受基线漂移、散射噪声、浓度差异等因素干扰的问题。包内完整实现一阶导数、二阶导数、矢量归一化(SNV)、多元散射校正(MSC)、数据中心化、直接信号校正和平滑处理等常用预处理方法,用户可灵活调整参数,为后续建模与特征提取提供高质量数据基础。资源共17个文件,包含12个m脚本程序及asv备份、xls表格、mat数据样本和txt说明文档,压缩包约704KB,体量精简、结构清晰。已有384人学习下载,适合需要快速掌握光谱预处理算法原理与MATLAB代码实现的进阶用户。
1. 为什么“一阶导数、二阶导数+SNV”会成为光谱预处理的固定组合
做近红外或拉曼光谱建模的人,手里拿到一批样本时,最头疼的往往不是算法不收敛,而是同一浓度下测出的光谱基线在飘、峰形在变。颗粒大小、装样松紧、光程差异都会让原始光谱整体抬升或缩放;仪器热漂移还会叠加上一条缓慢变化的直线。直接用原始光谱进 PLS、PCA 或分类器,模型学到的第一主成分常常是基线和散射,而不是化学信息。
一阶导数可以把常数基线直接清零,二阶导数进一步把线性漂移也消掉,SNV 则把每条光谱归一化成均值为 0、标准差为 1 的向量,用来吸收乘性散射效应。三者组合起来,正好覆盖加性偏移、线性漂移和幅度缩放这三类最普遍的干扰。但这里有一个反直觉的坑:求导在数学上是高频增强操作,原始光谱里的随机噪声会被放大一到两个数量级,直接对原始数据用 diff 得到的曲线几乎全是毛刺。所以正确的开发顺序,不是先求导再平滑,而是先设计好“平滑 → 求导 → SNV”的整体矩阵运算流程。这篇文章就把一阶导数、二阶导数与 SNV 在 matlab 里的离散计算、参数设置和边界处理一次讲透。
2. 先算清楚数学:一阶导数、二阶导数与 SNV 的离散表达
光谱仪输出的数据不是一个连续函数,而是一个等间隔采样的离散向量。把数学公式变成 matlab 代码之前,必须明确离散差分与连续求导之间的区别,否则代码运行后画出来的图形位置对不上,特征波长也会取错。
2.1 离散光谱的一阶导:diff 长度变短,gradient 保持长度
连续域里,一阶导数为 dx/dλ。离散光谱中,波长是等间隔栅格,常用的近似是前向差分与中心差分。前向差分写作 x(i+1)−x(i),它的定义域落在两个采样点之间,不严格等于 x(i) 处的导数值。中心差分写作:
x′(i) ≈ (x(i+1) − x(i−1)) / (2·Δλ)
它用一个对称邻域估计 x(i) 处的斜率,误差阶数比前向差分高,所以大多数数值计算工具都在内部使用中心差分的思想。matlab 的 diff 函数只做前向差分,调用 diff(x) 后输出长度比输入少 1;gradient 函数则默认在内部点使用中心差分,在端点使用单边差分,输出长度保持不变。这个长度差异直接影响到后续建模:特征矩阵多长,标签向量多长,二者必须对齐。很多人第一步用 diff,第二步发现光谱条带数少了一个,又手动补零或删掉末端波长,最后模型变量和原始波长坐标对不上,这就是最常见的坑。
如果只想要一个简单、可靠、长度不变的一阶导数,可以显式写中心差分并单独处理端点。设矩阵 X 的每一行是一条光谱,波长间隔为 dt,一阶导数可以写成 X(:, 3:end) − X(:, 1:end−2) 再除以 2·dt,首尾两点用单边差分公式补齐。这样每一个输出点仍然对应原有的波长坐标,画图时不需要重新生成半波长坐标。
2.2 二阶导数的中心差分与放大的噪声
二阶导数描述曲线的弯曲程度,在光谱上表现为吸收峰变窄、重叠峰被分开,峰位处对应二阶导数的负向极值。离散二阶导数的标准三点公式是:
x″(i) ≈ (x(i+1) − 2·x(i) + x(i−1)) / (Δλ²)
这个公式很漂亮,但在 matlab 里直接使用时会遇到一个问题:如果对 gradient 的结果再做一次 gradient,内部的中心差分系数与严格三点公式并不完全一致,且端点误差会被二次放大;如果对 diff 的结果再做 diff,数据长度会连续缩短两次,相位也会先偏移半个栅格再偏移半个栅格。因此光谱预处理代码里,我一般不用 diff(diff(x)),而是直接构造一个稀疏差分矩阵,或使用下面 3.3 节中显式的三点差分写法。二阶导数对噪声的放大比一阶导数更严重,因为差分计算本质上是一个高通滤波,噪声功率谱在高频段被乘以与 1/Δλ² 成正比的系数。这也是为什么实际项目中几乎不会对原始光谱直接求二阶导数,而是先做平滑。
2.3 SNV 是按行归一化,不是按变量的 z-score
SNV 的中文常被写成矢量归一化或标准正态变量变换,公式看起来和 z-score 一模一样,但作用维度完全不同。z-score 通常按列操作,即对同一个变量通道的所有样本做减均值除标准差;SNV 按行操作,对同一条光谱的全部波长点做:
x_snv = (x − mean(x)) / std(x)
其中 mean(x) 是这条光谱在所有波长上的平均值,std(x) 是这条光谱所有波长点的样本标准差。做完之后,每条光谱变成均值接近 0、标准差接近 1 的矢量。它不关心这个样本属于哪一组,也不关心不同样本之间的相对浓度差,只把每一条光谱的绝对幅度抹平。这种操作能够有效衰减颗粒散射造成的乘性干扰,但也意味着光谱里的绝对强度信息被主动丢弃。如果后续模型需要用到吸收强度,比如定量分析浓度梯度,SNV 有可能拿掉一部分真实化学信号,使用时需要结合交叉验证对比。
2.4 三种操作各解决哪类干扰
把三种预处理放在同一张表里看,会非常直观:
| 干扰类型 | 典型来源 | 对光谱的影响 | 对应处理方法 |
|---|---|---|---|
| 加性基线偏移 | 暗电流、背景光 | 光谱整体抬高或降低 | 一阶导数、SNV |
| 线性漂移 | 仪器升温、灯源衰减 | 光谱叠加一条斜线 | 二阶导数 |
| 乘性散射 | 颗粒度、装样密度 | 光谱整体乘一个系数 | SNV |
| 随机噪声 | 探测器热噪声 | 高频毛刺 | 导数前的平滑,SG 窗口 |
单独使用任何一项都不是完整方案。一阶导数能消基线,却会把乘性系数从常数变成导数;SNV 能消乘性干扰,却处理不了高频噪声的放大问题。因此实际顺序通常是先做 SNV,再做平滑与求导,也就是算法层面同时完成散射校正和基线校正。
3. 在 matlab 中实现一阶导数、二阶导数和 SNV
这一章直接给出可以复制运行的 matlab 代码。代码按矩阵运算实现,每一行样本代表一条光谱,每一列代表一个波长点,这也是化学计量学工具箱中最常见的数据组织方式。
3.1 生成一份可复现的模拟光谱矩阵
先构造一批带基线漂移、乘性散射和随机噪声的仿真光谱,用来演示三类预处理的效果。模拟数据能让我们在已知真值的情况下检验导数结果是否正确。
% 生成 30 个样本、351 个波长点的模拟近红外光谱 wn = 950:2:1650; % 波长,单位 nm,步长 2 nm n = numel(wn); m = 30; X = zeros(m, n); rng(2024); % 固定随机种子,便于结果复现 for i = 1:m base = 0.1 + 0.02 * i; % 每个样本有不同基线偏置 scatter = 0.9 + 0.03 * randn; % 乘性散射系数 y = base + scatter * ... ( 0.6 * exp(-((wn - 1150).^2) / (2 * 60^2)) + ... 0.4 * exp(-((wn - 1380).^2) / (2 * 80^2)) ) + ... 0.002 * wn + 0.02 * randn(1, n); X(i, :) = y; end dt = wn(2) - wn(1); % 采样间隔,后面求导要用这段代码模拟了两个高斯吸收峰,一个在 1150 nm,一个在 1380 nm,并给每个样本叠加了不同的基线偏置、线性漂移和乘性散射。rng(2024) 确保重跑脚本时随机数一致,便于调试和对比。提前算好 dt 是为了一阶、二阶导数的数值缩放;matlab 的 gradient 如果不传步长,默认步长为 1,导数值会与实际物理单位相差 2 倍。
3.2 一阶导数:diff、gradient 与显式中心差分
用三种方式计算一阶导数,并比较它们的差异:
% 方式一:diff,输出长度比原光谱少 1 d1_diff = diff(X, 1, 2); % 沿第 2 维做一阶差分 w_diff = 0.5 * (wn(1:end-1) + wn(2:end)); % 半波长坐标 % 方式二:gradient,长度不变,但端点使用单边差分 d1_grad = gradient(X, dt, 2); % 方式三:显式中心差分,首尾用单边公式补齐 d1_cent = zeros(size(X)); d1_cent(:, 2:end-1) = (X(:, 3:end) - X(:, 1:end-2)) / (2 * dt); d1_cent(:, 1) = (X(:, 2) - X(:, 1)) / dt; d1_cent(:, end) = (X(:, end) - X(:, end-1)) / dt;diff 方式返回 n−1 列数据,波长坐标需要用前后两个点的平均值重新生成,这会打断后续建模时“一个特征对应一个波长”的对齐关系。gradient 方式则完全保留原矩阵维度,代码最简单,适合快速预览。显式中心差分和 gradient 的主要区别在端点:gradient 在最左侧使用前向差分,最右侧使用后向差分;显式写法把端点导数直接退化成相邻点差分,效果更直观。实际项目中,我用 gradient 做粗筛,用显式中心差分或 SG 平滑求导做正式建模。
3.3 二阶导数:小心端点,不用 diff 套 diff
二阶导数对端点非常敏感,因为端点处没有左右邻域,任何差分公式都只能单侧逼近。展示一个稳健的矩阵化写法:
% 严格三点中心差分,边界点用三点前向/后向公式 d2_cent = zeros(size(X)); dt2 = dt^2; % 内部点 d2_cent(:, 2:end-1) = (X(:, 3:end) - 2 * X(:, 2:end-1) + X(:, 1:end-2)) / dt2; % 左端点:前向三点二阶差分 d2_cent(:, 1) = (X(:, 1) - 2 * X(:, 2) + X(:, 3)) / dt2; % 右端点:后向三点二阶差分 d2_cent(:, end) = (X(:, end) - 2 * X(:, end-1) + X(:, end-2)) / dt2;这组公式中间部分和理论公式完全一致,端点则使用向后方向差分。注意左端点公式的分子是 x(1) − 2·x(2) + x(3),符号上不要漏掉负号。如果使用 gradient 套 gradient,即 gradient(gradient(X, dt, 2), dt, 2),内部点会得到一个与三点公式不同权重的近似,端点处误差更大,而且代码也更难排查。对尺寸较大的光谱矩阵,上述矩阵化差分不会引入循环,运行速度很快,适合直接放进预处理脚本。
3.4 SNV:分两行代码完成全矩阵归一化
SNV 在 matlab 中实现极其简单,但维度和分母自由度必须写对:
% 对每一条光谱逐行归一化 mu = mean(X, 2); % 每行均值,m x 1 sd = std(X, 0, 2); % 每行样本标准差,m x 1 X_snv = (X - mu) ./ sd; % 广播相减并相除mean(X, 2) 和 std(X, 0, 2) 中的第二个参数 2 表示沿行方向计算,得到的结果是一个 m×1 的列向量;matlab 在 R2016b 之后支持自动广播,X − mu 会按行减去每个样本的均值。std(X, 0, 2) 中的 0 表示样本标准差,除数是 n−1;如果换成 1,则是总体标准差,分母是 n。当光谱波长点数量为几百个时,两种标准差差别很小,但 SNV 的原始定义默认使用样本标准差,多数化学计量学工具包也按这个习惯实现,所以推荐保留 std(X, 0, 2)。
SNV 的另一种误用是按列做标准化,也就是直接调用 zscore(X, 0, 2)。这样会把每个波长点的所有样本变成均值 0、方差 1,分布信息完全不同。建模前可以用 size 和数值分布检查:SNV 之后每行均值应接近 0,每行标准差应接近 1;zscore 之后每列均值接近 0,每列标准差接近 1。
3.5 导数前先做 SG 平滑
噪声放大的问题不能完全靠求导后的低通滤波解决,常见做法是在求导前先用萨维茨基-戈莱滤波,也就是 SG 平滑。matlab 的 smoothdata 提供了内置的 sgolay 方法:
% 先对每条光谱沿波长方向做 SG 平滑,再求导数 frame = 15; % 窗口宽度,必须是奇数 k = 3; % 多项式阶数 Xs = smoothdata(X, 2, 'sgolay', frame, 'Degree', k, 'IncludedPoints', 'all'); d1_sg = gradient(Xs, dt, 2); % SG 平滑后的一阶导数 d2_sg = gradient(d1_sg, dt, 2); % SG 平滑后的二阶导数smoothdata 的第三个参数指定方法,第四个参数是窗口宽度;Degree 参数控制多项式阶数。这里先把光谱平滑一遍,再用 gradient 求导,等价于做了一次近似的 SG 微分,但代码更直观,也不会出现自定义卷积核导致的相位偏移。注意 smoothdata 的边界策略选择 IncludedPoints,'all' 表示所有点都输出,边界位置会使用收紧的窗口,得到的光谱长度不变。如果使用早期 matlab 版本,没有 smoothdata,可以用 sgolayfilt(X, 3, 15) 达到同样的平滑效果。
4. 参数怎么设:窗口、阶数、处理顺序与边界控制
预处理脚本跑通很容易,真正影响模型效果的参数集中在 SG 窗口宽度、多项式阶数、SNV 与求导的顺序,以及边界点怎么处理。这几个参数没有全局最优,但对光谱数据存在一套可复用的经验区间和验证方法。
4.1 SG 窗口宽度与多项式阶数的选择
SG 平滑窗口太小时,噪声压不下来;窗口过大时,窄峰被抹平,二阶导数会出现明显的伪峰。实际经验值可以参考下面这张表:
| 预处理目标 | 窗口宽度 | 多项式阶数 | 说明 |
|---|---|---|---|
| 只做平滑 | 5~15 | 2 或 3 | 窗口超过 21 会拖慢边界 |
| 一阶导数 | 9~21 | 2 或 3 | 阶数 2 就能拟合一阶趋势 |
| 二阶导数 | 15~31 | 3 或 4 | 阶数太低拟合不了弯曲 |
| 强噪声光谱 | 31~51 | 3 | 同时接受峰高被压低的风险 |
窗口宽度必须为奇数,否则 smoothdata 会去重采样或报错。多项式阶数应小于窗口宽度,一般取 2~4 就够;阶数太高会把噪声也拟合成光滑曲线,达不到平滑目的。判断窗口是否合适有一个简单方法:对单个吸收峰逐步增大窗口,观察二阶导数的主峰是否出现双峰畸变。如果出现三峰交错,说明窗口过大,已经落后于自身的采样带宽。
4.2 先 SNV 还是先求导:光谱预处理的顺序问题
在化学计量学流程中,我更建议先做 SNV,再做平滑与求导。SNV 是逐行标定,乘法项在校正后会变成 1,但不会改变导数的频率特性;随后处理 SG 窗口时,所有样本的噪声幅度已经被拉开到同一个水平,导数结果的噪声结构更一致。如果先求导再 SNV,高频噪声的绝对值会因样本幅度不同而不同,SNV 又要除以整条光谱的标准差,可能把噪声谱整体放大到同样水平,导致原本信噪比更高的样本在预处理后反而更差。
但当建模目标是分类而不是定量时,先求导再 SNV 也有支持者:二阶导数已经去掉基线和线性漂移,SNV 再做一次幅度归一化,特征空间的向量长度完全一致,某些分类器的边界会更稳定。无论选择哪种顺序,训练集、验证集和测试集必须使用完全相同的顺序和参数,不能根据某个验证集的 R² 临时切换顺序。
4.3 求导后的边界点应该直接丢弃
任何有限差分和 SG 平滑都会在光谱两端产生不可靠数据。gradient 的首尾点使用单边差分,误差通常比内部点高一个数量级;SG 平滑在边界会收紧窗口,等效于使用了更短的卷积核。预处理完成后,我一般直接砍掉左右各 floor(frame/2) 个波长点,再送入模型。这样损失的点数不多,但能避免模型用首尾伪峰做特征。
如果业务上必须保留全部波长区间,不要把边界点补零或直接复制原始值,这会引入人为阶梯;更好的做法是把原始光谱左右各扩展一段反射填充,再平滑和求导,返回到原坐标后再截断。matlab 的 padarray 可以完成反射填充,但光谱预处理脚本里很少需要,因为近红外有效信息通常集中在 1100~2500 nm,两端不需要纳入建模。
4.4 用仿真峰验证导数结果的正确性
参数调完之后,先用合成光谱验证算法实现是否正确:构造一个已知位置和半高宽的高斯峰,加上很小的噪声,做一阶导数和二阶导数后检查峰位置。一阶导数在峰中心处应穿过零轴;二阶导数在峰中心处应是负半峰,最小值的波长与真实峰位一致。如果二阶导数极小值偏移超过两个采样间隔,说明 SG 窗口不对称或差分公式写错了。这一步比直接看模型指标更灵敏,能快速定位边界条件问题。
5. 把导数+SNV 封成管线,并对新样本重复同一套统计量
预处理代码一旦写进正式的机器学习流程,就不能在训练集上算一遍 SNV 参数、在测试集上再算一遍。SNV 的均值和标准差是训练数据的一个统计量,测试集如果使用自己的统计量,等于把测试集信息透传给模型,跨批次验证会失真。
把流程封装成一个小的 matlab 函数,最容易保证这一点:
function [Xp, stats] = preprocessNIR(X, dt, frame, stats) % 对光谱矩阵 X 做 SNV + SG 平滑 + 一阶或二阶导数 % 输入: % X m x n 的光谱矩阵,每行一个样本 % dt 波长间隔 % frame SG 窗口,必须为奇数,默认 15 % poly SG 多项式阶数,默认 3 % stats 训练集 SNV 参数,若为空则从 X 计算 % 输出: % Xp 预处理后的光谱矩阵 % stats 本次使用的 SNV 均值和标准差 if nargin < 3 || isempty(frame) frame = 15; end if nargin < 4 || isempty(stats) stats.mu = mean(X, 2); stats.sd = std(X, 0, 2); end Xsnv = (X - stats.mu) ./ stats.sd; Xs = smoothdata(Xsnv, 2, 'sgolay', frame, 'Degree', 3, 'IncludedPoints', 'all'); Xp = gradient(Xs, dt, 2); % 一阶导数;想要二阶导数时改成两次 gradient end调用时,训练集第一次调用返回 stats,之后验证集和测试集把训练算出的 stats 传进去:
[Xtr, stats] = preprocessNIR(Xtrain, dt, 15, []); [Xva, ~] = preprocessNIR(Xval, dt, 15, stats); [Xte, ~] = preprocessNIR(Xtest, dt, 15, stats);最后补一个快速断言,确认 SNV 统计量被正确套用:
assert(max(abs(mean(Xtr, 2))) < 1e-10, '训练集 SNV 均值未归零'); assert(max(abs(std(Xtr, 0, 2) - 1)) < 1e-6, '训练集 SNV 标准差未归 1');验证集和测试集不需要做同样的断言,因为它们使用的是训练集统计量,均值与标准差不会严格等于 0 和 1。真正要检查的是预处理后的验证集分布是否与训练集重叠;如果验证集某个样本的 SNV 光谱标准差出现明显离群值,优先怀疑该样本原始光谱存在采集异常,而不是先调整窗口参数。这样的一套管线写完后,后续模型迭代只改 frame 和多项式阶数,不再需要重复处理数据,也能保证跨验证的预处理逻辑完全一致。
本文还有配套的精品资源,点击获取