Amplitude Analysis 101 (PWA 101)#
Abstract
This document introduces Amplitude Analysis / Partial Wave Analysis (PWA) by demonstrating its application to a specific reaction channel and amplitude model. It aims to equip readers with a basic understanding of the full workflow and methodologies of PWA in hadron physics through a practical, hands-on example. Only basic Python programming and libraries (e.g. numpy
, scipy
, etc.) are used to illustrate the more fundamental steps in a PWA. Calculations with 4-vectors in this report are performed with the vector
package.
See also
A follow-up tutorial, PWA101 v2.0, is being prepared in ComPWA/gluex-nstar#13. Whereas this report focuses on common, numerical Python libraries, v2.0 formulates the amplitude model with a symbolic approach.
When performing an amplitude analysis, one usually starts with a long process of selecting data (âeventsâ) from a particle collider experiment for specific decay channels. It is a delicate process with the intend to select as much background noise from other decay channels, while maintaining sufficient signal events that an amplitude model can be âfitâ to it. This tutorial will not consider this event selection, but will focus on amplitude model formulation and fitting it to a data sample that is assumed to be come from experiment. As such, the tutorial is built up of the following main steps:
The following Python packages are all that we require to go through each of the steps.
Import Python libraries
from __future__ import annotations
import logging
import os
import warnings
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import phasespace
import scipy as sp
import tensorflow as tf
import vector
from iminuit import Minuit
from matplotlib import gridspec
from tqdm.auto import tqdm
from vector.backends.numpy import MomentumNumpy4D
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
logging.disable(logging.WARNING)
warnings.filterwarnings("ignore")
Amplitude model#
Theory#
This amplitude model is adapted from the Lecture 11 in STRONG2020 HaSP School by Vincent Mathieu.
The reaction \( \gamma p \to \eta \pi^0 p\) is one of the reaction channels that are studied in photo-production experiments like GlueX. For simplicity, we assume that the decay proceeds through three possible resonancesâ\(a_2\), \(\Delta^+\), and \(N^*\)âwith each in a different subsystem.
In the following, we denote \(1 \equiv \eta, 2 \equiv \pi^0, 3 \equiv p\). Given these three subsystems in this particle transition, we can construct three amplitudes,
where \(s, t, u\) are the Mandelstam variables (\(s_{ij}=(p_i+p_j)^2\), \(t_i=(p_a-p_i)^2\), and \(u_i=(p_b-p_i)^2\)), \(m\) is the resonance mass, \(\Gamma\) is the resonance width, \(Y^m_l\) are spherical harmonics functions, \(\Omega_i\) are pairs of Euler angles (polar angle \(\theta\) and azimuthal angle \(\phi\)) that represent the helicity decay angles, and \(a_i\), \(b_i\), and \(c_i\) are complex-valued coefficients. Note that the Mandelstam variables and angles come from measured events, while the other symbols are parameters that need to be modified in order to have the amplitude model match the data.
Note
All that an amplitude model requires as data input, are four-momenta. For this amplitude model, there are just three required four-momenta per event, one for each final state in \(p\gamma \to \eta\pi^0 p\).
The original full amplitude model from the Lecture 11 in STRONG2020 HaSP School is shown in Equation (1). In this report, only the BreitâWigner and Spherical harmonics terms are kept, while the exponential factor is abandoned, i.e.
The intensity \(I\) that describes our measured distributions is then expressed as a coherent sum of the amplitudes \(A^{ij}\),
Ultimately, the amplitude model in Equations (2) and (3) in this tutorial consists of three resonances, and each of them are formed by two components: a Breit-Wigner times some spherical harmonics (\(l = 2, 1, 0\)).
Assumption: spinless final state!
While the spin of the \(\eta\) meson and the \(\pi^0\) meson are both \(0\), the spin of the proton is spin-\(\frac{1}{2}\). However, in order to simplify the amplitude model, we treat the proton as a spin-0 particle. Overall, the \(\eta\), \(\pi^0\) and \(p\) are therefore all treated as spin-0 particles.
The primary motivation for assuming proton to be spin-\(0\) is to avoid the necessity of aligning the amplitudes (see e.g. ComPWA/ampform#6). This assumption enables the intensity \(I\) to be written as a coherent sum of the amplitudes of the subsystems without the need for additional Wigner rotations. In addition, the amplitude for each decay chain contains only one spherical harmonic, namely that of the resonance.
The spherical harmonics in Equation (2) are therefore relevant only to the resonances. Here, \(l\) represents the spin of the resonances and \(m\) represents its spin projection in the decay chain. The total angular momentum and coupled spin (for the two-body state of the two decay products of the resonance) are not considered. According to [Richman, 1984] and other classical references on helicity, this is known as the helicity basis. In contrast, the canonical basis does not sum over \(L\) and \(S\), resulting in a more complex coherent sum of amplitudes. The transformation between these bases is also discussed here on the AmpForm website.
In our case:
\(A^{12}\) represents a d-wave interaction, because we assume there is a \(a_2\) resonance (spin 2) in this subsystem. The possible \(m\) values are \(â2,â1,0,1,2\). Each of these values corresponds to different orientations of the d-wave.
\(A^{23}\) represents a p-wave interaction, because we assume this subsystem has a (spin-1) \(\Delta^+\) resonance. The possible \(m\)Â values are \(â1,0,1\).
\(A^{31}\) represents an s-wave interaction, because we assume there is one spin-0 \(N^*\) resonance in this subsystem. The only possible \(m\) value is 0 and, since \(Y_0^0=0\), the amplitude only consists of a BreitâWigner.
Implementation#
In the following, we define a few functions that implement parts of the amplitude model of Equation (2). Later on in Visualization, we can use these functions to visualize each components of the model individually.
Breit-Wigner (only) Model#
The following functions define the BreitâWigner function, as well as the following intensity function that only contains the BreitâWigner (dynamics) component of each amplitude.
def BW_model(s12, s23, s31, *, M12, Gamma12, M23, Gamma23, M31, Gamma31, **kwargs):
A12 = BW(s12, M12, Gamma12)
A23 = BW(s23, M23, Gamma23)
A31 = BW(s31, M31, Gamma31)
return np.abs(A12 + A23 + A31) ** 2
def BW(s, m, Gamma):
return 1 / (s - m**2 + m * Gamma * 1j)
Spherical Harmonics (only) Model#
The calculation of \(Y_l^m(\phi, \theta)\) is done via scipy.special.sph_harm()
. However, we use a different definition of \(\phi\) and \(\theta\), following a notation that is more common in hadron physics.
\(\phi\) is the azimuthal angle and ranges from -\(\pi\) to \(\pi\). SciPy represents this as \(\theta\), ranging from \(0\) to \(2\pi\).
\(\theta\) is the polar angle and ranges from \(0\) to \(\pi\). SciPy represents this as \(\phi\) with the same range.
This leads to
Tip
Alternatively, we can formulate the spherical harmonics in terms of a Wigner-\(d\) or Wigner-\(D\) function, as
In the following, we define functions to compute the spherical harmonics of the three subsystem amplitudes in Equation (2). Note how the function signature consists of two input data columns, theta
and phi
, and how the rest of the arguments are parameters. The final kwargs
(key-word arguments) is there so that we can compose larger functions from these function definitions.
def Ylm12(
theta: np.ndarray,
phi: np.ndarray,
*,
a_minus2,
a_minus1,
a_0,
a_plus1,
a_plus2,
**kwargs,
) -> np.ndarray:
return (
a_plus2 * sp.special.sph_harm(2, 2, phi, theta)
+ a_plus1 * sp.special.sph_harm(1, 2, phi, theta)
+ a_0 * sp.special.sph_harm(0, 2, phi, theta)
+ a_minus1 * sp.special.sph_harm(-1, 2, phi, theta)
+ a_minus2 * sp.special.sph_harm(-2, 2, phi, theta)
)
def Ylm23(
theta: np.ndarray, phi: np.ndarray, *, b_minus1, b_0, b_plus1, **kwargs
) -> np.ndarray:
return (
b_plus1 * sp.special.sph_harm(1, 1, phi, theta)
+ b_0 * sp.special.sph_harm(0, 1, phi, theta)
+ b_minus1 * sp.special.sph_harm(-1, 1, phi, theta)
)
def Ylm31(theta: np.ndarray, phi: np.ndarray, c_0: complex, **kwargs) -> np.ndarray:
return c_0 * sp.special.sph_harm(0, 0, phi, theta)
We now have the ingredients to define an intensity function that only contains spherical harmonics, that is, the angular part of the amplitude model:
def SH_model(phi1, theta1, phi2, theta2, *, c_0, **pars):
return np.abs(Ylm12(phi1, theta1, **pars) + Ylm23(phi2, theta2, **pars) + c_0) ** 2
Breit-Wigner \(\times\) Spherical Harmonics Model#
Finally, we combine the Breit-Wigner (only) Model and Spherical Harmonics (only) Model to get an implementation for Equation (2),
def BW_SH_model(
s12,
s23,
s31,
phi1,
theta1,
phi2,
theta2,
*,
M12,
Gamma12,
M23,
Gamma23,
M31,
Gamma31,
c_0,
**parameters,
):
A12 = BW(s12, M12, Gamma12) * Ylm12(phi1, theta1, **parameters)
A23 = BW(s23, M23, Gamma23) * Ylm23(phi2, theta2, **parameters)
A31 = BW(s31, M31, Gamma31) * c_0
return np.abs(A12 + A23 + A31) ** 2
Visualization#
As discussed in Theory, the amplitude model contains variables that are provided by experiment (âeventsâ as data input) as well as parameters that are to be optimized. The data input is usually in the form of four-momenta, while the model is formulated in the form of Mandelstam variables and helicity angles. We therefore have to compute these variables from the four-momenta.
Phase space generation#
First, however, we need to generate a phase space sample of four-momenta in order to plot the amplitude model as a distribution over each of the variables. In this section, we use the phasespace
package for generating four-momenta for the reaction \(p\gamma \to p\eta\pi^0\). The phase space sample will also be used later on to normalize the model when calculating the likelihood over the data sample (see Fitting).
Lab frame and CM frame#
In Center-of-Mass (CM) frame, the 4-momentum of the total system can be acquired by 4-momentum conservation:
Caution
The calculation here involved using values from the CM frame. While this frame is commonly used for theoretical calculations, experimental data is often analyzed in the lab frame. However, itâs worth noting that oin some collider experiments that do not have a fixed target, the CM frame can coincide with the lab frame.
The GlueX experiment at Jefferson Lab uses a fixed proton target with a linearly polarized photon beam, and the beam energy range in the lab frame is typically from 8 to 9 GeV.
We use the \(\gamma\) beam energy in the lab frame as input, e.g. \(E_{\gamma, \text{lab}} = 8.5 \; \text{GeV}\), and want to know the collision energy in the CM frame.
We can calculate the energy of the photon in the CM frame as follows. The four-momentum of photon (beam) in the lab frame is
Since the photon is massless, \(m_\gamma=0\), and
we get
The proton (target) is at rest in the lab frame, so we have
where \(m_p\) is the proton mass. We have a total four-momentum in the lab frame of
and we know
The CM total energy \(E_0\) expressed in terms of quantities from the lab frame is thus
Equivalently, from the CM frame perspective, since \(\vec{p}_{\gamma} = -\vec{p}_{p}\) and \(|\vec{p}_{\gamma}| = |\vec{p}_{p}|= p_{z}\), we find
Our implementation based on Equation (15) thus becomes:
E_lab_gamma = 8.5
m_proton = 0.938
m_eta = 0.548
m_pi = 0.135
m_0 = np.sqrt(2 * E_lab_gamma * m_proton + m_proton**2)
m_0
Thus, we then have the mass of the system \(m_0\) (or the mass of a âvirtualâ particle \(p\gamma\)) in CM frame of
Final state four-momenta#
The phasespace
library is a Python package designed to simulate particle decays according to the principles of relativistic kinematics and phase space distributions. We first use the phasespace
to generate decay particles.
phsp_events = 500_000
weights, particles = phasespace.nbody_decay(m_0, [m_eta, m_pi, m_proton]).generate(
n_events=phsp_events
)
Note that each event comes with a statistical weights for each generated event. These weights represent how likely each particular event configuration (set of momenta and energies) is, based on phase space considerations. In order to generate a flat distribution, we will have to use a hit-and-miss method over these weights.
def generate_phsp_decay(
size: int, seed: int | None = None, bunch_size: int = 500_000
) -> tuple[MomentumNumpy4D, MomentumNumpy4D, MomentumNumpy4D]:
rng = np.random.default_rng(seed=seed)
generated_seed = rng.integers(1_000_000)
phsp_sample = generate_phsp_bunch(bunch_size, generated_seed)
while get_size(phsp_sample) < size:
bunch = generate_phsp_bunch(bunch_size, generated_seed)
phsp_sample = concatenate(phsp_sample, bunch)
phsp_sample = remove_overflow(phsp_sample, size)
return tuple(to_vector(tensor) for tensor in phsp_sample)
def generate_phsp_bunch(
size: int, seed: int | None = None
) -> tuple[tf.Tensor, tf.Tensor, tf.Tensor]:
rng = np.random.default_rng(seed=seed)
final_state = [m_eta, m_pi, m_proton]
weights, particles = phasespace.nbody_decay(m_0, final_state).generate(
n_events=size, seed=seed
)
random_weights = rng.uniform(0, weights.numpy().max(), size=weights.shape)
selector = weights > random_weights
return tuple(particles[f"p_{i}"][selector] for i in range(len(final_state)))
def get_size(phsp: tuple[tf.Tensor, tf.Tensor, tf.Tensor]):
return len(phsp[0])
def concatenate(
phsp1: tuple[tf.Tensor, tf.Tensor, tf.Tensor],
phsp2: tuple[tf.Tensor, tf.Tensor, tf.Tensor],
) -> tuple[tf.Tensor, tf.Tensor, tf.Tensor]:
return tuple(tf.concat([phsp1[i], phsp2[i]], axis=0) for i in range(3))
def remove_overflow(phsp: tuple[tf.Tensor, tf.Tensor, tf.Tensor], size: int):
return tuple(tensor[:size] for tensor in phsp)
def to_vector(tensor: tf.Tensor) -> MomentumNumpy4D:
return vector.array({
key: tensor.numpy().T[j] for j, key in enumerate(["px", "py", "pz", "E"])
})
Hit-and-miss sampling
Step 1: Generate a small phase space sample bunch with
generate_phsp_bunch()
A NumPy random number generator (
rng
) is initialized with a fixed seed for reproducibility. This ensures that every time the simulation is run, it produces the same random numbers.The
phasespace
package is used to simulate a decay process where a parent particle decays into three daughter particles. This is set up by specifying the masses of the parent particle (m_0
) and the daughter particles (m_eta
,m_pi
,m_proton
). The functionnbody_decay
followed by generate is used to create the phase space and return both the weights of each decay event and the momenta of the decay products.As mentioned, the
phasespace
package generates four-momenta that are not evenly distributed .The distributions appear even only when multiplied by the generated weights. To get a sample bunch that is uniformly distributed, rejection sampling (âhit-and-missâ) is applied over the weights. To achieve this, a random set of weights (random_weights
) is generated, and only those events where the original weight exceeds the random weight are selected.
Step 2, Ensure sufficient sample size with
generate_phsp_decay()
The function starts by generating an initial phase space sample. If this initial sample does not meet the required size, more samples are generated and concatenated until the requested size is reached.
Once the target sample size is reached or exceeded,
remove_overflow()
trims the sample to precisely match the requested size.
Step 3, Convert tensors to four-momentum vectors objects The
phasespace
package generates four-momenta in the form of \(4\times N\) TensorFlow tensors, with \(N\) the number of events. For each tensor in the phase space tuple (representing the momentum components like \(E, p_x, p_y, p_z\)), theto_vector
function converts them into a structuredMomentumNumpy4D
array. This structured array includes keys for each momentum component and possibly the energy component and provides convenient methods for computing boosts etc.
Initial state four-momenta#
Because of the simple relation in Equation (8), the four-momenta for the initial state, \(\gamma\) (a) and \(p\) (b), do not have to be generated by a phase space generator.
To find \(p_a\) and \(p_b\) by 4-momentum conservation, we can use
Due to energy conservation, we have
Since \(m_a=0\) and \(p_a = -p_b\), we have
Reorganizing, we get,
and
Finally, this gives us
With this, our numerical implementation to compute four-momentum of \(p_a\) and \(p_b\) from Equation (17) becomes:
def compute_pa_pb(
p1: MomentumNumpy4D, p2: MomentumNumpy4D, p3: MomentumNumpy4D
) -> tuple[MomentumNumpy4D, MomentumNumpy4D]:
shape = p1.shape
E0 = (p1 + p2 + p3).e
px = np.zeros(shape)
py = np.zeros(shape)
pz = np.ones(shape) * (E0**2 - p3.m.mean() ** 2) / (2 * E0)
E = np.ones(shape) * np.sqrt(p3.m.mean() ** 2 + pz.mean() ** 2)
pa = MomentumNumpy4D({"E": pz, "px": px, "py": py, "pz": pz})
pb = MomentumNumpy4D({"E": E, "px": px, "py": py, "pz": -pz})
return pa, pb
Four-momenta of all particles#
We now create a function generate_phsp_all()
to create and compute all particles in phase space all-at-once, combining the two functions from the previous sections into one function.
def generate_phsp_all(
size: int, seed: int | None = None, bunch_size: int = 500_000
) -> tuple[
MomentumNumpy4D, MomentumNumpy4D, MomentumNumpy4D, MomentumNumpy4D, MomentumNumpy4D
]:
p1, p2, p3 = generate_phsp_decay(size, seed, bunch_size)
pa, pb = compute_pa_pb(p1, p2, p3)
return p1, p2, p3, pa, pb
%%time
p1_phsp, p2_phsp, p3_phsp, pa_phsp, pb_phsp = generate_phsp_all(
size=phsp_events, seed=42, bunch_size=1_000_000
)
Kinematic variable calculation#
Spherical coordinate system#
Before introducing CM and helicity angles, we first introduce polar angles and azimuthal angles in spherical coordinates.
In the spherical coordinates, the polar angle \(\theta\) and azimuthal angle \(\phi\) are defined as
In Equation (18), \(p_z\) is equivalent to \(z\), and \(|p|\) is \(r\) in figure above, while \(p_y\) equivalent to \(y\), and \(p_x\) is \(x\).
The sample is plotted to check whether the distribution looks uniformly distributed in the Dalitz plane. The Mandelstam variable \(s\) of each of the three subsystems can be easily computed with from the four-momentum objects as follows. Here, we use the methods and attributes provided by the vector
package.
p12_phsp = p1_phsp + p2_phsp
p23_phsp = p2_phsp + p3_phsp
p31_phsp = p3_phsp + p1_phsp
s12_phsp = p12_phsp.m2
s23_phsp = p23_phsp.m2
s31_phsp = p31_phsp.m2
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig = plt.figure(figsize=(12, 5))
fig.suptitle("Phase Space Dalitz Plot")
gs = gridspec.GridSpec(1, 3, width_ratios=[1, 1, 0.05]) # The last column for colorbar
ax1 = plt.subplot(gs[0])
ax2 = plt.subplot(gs[1])
cax = plt.subplot(gs[2]) # For colorbar
hist2 = ax2.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmin=1e-2,
density=True,
cmap="jet",
range=[[0, 10.3], [0.5, 13]],
)
ax2.set_title("2D Histogram")
ax2.set_xlabel(R"$m^2(\eta \pi^0)\;\left[\mathrm{GeV}^2\right]$")
ax1.scatter(s12_phsp, s23_phsp, s=1e-4, c="black", norm=mpl.colors.Normalize())
ax1.set_xlabel(R"$m^2(\eta \pi^0)\;\left[\mathrm{GeV}^2\right]$")
ax1.set_ylabel(R"$m^2(\pi^0 p)\;\left[\mathrm{GeV}^2\right]$")
ax1.set_title("Scatter Plot")
fig.colorbar(hist2[3], cax=cax)
fig.tight_layout()
fig.show()
Tip
There are different ways to represent the Dalitz plot, each with its advantages.
Scatter Plot: This method plots individual events as points, offering a clear view of the density and distribution of events within the phase space. It is particularly useful for visualizing smaller datasets or when high resolution is needed to identify specific features or clusters.
2D Histogram: This approach divides the phase space into bins and counts the number of events within each bin, representing the density of events using a color scale. It is effective for large datasets, providing a smooth and continuous representation of the phase space that highlights overall trends and structures.
In conclusion, to check if the phase space sample is evenly distributed, a scatter plot is typically more straightforward and visually clear.
CM Angles#
Angles in the CM frame are the polar and azimuthal angles of the spatial components of the four-momenta in the CM frame (i.e. the frame that satisfies the relations in Equation (8)). They are different than the helicity angles in each subsystem (which is after rotation and boost into the subsystem).
The values for phase space can be obtained directly in the CM frame, without boosting into a different frame after the generation. We denote these angles as
theta1_CM_phsp = p1_phsp.theta
theta2_CM_phsp = p2_phsp.theta
theta3_CM_phsp = p3_phsp.theta
phi1_CM_phsp = p1_phsp.phi
phi2_CM_phsp = p2_phsp.phi
phi3_CM_phsp = p3_phsp.phi
Helicity angles#
See also
The amplitude model shown in Equation (2) derives from the helicity formalism. This spin formalism breaks up the transition amplitudes of a decay chain into two-body decays (isobar model), which allows separating angular (spin) dependence from dynamics (e.g. BreitâWigner).
Crucially, the helicity formalism builds on a chain of boosts and rotations that align the reference frame of each two-body decay node such that the particle moves along the \(z\) direction. In the amplitude model of Equation (2), the rotations are still visible in the form of Wigner-\(D\) matrices, which derive from rotations of spin states. (See Equation (6) for the relation between \(Y^m_l\) and \(d\) and \(D\) functions.)
Helicity states and Wigner matrices
Helicity
Helicity \(\lambda\) is the projection of a particleâs spin \(S\) onto its direction of motion \(\vec{p}\). Mathematically, itâs given by
For massless particles (photons in particular), helicity is a Lorentz-invariant quantity, meaning it remains unchanged under boosts along the direction of motion.
Helicity state
Helicity states are eigenstates of the helicity operator. For a particle moving in the \(z\)Â direction, the helicity operator is \(S \cdot \hat{p}\).
Wigner-\(D\)Â matrix
Wigner-\(D\)Â matrices are used in the helicity formalism to describe the rotation of spin states. These matrices depend on the Euler angles of the rotation and are denoted by
where \(j\) is the spin, \(m'\) and \(m\) are the magnetic quantum numbers, \(\alpha, \beta, \gamma\) are the Euler angles of the rotation, and \(d^j_{m'm}(\beta)\) is an element of the orthogonal Wignerâs (small) \(d\)-matrix.
As can be seen in the amplitude model of Equation (2), that the rotations (represented by \(Y^m_l\)) contain solid angles \(\Omega=\phi,\theta\) (see spherical coordinates). They have to be computed in the helicity frame of the resonance, which means we have to boost into each of the three subsystems. For instance, for the \(a_2\) resonance (\(A^{12}\)), this would be a boost into subsystem \(p_1+p_2\), plus a rotation such that the \(z\) axis points in the direction of \(p_1+p_2\). The helicity angles \(\phi\) and \(\theta\) can then easily be computed from the spherical coordinates of the (boosted) \(p_1'\) or \(p_2'\).
Reference frames after boost
CM frame |
Helicity frame |
---|---|
Before boosting |
After boosting into the \(p_1+p_2\) subsystem |
The production plane (cyan) is defined by the momenta of the incoming particles that participate in the production of a particular state or particle. In the figure, we have \(a+b \to (R\to 1+2)+3\) (subsystem \(p_1+p_2\)), meaning that the production plane is spanned by the momenta of \(a\) (or \(b\)) and \(1\).
The decay plane (magenta) is defined by the momenta of the decay products of a particle. In the figure, the production plane is spanned by the momenta of \(1\) and \(2\).
Numerical angle computation#
To calculate the helicity angles \(\theta\) and \(\phi\), we define functions for boosting a combination of boost and rotation (around the \(y\) and \(z\)Â axis).
def theta_helicity(p_i: MomentumNumpy4D, p_ij: MomentumNumpy4D) -> np.ndarray:
return _boost_to_helicity_frame(p_i, p_ij).theta
def phi_helicity(p_i: MomentumNumpy4D, p_ij: MomentumNumpy4D) -> np.ndarray:
return _boost_to_helicity_frame(p_i, p_ij).phi
def _boost_to_helicity_frame(
p_i: MomentumNumpy4D, p_ij: MomentumNumpy4D
) -> MomentumNumpy4D:
return p_i.rotateZ(-p_ij.phi).rotateY(-p_ij.theta).boostZ(-p_ij.beta)
theta1_phsp = theta_helicity(p1_phsp, p12_phsp)
theta2_phsp = theta_helicity(p2_phsp, p23_phsp)
theta3_phsp = theta_helicity(p3_phsp, p31_phsp)
phi1_phsp = phi_helicity(p1_phsp, p12_phsp)
phi2_phsp = phi_helicity(p2_phsp, p23_phsp)
phi3_phsp = phi_helicity(p3_phsp, p31_phsp)
The distribution of the phase space sample over the kinematic variables is shown later in this section, together the generated data and weighted phase space (model).
Toy model parameter values#
Now that we have a numerical implementation for the amplitude model of Equation (2) and a means to compute the kinematic variables appearing in that expression, we are ready to visualize the model. First, however, we have to decide on some toy values for the parameters in the model. The toy model parameter values can be obtained from the data file from the Lecture 11 in STRONG2020 HaSP School. In this tutorial, the values are modified to make the structures in the Dalitz plot more visible.
toy_parameters = dict(
a_minus2=0,
a_minus1=0.5,
a_0=3.5,
a_plus1=4,
a_plus2=2.5,
b_minus1=-1.5,
b_0=4,
b_plus1=0.5,
c_0=2.5,
M12=1.32,
Gamma12=0.1,
M23=1.24 + 0.3,
Gamma23=0.1,
M31=1.57 + 0.3,
Gamma31=0.1,
)
Note that the masses \((M)\) and widths \((\Gamma)\) are properties of the three resonances.
Spherical harmonics#
We now have a look at the real part and imaginary part of \(\sum a_m Y_2^m (\Omega_1)\) as well as \(\sum b_m Y_1^m (\Omega_2)\) in Equation (2). For this, we define a grid of values for \(\phi\) and \(\theta\) over which to visualize the amplitudes.
PHI, THETA = np.meshgrid(
np.linspace(-np.pi, +np.pi, num=1_000),
np.linspace(0, np.pi, num=1_000),
)
Z12 = Ylm12(PHI, THETA, **toy_parameters)
Z23 = Ylm23(PHI, THETA, **toy_parameters)
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig, axes = plt.subplots(figsize=(10, 4), ncols=2, sharey=True, dpi=120)
cmap_real = axes[0].pcolormesh(
np.degrees(PHI), np.degrees(THETA), Z12.real, cmap=plt.cm.coolwarm
)
cmap_imag = axes[1].pcolormesh(
np.degrees(PHI), np.degrees(THETA), Z12.imag, cmap=plt.cm.coolwarm
)
axes[0].set_xlabel(R"$\phi$ [deg]")
axes[0].set_ylabel(R"$\theta$ [deg]")
axes[0].set_title(R"Re($\sum a_m Y_2^m (\Omega_1)$)")
axes[0].set_ylabel(R"$\theta$ [deg]")
axes[1].set_xlabel(R"$\phi$ [deg]")
axes[1].set_title(R"Im($\sum a_m Y_2^m (\Omega_1)$)")
cbar_real = fig.colorbar(cmap_real, ax=axes[0])
cbar_imag = fig.colorbar(cmap_imag, ax=axes[1])
fig.subplots_adjust(wspace=0.4, hspace=0.4)
fig.tight_layout()
plt.rcParams.update({"font.size": 10})
plt.show()
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig, axes = plt.subplots(figsize=(10, 4), ncols=2, sharey=True, dpi=120)
cmap_real = axes[0].pcolormesh(
np.degrees(PHI), np.degrees(THETA), Z23.real, cmap=plt.cm.coolwarm
)
cmap_imag = axes[1].pcolormesh(
np.degrees(PHI), np.degrees(THETA), Z23.imag, cmap=plt.cm.coolwarm
)
axes[0].set_xlabel(R"$\phi$ [deg]")
axes[0].set_ylabel(R"$\theta$ [deg]")
axes[0].set_title(R"Re($\sum b_m Y_1^m (\Omega_2)$)")
axes[0].set_ylabel(R"$\theta$ [deg]")
axes[1].set_xlabel(R"$\phi$ [deg]")
axes[1].set_title(R"Im($\sum b_m Y_1^m (\Omega_2)$)")
cbar_real = fig.colorbar(cmap_real, ax=axes[0])
cbar_imag = fig.colorbar(cmap_imag, ax=axes[1])
fig.subplots_adjust(wspace=0.4, hspace=0.4)
fig.tight_layout()
plt.rcParams.update({"font.size": 10})
plt.show()
Dalitz Plots of (sub)models#
Dalitz plots are ideal for visualizing the model over the Mandelstam variables \(s_{12}\), \(s_{23}\), and \(s_{31}\). In the following, we plot each of the âsub-modelsâ separately: Breit-Wigner (only), Spherical Harmonics (only), and Breit-Wigner \(\times\) Spherical Harmonics.
To integrate out the model dependence on the helicity angles, we plot the Dalitz plots as a histogram over the phase space sample, taking the computed intensities for each event in the sample as weight.
Compute intensities from models for plotting histograms
BW_intensities = BW_model(
s12_phsp,
s23_phsp,
s31_phsp,
**toy_parameters,
)
SH_intensities = SH_model(
phi1_phsp,
theta1_phsp,
phi2_phsp,
theta2_phsp,
**toy_parameters,
)
BW_SH_intensities = BW_SH_model(
s12_phsp,
s23_phsp,
s31_phsp,
phi1_phsp,
theta1_phsp,
phi2_phsp,
theta2_phsp,
**toy_parameters,
)
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig, axes = plt.subplots(figsize=(12, 4), ncols=3, sharey=True)
ax1, ax2, ax3 = axes
fig.suptitle("Dalitz Plots of sub-models")
ax1.set_title("BW model only")
ax2.set_title("SH model only")
ax3.set_title(R"BW $\times$ SH model")
for ax in axes:
ax.set_xlabel(R"$m^2(\eta \pi^0)$")
ax.set_ylabel(R"$m^2(\pi^0 p)$")
hist1 = ax1.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmap="jet",
cmin=1e-6,
density=True,
weights=BW_intensities,
)
hist2 = ax2.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmap="jet",
cmin=1e-6,
density=True,
weights=SH_intensities,
)
hist3 = ax3.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmap="jet",
cmin=1e-6,
density=True,
weights=BW_SH_intensities,
)
fig.colorbar(hist1[3], ax=ax1)
fig.colorbar(hist2[3], ax=ax2)
fig.colorbar(hist3[3], ax=ax3)
fig.tight_layout()
fig.show()
Data Generation#
In an actual amplitude analysis, we are now ready to âfitâ the model we formulated to a measured data distribution. In this tutorial, however, we generate the data ourselves and then perform a test fit using a starting model with some âguessedâ parameter values.
Hit and miss intensity sample#
The data can be generated with a similar hit-and-miss strategy as we saw in Phase space generation. In this case, the hit-and-miss is performed over the intensities computed from the model.
The following function generates a data sample based on a model by using hit-and-miss filter applied to the phase space sample. The output is a tuple
of five four-momenta \((p_1, p_2, p_3, p_a, p_b)\).
def generate_data(
model: callable, size: int, bunch_size: int = 500_000, seed: int | None = None
) -> tuple[MomentumNumpy4D, ...]:
rng = np.random.default_rng(seed=seed)
generated_seed = rng.integers(low=0, high=1_000_000)
progress_bar = tqdm(total=size)
phase_space = generate_phsp_all(bunch_size, generated_seed)
data_sample = _hit_and_miss_filter(model, phase_space, generated_seed)
progress_bar.n = min(get_size(data_sample), size)
progress_bar.update(n=0)
while get_size_data(data_sample) < size:
phase_space = generate_phsp_all(bunch_size, generated_seed)
bunch = _hit_and_miss_filter(model, phase_space)
data_sample = concatenate_data(data_sample, bunch)
progress_bar.n = min(get_size(data_sample), size)
progress_bar.update(n=0)
progress_bar.close()
return remove_overflow_data(data_sample, size)
def _hit_and_miss_filter(
model: callable, phase_space: tuple[MomentumNumpy4D, ...], seed: int | None = None
) -> tuple[MomentumNumpy4D, ...]:
p1, p2, p3, pa, pb = phase_space
p12 = p1 + p2
p23 = p2 + p3
p31 = p3 + p1
intensities: np.ndarray = model(
s12=p12.m2,
s23=p23.m2,
s31=p31.m2,
phi1=phi_helicity(p1, p12),
theta1=theta_helicity(p1, p12),
phi2=phi_helicity(p2, p23),
theta2=theta_helicity(p2, p23),
**toy_parameters,
)
rng = np.random.default_rng(seed=seed) # FIX seed is used here for reproducibility
random_intensities = rng.uniform(0, intensities.max(), size=intensities.shape)
selector = intensities > random_intensities
return (
p1[selector],
p2[selector],
p3[selector],
pa[selector],
pb[selector],
)
def get_size_data(
data: tuple[
MomentumNumpy4D,
MomentumNumpy4D,
MomentumNumpy4D,
MomentumNumpy4D,
MomentumNumpy4D,
],
):
return len(data[0])
def concatenate_data(
data1: tuple[MomentumNumpy4D, ...],
data2: tuple[MomentumNumpy4D, ...],
) -> tuple[MomentumNumpy4D, ...]:
return tuple(
concatenate_vectors((pi1, pj2)) for pi1, pj2 in zip(data1, data2, strict=True)
)
def concatenate_vectors(vectors: tuple[MomentumNumpy4D]) -> MomentumNumpy4D:
return vector.array({
"px": np.concatenate([p.px for p in vectors]),
"py": np.concatenate([p.py for p in vectors]),
"pz": np.concatenate([p.pz for p in vectors]),
"E": np.concatenate([p.e for p in vectors]),
})
def remove_overflow_data(
data: tuple[MomentumNumpy4D, ...], size: int
) -> tuple[MomentumNumpy4D, ...]:
return tuple(momentum[:size] for momentum in data)
Hit-and-miss data generation
A few functions are defined above for generating data sample from filtering phase space samples of particle decays and converting them into four-momentum vectors. The main ideas of them are:
generate_data()
: The main function used to generate the dataIt simulates events until it gathers a specified number of valid data points that meet the modelâs conditions.
Parameters:
model: A callable that computes the intensity of events based on given kinematic variables.
size: The desired number of valid events to generate.
bunch_size: The number of events to generate per iteration if the initial batch doesnât meet the required size. Defaults to size if not provided.
seed: Seed for random number generation to ensure reproducibility.
Process:
Initialize a random number generator.
Generate an initial set of phase space data (phase_space) and filter it using the model to obtain valid events (data_sample).
If the filtered data (data_sample) is less than the desired size, additional data is generated in batches (bunch_size) and filtered until the size requirement is met.
The function ensures the returned dataset size matches exactly the requested size.
_hit_and_miss_filter
: The other important function that filters the generated phase space datausing the hit-and-miss Monte Carlo method based on the model intensities.
Parameters:
model: The model function to calculate intensities.
phase_space: Tuple containing momentum vectors of particles involved in the event.
seed: Seed for random number generation.
Process:
Calculate invariant masses and other kinematic variables required by the model.
Call the model with these variables to compute intensities for each event.
Generate random numbers and select events where the model intensity is greater than a random threshold, simulating a probability proportional to the intensity.
There are other assisted functions that are used in the functions above:
get_size_data
:Simply returns the number of events in the data, used to check if more data needs to be generated.
concatenate_data
: - Merges two datasets into one, maintaining the structure of tuples of momentum vectors.concatenate_vectors
: - Helper function to concatenate individual momentum vectors within the data tuples.remove_overflow_data
: - Trims the dataset to the desired size if it exceeds the specified number due to batch generation.
Now we can use the function generate_data()
to generate our data sample:
data_events = 100_000
data = generate_data(BW_SH_model, data_events, seed=0, bunch_size=1_000_000)
p1_data, p2_data, p3_data, pa_data, pb_data = data
Convert four-momenta to kinematic variables
p12_data = p1_data + p2_data
p23_data = p2_data + p3_data
p31_data = p3_data + p1_data
s12_data = p12_data.m2
s23_data = p23_data.m2
s31_data = p31_data.m2
theta1_CM_data = p1_data.theta
theta2_CM_data = p2_data.theta
theta3_CM_data = p3_data.theta
phi1_CM_data = p1_data.phi
phi2_CM_data = p2_data.phi
phi3_CM_data = p3_data.phi
theta1_data = theta_helicity(p1_data, p12_data)
theta2_data = theta_helicity(p2_data, p23_data)
theta3_data = theta_helicity(p3_data, p31_data)
phi1_data = phi_helicity(p1_data, p12_data)
phi2_data = phi_helicity(p2_data, p23_data)
phi3_data = phi_helicity(p3_data, p31_data)
Dalitz plot of data distribution#
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig, ax = plt.subplots(figsize=(6, 5))
vmax = 0.15
fig.suptitle(f"Dalitz plot of generated data (with cut at max={vmax})")
hist = ax.hist2d(
s12_data,
s23_data,
bins=100,
cmin=1e-20,
density=True,
cmap="jet",
vmax=vmax,
)
ax.set_xlabel(r"$m^2_{\eta \pi^0}$")
ax.set_ylabel(r"$m^2_{\pi^0 p}$")
cbar = fig.colorbar(hist[3], ax=ax)
fig.tight_layout()
fig.show()
1D projections#
The 1D projection distribution of CM angles, the helicity angles and invariant mass of the model, phase space, and data are shown in this section.
Show code cell source
%config InlineBackend.figure_formats = ['svg']
thetaCM_subtitles = [
R"$\cos (\theta_{1}^{{CM}}) \equiv \cos (\theta_{\eta}^{{CM}})$",
R"$\cos (\theta_{2}^{{CM}}) \equiv \cos (\theta_{\pi^0}^{{CM}})$",
R"$\cos (\theta_{3}^{{CM}}) \equiv \cos (\theta_{p}^{{CM}})$",
]
phiCM_subtitles = [
R"$\phi_1^{CM} \equiv \phi_{\eta}^{CM}$",
R"$\phi_2^{CM} \equiv \phi_{\pi^0}^{CM}$",
R"$\phi_3^{CM} \equiv \phi_{p}^{CM}$",
]
theta_subtitles = [
R"$\cos (\theta_{1}^{{12}}) \equiv \cos (\theta_{\eta}^{{\eta \pi^0}})$",
R"$\cos (\theta_{2}^{{23}}) \equiv \cos (\theta_{\pi^0}^{{\pi^0 p}})$",
R"$\cos (\theta_{3}^{{31}}) \equiv \cos (\theta_{p}^{{p \eta}})$",
]
phi_subtitles = [
R"$\phi_1^{12} \equiv \phi_{\eta}^{{\eta \pi^0}}$",
R"$\phi_2^{23} \equiv \phi_{\pi^0}^{{\pi^0 p}}$",
R"$\phi_3^{31} \equiv \phi_{p}^{{p \eta}}$",
]
mass_subtitles = [
R"$m_{12} \equiv m_{\eta \pi^0}$",
R"$m_{23} \equiv m_{\pi^0 p}$",
R"$m_{31} \equiv m_{p \eta}$",
]
fig, (thetaCM_ax, phiCM_ax, theta_ax, phi_ax, mass_ax) = plt.subplots(
figsize=(13, 18), ncols=3, nrows=5
)
for i, ax1 in enumerate(thetaCM_ax, 1):
ax1.set_title(thetaCM_subtitles[i - 1])
ax1.set_xticks([-1, 0, 1])
for i, ax2 in enumerate(phiCM_ax, 1):
ax2.set_title(phiCM_subtitles[i - 1])
ax2.set_xticks([-np.pi, 0, np.pi])
ax2.set_xticklabels([R"-$\pi$", 0, R"$\pi$"])
for i, ax3 in enumerate(theta_ax, 1):
ax3.set_title(theta_subtitles[i - 1])
ax3.set_xticks([-1, 0, 1])
for i, ax4 in enumerate(phi_ax, 1):
ax4.set_title(phi_subtitles[i - 1])
ax4.set_xticks([-np.pi, 0, np.pi])
ax4.set_xticklabels([R"-$\pi$", 0, R"$\pi$"])
for i, ax5 in enumerate(mass_ax, 1):
ax5.set_title(mass_subtitles[i - 1])
thetaCM_ax[0].hist(
np.cos(theta1_CM_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
thetaCM_ax[1].hist(
np.cos(theta2_CM_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
thetaCM_ax[2].hist(
np.cos(theta3_CM_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
thetaCM_ax[0].hist(
np.cos(theta1_CM_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
thetaCM_ax[1].hist(
np.cos(theta2_CM_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
thetaCM_ax[2].hist(
np.cos(theta3_CM_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
thetaCM_ax[0].hist(
np.cos(theta1_CM_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
thetaCM_ax[1].hist(
np.cos(theta2_CM_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
thetaCM_ax[2].hist(
np.cos(theta3_CM_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
thetaCM_ax[0].hist(
np.cos(theta1_CM_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
thetaCM_ax[1].hist(
np.cos(theta2_CM_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
thetaCM_ax[2].hist(
np.cos(theta3_CM_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phiCM_ax[0].hist(
phi1_CM_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phiCM_ax[1].hist(
phi2_CM_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phiCM_ax[2].hist(
phi3_CM_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phiCM_ax[0].hist(
phi1_CM_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phiCM_ax[1].hist(
phi2_CM_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phiCM_ax[2].hist(
phi3_CM_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phiCM_ax[0].hist(
phi1_CM_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phiCM_ax[1].hist(
phi2_CM_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phiCM_ax[2].hist(
phi3_CM_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phiCM_ax[0].hist(
phi1_CM_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phiCM_ax[1].hist(
phi2_CM_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phiCM_ax[2].hist(
phi3_CM_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
theta_ax[0].hist(
np.cos(theta1_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
theta_ax[1].hist(
np.cos(theta2_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
theta_ax[2].hist(
np.cos(theta3_phsp),
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
theta_ax[0].hist(
np.cos(theta1_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
theta_ax[1].hist(
np.cos(theta2_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
theta_ax[2].hist(
np.cos(theta3_phsp),
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
theta_ax[0].hist(
np.cos(theta1_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
theta_ax[1].hist(
np.cos(theta2_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
theta_ax[2].hist(
np.cos(theta3_phsp),
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
theta_ax[0].hist(
np.cos(theta1_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
theta_ax[1].hist(
np.cos(theta2_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
theta_ax[2].hist(
np.cos(theta3_phsp),
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phi_ax[0].hist(
phi1_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phi_ax[1].hist(
phi2_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phi_ax[2].hist(
phi3_phsp,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
phi_ax[0].hist(
phi1_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phi_ax[1].hist(
phi2_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phi_ax[2].hist(
phi3_phsp,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
phi_ax[0].hist(
phi1_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phi_ax[1].hist(
phi2_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phi_ax[2].hist(
phi3_phsp,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
phi_ax[0].hist(
phi1_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phi_ax[1].hist(
phi2_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
phi_ax[2].hist(
phi3_phsp,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
mass_ax[0].hist(
p12_phsp.m,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
mass_ax[1].hist(
p23_phsp.m,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
mass_ax[2].hist(
p31_phsp.m,
bins=100,
color="black",
histtype="step",
label="phsp",
density=True,
)
mass_ax[0].hist(
p12_phsp.m,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
mass_ax[1].hist(
p23_phsp.m,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
mass_ax[2].hist(
p31_phsp.m,
bins=100,
weights=BW_intensities,
color="orange",
histtype="step",
label="only BW",
density=True,
)
mass_ax[0].hist(
p12_phsp.m,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
mass_ax[1].hist(
p23_phsp.m,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
mass_ax[2].hist(
p31_phsp.m,
bins=100,
weights=SH_intensities,
color="green",
histtype="step",
label="only SH",
density=True,
)
mass_ax[0].hist(
p12_phsp.m,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
mass_ax[1].hist(
p23_phsp.m,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
mass_ax[2].hist(
p31_phsp.m,
bins=100,
weights=BW_SH_intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
thetaCM_ax[0].hist(
np.cos(theta1_CM_data),
bins=100,
label="data",
density=True,
alpha=0.5,
)
thetaCM_ax[1].hist(
np.cos(theta2_CM_data),
bins=100,
label="data",
density=True,
alpha=0.5,
)
thetaCM_ax[2].hist(
np.cos(theta3_CM_data),
bins=100,
label="data",
density=True,
alpha=0.5,
)
phiCM_ax[0].hist(
phi1_CM_data,
bins=100,
label="data",
density=True,
alpha=0.5,
)
phiCM_ax[1].hist(
phi2_CM_data,
bins=100,
label="data",
density=True,
alpha=0.5,
)
phiCM_ax[2].hist(
phi3_CM_data,
bins=100,
label="data",
density=True,
alpha=0.5,
)
theta_ax[0].hist(
np.cos(theta1_data),
bins=100,
label="data",
density=True,
)
theta_ax[1].hist(
np.cos(theta2_data),
bins=100,
label="data",
density=True,
)
theta_ax[2].hist(
np.cos(theta3_data),
bins=100,
label="data",
density=True,
)
phi_ax[0].hist(
phi1_data,
bins=100,
label="data",
density=True,
)
phi_ax[1].hist(
phi2_data,
bins=100,
label="data",
density=True,
)
phi_ax[2].hist(
phi3_data,
bins=100,
label="data",
density=True,
)
mass_ax[0].hist(
p12_data.m,
bins=100,
label="data",
density=True,
)
mass_ax[1].hist(
p23_data.m,
bins=100,
label="data",
density=True,
)
mass_ax[2].hist(
p31_data.m,
bins=100,
label="data",
density=True,
)
thetaCM_ax[0].legend()
phiCM_ax[0].legend()
thetaCM_ax[1].legend()
phiCM_ax[1].legend()
thetaCM_ax[2].legend()
phiCM_ax[2].legend()
theta_ax[0].legend()
phi_ax[0].legend()
theta_ax[1].legend()
phi_ax[1].legend()
theta_ax[2].legend()
phi_ax[2].legend()
mass_ax[0].legend()
mass_ax[1].legend()
mass_ax[2].legend()
fig.tight_layout()
plt.show()
Fitting#
We now pretend that we do not know the parameter values for the data distribution. In this particular situation, the parameters (to be fitted) are mass \(M\) (\(M_{12}\), \(M_{23}\), and \(M_{31}\)) and decay width \(\Gamma\) (\(\Gamma_{12}\), \(\Gamma_{23}\), and \(\Gamma_{31}\)). We also guess the coefficient values, but note that we keep one coefficient âfixedâ. The reason is that the model is normalized through the likelihood estimator, so what matters for the fits are the ratios between the parameters.
Guessed parameter values#
guessed_parameters = dict(
M12=1.4,
Gamma12=0.15,
M23=1.59,
Gamma23=0.05,
M31=1.82,
Gamma31=0.2,
# a_minus2 is set to fixed and not changed during fitting
a_minus1=0.51,
a_0=3.49,
a_plus1=4.08,
a_plus2=2.6,
b_minus1=-1.2,
b_0=4.04,
b_plus1=0.41,
c_0=2.66,
)
Function for plotting model
def compare_model_to_data(parameters: dict):
fig, axes = plt.subplots(figsize=(13, 12), ncols=3, nrows=5)
fig.suptitle(
R"CM angles, Helicity angles and invariant mass (Before fitting: starting"
R" parameters for BW $\times$ SH model)",
y=1,
)
thetaCM_ax, phiCM_ax, theta_ax, phi_ax, mass_ax = axes
for i, ax in enumerate(thetaCM_ax, 1):
ax.set_title(thetaCM_subtitles[i - 1])
ax.set_xticks([-1, 0, 1])
for i, ax in enumerate(phiCM_ax, 1):
ax.set_title(phiCM_subtitles[i - 1])
ax.set_xticks([-np.pi, 0, np.pi])
ax.set_xticklabels([R"-$\pi$", 0, R"$\pi$"])
for i, ax in enumerate(theta_ax, 1):
ax.set_title(theta_subtitles[i - 1])
ax.set_xticks([-1, 0, 1])
for i, ax in enumerate(phi_ax, 1):
ax.set_title(phi_subtitles[i - 1])
ax.set_xticks([-np.pi, 0, np.pi])
ax.set_xticklabels([R"-$\pi$", 0, R"$\pi$"])
for i, ax in enumerate(mass_ax, 1):
ax.set_title(mass_subtitles[i - 1])
data_kwargs = dict(bins=100, label="data", density=True)
data_phi_kwargs = dict(bins=50, label="data", density=True)
thetaCM_ax[0].hist(np.cos(theta1_CM_data), **data_kwargs, alpha=0.5)
thetaCM_ax[1].hist(np.cos(theta2_CM_data), **data_kwargs, alpha=0.5)
thetaCM_ax[2].hist(np.cos(theta3_CM_data), **data_kwargs, alpha=0.5)
theta_ax[0].hist(np.cos(theta1_data), **data_kwargs)
theta_ax[1].hist(np.cos(theta2_data), **data_kwargs)
theta_ax[2].hist(np.cos(theta3_data), **data_kwargs)
phiCM_ax[0].hist(phi1_CM_data, **data_phi_kwargs, alpha=0.5)
phiCM_ax[1].hist(phi2_CM_data, **data_phi_kwargs, alpha=0.5)
phiCM_ax[2].hist(phi3_CM_data, **data_phi_kwargs, alpha=0.5)
phi_ax[0].hist(phi1_data, **data_phi_kwargs)
phi_ax[1].hist(phi2_data, **data_phi_kwargs)
phi_ax[2].hist(phi3_data, **data_phi_kwargs)
mass_ax[0].hist(p12_data.m, **data_kwargs)
mass_ax[1].hist(p23_data.m, **data_kwargs)
mass_ax[2].hist(p31_data.m, **data_kwargs)
intensities = BW_SH_model(
s12_phsp,
s23_phsp,
s31_phsp,
phi1_phsp,
theta1_phsp,
phi2_phsp,
theta2_phsp,
**{**toy_parameters, **parameters},
)
model_kwargs = dict(
bins=100,
weights=intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
model_phi_kwargs = dict(
bins=50,
weights=intensities,
color="red",
histtype="step",
label="BW&SH",
density=True,
)
thetaCM_ax[0].hist(np.cos(theta1_CM_phsp), **model_kwargs)
thetaCM_ax[1].hist(np.cos(theta2_CM_phsp), **model_kwargs)
thetaCM_ax[2].hist(np.cos(theta3_CM_phsp), **model_kwargs)
phiCM_ax[0].hist(phi1_CM_phsp, **model_phi_kwargs)
phiCM_ax[1].hist(phi2_CM_phsp, **model_phi_kwargs)
phiCM_ax[2].hist(phi3_CM_phsp, **model_phi_kwargs)
theta_ax[0].hist(np.cos(theta1_phsp), **model_kwargs)
theta_ax[1].hist(np.cos(theta2_phsp), **model_kwargs)
theta_ax[2].hist(np.cos(theta3_phsp), **model_kwargs)
phi_ax[0].hist(phi1_phsp, **model_phi_kwargs)
phi_ax[1].hist(phi2_phsp, **model_phi_kwargs)
phi_ax[2].hist(phi3_phsp, **model_phi_kwargs)
mass_ax[0].hist(p12_phsp.m, **model_kwargs)
mass_ax[1].hist(p23_phsp.m, **model_kwargs)
mass_ax[2].hist(p31_phsp.m, **model_kwargs)
for ax in axes.flatten():
ax.legend()
fig.tight_layout()
plt.show()
Show code cell source
%config InlineBackend.figure_formats = ['svg']
compare_model_to_data(guessed_parameters)
Estimator: likelihood function#
To optimize the model parameters so that the model âfitsâ the data distribution, we need to define a measure for the âdistanceâ between the model to the data. The common choice for complicated, multidimensional models like an amplitude model is the unbinned negative log likelihood function (NLL).
def unbinned_nll(**parameters: float) -> float:
phsp = (
s12_phsp,
s23_phsp,
s31_phsp,
phi1_phsp,
theta1_phsp,
phi2_phsp,
theta2_phsp,
)
data = (
s12_data,
s23_data,
s31_data,
phi1_data,
theta1_data,
phi2_data,
theta2_data,
)
model_integral = BW_SH_model(*phsp, **parameters).mean()
data_intensities = BW_SH_model(*data, **parameters)
likelihoods = data_intensities / model_integral
log_likelihood = np.log(likelihoods).sum()
return -log_likelihood
Tip
The phase space (phsp) is used for the normalization calculation for the unbinned log likelihood function
Optimizer: Minuit#
As a final step, we find the parameter values that minimize the unbinned Log likelihood function, i.e., we optimize the model with respect to the data distribution. In Python, this can be performed by e.g. iminuit
package.
arg_names = tuple(guessed_parameters)
progress_bar = None
def wrapped_estimator(*args: float) -> float:
# minuit migrad expects positional-argument functions
global progress_bar
if progress_bar is None:
progress_bar = tqdm(desc="Running fit")
new_kwargs = {
**toy_parameters,
**dict(zip(arg_names, args, strict=True)),
}
estimator_value = unbinned_nll(**new_kwargs)
progress_bar.set_postfix({"estimator": estimator_value})
progress_bar.update()
return estimator_value
optimizer = Minuit(
wrapped_estimator,
**guessed_parameters,
name=guessed_parameters,
)
optimizer.errordef = Minuit.LIKELIHOOD
optimizer.migrad()
progress_bar.close()
progress_bar = None # reset progress bar
Final fit result#
Show code cell source
optimized_parameters = {p.name: p.value for p in optimizer.params}
pd.DataFrame({
"Parameter": list(toy_parameters.keys()),
"Toy value": list(toy_parameters.values()),
"Starting value": [guessed_parameters.get(name) for name in toy_parameters],
"Optimized value": [optimized_parameters.get(name) for name in toy_parameters],
"Difference": [
f"{100 * abs(value - optimized_parameters.get(name, value)) / value if value else 0:.1f}%"
for name, value in toy_parameters.items()
],
})
Parameter | Toy value | Starting value | Optimized value | Difference | |
---|---|---|---|---|---|
0 | a_minus2 | 0.00 | NaN | NaN | 0.0% |
1 | a_minus1 | 0.50 | 0.51 | 0.518316 | 3.7% |
2 | a_0 | 3.50 | 3.49 | 3.643171 | 4.1% |
3 | a_plus1 | 4.00 | 4.08 | 4.172394 | 4.3% |
4 | a_plus2 | 2.50 | 2.60 | 2.671472 | 6.9% |
5 | b_minus1 | -1.50 | -1.20 | -1.600444 | -6.7% |
6 | b_0 | 4.00 | 4.04 | 4.120737 | 3.0% |
7 | b_plus1 | 0.50 | 0.41 | 0.578660 | 15.7% |
8 | c_0 | 2.50 | 2.66 | 2.592561 | 3.7% |
9 | M12 | 1.32 | 1.40 | 1.320359 | 0.0% |
10 | Gamma12 | 0.10 | 0.15 | 0.100516 | 0.5% |
11 | M23 | 1.54 | 1.59 | 1.539956 | 0.0% |
12 | Gamma23 | 0.10 | 0.05 | 0.098987 | 1.0% |
13 | M31 | 1.87 | 1.82 | 1.869946 | 0.0% |
14 | Gamma31 | 0.10 | 0.20 | 0.100307 | 0.3% |
Now we see the fit results of CM angles, Helicity angles and invariant mass as a comparison of data in 1D projection plots
Show code cell source
%config InlineBackend.figure_formats = ['svg']
compare_model_to_data(optimized_parameters)
Dalitz plots of phsp, data, and fit result#
To conclude our analysis in this document, we examine the Dalitz plots for the following cases: phase space, generated data, default parameters of the model, and fitted parameters of the model.
Phase Space Dalitz Plot:
The plot displays a flat distribution, representing the kinematically allowed region for the reaction. This flat distribution serves as a baseline, showing no dynamical effects or resonant structures.
Generated Data Dalitz Plot:
This plot is derived from synthetic data that simulates the actual experimental conditions. It includes potential resonances (and background processes). The generated data reflects the physical phenomena expected in the real experimental scenario.
Model with Default Parameters Dalitz Plot:
Here, the Dalitz plot illustrates the modelâs predictions using the initial set of parameters. This provides a theoretical reference for comparison with actual data and the fitted model.
Fitted Parameters Dalitz Plot:
After performing the fitting procedure, this plot shows the modelâs predictions with optimized parameters. The resonances and their characteristics are clearly visible, indicating the physical information extracted from the data.
By comparing these Dalitz plots, we can observe how the model evolves from theoretical predictions to a fitted solution that aligns with the experimental or generated data. The physical information about possible resonances becomes more apparent, allowing us to extract meaningful insights into the underlying processes of the reaction.
This comprehensive analysis showcases the interplay between kinematics and dynamics in understanding particle interactions, highlighting the power of Partial Wave Analysis in uncovering the nuances of hadronic reactions.
Show code cell source
%config InlineBackend.figure_formats = ['png']
fig, (ax1, ax2) = plt.subplots(figsize=(12, 5), ncols=2, sharey=True)
fig.suptitle(R"Dalitz Plots of parameter defaults and fitted parameters")
hist1 = ax1.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmap="jet",
cmin=1e-6,
density=True,
vmax=0.15,
weights=BW_SH_intensities,
)
ax1.set_title(R"Parameters defaults BW $\times$ SH model (with cut at max=0.15)")
ax1.set_xlabel(R"$m^2(\eta \pi)$")
ax1.set_ylabel(R"$m^2(\pi p)$")
BW_SH_intensities_fitted = BW_SH_model(
s12_phsp,
s23_phsp,
s31_phsp,
phi1_phsp,
theta1_phsp,
phi2_phsp,
theta2_phsp,
**{**toy_parameters, **optimized_parameters},
)
hist2 = ax2.hist2d(
s12_phsp,
s23_phsp,
bins=100,
cmap="jet",
cmin=1e-6,
density=True,
vmax=0.15,
weights=BW_SH_intensities_fitted,
)
ax2.set_title(R"Fitted parameters BW $\times$ SH model (with cut at max=0.15)")
ax2.set_xlabel(R"$m^2(\eta \pi)$")
ax2.set_ylabel(R"$m^2(\pi p)$")
cbar1 = fig.colorbar(hist1[3], ax=ax1)
cbar2 = fig.colorbar(hist2[3], ax=ax2)
cbar3 = fig.colorbar(hist3[3], ax=ax3)
fig.tight_layout()
fig.show()
Summary#
In this document, we have covered the general workflow of PWA, which consists of three major parts:
As a summary, what have we done in this document consists of three major parts:
Amplitude Model formulation
Introduced the theoretical model for the amplitude of the reaction.
Defined the mathematical expressions representing the partial waves and their respective contributions.
Incorporated the relevant physical parameters, such as resonance properties.
Phase space and data sample generation (or data analysis)
Generated phase space distributions for the three-body decay processes.
Created synthetic data samples or analyzed experimental data.
Ensured that the data accurately reflects the kinematic constraints and dynamics of the reaction.
Perform fitting, analyze and evaluate the results
Performed a fit of the amplitude model to the generated or experimental data.
Used statistical techniques to optimize the model parameters.
Analyzed the fit results to extract physical parameters.
Evaluated the quality of the fit and the consistency of the model with the data.
Interpreted the physical significance of the extracted parameters and identified potential resonances and their properties.
This structured approach provides a comprehensive understanding of both the reaction dynamics and kinematics, and helps in extracting meaningful physical insights from the data.
Tip
Extra
have a look for another example of PWA for \(J/\psi \to \gamma \pi^0 \pi^0\) by using ComPWA
at here .