第2章 原始数据格式:mzML、mzXML、NetCDF 解析与元数据结构
2.1 先搞清楚为什么这事绕不开
做质谱数据分析,第一关就是原始数据格式。你从仪器上导出的文件,可能是.mzML,也可能是.mzXML,或者.cdf。我之前带过一个实习生,上来就写解析脚本,结果被格式折腾了整整三天——编码不对、元数据读不出来、峰列表是空的。嗯,这事其实可以避免。
说白了,这三种格式就是质谱界的"普通话"和"方言"。你要跟仪器厂商、开源工具、下游分析软件打交道,不懂它们的结构,寸步难行。我个人习惯是,拿到一个新格式先看它的元数据骨架——扫描参数、仪器配置、峰列表存放方式,这些东西搞清楚了,代码自然写得顺。
下面这张图是我自己整理的解析框架,你可以先有个整体印象:
2.2 mzML:当代标准,结构严谨
mzML 是 HUPO-PSI 推出的标准格式,用来统一质谱数据交换。我记得它刚出来的时候,很多人觉得太复杂,但现在基本是主流了。它的结构像一棵树,根节点是 <mzML>,下面挂 <run>、<spectrumList>、<chromatogramList> 三大分支。
元数据藏在哪?主要在 <cvParam> 和 <userParam> 里。前者是受控词汇,比如仪器型号、电离模式、质量分析器类型;后者是用户自定义字段,灵活但需要你自己解析。
核心概念:mzML 的元数据是"自描述"的——每个参数都绑定一个 CV 词条,你不需要猜它的含义。
2.3 mzXML:经典格式,简洁实用
mzXML 比 mzML 出生更早,由西雅图的 ISB 团队开发。你想想看,那时候还没有统一标准,各家仪器厂商各玩各的,mzXML 算是一次"车同轨"。它的结构比 mzML 扁平,根节点是 <mzXML>,核心是 <msRun> 下面的 <scan> 列表。
每个 <scan> 节点里,既有元数据(扫描编号、保留时间、母离子 m/z),也有峰列表数据(m/z 和强度对)。我曾在项目中遇到过一批老仪器导出的 mzXML,编码用的不是 UTF-8,导致解析乱码——那会儿才意识到,格式兼容性不是一句空话。
小技巧:mzXML 的 <peaks> 数据通常用 base64 编码压缩。解析时记得先解压,再按精度位宽(32位或64位)还原成浮点数。
2.4 NetCDF:科学计算老将,自描述典范
NetCDF 最早是气象和海洋学用的,后来被质谱领域借用来存储数据。你会在一些老旧的仪器软件或者大型数据库(比如 Metabolights)里见到它。它的核心哲学是"数组即一切"——所有数据都组织成多维数组,用维度和变量来描述。
元数据以属性的形式附着在变量或全局对象上。举个例子,变量 mass_values 可能附带 units="m/z" 和 scale_factor=0.1 这样的属性。这种方式非常灵活,但缺点也很明显——没有强制标准,不同仪器厂商写出来的 NetCDF 文件结构差异很大。我个人习惯是,先用 ncdump -h 看一眼元数据头,再决定怎么往下走。
2.5 元数据结构横向对比
三种格式的元数据组织方式差异明显。下面这张表是我平时做工具选型时参考的,你可以存下来:
| 对比维度 | mzML | mzXML | NetCDF |
|---|---|---|---|
| 标准化程度 | 高(HUPO-PSI 标准) | 中(社区事实标准) | 低(因厂商而异) |
| 元数据位置 | cvParam / userParam | scan 属性 / msRun 属性 | 全局属性 / 变量属性 |
| 峰列表编码 | base64 + zlib 压缩 | base64 + zlib 压缩 | 二进制数组(直接读取) |
| 扩展性 | 强(通过 CV 词条扩展) | 中(通过 userParam 扩展) | 强(任意属性) |
| 解析成熟度 | 高(pyopenms, pymzml) | 中(lxml, pyteomics) | 高(netCDF4-python) |
2.6 代码实战:一行行解析给你看
理论说完,上代码。我会用 Python 分别解析三种格式,重点展示如何提取元数据和峰列表。
解析 mzML(使用 pyopenms):
import pyopenms as oms
exp = oms.MSExperiment()
oms.MzMLFile().load("sample.mzML", exp)
# 提取元数据:仪器配置
for spec in exp.getSpectra():
scan_number = spec.getMetaValue("scan number")
rt = spec.getRT()
ms_level = spec.getMSLevel()
# 峰列表
mz, intensity = spec.get_peaks()
print(f"Scan {scan_number}: RT={rt:.2f}s, MS{ms_level}, "
f"peaks={len(mz)}")
break # 只演示第一张谱
解析 mzXML(使用 lxml):
from lxml import etree
import base64, struct, zlib
tree = etree.parse("sample.mzXML")
root = tree.getroot()
ns = {"ns": "http://sashimi.sourceforge.net/schema_revision/mzXML_3.2"}
for scan in root.iter("scan"):
# 元数据
num = scan.get("num")
rt = float(scan.get("retentionTime").replace("PT", "").replace("S", ""))
ms_level = int(scan.get("msLevel"))
# 峰列表(base64 解压)
peaks_str = scan.find("peaks", ns).text
decoded = base64.b64decode(peaks_str)
decompressed = zlib.decompress(decoded)
# 32位浮点数
fmt = "<" + "f" * (len(decompressed) // 4)
floats = struct.unpack(fmt, decompressed)
mz = floats[::2]
intensity = floats[1::2]
print(f"Scan {num}: RT={rt:.2f}s, MS{ms_level}, peaks={len(mz)}")
break
解析 NetCDF(使用 netCDF4):
from netCDF4 import Dataset
nc = Dataset("sample.cdf", "r")
# 全局元数据(属性)
for attr in nc.ncattrs():
print(f"Global attr: {attr} = {getattr(nc, attr)}")
# 变量列表
for var_name in nc.variables:
var = nc.variables[var_name]
print(f"Variable: {var_name}, shape={var.shape}, dtype={var.dtype}")
for attr in var.ncattrs():
print(f" attr: {attr} = {getattr(var, attr)}")
# 读取峰列表(变量名因厂商而异,常见的是 mass_values / intensity_values)
if "mass_values" in nc.variables:
mz = nc.variables["mass_values"][:]
intensity = nc.variables["intensity_values"][:]
print(f"Peaks: {len(mz)} data points")
nc.close()
注意:NetCDF 的变量名没有统一规范。我在项目中遇到过用 mass、mz_array、scan_mass 等不同命名的文件。建议解析前先执行 list(nc.variables.keys()) 查看实际变量名,不要硬编码。
2.7 避坑指南
最后说几个我踩过的坑,你遇到了就能少走弯路。
- 编码问题:我曾经因为 mzXML 文件头没有声明编码,直接按 UTF-8 解析,结果中文注释全变乱码。解决办法是先用
chardet检测实际编码,再喂给解析器。 - 压缩标志位:mzML 和 mzXML 的峰列表数据可能压缩也可能不压缩,取决于生成软件的设置。解析前要检查
compressionType或compression属性,不要默认就解压。 - 字节序:NetCDF 文件的字节序取决于生成它的平台。如果读出来的数据全是 NaN 或异常大,试试设置
mmap=False或者检查byteorder属性。 - 版本差异:mzML 1.1 和 1.2 在元数据命名空间上有细微差别。解析时最好用 XSD 校验一下,或者至少捕获
XMLSyntaxError。
推荐工具链:日常开发我用 pyopenms 处理 mzML,用 pyteomics 处理 mzXML(它内置了 base64 解压逻辑),用 netCDF4 处理 NetCDF。这三把刀基本覆盖了 95% 的场景。
嗯,格式解析这块内容比较细,但它是整个质谱分析流水线的第一道关口。格式没吃透,后面所有的定量、统计、AUC 计算都是空中楼阁。你花半小时把这些结构理清楚,能省下后面几天的调试时间。