Astro Imaging Example

You can also download this example as a Jupyter notebook and a plain Julia source file.

Introduction

In this example we reconstruct a simulated astronomical image from photon counts. The sky model has two components: diffuse emission (dust, nebulae) with unknown brightness, contrast and correlation structure, and point-like stars that can outshine the diffuse background in single pixels. Each pixel of the simulated detector records Poisson-distributed counts, and MGVI fits both components jointly.

Prepare the environment

using MGVI

using Distributions
using FFTW
using Random
using Statistics
using Plots
using ForwardDiffPullbacks

import ForwardDiff, Zygote
import DistributionsAD # provides the AD rules Zygote needs for Distributions
using AutoDiffOperators

context = MGVIContext(ADSelector(Zygote))

Plots.default(dpi=120, size=(600, 500))
Random.seed!(122);

The sky model

The detector image spans data_dim pixels. The diffuse emission is modeled on a pad_factor times larger grid, so that the periodic boundary conditions of its Fourier-space representation cannot fold emission from one image edge into the opposite one. A SkyModel object carries the grid sizes and the harmonic wave numbers k of the modeling grid, everything else is derived from them:

struct SkyModel <: Function
    data_dim::NTuple{2,Int}
    gp_dim::NTuple{2,Int}
    k::Matrix{Float64}
    k_ref::Float64
end

function SkyModel(; data_dim::NTuple{2,<:Integer}, pad_factor::Integer = 2)
    gp_dim = pad_factor .* data_dim
    kfreq(n) = [(i <= n ÷ 2 ? i : i - n) / n for i in 0:(n - 1)]
    k = hypot.(kfreq(gp_dim[1]), permutedims(kfreq(gp_dim[2])))
    SkyModel(data_dim, gp_dim, k, 1 / maximum(gp_dim))
end;

MGVI models map a flat vector p of parameters with standard normal priors to a distribution of the data. The first three entries of p are the hyperparameters of the diffuse emission, followed by one latent parameter per modeling grid cell for the diffuse emission and one parameter per image pixel for the stars:

hyper_idxs(::SkyModel) = 1:3
gp_idxs(m::SkyModel) = (1:prod(m.gp_dim)) .+ last(hyper_idxs(m))
star_idxs(m::SkyModel) = (1:prod(m.data_dim)) .+ last(gp_idxs(m))
n_params(m::SkyModel) = last(star_idxs(m));

The image pixels form the central block of the padded modeling grid:

data_idxs(m::SkyModel) = map((o, n) -> (o + 1):(o + n), (m.gp_dim .- m.data_dim) .÷ 2, m.data_dim);

Harmonic transform

As harmonic transform we choose the discrete Hartley transform (DHT), which maps real-valued functions to real-valued harmonic coefficients and is its own inverse up to a factor of the grid size. We compute it via a complex FFT as real(F) - imag(F): unlike FFTW's real-to-real plans, this composes with automatic differentiation and program tracing, and it generalizes correctly to multiple dimensions (the two-dimensional Hartley kernel is not the product of two one-dimensional ones):

function dht(x::AbstractArray)
    F = fft(Complex.(x))
    return real.(F) .- imag.(F)
end;

Reverse-mode AD handles dht out of the box. For forward-mode AD (MGVI uses it for Jacobian-vector products) we exploit that the DHT is linear, so it transforms dual values and partial derivatives independently:

function dht(x::AbstractArray{ForwardDiff.Dual{T,V,N}}) where {T,V,N}
    val = dht(ForwardDiff.value.(x))
    parts = ntuple(i -> dht(ForwardDiff.partials.(x, i)), Val(N))
    return ForwardDiff.Dual{T}.(val, parts...)
end;

Emission components

The log-brightness of the diffuse emission is a Gaussian random field with a power-law amplitude spectrum A(k) ∝ k^-α. Its three hyperparameters — mean log-brightness offset, contrast fluctuations and spectral slope α — have standard normal priors like all other parameters, so they are rescaled to their intended ranges inside the model. The amplitude spectrum is normalized such that fluctuations directly gives the a priori standard deviation of the log-brightness:

function diffuse_emission(m::SkyModel, p::AbstractVector)
    ξ_offset, ξ_fluct, ξ_slope = p[hyper_idxs(m)]
    offset = log(3.0) + 1.0 * ξ_offset
    fluctuations = 1.5 * exp(0.3 * ξ_fluct)
    α = 1.5 + 0.25 * ξ_slope
    k_safe = ifelse.(m.k .== 0, one.(m.k), m.k)
    A_raw = ifelse.(m.k .== 0, zero(α), (k_safe ./ m.k_ref) .^ -α)
    A = fluctuations .* A_raw ./ sqrt(sum(abs2, A_raw) / length(A_raw))
    ξ_gp = reshape(p[gp_idxs(m)], m.gp_dim)
    log_brightness = offset .+ dht(A .* ξ_gp) ./ sqrt(prod(m.gp_dim))
    exp.(log_brightness[data_idxs(m)...])
end;

Stars are point-like, so they need no correlation structure: each image pixel gets an independent star brightness with a heavy-tailed log-normal prior. Most pixels stay dark, but dozens of the brightest ones a priori host stars that outshine the diffuse emission:

function star_emission(m::SkyModel, p::AbstractVector)
    exp.(-6.5 .+ 3.2 .* reshape(p[star_idxs(m)], m.data_dim))
end;

total_emission(m::SkyModel, p::AbstractVector) = diffuse_emission(m, p) .+ star_emission(m, p);

Each pixel records counts drawn from a Poisson distribution with the total emission as rate. Wrapping the Poisson constructor via fwddiff from ForwardDiffPullbacks makes the broadcast efficient for reverse-mode AD:

function (m::SkyModel)(p::AbstractVector)
    product_distribution(fwddiff(Poisson).(vec(total_emission(m, p))))
end;

Simulated data

We instantiate the sky model and draw the ground truth from the prior:

sky = SkyModel(data_dim = (96, 96))

true_params = randn(n_params(sky));
data = rand(sky(true_params));

plot(
    heatmap(diffuse_emission(sky, true_params), title="true diffuse emission"),
    heatmap(star_emission(sky, true_params), title="true stars"),
    heatmap(log10.(total_emission(sky, true_params)), title="true total emission (log10)"),
    heatmap(log10.(reshape(data, sky.data_dim) .+ 1), title="observed counts (log10(1+n))"),
    layout=(2, 2), size=(900, 700)
)
savefig("astro-img-plot1.png")
"/home/runner/work/MGVI.jl/MGVI.jl/docs/build/astro-img-plot1.png"

Plot

Fitting

We prepare the MGVI step once for the model, data and parameter size, and then iterate it, starting from the prior mean. Preparation makes repeated steps cheap, and for accelerator devices (via the device keyword of mgvi_prepare) the prepared step can even be compiled into a single device program. Each step draws new residual samples around the current parameter estimate, so the mean negative log-posterior mnlp fluctuates slightly even after convergence:

config = MGVIConfig(
    linsolver_opts = (;maxiters = 30),
    optimizer = MGVI.NewtonCG(steps = 3, cg_maxiter = 20)
)

center = zeros(n_params(sky))
prepared = mgvi_prepare(sky, data, 4, center, config, context)

mnlp_series = Float64[]
result = nothing
for i in 1:12
    global result, center = mgvi_step(prepared, center)
    push!(mnlp_series, result.mnlp)
end

plot(mnlp_series, yscale=:log10, marker=:circle, label="mnlp", xlabel="iteration")
savefig("astro-img-plot2.pdf")

Plot

Results

The reconstruction separates the two components: the diffuse emission is recovered as a smooth field, and the bright stars are absorbed by the point-source component instead of leaving imprints in the diffuse map:

plot(
    heatmap(diffuse_emission(sky, true_params), title="true diffuse emission"),
    heatmap(diffuse_emission(sky, center), title="reconstructed diffuse emission"),
    heatmap(star_emission(sky, true_params), title="true stars"),
    heatmap(star_emission(sky, center), title="reconstructed stars"),
    layout=(2, 2), size=(900, 700)
)
savefig("astro-img-plot3.png")
"/home/runner/work/MGVI.jl/MGVI.jl/docs/build/astro-img-plot3.png"

Plot

The brightness of the reconstructed stars closely follows the truth for stars that stand out of the diffuse background, while stars fainter than the background are only weakly constrained by the data:

star_sel = findall(vec(star_emission(sky, true_params)) .> 1);

scatter(
    vec(star_emission(sky, true_params))[star_sel], vec(star_emission(sky, center))[star_sel],
    xscale=:log10, yscale=:log10, xlabel="true star brightness",
    ylabel="reconstructed star brightness", label=nothing
)
plot!(identity, label="truth")
savefig("astro-img-plot4.pdf")

Plot

MGVI approximates the posterior by a Gaussian in the standardized parameters, centered on the final parameter estimate with the inverse Fisher metric as covariance. Samples from this posterior approximation can be drawn at any time via MGVI.sample_residuals, as antithetic pairs center ± residual:

residuals = MGVI.sample_residuals(
    sky, center,
    randn(prod(sky.data_dim), 16), randn(n_params(sky), 16),
    context.ad
);
post_samples = hcat(center .+ residuals, center .- residuals);

Per-pixel standard deviations of the log-brightness over the posterior samples provide an uncertainty map of the reconstruction: the emission is tightly constrained where it is bright, while for dark pixels the data only provide upper limits on a possible star, so the uncertainty is dominated by the star prior there:

sample_log_emissions = [log10.(total_emission(sky, Vector(s))) for s in eachcol(post_samples)];

plot(
    heatmap(log10.(total_emission(sky, center)), title="reconstructed total emission (log10)"),
    heatmap(std(sample_log_emissions), title="posterior uncertainty (dex)"),
    layout=(1, 2), size=(900, 350)
)
savefig("astro-img-plot5.png")
"/home/runner/work/MGVI.jl/MGVI.jl/docs/build/astro-img-plot5.png"

Plot


This page was generated using Literate.jl.