Fusing RNG, flow, mapping and matrix element into one graph

The previous two integrators call the mapping, and then the cross section, as two separate Python-level steps. MadSpace also carries its own normalizing flow, Flow, and its own compute graph builder, FunctionBuilder. This example uses the builder to fuse everything, from drawing the random numbers to the final cross-section weight, into a single compiled graph, and trains the flow’s weights with a plain PyTorch optimizer.

Setting up the physics

The mapping, cuts, matrix element, PDF and scale are exactly the same building blocks as the earlier integrator, generated with output mg7 PROC_ggttg and make BACKEND=scalar beforehand:

import os

import numpy as np
import torch
import madspace as ms
from madspace.torch import FunctionModule

torch.set_default_dtype(torch.float64)
torch.manual_seed(0)

E_CM = 13000.0
masses = [0.0, 0.0, 173.0, 173.0, 0.0]  # g g -> t t~ g
pids = [21, 21, 6, -6, 21]

O = ms.Observable
cuts = ms.Cuts([
    ms.CutItem(O(pids, O.obs_pt, [O.jet_pids]), min=20.0),
    ms.CutItem(O(pids, O.obs_eta_abs, [O.jet_pids]), max=5.0),
    ms.CutItem(O(pids, O.obs_delta_r, [O.jet_pids]), min=0.4),
])
mapping = ms.PhaseSpaceMapping(masses, E_CM, mode="propagator", cuts=cuts)

proc_dir = "PROC_ggttg"
me_path = os.path.join(proc_dir, "lib", "libmadmatrix_P0_gg_ttxg_scalar.so")
param_card = os.path.join(proc_dir, "Cards", "param_card.dat")

ctx = ms.default_context()
api = ctx.load_matrix_element(me_path, param_card)
MEI = ms.MatrixElement.MatrixElementInput
MEO = ms.MatrixElement.MatrixElementOutput
matrix_element = ms.MatrixElement(
    api, [MEI.momenta_in, MEI.alpha_s_in], [MEO.matrix_element_out], True
)

PDF_SET = "NNPDF40_lo_as_01180"
import lhapdf  # only used below to locate the installed PDF set
pdf_dir = os.path.join(lhapdf.paths()[0], PDF_SET)
pdf_grid = ms.PdfGrid(os.path.join(pdf_dir, f"{PDF_SET}_0000.dat"))
pdf_grid.initialize_globals(ctx)
alphas_grid = ms.AlphaSGrid(os.path.join(pdf_dir, f"{PDF_SET}.info"))
alphas_grid.initialize_globals(ctx)
alpha_s = ms.RunningCoupling(alphas_grid)

scale = ms.EnergyScale(
    particle_count=5, type=ms.EnergyScale.DynamicalScaleType.half_transverse_mass
)
cross_section = ms.DifferentialCrossSection(
    matrix_element=matrix_element,
    cm_energy=E_CM,
    running_coupling=alpha_s,
    energy_scale=scale,
    pid_options=[[21, 21]],
    pdf1=pdf_grid, pdf2=pdf_grid,
    input_momentum_fraction=True,
)

Declaring the flow

A Flow reshapes mapping.random_dim() unit-hypercube coordinates into a learned sampling distribution. Its weights live in the context as trainable globals, created by initialize_globals:

flow = ms.Flow(
    input_dim=mapping.random_dim(), prefix="flow", bin_count=10,
    subnet_hidden_dim=32, subnet_layers=2,
)
flow.initialize_globals(ctx, seed=0)

Building the fused sampling graph

FunctionBuilder.random draws the random numbers inside the graph itself, given a symbolic batch size. Every other piece is embedded the same way each building block embeds any other: build_forward for a Mapping, build_function for a FunctionGenerator. The three Jacobians, from the flow, the mapping and the cross section itself, multiply into one weight. Besides that weight, the graph also returns y, the point the flow produced before the mapping turned it into momenta, needed below to train the flow:

fb = ms.FunctionBuilder(
    ms.NamedTypes([("batch_size", ms.Type([ms.batch_size]))]),
    ms.NamedTypes([
        ("weight", ms.batch_float),
        ("y", ms.batch_float_array(mapping.random_dim())),
    ]),
)
n = fb.input(0)
r = fb.random(n, ms.Value(mapping.random_dim()))
flow_out = flow.build_forward(fb, [r], [])
mapping_out = mapping.build_forward(fb, [flow_out["data"]], [])
pdf_id = fb.full([ms.Value(0), n])  # only one entry in pid_options
xsec_out = cross_section.build_function(
    fb, [mapping_out["momenta"], mapping_out["x1"], mapping_out["x2"], pdf_id]
)
weight = fb.mul(fb.mul(flow_out["det"], mapping_out["det"]), xsec_out["matrix_element"])
fb.output(0, weight)
fb.output(1, flow_out["data"])
sampling_func = fb.function()

The compiled function takes a single argument, how many events to generate.

Inspecting the graph

Printing a Function lists every input, trainable global, instruction and output it was built from, which is often the fastest way to check that a graph does what it should:

print(sampling_func)

Fusing a flow, a phase-space mapping, cuts, a PDF and a matrix element into one graph makes for a long instruction list; the inputs, a few of the globals and instructions, and the outputs already show the shape of it:

Inputs:
  %0 : {batch_size}=batch_size
Globals:
  %192 : float[1, 16, 11, 9850] = pdf_coefficients
  %190 : float[1, 198] = pdf_logx
  %103 : float[1, 93] = flow.subnet3a.layer2.bias
  %61 : float[1, 32, 4] = flow.subnet2a.layer1.weight
  ...
Instructions:
  %2 = random(%0, 7)
  %4 = select(%2, {1, 3, 5})
  %6 = select(%2, {0, 2, 4, 6})
  ...                                    # 113 instructions in total
  %209 = mul(%118, %185)
  %210 = mul(%209, %208)
Outputs:
  weight=%210 : float[batch_size]
  y=%116 : float[batch_size, 7]

The flow’s own probability

Training needs the density the flow assigns to a point it already produced, not just the point itself. A Flow is invertible, so Mapping.inverse_function builds that as a standalone function of y, with madspace.torch.FunctionModule wrapping it so PyTorch can differentiate through it with respect to the flow’s parameters:

flow_prob_module = FunctionModule(flow.inverse_function(), ctx)

Training

Sampling and evaluating the cross section, done through sampling_func above, does not support PyTorch autograd, since the matrix element is not differentiable with respect to the flow’s parameters this way. Training instead follows MadNIS: draw a batch from the current flow with sampling_func, treat its weight as a constant, and adjust the flow’s parameters so that its own density tracks that weight, minimizing the self-normalized Kullback-Leibler divergence between the two:

sampling_runtime = ms.FunctionRuntime(sampling_func, ctx)
optimizer = torch.optim.Adam(flow_prob_module.parameters(), lr=1e-3)
batch_size = np.array([256], dtype=np.int32)

for step in range(300):
    weight, y = sampling_runtime(batch_size)
    weight, y = torch.tensor(weight), torch.tensor(y)

    optimizer.zero_grad()
    _, flow_prob = flow_prob_module(y)
    loss = -(weight / weight.mean() * torch.log(flow_prob)).mean()
    loss.backward()
    optimizer.step()

Because the flow already integrates to one over its own samples, the average weight is an unbiased estimate of the cross section no matter how well the flow is trained; this loss only reshapes the flow to reduce that estimate’s variance.

Checking the result

The trained weights already live in ctx, so evaluating sampling_func again with the same NumPy runtime reports the trained integrator’s performance:

def integrate(n_events):
    weight, _ = sampling_runtime(np.array([n_events], dtype=np.int32))
    return weight.mean(), weight.std() / np.sqrt(n_events)

sigma, error = integrate(50000)
print(f"sigma = {sigma:.2f} +- {error:.2f} pb  (rel. error {error / sigma * 100:.2f}%)")
sigma = 239.42 +- 1.15 pb  (rel. error 0.48%)

This is a shorter, less tuned training loop than either MadGraph7’s own event generator or the external MadNIS package, both of which use more sophisticated losses and training schedules. Even so, the relative error is well below what an untrained flow gives for the same number of samples.