☰
ATLAS开放数据实战:重建Z玻色子的完整分析流程
2026/10/19 8:49:22 网站建设 项目流程

1. 为什么选ATLAS开放数据作为粒子物理入门入口

1.1 那些免费可用的真实对撞数据从哪来

ATLAS是欧洲核子研究中心大型强子对撞机上的一个通用粒子探测器,名字本身就是实验代号的简称。它记录的是质子以接近光速对撞后产生的次级粒子信号,每年积累的数据量以PB计。大多数人以为这些数据被锁在实验室里,只有内部合作组成员才能访问,其实不完全对。ATLAS合作组在CERN开放数据门户上发布了一部分经过处理的简化数据集,任何人注册后都能下载,我这次用的就是2012年质心能量8 TeV那批公开样例。

这批数据有一个关键特点:它不是最原始的探测器电信号,而是已经经过重建的"分析级"数据。也就是说,你拿到的每一行,已经是某个粒子候选者的动量、能量、电荷、探测器区域这类物理量。好处是门槛大幅降低,不需要理解探测器几何和簇射重建;代价是你只能在别人给定的框架里做分析,不能从头造一个自己的重建算法。对于学习粒子物理数据分析方法,这个取舍非常合理。

作为一个想真正动手跑一遍物理分析的人,我强烈建议不要一上来就啃软件框架源码,而是直接从这批公开数据入手。你在社区里能找到对应的Jupyter Notebook示例,但那些Notebook演示性质很强,真正换一批数据、换一个物理目标,很多人就不知道该怎么改了。这篇文章记录的,就是我抛开示例、从零重写一条完整分析链路的全过程,目标是重建Z玻色子。

1.2 开放数据能做什么、不能做什么

先说能做什么。用这批数据可以做几十种典型的"共振态寻找"练习:Z玻色子衰变到双轻子、希格斯玻色子衰变到双光子、W玻色子衰变到轻子加中微子,甚至做简单的截面比测量。这些内容覆盖了高能物理数据分析的核心三步:挑选候选事例、重建物理量、拿数据跟蒙特卡洛模拟做对比。

不能做的事情也要说清楚,免得有读者抱有不切实际的期待。第一,公开数据里不带原始的探测器击中信息,所以想做探测器性能研究、想做触发效率测量的人会失望。第二,这批数据为了可读性做过格式化处理,粒子动量、能量这些值有效位数有限,精度达不到发论文的水平。第三,数据对应的亮度积分值在文档里有说明,但那是一个教育用途的约化值,不能拿去做正规物理结果的亮度归一化。明白这些边界,你就知道这个项目的定位是"学习真实分析流程",而不是"做出新物理发现"。

1.3 具备什么基础才能上手

我碰到过不少同学问,是不是得先把四大力学学完才能碰这些数据。我的回答是:不用等那么久。你只需要三样东西:一是基础的Python语法,会写循环和函数就够;二是狭义相对论里能量动量关系的基本概念,知道(E^2 = p^2c^2 + m^2c^4)就行;三是线性代数的简单矩阵操作,因为四动量计算需要做矢量相加。听起来不多,但实际动手时你会发现,真正卡住你的往往不是物理,而是数据格式和工具链。

如果你是零基础但特别想学,我的建议是两步走。先用官方Notebook跑通"显示直方图"这一步,建立感性认识;再回到本文,跟着重走一遍包含数据清洗、物理量计算、拟合对比的完整流程。两条路走完,你就算真正入了数据分析的门。

2. 环境搭建:ROOT框架与Python工作流的整合

2.1 拿到数据文件以后的第一件事

我下载的是ATLAS开放数据项目提供的HDF5格式文件。有人可能会问,为什么不用传统的高能物理ROOT文件?答案很简单:HDF5可以直接用Python生态处理,连ROOT环境都不用装,这对新手友好得多。但这里有个容易踩的坑:HDF5文件打开之后,里面的数据结构层级很复杂,得先花时间把结构摸清楚。

我先用一段脚本把文件结构打出来:

import h5py with h5py.File('ATLAS_OpenData_8TeV.h5', 'r') as f: def print_structure(name, obj): print(name, type(obj)) f.visititems(print_structure)

打印出来的结果是一大批以muons_pt、muons_eta、muons_phi、muons_charge、electrons_pt这类字段命名的数据集。你看一眼就明白,数据是按"粒子类型+物理量"的方式组织的。这种扁平命名虽然不够优雅,但胜在直观,你不需要去理解复杂的对象关系。

建议你做的第一件事不是着急算物理量,而是先逐列检查数据的合理性。我记得第一次做的时候,光看字段名就动手,结果半天没搞明白某些事例里为什么会有三个缪子,后来才发现是不同物理过程的事件混在同一个文件里。数据文件里通常包含信号事例和背景事例,区分它们的标记字段一般是process_id或者mc_weight这类列。先把这部分理清,后面的分析才有意义。

2.2 用uproot替代ROOT交互终端的现代工作流

传统的高能物理数据分析几乎离不开ROOT,它的交互终端和宏语言Cling让一代代物理学家又爱又恨。但如果你不是专门做实验物理的,用ROOT的C++接口写分析代码,学习曲线相当陡峭。这次我全程用Python完成,核心读取库选的是uproot。

import uproot import pandas as pd file = uproot.open('ATLAS_OpenData_8TeV.h5') tree = file['mini'] df = tree.arrays(library='pd')

uproot能把ROOT格式的tree直接读成Pandas DataFrame,后面你就可以用自己熟悉的Pandas操作做筛选、分组、聚合。为什么这条路更顺?因为数据处理链路的每一步都是透明的,你随时可以打印中间结果确认,不像在ROOT终端里一条命令出来一个图,中间的逻辑全闷在黑盒子里。

装环境的具体步骤不复杂,用pip install uproot pandas numpy matplotlib scipy iminuit一条命令能装完。如果你的网络环境比较特殊,装不上iminuit,也可以用scipy.optimize.curve_fit代替做拟合。我不建议一开始就上GPU或者分布式框架,这个量级的数据用单机Pandas处理完全够。

2.3 单粒子触发与筛选条件初步查看

逻辑上,正式分析之前还有一个环节叫"触发"。真实实验里,每秒有上千万次质子对撞,不可能把每次对撞的数据都存下来,所以触发系统会瞬间判断哪些事例值得保留。开放的模拟数据已经默认保留了通过触发的事例,所以你不需要自己实现触发算法,但你还是应该知道,分析里经常要求至少有一个缪子的横动量高于某个阈值。

我把这一步做成了一个简单的预筛选函数:

def initial_selection(df): # 要求至少有两个缪子 muon_count = df['muons_pt'].apply(lambda x: len(x)) mask = muon_count >= 2 return df[mask]

这个阶段不急着加太复杂的条件,先把宽泛的候选事例选出来,后面再逐层收紧。这么设计的原因很实际:如果一开始条件就卡得很死,后续调参时出了问题,你很难判断是哪个条件导致的。

3. Z玻色子候选重建:双缪子不变质量的完整推导

3.1 为什么要盯住双缪子末态

Z玻色子是个电中性粒子,寿命极短,飞行不到一纳米的距离就衰变了。探测器根本不可能直接看到它,物理学家只能通过测量它的衰变产物来"重建"它的存在。Z玻色子有很多衰变通道,其中末态是带电轻子的通道信噪比最高,因为轻子不容易跟强子混淆。我选双缪子通道的原因有几层。

第一,缪子在探测器里留下的信号模式非常清晰,能从强子背景里干净地分离出来。第二,与电子通道相比,缪子的重建对能量分辨率的要求低一些,在开放数据集里的质量峰形更尖锐。第三,双缪子末态的物理计算最简单:你只需要把两个缪子的四动量相加,然后求总四动量的不变质量即可。用两个粒子重建一个共振峰,这是所有共振态寻找的原型,理解了它,理解H到双光子或者W到轻子中微子只是换几个变量的事。

3.2 不变质量公式与四动量计算

在自然单位制里,光速c取1,所以能量、动量、质量用同一套单位。一个粒子的四动量写成((E, p_x, p_y, p_z)),它的不变质量由下面的关系给出:

[ m = \sqrt{E^2 - (p_x^2 + p_y^2 + p_z^2)} ]

两个粒子组成的不变质量,等于它们四动量先相加、再求模:

[ m_{\mu\mu} = \sqrt{(E_1+E_2)^2 - (p_{x1}+p_{x2})^2 - (p_{y1}+p_{y2})^2 - (p_{z1}+p_{z2})^2} ]

你可能会疑惑,为什么探测器测到的不是能量E和动量分量,而是横动量(p_T)、赝快度(\eta)和方位角(\phi)?这其实是由加速器的对撞几何决定的。在LHC这种质子对撞机里,参与碰撞的是质子内部的夸克和胶子,它们带着不同的纵向动量分数,所以碰撞质心系在实验室系里有一个未知的纵向boost。为了抵消这个boost的影响,物理学家习惯用纵向上洛伦兹变换性质简单的(p_T)、(\eta)、(\phi)来描述末态粒子。相应地,能量E可以写成:

[ E = \sqrt{p_T^2\cosh^2\eta + m^2} ]

缪子质量约0.1057 GeV,跟它典型的几十GeV动量相比完全可以忽略,所以实际计算时可以直接把缪子当无质量粒子处理。这样能量就近似等于(p_T\cosh\eta)。有了这个近似,两个缪子的不变质量可以完全由运动学变量算出来。

3.3 动量和能量的单位习惯:GeV/c 与质心系

先统一一个单位问题。高能物理里最常用的能量单位是电子伏特,1 GeV就是10亿电子伏特。LHC上质子的总碰撞能量是8 TeV,也就是8000 GeV。而Z玻色子的质量大约是91.2 GeV,这意味着在所有双缪子候选事例里,不变质量在91 GeV附近的事例,最有可能是Z玻色子衰变来的。

我现在把计算不变质量的函数写出来:

import numpy as np def invariant_mass(px1, py1, pz1, E1, px2, py2, pz2, E2): total_E = E1 + E2 total_px = px1 + px2 total_py = py1 + py2 total_pz = pz1 + pz2 return np.sqrt(total_E**2 - total_px**2 - total_py**2 - total_pz**2) # 实际处理时,用矢量化运算,避免逐行进for循环

写代码的时候我建议直接用NumPy的数组运算,不要写逐事件的for循环。数据量虽然只有几十万行,但Python的for循环效率低,跑一轮要等很久。矢量化写法一行能顶得上几百行循环的效果。

3.4 基线筛选的每一刀怎么定

在算不变质量之前,先要对双缪子候选做几个标准的基线选择。筛选条件不是越多越好,但基本的要求能去掉大量背景。

第一个要求是两个缪子电荷相反,它们的电荷乘积必须为负。因为Z玻色子是中性的,它只能衰变成正反轻子对。如果两个缪子电荷相同,那这个事例要么测量误差,要么是背景过程。第二个要求是每个缪子的横动量要足够高,一般取(p_T > 25) GeV。这个值不是随便定的,它要求缪子确实来自硬散射过程,而不是低能的强子衰变产物。第三个要求是赝快度的绝对值(|\eta| < 2.5),这个范围对应探测器桶部和端盖的接收度,超出这个范围探测器测量精度就不够了。

这些条件在代码里实现很直白:

import numpy as np def baseline_selection(df): valid_events = [] for _, row in df.iterrows(): pts = np.array(row['muons_pt']) etas = np.array(row['muons_eta']) phis = np.array(row['muons_phi']) charges = np.array(row['muons_charge']) if len(pts) < 2: continue pass_momentum = (pts > 25) & (np.abs(etas) < 2.5) pass_charge = (charges[0] * charges[1] < 0) if len(charges) >= 2 else False if np.all(pass_momentum) and pass_charge: valid_events.append(row) return pd.DataFrame(valid_events)

这段代码故意用循环写,是为了逻辑清楚,实战时建议优化成纯向量化操作。单次筛选就能把数据量压掉一大半,你会非常直观地感受到探测器接受度和运动学要求对统计量的影响。

4. 信号与背景:直方图叠加、MC归一化与显著性粗估

4.1 数据、信号MC和背景MC叠在一起怎么看

拿到筛选后的事件集合,下一步就是把双缪子不变质量的分布画出来。这个分布会有一个明显的特征:在91 GeV附近出现一个峰,叠加在平滑的本底之上。峰就是Z玻色子信号,底下平滑的部分来自其他物理过程,比如Drell-Yan过程的连续谱,或者一对W玻色子衰变产生的双缪子。

我用matplotlib把数据直方图、蒙特卡洛信号样本和蒙特卡洛背景样本画在同一张图上:

import matplotlib.pyplot as plt import numpy as np plt.figure(figsize=(10, 6)) plt.hist(data_mass, bins=60, range=(60, 120), histtype='step', label='Data') plt.hist(signal_mc_mass, bins=60, range=(60, 120), histtype='stepfilled', alpha=0.5, label='Signal MC') plt.hist(background_mc_mass, bins=60, range=(60, 120), histtype='stepfilled', alpha=0.3, label='Background MC') plt.xlabel('Dimuon invariant mass [GeV]') plt.ylabel('Events / 1 GeV') plt.legend()

这里有一个很重要的细节:数据、信号MC、背景MC的统计量往往不一样,直接叠加会错得离谱。你必须先做归一化。常见的做法是以数据总事例数为目标,给MC样本乘一个权重因子,公式是:

[ w = \frac{N_{\text{data}}}{N_{\text{MC}}} ]

但更严格的做法是按亮度归一化,因为MC模拟样本对应的事例数多寡跟数据不是一回事。在开放数据集里,文档会给一个参考亮度值和MC总权重,你直接用参考亮度比例归一化最合理。如果没有文档支持,退而求其次用总面积归一化,也能出正确的峰位。

4.2 归一化方式与经验比例

我当时做的第一版图,信号MC的峰明显比数据矮一截。排查了半天,发现是忘了乘MC权重。ATLAS开放数据里的MC事件每个样本都带一个mcWeight字段,不同样本的权重不同,因为产生截面不同。正确做法是先按权重加总所有MC事件,再做比例归一化。

经验上,Z到双缪子这个通道,在8 TeV、数据亮度约10 fb⁻¹的条件下,经过筛选后数据里大约有几十万个候选事例。信号MC的原始事例数可能只有几万,需要乘的权重可能是几倍到几十倍不等。你画完图后如果不放心,可以把峰周围(86到96 GeV)的数据事例数跟信号MC事例数做个比值,应该在1附近才对。

4.3 肉眼可见的91 GeV峰与残差图

图上最直观的信息就是峰的位置。理论值Z玻色子质量是91.1876 GeV,你会发现数据峰位落在91到92 GeV之间。导致略微偏离的因素有两个:一是测量分辨率导致质量峰被展宽,但峰位一般不会偏太多;二是如果动量刻度有点偏差,峰位会系统性移动。做正规分析时,物理学家会把峰位作为一个自由参数去拟合,看它是否跟已知质量一致,用来校准能量刻度。

我在分析里做了两件事:第一,直接看直方图峰位;第二,把数据和信号MC的残差画出来。残差定义是((N_{\text{data}} - N_{\text{MC}})/\sigma_{\text{data}}),其中(\sigma_{\text{data}} = \sqrt{N_{\text{data}}})是统计误差。残差应该围绕零上下浮动,如果在某个区间出现系统性偏离,说明那个地方有没建好的背景模型。

残差图不是可选步骤,我建议每个做这类分析的人都养成画残差的习惯。它不会给你新物理,但能帮你确认自己对工具和样品的理解是否正确。

5. 实际运行中踩过的坑与提速清单

5.1 读取慢、内存爆掉的处理

HDF5文件里存的不是标准化的Numpy数组,我第一版脚本用Pandas逐行读,结果跑了十分钟才加载完数据,内存也冲到16 GB以上。原因是我在循环里反复拷贝DataFrame子集。后来改成纯向量化筛选,先提前把数组从Pandas里取出来转成NumPy数组,最终的运行时间从十分钟降到了几十秒。

如果你处理的数据比这个大得多,我的建议是用dask或者直接分块读。但在开放数据这个体量下,最简单有效的是把循环去掉。每一条逐事件操作,都先问自己能不能用数组运算表达。例如计算双缪子不变质量时,不需要循环每对缪子,而是把两个候选缪子的动量和能量各取成一个数组,然后一次性做运算。

5.2 带电粒子与中性粒子的区分

这是最容易出逻辑错误的地方。ATLAS开放数据里,电子的字段名称带electrons,缪子字段带muons,光子字段带photons,看起来分得很清楚。但真实探测器里,一个事例经常同时包含多种类型的候选粒子。如果只做Z到双缪子分析,必须确保两个候选都来自muons字段,而不是把电子或者光子误当成缪子。

我自己就犯过这个错:为了图方便,我把muons_pt和electrons_pt拼在一起找最高的两个粒子,结果重建出来的"质量峰"莫名其妙变成了两个宽包。后来查了文档才明白,不同粒子类型的重建算法和鉴别条件不一样,不能混用。物理分析里这叫"粒子鉴别",在开放数据里就表现为严格区分字段。做分析之前,先把每个字段的物理含义过一遍,能省掉后面大量的调试时间。

5.3 拟合峰位:低统计量时的取舍

我拟合Z峰时用的拟合函数是"高斯信号+指数背景",这条公式在高能物理里很经典:

[ f(m) = A \exp\left(-\frac{(m-\mu)^2}{2\sigma^2}\right) + B \exp(-\lambda m) ]

在数据量足够的时候,最小二乘拟合很快就能收敛。但开放数据里,如果你把筛选条件卡得特别严,比如要求两个缪子都是高横动量、高隔离度,最后保留下来的事例数可能只有几千个,这时候拟合就会出问题。(\mu)的误差会变大,(\sigma)可能会拟合到局部极小值。

我的做法是,先做一个"宽松基线"拟合,把峰位固定在91.2 GeV附近,只拟合宽度和背景参数,等确认一切正常后,再放开峰位参数做完整拟合。这是一种很实用的稳健策略,适合统计量有限、自动拟合容易跑偏的场景。

6. 我留下的一段核心代码与后续扩展方向

6.1 可以直接改的数据分析脚本骨架

以下是这次分析的核心骨架,我把注释写得很详细,直接改成你自己的输入文件路径就能跑:

import uproot import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import curve_fit # 读数据 file = uproot.open('ATLAS_OpenData_8TeV.h5') tree = file['mini'] df = tree.arrays(library='pd') # 预筛选 df = df[df['muons_pt'].apply(lambda x: len(x) >= 2)] # 对每个事例取前两个高p_T缪子 def first_two_muons_pt(row): pts = np.sort(np.array(row['muons_pt']))[::-1] return pts[:2] df['leading_muon_pts'] = df.apply(first_two_muons_pt, axis=1) # 计算不变质量向量化版本 def calc_dimuon_mass_vectorized(row): pts = np.array(row['muons_pt']) etas = np.array(row['muons_eta']) phis = np.array(row['muons_phi']) # 取前两个 if len(pts) < 2: return np.nan idx = np.argsort(pts)[::-1][:2] pt1, pt2 = pts[idx] eta1, eta2 = etas[idx] phi1, phi2 = phis[idx] px1 = pt1 * np.cos(phi1) py1 = pt1 * np.sin(phi1) pz1 = pt1 * np.sinh(eta1) E1 = np.sqrt(px1**2 + py1**2 + pz1**2) px2 = pt2 * np.cos(phi2) py2 = pt2 * np.sin(phi2) pz2 = pt2 * np.sinh(eta2) E2 = np.sqrt(px2**2 + py2**2 + pz2**2) total_E = E1 + E2 total_px = px1 + px2 total_py = py1 + py2 total_pz = pz1 + pz2 return np.sqrt(total_E**2 - total_px**2 - total_py**2 - total_pz**2) df['dimuon_mass'] = df.apply(calc_dimuon_mass_vectorized, axis=1) df = df.dropna(subset=['dimuon_mass'])

这个骨架离生产级还有一段距离,但作为学习用的主干逻辑已经足够。后续所有物理分析都是在df这个DataFrame上加筛选条件、画分布、做拟合。

6.2 从Z到H到双光子的思路

双缪子分析跑通之后,后续的扩展路径非常多。推荐下一个练习是寻找希格斯玻色子到双光子的衰变,这是一个完全不同挑战:光子没有电荷,不能用电荷条件筛选;光子本底比缪子光子复杂得多,来自中性π介子衰变的假光子是主要背景;不变质量峰的重建依赖光子能量分辨率,因此光子能量刻度的理解就变成核心问题。

另外,你还能换个角度,研究运动学变量:比如双轻子横动量谱、前后不对称性,甚至通过大规模样本去学习怎么用机器学习区分信号和背景。这些方向都以本文的双缪子分析为基础,因为研究对象的框架一样:选出候选事例、重建不变量、对比模拟。

6.3 给后来者的建议

最后根据我这次实验总结几条具体建议。

第一,代码版本管理越早开始越好。我一开始偷懒没有用Git,结果改了十几个版本之后分不清哪个图对应的哪版筛选条件,后来只能重新整理。现在我会在建好项目目录的同时顺手git init,每个物理量改动都留一次提交记录,排查问题时能清晰定位到是哪一个改动导致结果变化。

第二,时刻保存中间结果。我在完成预筛选后把DataFrame缓存成了CSV文件,之后每次改拟合代码时直接读CSV,不用重新跑一遍全流程。这个习惯能节约大量时间,也方便在后续分析里做交叉检查。

第三,物理量的单位、坐标定义要提前搞明白。我在刚开始时把(\phi)角定义搞反了,导致重建不变质量整体偏低,最后对了一遍官方文档才发现问题。这种错最容易浪费时间,也最不值得。

第四,面对看似"异常"的结果别急着调参数。先用简单的逻辑检查,比如画几个角分布看看物理上是否合理,再怀疑代码有bug。很多时候所谓的异常峰,不过是某个筛选条件写成了大于和小于的区别。这种事我在实际调试中经历过不止一次,慢下来系统排查,往往比反复调参更快找到真问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询