4. 数据清洗与预处理:去除基线漂移、平滑去噪、归一化、扣除背景光谱

拿到原始光谱数据,别急着分析。我见过太多人上来就跑模型,结果被基线漂移坑得找不着北。数据清洗这步,说白了就是给光谱「卸妆」——把那些不属于样品本身的干扰信号统统去掉。

这一章我们重点解决四个问题:基线漂移怎么扣?噪声怎么滤?数据怎么归一?背景怎么减?嗯,一个一个来。

4.1 去除基线漂移

基线漂移是个老顽固。温度变化、光源老化、探测器响应不均,都会让基线慢慢「飘」起来。我早期做近红外分析时,有批数据基线飘了将近20%,差点把模型搞崩。

常用的方法有两种:多项式拟合ALS算法

4.1.1 多项式拟合

思路很简单:找一段没有样品吸收的「空白区」,用多项式把基线形状拟合出来,然后从原始光谱里减掉。

我个人习惯用3~5阶多项式。阶数太低拟合不准,太高容易过拟合——把真实信号也当基线扣掉了。

import numpy as np
from numpy.polynomial import polynomial as P

def poly_baseline(x, y, degree=3, baseline_region=None):
    """
    多项式拟合基线
    x: 波数/波长
    y: 光谱强度
    degree: 多项式阶数
    baseline_region: 基线区域索引,如 [(0,200), (1800,2000)]
    """
    if baseline_region is None:
        # 默认取两端各10%作为基线区域
        n = len(x)
        baseline_region = [(0, int(n*0.1)), (int(n*0.9), n-1)]
    
    mask = np.zeros_like(x, dtype=bool)
    for start, end in baseline_region:
        mask[start:end] = True
    
    coeffs = P.polyfit(x[mask], y[mask], degree)
    baseline = P.polyval(x, coeffs)
    return y - baseline, baseline
我的经验:基线区域选不好,结果天差地别。我建议先用肉眼扫一遍光谱,找那些「看起来平」的区域。如果实在找不到,就用ALS算法——它不需要手动选区域。

4.1.2 ALS算法(不对称最小二乘法)

ALS算法是近年来的主流。它通过迭代加权,自动把基线「压」在信号下方。说白了,就是让基线尽量贴着光谱的下包络走。

def als_baseline(y, lam=1e5, p=0.01, niter=10):
    """
    ALS算法去除基线
    y: 光谱强度
    lam: 平滑参数(越大越平滑)
    p: 不对称权重(越小越严格)
    """
    from scipy import sparse
    from scipy.sparse.linalg import spsolve
    
    L = len(y)
    D = sparse.diags([1, -2, 1], [0, -1, -2], shape=(L, L-2))
    w = np.ones(L)
    
    for _ in range(niter):
        W = sparse.spdiags(w, 0, L, L)
        Z = W + lam * D.dot(D.T)
        z = spsolve(Z, w * y)
        w = p * (y > z) + (1 - p) * (y < z)
    
    return y - z, z
注意:ALS的两个参数lam和p需要调。lam太大基线会「翘起来」,太小又扣不干净。我一般从lam=1e5、p=0.01开始试,效果不对再微调。

4.2 平滑去噪

光谱里的噪声,就像照片里的噪点。不处理吧,后续分析全是毛刺;处理过头吧,细节又丢了。这里我推荐两个工具:Savitzky-Golay滤波器移动平均

4.2.1 Savitzky-Golay滤波器

SG滤波器是我最常用的。它用局部多项式拟合来平滑数据,既能去噪又能保留峰的形状。窗口大小和多项式阶数是关键。

from scipy.signal import savgol_filter

def sg_smooth(y, window_length=11, polyorder=3):
    """
    Savitzky-Golay平滑
    window_length: 窗口长度(必须为奇数)
    polyorder: 多项式阶数
    """
    return savgol_filter(y, window_length, polyorder)
避坑指南:我曾经把窗口设得太大(比如51点),结果把两个相邻的小峰平滑成了一个。记住:窗口长度不要超过峰宽的1/3,多项式阶数不要超过窗口长度的1/5。

4.2.2 移动平均

移动平均更简单粗暴——取相邻点的平均值。优点是快,缺点是会把峰展宽。适合噪声不大、峰比较宽的情况。

def moving_average(y, window=5):
    """
    移动平均平滑
    """
    return np.convolve(y, np.ones(window)/window, mode='same')
方法 优点 缺点 适用场景
SG滤波器 保留峰形,去噪效果好 计算稍慢 精细分析、窄峰
移动平均 速度快,实现简单 展宽峰,损失细节 快速预览、宽峰

4.3 归一化

归一化是为了消除样品厚度、浓度、光程等差异带来的强度变化。你想想看,同一瓶溶液,装在不同厚度的比色皿里,光谱强度能一样吗?

常用的有三种:最大最小值归一面积归一向量归一

4.3.1 最大最小值归一

把光谱缩放到[0,1]区间。简单直观,但容易受噪声影响——一个异常点就能把整个范围拉偏。

def minmax_normalize(y):
    return (y - y.min()) / (y.max() - y.min())

4.3.2 面积归一

把光谱下的总面积归一化为1。适合比较峰的形状,不受绝对强度影响。我在做混合物分析时常用这个。

def area_normalize(x, y):
    area = np.trapz(y, x)
    return y / area

4.3.3 向量归一

把光谱看作一个向量,归一化到单位长度。这是化学计量学里的标准做法,尤其适合PCA、PLS等多元分析。

def vector_normalize(y):
    norm = np.linalg.norm(y)
    return y / norm
我的建议:如果后续做定性分析(比如分类),用向量归一;如果做定量分析(比如浓度预测),用面积归一。最大最小值归一适合快速可视化,但不推荐用于建模。

4.4 扣除背景光谱

背景光谱,就是没有样品时测到的信号。它包含了光源、光学元件、溶剂等的贡献。扣除背景,说白了就是「空白的信号不要算在样品头上」。

标准做法是:先测背景,再测样品,然后直接相减。

def subtract_background(sample, background):
    """
    扣除背景光谱
    sample: 样品光谱
    background: 背景光谱
    """
    return sample - background

但现实往往没那么理想。背景和样品可能不是同时测的,或者环境发生了变化。这时候就需要一些技巧:

  • 动态背景扣除:如果背景随时间漂移,可以用插值法在两次背景测量之间估算当前背景。
  • 多点背景扣除:测多个背景点取平均,减少随机误差。
  • 加权背景扣除:根据样品和背景的测量时间差,给不同背景点分配权重。
我曾经踩过的坑:有次做拉曼光谱,背景扣完发现负峰。查了半天,原来是背景测量时光源功率比样品测量时高了5%。从那以后,我每次扣背景前都会先检查测量条件是否一致。

知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以把它当作操作流程图:拿到原始光谱后,先扣基线,再平滑去噪,然后归一化,最后扣除背景。每一步都有多种方法可选,具体用哪个取决于你的数据和目标。

光谱数据清洗与预处理流程 原始光谱数据 步骤1:去除基线漂移 多项式拟合 | ALS算法 步骤2:平滑去噪 Savitzky-Golay滤波器 | 移动平均 步骤3:归一化 最大最小值 | 面积归一 | 向量归一 步骤4:扣除背景光谱

数据清洗没有银弹。每种方法都有它的脾气,关键是多试、多看、多对比。我做了十年光谱分析,到现在拿到新数据还是会先画几张图看看效果。别嫌麻烦——数据清洗做得好,后续分析事半功倍。


公众号:蓝海资料掘金营,微信deep3321