Posterior estimation with NumPyro bridge

import astropy.units as u
import jax
import jax.numpy as jnp
import numpy as np
import numpyro
import numpyro.distributions as dist
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.time import Time
from numpyro.infer import MCMC, NUTS, init_to_value

import nyx
from nyx import (
    Airglow,
    EffectiveApertureInstrument,
    Geometry,
    Moon,
    Observation,
    Scene,
    SingleScattering,
    Stars,
    ZodiacalLight,
    freeze_all,
    unfreeze,
)
from nyx.infer import init_values, scene_model

nyx.configure()
JAX is not using 64-bit precision. Results will diverge from healpy even at moderate nside and can overflow for nside > 8192. See the README on numerical precision.

Building the model

geo = Geometry(
    wvls=jnp.linspace(300, 700, 40) * u.nm,  # wavelength grid
    nside=32,  # HEALPix resolution
    ngrid=2,  # FOV eval grid
    fov=3.5 * u.deg,  # field of view
)

hess_ct1 = EffectiveApertureInstrument.from_iactrace_table(geo, "CT1_aperture.npz")
hess_stereo = {"CT1": hess_ct1, "CT2": hess_ct1, "CT3": hess_ct1, "CT4": hess_ct1}
atmosphere = SingleScattering.from_hg_ozone(geo)

sources = {
    "airglow": Airglow.from_eso_skycalc(geo, sfu=100.0),
    "zodiacal": ZodiacalLight.from_leinert1998(geo),
    "GaiaDR3": Stars.from_gaia_dr3(geo, lim_mag=12),
    "moon": Moon.from_jones2013(geo),
}
location_ct1 = EarthLocation.from_geodetic(
    lon=16.50091 * u.deg,
    lat=-23.27153 * u.deg,
    height=1.835 * u.km,
)
location_ct2 = EarthLocation.from_geodetic(
    lon=16.50010 * u.deg,
    lat=-23.27075 * u.deg,
    height=1.835 * u.km,
)
location_ct3 = EarthLocation.from_geodetic(
    lon=16.49925 * u.deg,
    lat=-23.27151 * u.deg,
    height=1.835 * u.km,
)
location_ct4 = EarthLocation.from_geodetic(
    lon=16.50012 * u.deg,
    lat=-23.27232 * u.deg,
    height=1.835 * u.km,
)

times = Time("2021-01-12T21:12:16", scale="utc") + jnp.linspace(0, 30, 3) * 60 * u.s
target = SkyCoord(l=184 * u.deg, b=-6 * u.deg, frame="galactic")

observations = {
    "CT1": Observation(location_ct1, times, target, geo),
    "CT2": Observation(location_ct2, times, target, geo),
    "CT3": Observation(location_ct3, times, target, geo),
    "CT4": Observation(location_ct4, times, target, geo),
}

scene = Scene.build(hess_stereo, atmosphere, sources, observations)
Scene(instruments: CT1(3 obs), CT2(3 obs), CT3(3 obs), CT4(3 obs); sources: airglow, zodiacal, GaiaDR3, moon; atmosphere: SingleScattering; 3884 of 3884 parameter values free)

Synthetic truth data

We changed aod_500, anstrom_exp, pointing shifts and instrument efficiency. We also add a 5% relative error.

rng = np.random.default_rng(44)
true_shifts = {
    name: np.deg2rad(rng.uniform(-0.05, 0.05, size=(scene.nobs[name], 2))) for name in scene
}

truth = scene.set_params(
    {f"instruments.{name}.shift": jnp.asarray(s) for name, s in true_shifts.items()}
    | {
        "atmosphere.components.Mie.aod_500": 0.15,
        "atmosphere.components.Mie.angstrom_exp": 1.3,
        "instruments.*.efficiency": 0.8,
    }
)

rel_error = 0.05
truth_images = {
    name: jnp.asarray(np.asarray(r) * rng.lognormal(0.0, rel_error, size=r.shape))
    for name, r in truth.render().items()
}

Parameter freezing and priors

We freeze all parameters, then selectively unfreeze all parameters we want to sample.

model_scene = unfreeze(
    freeze_all(scene),
    "atmosphere.components.Mie.aod_500",
    "atmosphere.components.Mie.angstrom_exp",
    "instruments.*.shift",
    "instruments.*.rotation",
    "instruments.*.efficiency",
)

We define our priors using numpyro dists and then create a numpyro scene_model, with the likelihood containing a fittable sigma.

priors = {
    "atmosphere.components.Mie.aod_500": dist.Uniform(0.001, 2.0),
    "atmosphere.components.Mie.angstrom_exp": dist.Uniform(0.0, 2.5),
    "instruments.*.shift": dist.Uniform(-jnp.deg2rad(0.1), jnp.deg2rad(0.1)),
    "instruments.*.rotation": dist.Uniform(-jnp.deg2rad(1.0), jnp.deg2rad(1.0)),
    "instruments.*.efficiency": dist.Uniform(0.5, 2.0),
}


def nyx_model():
    sigma = numpyro.sample("rel_error", dist.HalfNormal(0.1))
    scene_model(
        model_scene,
        truth_images,
        priors,
        lambda predicted: dist.LogNormal(jnp.log(predicted), sigma),
    )()

Running NUTS

init_values gives the current values of the model_scene, which enables us to start from a fit or other initialization.

kernel = NUTS(
    nyx_model,
    max_tree_depth=6,
    init_strategy=init_to_value(values=init_values(model_scene)),
    dense_mass=[
        (
            "atmosphere.components.Mie.aod_500",
            "atmosphere.components.Mie.angstrom_exp",
            *(f"instruments.{name}.efficiency" for name in scene),
            "rel_error",
        )
    ],
)

mcmc = MCMC(kernel, num_warmup=500, num_samples=2000, num_chains=1)
mcmc.run(jax.random.PRNGKey(0), extra_fields=("num_steps",))
mcmc.print_summary()

num_steps = mcmc.get_extra_fields()["num_steps"]
print(f"leapfrog steps per sample - mean {num_steps.mean():.1f}, max {num_steps.max()}")
sample: 100%|████████████| 2500/2500 [02:33<00:00, 16.29it/s, 15 steps of size 2.73e-01. acc. prob=0.88]
                                              mean       std    median      5.0%     95.0%     n_eff     r_hat
  atmosphere.components.Mie.angstrom_exp      1.13      0.64      1.10      0.08      2.11    767.06      1.00
       atmosphere.components.Mie.aod_500      0.14      0.02      0.14      0.11      0.16   1506.11      1.00
              instruments.CT1.efficiency      0.80      0.00      0.80      0.79      0.80   2375.79      1.00
             instruments.CT1.rotation[0]      0.00      0.00      0.00      0.00      0.00   4643.19      1.00
             instruments.CT1.rotation[1]     -0.00      0.00     -0.00     -0.00      0.00   1881.57      1.00
             instruments.CT1.rotation[2]      0.00      0.00      0.00     -0.00      0.00   2577.71      1.00
              instruments.CT1.shift[0,0]     -0.00      0.00     -0.00     -0.00     -0.00   3626.40      1.00
              instruments.CT1.shift[0,1]     -0.00      0.00     -0.00     -0.00     -0.00   3524.91      1.00
              instruments.CT1.shift[1,0]     -0.00      0.00     -0.00     -0.00     -0.00   2305.23      1.00
              instruments.CT1.shift[1,1]      0.00      0.00      0.00      0.00      0.00   1891.66      1.00
              instruments.CT1.shift[2,0]     -0.00      0.00     -0.00     -0.00     -0.00   2818.78      1.00
              instruments.CT1.shift[2,1]      0.00      0.00      0.00      0.00      0.00   3350.32      1.00
              instruments.CT2.efficiency      0.80      0.00      0.80      0.79      0.80   2494.15      1.00
             instruments.CT2.rotation[0]      0.00      0.00      0.00     -0.00      0.00   1890.79      1.00
             instruments.CT2.rotation[1]      0.00      0.00      0.00     -0.00      0.00   2880.44      1.00
             instruments.CT2.rotation[2]     -0.00      0.00     -0.00     -0.00      0.00   3772.14      1.00
              instruments.CT2.shift[0,0]     -0.00      0.00     -0.00     -0.00     -0.00   4253.78      1.00
              instruments.CT2.shift[0,1]     -0.00      0.00     -0.00     -0.00     -0.00   1952.28      1.00
              instruments.CT2.shift[1,0]      0.00      0.00      0.00      0.00      0.00   3810.28      1.00
              instruments.CT2.shift[1,1]      0.00      0.00      0.00      0.00      0.00   3646.89      1.00
              instruments.CT2.shift[2,0]      0.00      0.00      0.00      0.00      0.00   5637.79      1.00
              instruments.CT2.shift[2,1]     -0.00      0.00     -0.00     -0.00     -0.00   3051.60      1.00
              instruments.CT3.efficiency      0.80      0.00      0.80      0.79      0.80   2233.11      1.00
             instruments.CT3.rotation[0]      0.00      0.00      0.00     -0.00      0.00   3608.24      1.00
             instruments.CT3.rotation[1]      0.00      0.00      0.00     -0.00      0.00   2733.53      1.00
             instruments.CT3.rotation[2]     -0.00      0.00     -0.00     -0.00      0.00   2880.03      1.00
              instruments.CT3.shift[0,0]      0.00      0.00      0.00      0.00      0.00   3263.44      1.00
              instruments.CT3.shift[0,1]      0.00      0.00      0.00      0.00      0.00   3998.01      1.00
              instruments.CT3.shift[1,0]      0.00      0.00      0.00      0.00      0.00   3620.75      1.00
              instruments.CT3.shift[1,1]     -0.00      0.00     -0.00     -0.00     -0.00   2876.97      1.00
              instruments.CT3.shift[2,0]      0.00      0.00      0.00      0.00      0.00   3087.71      1.00
              instruments.CT3.shift[2,1]      0.00      0.00      0.00      0.00      0.00   2933.59      1.00
              instruments.CT4.efficiency      0.80      0.00      0.80      0.79      0.80   2466.84      1.00
             instruments.CT4.rotation[0]      0.00      0.00      0.00     -0.00      0.00   4092.25      1.00
             instruments.CT4.rotation[1]      0.00      0.00      0.00     -0.00      0.00   3341.13      1.00
             instruments.CT4.rotation[2]      0.00      0.00      0.00     -0.00      0.00   2324.41      1.00
              instruments.CT4.shift[0,0]      0.00      0.00      0.00      0.00      0.00   4029.80      1.00
              instruments.CT4.shift[0,1]     -0.00      0.00     -0.00     -0.00     -0.00   5105.67      1.00
              instruments.CT4.shift[1,0]     -0.00      0.00     -0.00     -0.00     -0.00   3394.59      1.00
              instruments.CT4.shift[1,1]      0.00      0.00      0.00      0.00      0.00   4527.76      1.00
              instruments.CT4.shift[2,0]     -0.00      0.00     -0.00     -0.00     -0.00   2465.06      1.00
              instruments.CT4.shift[2,1]      0.00      0.00      0.00      0.00      0.00   3755.51      1.00
                               rel_error      0.05      0.00      0.05      0.05      0.05   5061.30      1.00

Number of divergences: 0
leapfrog steps per sample - mean 15.0, max 15

Compared to the injected truth

samples = mcmc.get_samples()

for name, injected in [
    ("atmosphere.components.Mie.aod_500", 0.15),
    ("atmosphere.components.Mie.angstrom_exp", 1.3),
    ("rel_error", rel_error),
    *((f"instruments.{name}.efficiency", 0.8) for name in scene),
]:
    s = samples[name]
    print(f"{name:45s} {s.mean():8.4f} +- {s.std():.4f}   injected {injected}")

for name, injected in true_shifts.items():
    posterior = np.asarray(samples[f"instruments.{name}.shift"]).mean(axis=0)
    residual = np.rad2deg(posterior - injected) * 3600
    print(f"{name} shift residual rms {np.std(residual):.1f} arcsec")
atmosphere.components.Mie.aod_500               0.1351 +- 0.0157   injected 0.15
atmosphere.components.Mie.angstrom_exp          1.1302 +- 0.6425   injected 1.3
rel_error                                       0.0500 +- 0.0003   injected 0.05
instruments.CT1.efficiency                      0.7967 +- 0.0022   injected 0.8
instruments.CT2.efficiency                      0.7972 +- 0.0022   injected 0.8
instruments.CT3.efficiency                      0.7972 +- 0.0022   injected 0.8
instruments.CT4.efficiency                      0.7958 +- 0.0022   injected 0.8
CT1 shift residual rms 1.8 arcsec
CT2 shift residual rms 2.5 arcsec
CT3 shift residual rms 2.3 arcsec
CT4 shift residual rms 1.7 arcsec

Corner plot for scalar parameters:

import corner
import matplotlib.pyplot as plt

params = {
    "atmosphere.components.Mie.aod_500": ("AOD$_{500}$", 0.15),
    "atmosphere.components.Mie.angstrom_exp": (r"$\alpha$", 1.3),
    **{f"instruments.{n}.efficiency": (rf"$\epsilon_{{{n}}}$", 0.8) for n in scene},
    "rel_error": (r"$\sigma$", rel_error),
}
data = np.column_stack([np.asarray(samples[k]) for k in params])
labels = [v[0] for v in params.values()]
truths = [v[1] for v in params.values()]

fig = corner.corner(
    data,
    labels=labels,
    truths=truths,
    truth_color="C3",
    quantiles=[0.16, 0.5, 0.84],
    show_titles=True,
    title_fmt=".3f",
    smooth=1.0,
    levels=(0.68, 0.95),
)
plt.show()
../_images/c218780014d4f6f58103bc42c26e2f795a23caeb43812e036d702bcb4e8eb683.png
t = 2  # time index
names = list(scene)
colors = [f"C{i}" for i in range(len(names))]
labels = [r"$\Delta x$ [arcsec]", r"$\Delta y$ [arcsec]", "rot [arcmin]"]


def timestep_data(name, t):
    shift = np.asarray(samples[f"instruments.{name}.shift"])
    rot = np.asarray(samples[f"instruments.{name}.rotation"])
    n, nobs = shift.shape[0], scene.nobs[name]
    shift = shift.reshape(n, nobs, 2)[:, t]
    rot = rot.reshape(n, nobs, -1)[:, t, 0]
    dshift = np.rad2deg(shift - true_shifts[name][t]) * 3600
    return np.column_stack([dshift, np.rad2deg(rot) * 60])


data = {name: timestep_data(name, t) for name in names}
allv = np.concatenate(list(data.values()))
lo, hi = np.percentile(allv, [0.5, 99.5], axis=0)
pad = 0.1 * (hi - lo)
ranges = list(zip(lo - pad, hi + pad, strict=False))

fig = plt.figure(figsize=(14, 14))
subfigs = fig.subfigures(2, 2)

for sf, name, _c in zip(subfigs.flat, names, colors, strict=False):
    corner.corner(
        data[name],
        fig=sf,
        color="black",
        labels=labels,
        range=ranges,
        truths=[0.0, 0.0, 0.0],
        truth_color="red",
        quantiles=[0.16, 0.5, 0.84],
        show_titles=True,
        title_fmt=".1f",
        smooth=1.0,
        levels=(0.68, 0.95),
    )
    sf.suptitle(name, fontsize=14)
../_images/f1183228f0e68b8e56672e7a9394db385f399908e8caf9f8961256fd08b9eb7a.png