Raman Spectra Preprocessing and Non-Negative Least Squares Modelling in Python: Monitoring Zeolite Crystallization
TL;DR · AI 摘要
使用Python进行拉曼光谱预处理和非负最小二乘建模,可实时监测沸石结晶过程,包含完整数据集和代码示例。
核心要点
- 拉曼光谱需经过基线校正、归一化等预处理步骤才能用于定量分析
- NNLS方法能有效分解混合光谱,追踪FAU到GIS的晶相转变过程
- 实验数据集可通过Zenodo获取,包含NeXus/HDF5格式的原始光谱数据
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 拉曼光谱分析沸石结晶
- 数据预处理
- 基线校正
- 归一化
- 建模方法
- NNLS算法
- 晶相分解
- 应用案例
- FAU→GIS转变监测
金句 / Highlights
值得收藏与分享的关键句。
Raman光谱必须经过基线校正和归一化处理才能提取定量信息
NNLS方法通过非负组合参考光谱,可准确解析混合相的演化过程
完整实验数据集已开放获取,包含177865个光谱数据点
Python中拉曼光谱预处理与非负最小二乘建模:监测沸石结晶 | Towards Data Science
数据分析
Python中拉曼光谱预处理与非负最小二乘建模:监测沸石结晶
使用真实原位沸石转化数据进行光谱解混的简短可复现教程
Nicolas Coca Lopez
2026年10月22日
10分钟阅读
在Google上推荐我们
分享
原位拉曼监测沸石结晶及沸石(FAU)→ 吉松石(GIS)的沸石间转化。图形摘要来自Coca-Lopez等人,化学工程期刊543(2026)177865。https://doi.org/10.1016/j.cej.2026.177865
展示原位拉曼光谱用于实时监测沸石结晶的图形摘要。拉曼探针在合成过程中监测混合物,随着结晶时间推移,拉曼光谱发生变化,揭示了沸石(FAU)向吉松石(GIS)的转化。该工作流程描述于"沸石结晶过程监测:连接原位拉曼光谱、离位XRD和预测性机器学习模型",化学工程期刊543(2026)177865。
拉曼光谱很少直接从光谱仪进入模型。在提取定量信息之前,通常需要进行预处理步骤,包括裁剪有用光谱区域、去除尖峰、减去基线和归一化数据。
在本教程中,我将使用我们最近发表的论文"沸石结晶过程监测:连接原位拉曼光谱、离位XRD和预测性机器学习模型"中的真实拉曼光谱,演示这一工作流程。
沸石间转化发生在沸石合成过程中,当一种结晶相转化为另一种时,这使得追踪各相贡献随时间的变化变得有用。此过程可通过拉曼光谱追踪,但需要对光谱进行解卷积。这就是非负最小二乘法(NNLS)特别有用的地方:测得的拉曼光谱可以描述为各相参考光谱的非负组合。
本文将重点放在拉曼数据处理和NNLS上,从原始光谱开始,恢复结晶过程中沸石(FAU)和吉松石(GIS)的相对演化。
完整的实验数据集以NeXus/HDF5格式公开存放在Zenodo上。此外,本教程也可作为Jupyter笔记本在作者的GitHub账户中找到。
1. 从NeXus文件加载拉曼光谱
NeXus对于存储此类实验数据特别方便,因为光谱和实验元数据(包括结晶时间)会一起存储。我们将使用文件raman_crystallization_solid.nxs。从Zenodo记录(https://doi.org/10.5281/zenodo.18972297)下载该文件,并将其放置在与本笔记本相同的文件夹中。
首先,我们加载所需的库和数据:
python
# 如果需要:
# !pip install nexusformat scipy pybaselines matplotlib
import numpy as np
import matplotlib.pyplot as plt
from nexusformat.nexus import nxload
filename = "raman_crystallization_solid.nxs"
root = nxload(filename)
entry = root["entry"]现在,我们遍历NeXus数据组,将每个拉曼光谱与其采集时间和光谱轴一起存储:
spectra = []
# 遍历 NeXus 数据组,将每条拉曼光谱与其采集时间和光谱轴一起存储:
for name in entry.keys():
if name.startswith("data_"):
d = entry[name]
spectra.append({
"name": name,
"time": float(np.asarray(d["time_hours"])) if "time_hours" in d else np.nan,
"x": np.asarray(d["wavenumber"], dtype=float),
"y": np.asarray(d["intensity"], dtype=float),
})
# 按结晶时间对光谱进行排序:
spectra_all = sorted(spectra, key=lambda s: s["time"])
# 将结晶时间存储为 NumPy 数组:
times_all = np.array([s["time"] for s in spectra])
print(f"光谱数量: {len(spectra_all)}")
print("时间 (h):", times_all)让我们绘制所有原始光谱以了解数据情况:
plt.figure(figsize=(10, 5))
for s in spectra:
plt.plot(s["x"], s["y"], label=f"{s['time']:g} h")
plt.xlabel(r"拉曼位移 (cm$^{-1}$)")
plt.ylabel("强度")
plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.savefig("figure.png", dpi=600, bbox_inches="tight")
plt.show()0 小时和 16 小时采集的光谱未用于后续分析,因为此时与结晶沸石相相关的拉曼特征尚未具有足够的信息量。因此我们从 18 小时后的数据开始分析:
spectra = [s for s in spectra_all if s["time"] >= 18]
times = np.array([s["time"] for s in spectra])2. 裁剪并插值到统一的拉曼位移轴
第一个预处理步骤是移除不需要的光谱区域。对于本数据集,我保留 250–1250 cm⁻¹ 区域。
由于光谱并非都记录在完全相同的拉曼位移轴上,因此裁剪后需要将每条光谱插值到统一的轴上。这是在将光谱组合成单个数据矩阵进行后续预处理步骤和 NNLS 分析之前必须进行的操作。
重要的是,裁剪和插值操作会在去除尖峰和基线校正之前完成,因此这些算法仅作用于信号中相关且协调的部分。
ruby
/think
# ----------------------------------------------------------
# 将所有光谱裁剪并插值到统一的拉曼位移轴上
# ----------------------------------------------------------
low_end = 250
high_end = 1250
# 找出所有光谱实际共有的光谱范围
x_min = max(low_end, max(np.min(s["x"]) for s in spectra))
x_max = min(high_end, min(np.max(s["x"]) for s in spectra))
# 使用第一个光谱作为参考轴
x0 = spectra[0]["x"]
mask_ref = (x0 >= x_min) & (x0 <= x_max)
x_crop = x0[mask_ref]
# 确保统一的轴从低到高拉曼位移排列
x_crop = np.sort(x_crop)
Y_raw = []
for s in spectra:
x = s["x"]
y = s["y"]
# 单独裁剪每个光谱
mask = (x >= x_min) & (x <= x_max)
x_i = x[mask]
y_i = y[mask]
# np.interp 需要递增的x
order = np.argsort(x_i)
x_i = x_i[order]
y_i = y_i[order]
# 插值到统一轴上
y_interp = np.interp(x_crop, x_i, y_i)
Y_raw.append(y_interp)
Y_raw = np.array(Y_raw)
# 绘图
plt.figure(figsize=(10, 5))
for t, y in zip(times, Y_raw):
plt.plot(x_crop, y, label=f"{t:g} h")
plt.xlabel(r"Raman shift (cm$^{-1}$)")
plt.ylabel("Intensity (a.u.)")
plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.savefig("figure_2.png", dpi=600, bbox_inches="tight")
plt.show()
print("共同拉曼范围:", x_crop.min(), "-", x_crop.max(), "cm-1")
print("共同点数:", len(x_crop))
print("Y_raw 形状:", Y_raw.shape)3. 去除尖峰
宇宙射线和探测器伪影可能产生非常狭窄的正向(有时为负向)特征,称为尖峰。单个尖峰可能严重影响归一化或多元模型性能。
这里我采用《基于峰显著度和宽度的拉曼光谱尖峰去除直观方法》中开发的显著度-宽度尖峰去除方法。其原理直观:尖峰比真实的拉曼峰要窄得多。该算法在光谱中找到所有峰,并在它们的显著度和宽度上设置阈值以检测尖峰。下面的实现也对-y应用相同逻辑,从而可以检测负向尖峰。
from scipy.signal import find_peaks, peak_widths
from scipy import interpolate
import warnings
def spike_removal(
y,
width_threshold,
prominence_threshold,
interpolation_window=10,
width_param_rel=0.8,
interp_kind="linear",
remove_positive=True,
remove_negative=True,
):
"""使用峰显著度和宽度从拉曼光谱中去除尖峰。尖峰检测基于Coca-Lopez在《Analytica Chimica Acta 1295 (2024) 342312》中描述的显著度/FWHM方法。https://doi.org/10.1016/j.aca.2024.342312
显著度高于'prominence_threshold'且宽度低于'width_threshold'的峰被归类为尖峰,并通过插值替换为邻近未损坏点的值。
该实现通过将相同过程应用于-y来扩展原始方法,可选择检测负尖峰。它还使用保守检查,以避免在检测到的尖峰两侧没有干净数据时进行外推或插值。"""
y = np.asarray(y, dtype=float)
def detect_spikes(signal):
spike_mask = np.zeros(len(signal), dtype=bool)
peaks, _ = find_peaks(signal, prominence=prominence_threshold)
if len(peaks) == 0:
return spike_mask
widths = peak_widths(signal, peaks, rel_height=0.5)[0]
width_results = peak_widths(signal, peaks, rel_height=width_param_rel)
for width, left, right in zip(widths, width_results[2], width_results[3]):
if width < width_threshold:
start = max(int(np.floor(left)) - 1, 0)
end = min(int(np.ceil(right)) + 2, len(signal))
spike_mask[start:end] = True
return spike_mask
spikes = np.zeros(len(y), dtype=bool)
if remove_positive:
spikes |= detect_spikes(y)
if remove_negative:
spikes |= detect_spikes(-y)
if not np.any(spikes):
return y.copy()
y_out = y.copy()
padded = np.r_[False, spikes, False]
changes = np.diff(padded.astype(int))
starts = np.where(changes == 1)[0]
ends = np.where(changes == -1)[0] - 1
required = {"linear": 2, "quadratic": 3, "cubic": 4}
if interp_kind not in required:
raise ValueError("interp_kind必须为'linear'、'quadratic'或'cubic'。")
skipped = []
for start, end in zip(starts, ends):
window = np.arange(
max(start - interpolation_window, 0),
min(end + interpolation_window + 1, len(y)),
)
good = window[~spikes[window]]
bad = np.arange(start, end + 1)
if len(good) < required[interp_kind]:
skipped.append((start, end, "清洁点不足"))
continue
if not np.any(good < start) or not np.any(good > end):
skipped.append((start, end, "两侧均无清洁数据"))
continue
if bad[0] < good.min() or bad[-1] > good.max():
skipped.append((start, end, "需要外推"))
continue
try:
f = interpolate.interp1d(
good,
y[good],
kind=interp_kind,
bounds_error=True,
)
y_out[bad] = f(bad)
except ValueError:
skipped.append((start, end, "插值失败"))
if skipped:
warnings.warn(
f"{len(skipped)}个尖峰区域未被修正,因为插值被认为不安全: {skipped[:10]}"
)
return y_out以下单元格将尖峰去除算法应用于所有光谱,并绘制去除尖峰前后的光谱图。需要视觉检查的两个参数是WIDTH_THRESHOLD和PROMINENCE_THRESHOLD。应根据光谱分辨率和真实拉曼峰的宽度对这两个参数进行调整。
WIDTH_THRESHOLD = 2
PROMINENCE_THRESHOLD = 100
WINDOW = 5
Y_despiked = np.array([
spike_removal(
y,
width_threshold=WIDTH_THRESHOLD,
prominence_threshold=PROMINENCE_THRESHOLD,
interpolation_window=WINDOW,
)
for y in Y_raw
])
# 找出经过尖峰去除处理的光谱
affected = np.where(
np.any(~np.isclose(Y_raw, Y_despiked), axis=1)
)[0]
print(f"包含已修正尖峰的光谱数量: {len(affected)}")
print("时间:", times[affected])
# 绘制所有受影响的光谱
for i in affected:
plt.figure(figsize=(10, 4))
plt.plot(
x_crop,
Y_raw[i],
label=f"原始数据 — {times[i]:g} h",
alpha=0.7
)
plt.plot(
x_crop,
Y_despiked[i],
label="去尖峰处理",
lw=2
)
plt.xlabel(r"拉曼位移 (cm$^{-1}$)")
plt.ylabel("强度 (任意单位)")
plt.legend()
plt.tight_layout()
plt.savefig("figure_3_spike.png", dpi=600, bbox_inches="tight")
plt.show()示例:
4. 基线校正
下一个主要的光谱伪影是缓慢变化的背景信号。这里我使用非对称最小二乘法(AsLS)基线估计,该方法由D. Erb在pybaselines库中实现(https://github.com/derb12/pybaselines)。
参数设置与原始分析接近:
- lam = 3e3 : 基线平滑度
- p = 0.001 : 非对称参数
- max_iter = 300 : 最大迭代次数
以下单元格展示了光谱校正过程:
from pybaselines import Baseline
baseline_fitter = Baseline(x_data=x_crop)
baselines = []
Y_corrected = []
for y in Y_despiked:
baseline, _ = baseline_fitter.asls(
y,
lam=3e3,
p=0.001,
max_iter=300
)
baselines.append(baseline)
Y_corrected.append(y - baseline)
baselines = np.array(baselines)
Y_corrected = np.array(Y_corrected)
# 绘制所有光谱及其估计基线
for i in range(len(Y_despiked)):
plt.figure(figsize=(10, 4))
plt.plot(x_crop, Y_despiked[i], label=f"光谱 — {times[i]:g} h")
plt.plot(x_crop, baselines[i], label="估计基线", lw=2)
plt.xlabel(r"拉曼位移 (cm$^{-1}$)")
plt.ylabel("强度 (任意单位)")
plt.legend()
plt.tight_layout()
plt.savefig("figure_4_baseline.png", dpi=600, bbox_inches="tight")
plt.show()让我们一起查看所有光谱:
plt.figure(figsize=(10, 5))
for t, y in zip(times, Y_corrected):
plt.plot(x_crop, y, label=f"{t:g} h")
plt.xlabel(r"拉曼位移 (cm$^{-1}$)")
plt.ylabel("基线校正后的强度 (任意单位)")
plt.tight_layout()
plt.savefig("figure_5_baselined.png", dpi=600, bbox_inches="tight")
plt.show()5. 聚焦于FAU/GIS诊断区域
对于NNLS分析,我们不需要完整的拉曼光谱。最有价值的变化发生在包含特征FAU/GIS框架带的狭窄区域。
因此我们:
- 截取460–540 cm⁻¹范围,
- 通过总强度对每个光谱进行归一化,
mask_nnls = (x_crop >= 460) & (x_crop <= 540)
x_nnls = x_crop[mask_nnls]
Y_nnls = Y_corrected[:, mask_nnls].copy()
Y_nnls /= Y_nnls.sum(axis=1, keepdims=True)
cmap = plt.cm.plasma
plt.figure(figsize=(9, 5))
for i, (t, y) in enumerate(zip(times, Y_nnls)):
plt.plot(x_nnls, y, color=cmap(i / (len(times) - 1)), label=f"{t:g} h")
plt.axvline(490, color="magenta", alpha=0.35, ls="--")
plt.axvline(509.5, color="green", alpha=0.35, ls="--")
plt.xlabel(r"拉曼位移 (cm$^{-1}$)")
plt.ylabel("归一化强度")
plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.savefig("figure_spectra_evolution.png", dpi=600, bbox_inches="tight")
plt.show()6. 非负最小二乘法
包含两个相的拉曼光谱(y)可以近似表示为两个参考光谱 S F A U S_{FAU} S F A U 和 S G I S S_{GIS} S G I S 的线性组合:
y ≈ C F A U S F A U + C G I S S G I S y \approx C_{\mathrm{FAU}} S_{\mathrm{FAU}} + C_{\mathrm{GIS}} S_{\mathrm{GIS}} y ≈ C FAU S FAU + C GIS S GIS
其中 C F A U C_{FAU} C F A U 和 C G I S C_{GIS} C G I S 是对应的系数。NNLS 通过约束系数非负实现优化:
min c ≥ 0 ∥ S C − y ∥ 2 2 ≥ 0 \min_{c \ge 0}\|SC-y\|_2^2 ≥ 0 min c ≥ 0 ∥ S C − y ∥ 2 2 ≥ 0
其中 S 是包含光谱的矩阵,C 是包含系数的列向量。这使 NNLS 在简单光谱解混中具有吸引力:模型计算速度快、透明且易于解释。
参考光谱
此处分别使用 18 小时和 144 小时采集的光谱作为 FAU 和 GIS 的参考光谱。计算过程如下:
from scipy.optimize import nnls
FAU_TIME = 18
GIS_TIME = 144
i_fau = np.argmin(np.abs(times - FAU_TIME))
i_gis = np.argmin(np.abs(times - GIS_TIME))
R = np.column_stack([Y_nnls[i_fau], Y_nnls[i_gis]])
coef = np.array([nnls(R, y)[0] for y in Y_nnls])
# 从系数到加权分数:
fractions = 100 * coef / coef.sum(axis=1, keepdims=True)
fau_fraction = fractions[:, 0]
gis_fraction = fractions[:, 1]以下图表展示了两相的演化过程:
plt.figure(figsize=(9, 4.5))
plt.plot(times, fau_fraction, "o-", color="green", label="FAU", lw=2, ms=8)
plt.plot(times, gis_fraction, "^-", color="purple", label="GIS", lw=2, ms=8)
plt.xlabel("Crystallization time (h)")
plt.ylabel("Relative Raman phase fraction (%)")
plt.legend()
plt.show()NNLS 结果清晰地展示了合成过程中两个晶相的演化。结晶初期,拉曼信号主要由 FAU 主导,随着 GIS 的形成,其相对贡献逐渐减少。这捕捉到了 FAU → GIS 的介孔沸石转化过程,并展示了拉曼光谱如何直接用于跟踪合成过程中的相演化。
我们做了什么?
完整的工作流程如下:
原始拉曼光谱 → 截取 → 峰值去除 → 基线校正 → 诊断区域截取 → 归一化 → NNLS
有趣的是,最终模型极其简单。一旦光谱经过适当预处理,NNLS 只需各相的代表性光谱特征即可恢复其在过程中的相对演化。
在本例中,拉曼光谱与光谱解混的结合使我们能够跟踪沸石结晶过程中 FAU 到 GIS 的转化。在完整论文中,这种基于拉曼的演化过程与原位 XRD/Rietveld 分析进行了对比,并成为更广泛过程监控策略的一部分。
更重要的是,该流程不仅限于这种特定合成。同样的通用方法可作为过程分析技术(PAT)应用于其他过程,其中测量光谱的演化表现为已知组分的组合,例如相变、结晶过程、化学反应或材料组成变化。当有合适的参考光谱时,将原位拉曼光谱与 NNLS 等数据处理方法结合,为将复杂的光谱演化转化为化学信息提供了一种相对简单的方式。
数据与参考文献
主论文 N. Coca-Lopez, F. Rodriguez, L. F. Lundegaard, X. Li, E. Lozano Diz, S. B. Rasmussen, F. Tielens, M. Calatayud, M. A. Bañares, R. Portela 和 P. Beato, "沸石结晶过程监控:结合原位拉曼光谱、离位XRD与预测性机器学习模型", 《化学工程杂志》543 (2026) 177865. https://doi.org/10.1016/j.cej.2026.177865
开放数据集 沸石结晶过程监控:结合原位拉曼光谱、离位XRD与预测性机器学习模型, Zenodo. https://doi.org/10.5281/zenodo.18972297
尖峰去除 N. Coca-Lopez, "基于峰显著性与宽度的拉曼光谱尖峰去除直观方法", 《分析化学杂志》1295 (2024) 342312. https://doi.org/10.1016/j.aca.2024.342312
人工智能与拉曼光谱综述 N. Coca-Lopez 等, "通过开放科学与FAIR原则实现人工智能驱动的拉曼光谱技术", 《ACS Nano》2025年11月11日; 19 (44): 38189–38218. https://doi.org/10.1021/acsnano.5c09165