ACTS
Experiment-independent tracking
Loading...
Searching...
No Matches
The Gaussian Sum Filter

Multi-component fitter for electrons with non-Gaussian energy loss.

Remarks
This page documents the GSF as implemented in ACTS today. For the pointwise multiple-scattering and ionization formalism that the GSF shares with the ordinary Kalman filters, see Material effects in the Kalman filters; for the high-level conceptual picture see Material effects. The reference implementation and the tuning studies quoted below are described in detail in [8].

Why a mixture of Gaussians?

The Kalman Filter (KF) is the optimal estimator only as long as every involved distribution is Gaussian. For electrons this breaks down: their dominant energy loss is bremsstrahlung, whose Bethe–Heitler distribution is strongly non-Gaussian and heavily tailed. Feeding that through the single-Gaussian pointwise update of the KF (see the energy-loss note) biases the momentum estimate and wrecks its error estimate.

The Gaussian Sum Filter (GSF) [5] addresses this by modelling the track state as a weighted mixture of Gaussians instead of a single one,

\[ p(\vec x) = \sum_i^{N_c} w_i\, \mathcal N(\vec x \mid \vec\mu_i, \mathbf\Sigma_i), \qquad \sum_i^{N_c} w_i = 1 , \]

and running, in effect, one Kalman filter per component. Each component carries a weight, a bound parameter vector and a bound covariance:

struct GsfComponent {
double weight = 0;
BoundVector boundPars = BoundVector::Zero();
BoundMatrix boundCov = BoundMatrix::Zero();
};
Note
The GSF is substantially more expensive than the KF and is therefore typically only run when a track is likely to be an electron — usually to re-fit a silicon track that has been associated with an electromagnetic-calorimeter cluster [8]. The GSF itself is a fitter — its measurement sequence is taken as given — but the same Bethe–Heitler mixture machinery can also run inside the CKF during track finding to recover electron tracks; see Bremsstrahlung recovery in the CKF.

Bethe–Heitler energy loss as a mixture

On a material surface the GSF replaces the KF's deterministic ionization loss and Landau straggling on \(q/p\) with an explicit mixture model of the Bremsstrahlung loss. The Bethe–Heitler probability density of the energy retention \(z = E_f/E_i\) depends only on the traversed thickness in radiation lengths \(x/X_0\),

\[ f(z) = \frac{[-\ln z]^{c-1}}{\Gamma(c)}, \quad 0 \le z \le 1, \qquad c = \frac{x/X_0}{\ln 2} . \]

This is approximated by a 1D Gaussian mixture in \(z\), \(f(z) \approx \sum_n^{N_{bh}} \pi_n\,\mathcal N(z \mid \mu_{z,n}, \sigma_{z,n})\). For a single component the natural choice keeps the first two moments of the exact distribution [8],

\[ \mu_z = e^{-t}, \qquad \sigma_z^2 = 3^{-c} - 4^{-c}, \qquad t = x/X_0 , \]

which is what Acts::BetheHeitlerApproxSingleCmp evaluates:

const double c = xOverX0 / std::numbers::ln2;
mixture[0].mean = std::pow(2, -c);
mixture[0].var = std::pow(3, -c) - std::pow(4, -c);

A single Gaussian reflects the true, tailed distribution very poorly (see the figure below), so in practice a multi-component approximation is used. Because the mixture cannot be derived in closed form, its weights, means and variances are pre-fitted (minimising either the Kullback–Leibler divergence or the CDF distance to \(f(z)\)) and stored as polynomials in \(x/X_0\) so they can be interpolated at run time. Any approximation is accessed through the abstract interface:

virtual std::size_t maxComponents() const = 0;
virtual bool validXOverX0(double xOverX0) const = 0;
virtual std::span<Component> mixture(double xOverX0,
std::span<Component> mixture) const = 0;

mixture() writes \(N_{bh}\) one-dimensional components (weight, mean, variance in \(z\)) into a caller-provided span:

struct GaussianComponent {
double weight = 0;
double mean = 0;
double var = 0;
};

The true Bethe--Heitler distribution compared with a Gaussian mixture approximation, with the individual components drawn as thin lines, at a thickness of t = 0.1 (roughly 10 mm of silicon).

The concrete Acts::PolynomialBetheHeitlerApprox implements the polynomial form; the default parametrisation shipped in the source (Acts::makeDefaultBetheHeitlerApprox) and the reference JSON configuration used by the examples (betheHeitler_geantSim_cdf_nC6_O5.json) both use a six-component, fifth-order CDF fit, split into a low- and a high-thickness range at \(x/X_0 = 0.1\) [8]. When a surface exceeds the valid \(x/X_0\) range the fitter counts the occurrence and emits a warning.

Applying the loss convolves every track-state component with every Bethe–Heitler component, so a mixture of \(N_c\) components becomes \(N_c \cdot N_{bh}\). In the backward pass an effective energy gain is applied instead.

The algorithm on a surface

The GSF actor (Acts::detail::Gsf::GsfActor) drives the fit as a propagator actor. When the multi-stepper reports the state on a surface, the actor executes the following, in this order (see also the figure below):

  1. Transport each component's covariance onto the surface.
  2. If the surface has material, apply multiple scattering as PreUpdate (measurement surface) or FullUpdate (passive surface) — this reuses exactly the pointwise machinery documented in Multiple Coulomb scattering.
  3. Perform the Kalman update: run the measurement update for every component and re-weight them. Up to a normalisation, a component's weight is scaled by the likelihood of the measurement given that component,

    \[ w_{k|k}^i \;\propto\; w_{k|k-1}^i \, \mathcal N\!\bigl(m_k \mid \mathbf H_k \vec x_{k|k-1}^i,\; \mathbf V_k + \mathbf H_k \mathbf\Sigma_{k|k-1}^i \mathbf H_k^{\mathsf T}\bigr), \]

    i.e. components incompatible with the measurement are exponentially suppressed. On a passive surface a no-measurement update is done instead (which may flag a hole). Concretely, the weight is multiplied by \(\sqrt{1/\det R}\,\exp(-\tfrac12\chi^2)\), with the smallest \(\chi^2\) over the components factored out for numerical stability and the weights normalised afterwards:
    // Loop over the tips and compute new weights
    for (auto tip : tips) {
    const auto state = mt.getTrackState(tip);
    const double chi2 = state.chi2() - minChi2;
    const double detR = calculateDeterminant(
    state.effectiveCalibratedCovariance().data(),
    state.predictedCovariance(), state.projectorSubspaceIndices(),
    state.calibratedSize());
    if (detR <= 0) {
    // If the determinant is not positive, just leave the weight as it is
    continue;
    }
    const double factor = std::sqrt(1. / detR) * safeExp(-0.5 * chi2);
    if (!std::isfinite(factor)) {
    // If something is not finite here, just leave the weight as it is
    continue;
    }
    weights.at(tip) *= factor;
    }
  4. Apply the Bethe–Heitler convolution of Bethe–Heitler energy loss as a mixture to every component, expanding the mixture.
  5. Reduce the mixture back down (see Mixture reduction) and drop components below the weight cutoff.
  6. Push the reduced mixture back into the stepper, then apply the PostUpdate scattering on measurement surfaces.
Note
The measurement update (step 3) always happens before the Bremsstrahlung loss (step 4): the update must use the state estimated from the material seen between the previous and the current surface. The mixture reduction (step 5) runs only after the loss, because the Kalman update cannot increase the component count — only the convolution can.

Simplified overview of the GSF algorithm.

Mixture reduction

Left unchecked, the component count would grow by a factor \(N_{bh}\) per material surface. To keep it bounded, a reducer is invoked after each convolution to bring the mixture down to min(stepper.maxComponents, maxComponents) components. The reducer is a delegate, so it can be swapped out:

Delegate<void(std::vector<GsfComponent> &, std::size_t, const Surface &)>;

Two production reducers are provided (a third, …Naive, is a reference/benchmark baseline):

  • Acts::reduceMixtureLargestWeights — simply discards the lowest-weight components. Fast, but loses information.
  • Acts::reduceMixtureWithKLDistance — greedily merges the pair of components with the smallest symmetric Kullback–Leibler distance (evaluated on the \(q/p\) dimension only) until the target count is reached. Slower, but markedly better, and the recommended choice [8].

The pairwise distance driving that greedy merge is the symmetric KL divergence, restricted to the \(q/p\) dimension:

// Symmetric KL distance, evaluated on the q/p dimension only
const double parsA = a.boundPars[eBoundQOverP];
const double parsB = b.boundPars[eBoundQOverP];
const double covA = a.boundCov(eBoundQOverP, eBoundQOverP);
const double covB = b.boundCov(eBoundQOverP, eBoundQOverP);
assert(covA != 0.0);
assert(std::isfinite(covA));
assert(covB != 0.0);
assert(std::isfinite(covB));
const double kl = covA * (1 / covB) + covB * (1 / covA) +
(parsA - parsB) * (1 / covA + 1 / covB) * (parsA - parsB);

Mixture merging

Mixture merging (or component merging) collapses a mixture into a single parameter vector and covariance — as opposed to the mixture reduction above, which brings a mixture down to a smaller mixture. Several steps need it: storing an intermediate state, and producing the final fitted parameters. The method is selectable via the Acts::ComponentMergeMethod enum:

enum class ComponentMergeMethod { eMean, eMaxWeight };
  • eMean keeps the first two moments (weighted mean and covariance of the mixture). The mean is a poor summary of a tailed distribution and can bias the result.
  • eMaxWeight (the default) takes the parameters of the highest-weight component as the point estimate while still reporting the full mixture covariance. When one component dominates, this approximates the mode well and avoids the bias.

Merging must respect cyclic bound coordinates. Which coordinates are cyclic depends on the surface type, encoded as compile-time angle descriptions (note that on a cylinder the local \(R\phi\) coordinate is cyclic, scaled by the radius):

template <Surface::SurfaceType type_t>
struct AngleDescription {
using Desc = std::tuple<CyclicAngle<eBoundPhi>>;
};
template <>
struct AngleDescription<Surface::Disc> {
using Desc = std::tuple<CyclicAngle<eBoundLoc1>, CyclicAngle<eBoundPhi>>;
};
template <>
struct AngleDescription<Surface::Cylinder> {
using Desc =
std::tuple<CyclicRadiusAngle<eBoundLoc0>, CyclicAngle<eBoundPhi>>;
};

The mean itself is then formed with complex-phase arithmetic — each cyclic coordinate is mapped onto the unit circle, averaged as a complex number, and converted back with std::arg — so that angles wrap correctly:

using CVec = Eigen::Matrix<std::complex<double>, eBoundSize, 1>;
CVec cMean = CVec::Zero();
double sumOfWeights = 0;
for (const auto &cmp : cmps) {
const auto [weight_l, pars_l] = projector(cmp);
CVec cPars_l = pars_l;
const auto setPolar = [&](const auto &desc) {
cPars_l[desc.idx] = std::polar(1.0, pars_l[desc.idx] / desc.constant);
};
std::apply([&](auto... dsc) { (setPolar(dsc), ...); }, angleDesc);
sumOfWeights += weight_l;
cMean += weight_l * cPars_l;
}
cMean /= sumOfWeights;
BoundVector mean = cMean.real();
const auto getArg = [&](const auto &desc) {
mean[desc.idx] = desc.constant * std::arg(cMean[desc.idx]);
};
std::apply([&](auto... dsc) { (getArg(dsc), ...); }, angleDesc);

Multi-component transport

Each component must be transported individually, so the GSF runs on the Acts::MultiEigenStepperLoop rather than the single-component stepper. The navigator, however, must see a single trajectory. The stepper therefore presents a reduced representation to the navigation, configurable through the reducer type; the default is the highest-weight component (Acts::MaxWeightReducerLoop, with Acts::MaxMomentumReducerLoop as an alternative), which keeps the navigation stream close to the bulk of the mixture.

Determining when the whole multi-component state has "reached" a surface is handled by Acts::MultiStepperSurfaceReached, which by default treats the state as on-surface once its average is within tolerance. This guards against a pathology described in [8] — low-momentum components approaching a cylinder on a straight-line intersection can spiral indefinitely while always reporting reachable. A step limit that engages once the first component lands on the surface (stepLimitAfterFirstComponentOnSurface, default 50) forces the remaining stragglers to unreachable and removes them, after which the weights are renormalised:

for (auto& cmp : components) {
if (cmp.status != Status::onSurface) {
cmp.status = Status::unreachable;
}
}
ACTS_VERBOSE("Stepper performed "
<< m_stepLimitAfterFirstComponentOnSurface
<< " steps after the first component hit a surface.");
"-> remove all components not on a surface, perform no step");
removeMissedComponents(state);
reweightComponents(state);

Forward/backward passes and output

Acts::GaussianSumFitter is constructed from a propagator, a shared Bethe–Heitler approximation and a logger, and exposes two fit overloads: one for the standard Acts::Navigator, and one taking an explicit surface sequence for use with the Acts::DirectNavigator — the latter is the re-fitting entry point used in the electron workflow above.

A fit runs a forward pass from the start parameters, then a backward pass that starts from the last measurement with its covariance inflated by reverseFilteringCovarianceScaling (default 100) and targets the reference surface. Measurement surfaces that were seen going forward but not on the way back are flagged as outliers. The multi-component state is merged (Mixture merging) into the single set of parameters that downstream algorithms expect; the full final mixture can optionally be attached to the track.

Note
The current implementation stores only the means of the per-surface states in the Acts::MultiTrajectory, so it performs no dedicated component smoothing of the kind originally described for the GSF — the backward pass plays the role of the smoother.

Configuration and tuning

The knobs on Acts::GsfOptions trade physics performance against runtime. The values below summarise the scan in [8]; the ACTS example chain uses 12 components, KL-distance reduction, eMaxWeight merging and a weight cutoff of \(10^{-4}\).

Option Effect Guidance [8]
maxComponents mixture size after each reduction runtime grows \(\approx\) quadratically; physics plateaus beyond \(\sim 12\) (library default 4, example default 12)
weightCutoff discard components below this weight \(10^{-4}\) is a good default; \(0.1\) is too aggressive (fit failures spike)
mixtureReducer reduction algorithm KL-distance clearly beats the weight cut at modest extra cost
componentMergeMethod mixture → single estimate eMaxWeight avoids the \(q/p\) bias seen with eMean
Bethe–Heitler approx mixture model of the loss 6-component CDF polynomials, split at \(x/X_0=0.1\)
reverseFilteringCovarianceScaling covariance inflation for the backward pass default 100 (not tuned for all setups)
disableAllMaterialHandling switch off convolution and scattering debugging only

The payoff: against the KF, the 12-component GSF turns a heavily one-sided \(q/p\) residual into a near-symmetric one and shrinks its width, while the KF's \(q/p\) pull — its error estimate — is badly distorted by the non-Gaussian loss [8]. A single-component GSF (equivalent to a KF using the Bethe–Heitler mean and variance) is visibly biased, which is what motivates the mixture in the first place.

Bremsstrahlung recovery in the CKF

The per-component Bethe–Heitler application of Bethe–Heitler energy loss as a mixture is not exclusive to the fitter. The Acts::CombinatorialKalmanFilter can optionally run in a bremsstrahlung-recovery mode that reuses the same machinery to find electron tracks that a single-component filter would otherwise lose to a large, non-Gaussian energy loss.

The mode is selected purely by the stepper type. When the CKF is built over a multi-component stepper (Acts::MultiEigenStepperLoop) an IsMultiStepper trait is true and the filter compiles in a multi-component path via if constexpr; the plain single-component filter therefore carries no runtime cost, and the extra per-actor state is elided entirely with [[no_unique_address]]. On each material surface the track state is convoluted with the Bethe–Heitler mixture through the shared Acts::detail::Gsf::applyBetheHeitler, the mixture is reduced (through the same mixtureReducer delegate, now also part of the CKF extensions) and merged back to a single representation before the measurement update — the GSF's surface algorithm of The algorithm on a surface, embedded in the combinatorial search.

The multi-component knobs (maxComponents, weightCutoff, mergeMethod, betheHeitlerApprox) live on Acts::BremCombinatorialKalmanFilterOptions. The filter's Options alias resolves to that type only for a multi-stepper, so a single-component configuration cannot even name the multi-component parameters. In the examples, electron-hypothesis seeds are routed to a brem-enabled finder built over a MultiStepperLoop, using KL-distance reduction and the default Bethe–Heitler approximation, while all other seeds use the plain finder.

Implementation pointers

The per-surface algorithm itself lives in the internal Acts::detail::Gsf code; the snippets above are extracted directly from the corresponding headers.