Advanced Fitting

VegasAfterglow’s Fitter is built directly on the Model class, giving you full control over the fitting process. You can customize:

  • Priors: Use informative or custom prior distributions

  • Likelihood: Implement non-standard likelihood functions (e.g., upper limits, systematic uncertainties)

  • Jet profiles: Define arbitrary angular energy and Lorentz factor distributions

  • Medium profiles: Define custom circumburst density structures

The Fitter evaluates models using Python’s ThreadPoolExecutor, where each thread constructs a Model and calls flux_density() with the GIL released during C++ computation. This provides near-native parallelism with full Python flexibility.

Custom Priors

Pass a priors dictionary to fit() to apply custom prior distributions. The same interface works for all samplers (emcee, dynesty, etc.).

Keys are parameter labels (using the log10_ prefix for LOG-scale parameters), and values are bilby.core.prior.Prior objects. Parameters not in the dict automatically get uniform priors based on the ParamDef bounds.

import bilby

custom_priors = {
    "p": bilby.core.prior.Gaussian(
        mu=2.3, sigma=0.1,
        minimum=2.1, maximum=2.8,
        name="p", latex_label=r"$p$",
    ),
    "log10_eps_e": bilby.core.prior.Gaussian(
        mu=-1.0, sigma=0.5,
        minimum=-3, maximum=np.log10(0.5),
        name="log10_eps_e",
        latex_label=r"$\log_{10}(\epsilon_e)$",
    ),
}

# Works with emcee
result = fitter.fit(
    params,
    sampler="emcee",
    nsteps=10000,
    priors=custom_priors,
)

# Same priors work with dynesty
result = fitter.fit(
    params,
    sampler="dynesty",
    nlive=1000,
    priors=custom_priors,
)

Important

When using LOG-scale parameters, the prior keys must use the log10_ prefix (e.g., log10_eps_e, not eps_e), since the sampler operates in log-space.

Custom Likelihood

The default likelihood is Gaussian: \(\ln\mathcal{L} = -\chi^2/2\). Override this with a log_likelihood_fn that maps \(\chi^2\) to log-likelihood:

def student_t_likelihood(chi2):
    """Student-t likelihood with nu=5 degrees of freedom.

    More robust to outliers than Gaussian.
    """
    nu = 5
    n_data = 50  # number of data points
    return -0.5 * (nu + n_data) * np.log(1 + chi2 / nu)

result = fitter.fit(
    params,
    sampler="emcee",
    log_likelihood_fn=student_t_likelihood,
)

Upper Limits

For non-detections, a common approach is to weight the likelihood so data points above the upper limit are penalized:

def likelihood_with_upper_limits(chi2):
    """Standard Gaussian likelihood; upper limits encoded in data weights."""
    return -0.5 * chi2

# Upper limits: set flux to 0 and error to the upper limit value,
# so chi2 = (0 - model)^2 / upper_limit^2, which penalizes models above the limit
fitter.add_flux_density(
    nu=1e10, t=[1e5], f_nu=[0.0], err=[3e-29],
    weights=[1.0],
)

Custom Jet Profiles

The Fitter accepts a custom jet factory function via the jet keyword argument. This lets you define arbitrary angular profiles using the same Ejecta class used for direct model calculations (see Examples).

How it works:

  1. Define functions for the energy and Lorentz factor angular profiles

  2. Wrap them in a factory function that takes params (the current MCMC sample) and returns an Ejecta

  3. Pass the factory to Fitter(jet=...)

The fitter calls your factory on every MCMC step with fresh parameter values.

from VegasAfterglow import Ejecta, Fitter, ParamDef, Scale

def double_gaussian_jet(params):
    """Double-Gaussian jet: narrow core + wide wing."""

    def E_iso_func(phi, theta):
        core = params.E_iso * np.exp(-(theta / params.theta_c) ** 2)
        wing = params.E_iso_w * np.exp(-(theta / params.theta_w) ** 2)
        return core + wing

    def Gamma_func(phi, theta):
        return 1 + (params.Gamma0 - 1) * np.exp(-(theta / params.theta_c) ** 2)

    return Ejecta(E_iso=E_iso_func, Gamma0=Gamma_func, duration=params.tau)

mc_params = [
    ParamDef("E_iso",   1e50,  1e54,  Scale.log),
    ParamDef("Gamma0",    50,   500,  Scale.log),
    ParamDef("theta_c", 0.02,   0.15, Scale.linear),
    ParamDef("theta_v",    0,   0.5,  Scale.linear),
    ParamDef("E_iso_w", 1e48,  1e52,  Scale.log),
    ParamDef("theta_w",  0.1,   0.5,  Scale.linear),
    ParamDef("tau",        1,   1e3,  Scale.log),
    ParamDef("n_ism",   1e-3,   100,  Scale.log),
    ParamDef("p",        2.1,   2.8,  Scale.linear),
    ParamDef("eps_e",   1e-3,   0.5,  Scale.log),
    ParamDef("eps_B",   1e-5,   0.1,  Scale.log),
    ParamDef("xi_e",     0.1,   1.0,  Scale.linear),
]

fitter = Fitter(z=1.58, lumi_dist=3.364e28, jet=double_gaussian_jet)
fitter.add_flux_density(nu=4.84e14, t=t_data, f_nu=flux_data, err=flux_err)

result = fitter.fit(mc_params, sampler="emcee", nsteps=10000)

The Ejecta constructor supports these optional keyword arguments:

  • sigma0: Magnetization profile sigma0(phi, theta) (default: 0)

  • E_dot: Energy injection rate E_dot(phi, theta, t) in erg/s (default: 0)

  • M_dot: Mass injection rate M_dot(phi, theta, t) in g/s (default: 0)

  • spreading: Enable lateral spreading (default: False)

  • duration: Jet duration in seconds, for reverse shock (default: 1)

Note

When using a custom jet, parameter validation is automatically skipped since the fitter cannot know which parameters your factory requires. Ensure your factory function handles all necessary parameters.

Energy and Mass Injection

The Ejecta class also supports time-dependent energy and mass injection via E_dot and M_dot. These are functions of (phi, theta, t) returning injection rates in erg/s and g/s respectively:

def jet_with_injection(params):
    """Custom jet with energy injection (spin-down luminosity)."""

    def E_iso_func(phi, theta):
        return params.E_iso * np.exp(-(theta / params.theta_c) ** 2)

    def Gamma_func(phi, theta):
        return 1 + (params.Gamma0 - 1) * np.exp(-(theta / params.theta_c) ** 2)

    def E_dot_func(phi, theta, t):
        """Spin-down energy injection: L(t) = L0 / (1 + t/t_sd)^2."""
        return params.L0 / (1 + t / params.t_sd) ** 2

    def M_dot_func(phi, theta, t):
        """Mass injection: decaying mass loading."""
        return params.M_dot0 * np.exp(-t / params.t_sd)

    return Ejecta(
        E_iso=E_iso_func, Gamma0=Gamma_func,
        E_dot=E_dot_func, M_dot=M_dot_func,
        duration=params.tau,
    )

mc_params = [
    ParamDef("E_iso",   1e50,  1e54,  Scale.log),
    ParamDef("Gamma0",    50,   500,  Scale.log),
    ParamDef("theta_c", 0.02,   0.15, Scale.linear),
    ParamDef("theta_v",    0,   0.5,  Scale.linear),
    ParamDef("L0",      1e44,  1e48,  Scale.log),       # Injection luminosity [erg/s]
    ParamDef("t_sd",      10,  1e4,   Scale.log),       # Spin-down timescale [s]
    ParamDef("M_dot0",  1e20,  1e26,  Scale.log),       # Mass injection rate [g/s]
    ParamDef("tau",        1,   1e3,  Scale.log),
    # ... other standard params (n_ism, p, eps_e, eps_B, xi_e)
]

fitter = Fitter(z=1.58, lumi_dist=3.364e28, jet=jet_with_injection)

See Models and Radiation for more user-defined jet examples.

Custom Medium Profiles

Similarly, pass a custom medium factory via the medium keyword argument to define custom density profiles using the Medium class:

from VegasAfterglow import Medium, Fitter, ParamDef, Scale

def exponential_medium(params):
    """Exponential density profile: rho = mp * n_ism * exp(-r / r_scale)."""
    mp = 1.67e-24  # proton mass [g]

    def density_func(phi, theta, r):
        return mp * params.n_ism * np.exp(-r / params.r_scale)

    return Medium(rho=density_func)

fitter = Fitter(z=1.58, lumi_dist=3.364e28, medium=exponential_medium)

The Medium class takes a callable rho(phi, theta, r) that returns the mass density [g/cm³] at position (phi, theta, r). See Models and Radiation for more user-defined medium examples.

Custom MCMC Parameters

When using custom jet or medium functions, you can define arbitrary MCMC parameters beyond the standard set. Simply include them in the ParamDef list – the fitter automatically uses a Python-based parameter transformer when it detects non-standard parameter names:

mc_params = [
    ParamDef("E_iso",   1e50,  1e54,  Scale.log),
    ParamDef("Gamma0",    50,   500,  Scale.log),
    ParamDef("theta_c", 0.02,   0.15, Scale.linear),
    ParamDef("theta_v",    0,     0,  Scale.fixed),
    ParamDef("n_ism",   1e-3,   100,  Scale.log),
    ParamDef("r_scale", 1e16,  1e20,  Scale.log),     # custom parameter
    ParamDef("p",        2.1,   2.8,  Scale.linear),
    ParamDef("eps_e",   1e-3,   0.5,  Scale.log),
    ParamDef("eps_B",   1e-5,   0.1,  Scale.log),
    ParamDef("xi_e",     0.1,   1.0,  Scale.linear),
]

fitter = Fitter(z=1.58, lumi_dist=3.364e28, medium=exponential_medium)
fitter.add_flux_density(nu=4.84e14, t=t_data, f_nu=flux_data, err=flux_err)
result = fitter.fit(mc_params, sampler="emcee", nsteps=10000)

The custom parameter r_scale is accessible inside the factory via params.r_scale, and it is sampled by the MCMC just like any standard parameter. Standard parameters (theta_v, eps_e, etc.) retain their defaults and are still used internally for observer geometry and radiation physics.

Note

Standard ModelParams fields (like theta_v, eps_e, eps_B, p, xi_e) must still be included in the ParamDef list – they are needed by the Observer and Radiation objects that the fitter constructs internally.

Combining Custom Jet and Medium

fitter = Fitter(
    z=1.58, lumi_dist=3.364e28,
    jet=double_gaussian_jet,
    medium=exponential_medium,
)
fitter.add_flux_density(nu=4.84e14, t=t_data, f_nu=flux_data, err=flux_err)
fitter.add_flux_density(nu=2.4e17, t=t_xray, f_nu=flux_xray, err=err_xray)

result = fitter.fit(
    mc_params,
    sampler="emcee",
    nsteps=20000,
    nburn=5000,
    log_likelihood_fn=student_t_likelihood,
)

Speeding Up Custom Profiles with @gil_free

The custom jet and medium examples above use plain Python callbacks. Each time C++ evaluates your profile function, it must cross the Python↔C++ boundary — acquiring the GIL, calling into the interpreter, and returning. During blast wave evolution, this happens hundreds of times per model evaluation, adding overhead even in single-threaded mode and preventing true parallelism in multi-threaded MCMC.

The @gil_free decorator compiles your profile function to native machine code using numba, so C++ calls it directly as a C function pointer — no Python interpreter, no GIL, no boundary crossing:

pip install numba

The two differences from plain Python callbacks are:

  1. Add the @gil_free decorator

  2. Pass MCMC parameters as explicit function arguments (after the spatial coordinates) instead of capturing them from the enclosing scope — you can add as many parameters as you need

Here is a complete example — a tophat jet with @gil_free:

import math
from VegasAfterglow import Fitter, Ejecta, ParamDef, Scale, gil_free

# Decorate profile functions with @gil_free.
# First 2 args (phi, theta) are spatial coordinates from C++.
# Additional args are MCMC parameters, bound by keyword below.
@gil_free
def tophat_energy(phi, theta, E_iso, theta_c):
    return E_iso if theta <= theta_c else 0.0

@gil_free
def tophat_gamma(phi, theta, Gamma0, theta_c):
    return Gamma0 if theta <= theta_c else 1.0

# Factory: bind MCMC parameters by keyword each step
def jet_factory(mc_params):
    return Ejecta(
        E_iso=tophat_energy(E_iso=mc_params.E_iso, theta_c=mc_params.theta_c),
        Gamma0=tophat_gamma(Gamma0=mc_params.Gamma0, theta_c=mc_params.theta_c),
    )

fitter = Fitter(z=1.58, lumi_dist=3.364e28, jet=jet_factory, medium="wind")
fitter.add_flux_density(nu=4.84e14, t=t_data, f_nu=flux_data, err=flux_err)

mc_params = [
    ParamDef("E_iso",    1e51,  1e54,  Scale.log,    1e53),
    ParamDef("Gamma0",      5,  1000,  Scale.log,      20),
    ParamDef("theta_c",  0.01,   0.5,  Scale.linear,  0.1),
    ParamDef("p",           2,     3,  Scale.linear,  2.3),
    ParamDef("eps_e",    1e-2,   0.3,  Scale.log,    0.05),
    ParamDef("eps_B",    1e-4,   0.3,  Scale.log,    0.03),
    ParamDef("xi_e",     1e-3,   0.1,  Scale.log,    0.01),
    ParamDef("A_star",   1e-3,    10,  Scale.log,    0.05),
]

result = fitter.fit(mc_params, sampler="emcee", nsteps=10000)

The same decorator works for custom medium density functions (3 spatial coordinates phi, theta, r) and for energy/mass injection functions (phi, theta, t):

@gil_free
def custom_wind(phi, theta, r, A_star):
    return A_star * 5e11 * 1.67e-24 / (r * r)

def medium_factory(mc_params):
    return Medium(rho=custom_wind(A_star=mc_params.A_star))

@gil_free
def spindown_injection(phi, theta, t, L0, t_sd):
    return L0 / (1.0 + t / t_sd) ** 2

Tip

Functions decorated with @gil_free must use the math module (not numpy) and only simple arithmetic — no Python objects, arrays, or closures. If you need more complex logic, use the plain Python callback approach instead.

Note

Built-in jet types ("tophat", "gaussian", "powerlaw", etc.) are already implemented in C++ and do not need this decorator.

Performance Notes

Threading vs. Multiprocessing

The Fitter uses ThreadPoolExecutor (threads, not processes) because:

  1. GIL is released during the C++ flux_density() computation, so threads run truly in parallel

  2. No pickling overhead: Model objects stay in the same process

  3. Shared memory: All threads share observation data without copying

For typical afterglow models, the Python overhead (object creation, GIL acquisition) is <5% of total time for synchrotron-only models and <0.1% for SSC models.

Parallelism Tips

  • Emcee: npool defaults to the number of CPU cores.

  • Dynesty: npool controls thread count. The queue_size is automatically optimized.

  • Custom jet/medium with ``@gil_free``: Full thread parallelism is preserved. All profile evaluations run as native C function calls without the GIL.

  • Custom jet/medium with plain Python callbacks: Python callbacks require the GIL, which serializes the angular profile evaluation across threads. The blast wave evolution and radiation computation still run without the GIL, but the profile evaluation becomes a bottleneck. Use @gil_free to eliminate this overhead.

Troubleshooting

For comprehensive troubleshooting help including MCMC convergence issues, data selection problems, memory optimization, and performance tuning, see Troubleshooting.

See also

Validation & Testing for code validation and comparison with other afterglow codes. Parameter Reference for the complete parameter reference.