Skip to content

Commit eebb6f2

Browse files
committed
Update PS fitting tutorial
1 parent ba48b4c commit eebb6f2

File tree

2 files changed

+123
-125
lines changed

2 files changed

+123
-125
lines changed

examples/advanced/FitPS/Project.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ FiniteDifferences = "26cc04aa-876d-5657-8c51-4c34ba976000"
99
LogDensityProblems = "6fdf6af0-433a-55f7-b3ed-c6c6e0b8df7c"
1010
NonuniformFFTs = "cd96f58b-6017-4a02-bb9e-f4d81626177f"
1111
Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba"
12-
OptimizationOptimJL = "36348300-93cb-4f02-beb5-3c3902f8871e"
12+
OptimizationLBFGSB = "22f7324a-a79d-40f2-bebe-3af60c77bd15"
13+
OptimizationOptimisers = "42dfb2eb-d2b4-4451-abcd-913932933ac1"
14+
OptimizationSophia = "892fee11-dca1-40d6-b698-84ba0d87399a"
1315
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
1416
Pyehtim = "3d61700d-6e5b-419a-8e22-9c066cf00468"
1517
StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3"
@@ -21,7 +23,7 @@ VLBILikelihoods = "90db92cd-0007-4c0a-8e51-dbf0782ce592"
2123
CairoMakie = "0.15"
2224
DisplayAs = "0.1"
2325
Distributions = "0.25"
24-
Optimization = "4"
26+
Optimization = "5"
2527
Pkg = "1"
2628
Pyehtim = "0.2"
2729
StableRNGs = "1"

examples/advanced/FitPS/main.jl

Lines changed: 119 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@ close(pkg_io) #hide
1414
# Where we can fit multiple scales of the power spectrum simultaneously up to some order.
1515

1616

17-
# ## Introduction to Closure Imaging
18-
#
19-
# In this tutorial, we will do closure-only modeling of the AGN XYZ observed with the VLBA at XYZ GHz.
17+
# In this tutorial, we will do closure-only modeling of the AGN DA 193 observed with the VLBA
18+
# at 15 GHz with the Mojave AGN project. Unlike the previous tutorials, we will constrain the
19+
# power spectrum slope and using a Markov random field expansion. This will allow us to model
20+
# more complex and multi-scale processes in AGN, which is expected to be common in black hole
21+
# jets.
22+
2023

2124

2225
# To get started, we will load Comrade
2326
using Comrade
2427
using LinearAlgebra
2528
LinearAlgebra.BLAS.set_num_threads(1)
26-
VLBISkyModels.FFTW.set_num_threads(Threads.nthreads())
2729
# Pyehtim loads eht-imaging using PythonCall this is necessary to load uvfits files
2830
# currently.
2931
using Pyehtim
@@ -35,16 +37,16 @@ rng = StableRNG(123)
3537

3638

3739
# ## Load the Data
38-
# To download the data visit https://doi.org/10.25739/g85n-f134
40+
# For this tutorial we will image publicly available VLBA data of the AGN
3941
# To load the eht-imaging obsdata object we do:
40-
# obs = ehtim.obsdata.load_uvfits("/home/ptiede/Smithsonian External Dropbox/Paul Tiede/MixedPolPaper/data/VLBA/BU/0954+658Q.2025-03-23.UVP.gz")
41-
obs0 = ehtim.obsdata.load_uvfits(joinpath(__DIR, "..", "..", "Data", "SR1_M87_2017_096_lo_hops_netcal_StokesI.uvfits"))
42+
file = Base.download("https://www.bu.edu/blazars/VLBA_GLAST/1308/1308+326Q.2021-03-19.UVP.gz")
43+
obs0 = ehtim.obsdata.load_uvfits(file)
4244

4345
# Now we do some minor preprocessing:
4446
# - Scan average the data since the data have been preprocessed so that the gain phases
4547
# are coherent.
46-
# - Add 2% systematic noise to deal with calibration issues such as leakage.
47-
obs = scan_average(obs)
48+
# - Add 0.5% systematic noise to deal with calibration issues such as leakage.
49+
obs = scan_average(obs0).add_fractional_noise(0.005)
4850

4951
# Now, we extract our closure quantities from the EHT data set. We flag now SNR points since
5052
# the closure likelihood we use is only applicable to high SNR data.
@@ -54,65 +56,48 @@ dlcamp, dcphase = extract_table(obs, LogClosureAmplitudes(; snrcut = 3), Closure
5456
# Fitting low SNR closure data is complicated and requires a more sophisticated likelihood.
5557
# If low-SNR data is very important we recommend fitting visibilties with a instrumental model.
5658

57-
function fast_centroid(img::IntensityMap{<:Real, 2})
58-
x0 = zero(eltype(img))
59-
y0 = zero(eltype(img))
60-
dp = domainpoints(img)
61-
fs = Comrade._fastsum(img)
62-
@inbounds for i in CartesianIndices(img)
63-
x0 += dp[i].X * img[i]
64-
y0 += dp[i].Y * img[i]
65-
end
66-
return x0 / fs, y0 / fs
67-
end
68-
69-
7059

7160
# ## Build the Model/Posterior
72-
# For our model, we will be using an image model that consists of a raster of point sources,
73-
# convolved with some pulse or kernel to make a `ContinuousImage`.
74-
# To define this model we define the standard two argument function `sky` that defines the
75-
# sky model we want to fit. The first argument are the model parameters, and are typically
76-
# a NamedTuple. The second argument defines the metadata
77-
# for the model that is typically constant. For our model the constant `metdata` will just
78-
# by the mean or prior image.
61+
# Most of the model building here will look very similar to the previous [Imaging a Black Hole using only Closure Quantities](@ref)
62+
# tutorial. However, we will be utilizing a more complex image prior. Specifically, [`VLBIImagePriors`](@ref)
63+
# provides a basic framework for building stationary Gaussian random fields with cyclic boundary conditions.
64+
# To define the random field we just need to define a spectral model. For this work we will use a
65+
# Markovian spectral model. Namely, our power spectrum will be modeled as
66+
# ```math
67+
# P(k) \propto \frac{\sigma}{1 + \sum_s \rho_s k^{2s}}
68+
# ```
69+
# where `σ` is the marginal variance of the image, `ρs` are the coefficients of the Markovian expansion,
70+
# and `k` is the norm of the spatial wavenumber.
71+
using VLBIImagePriors ## Defines the `MarkovPS` power spectrum model and `StationaryRandomField`
7972
function sky(θ, metadata)
80-
(; fb, c, ρs, τ, ξτ, σimg) = θ
73+
(; fb, c, ρs, σimg) = θ
8174
(; mimg, pl) = metadata
8275
## Apply the GMRF fluctuations to the image
83-
x = genfield(StationaryRandomField(MarkovPS(ρs), pl), c)
76+
x = genfield(StationaryRandomField(MarkovPS(ρs.^2), pl), c)
8477
x .= σimg .* x
8578
fbn = fb/length(mimg)
8679
mb = mimg.*(1 - fb) .+ fbn
87-
rast = apply_fluctuations(UnitFluxMap(exp), mb, x)
88-
x0, y0 = fast_centroid(rast)
89-
m = ContinuousImage(rast, DeltaPulse())
90-
## Force the image centroid to be at the origin
91-
## Add a large-scale gaussian to deal with the over-resolved mas flux
92-
return shifted(m, -x0, -y0)
80+
rast = apply_fluctuations(CenteredLR(), mb, x)
81+
m = ContinuousImage(rast, BSplinePulse{3}())
82+
return m
9383
end
9484

95-
96-
# Now, let's set up our image model. The EHT's nominal resolution is 20-25 μas. Additionally,
97-
# the EHT is not very sensitive to a larger field of views; typically, 60-80 μas is enough to
98-
# describe the compact flux of M87. Given this, we only need to use a small number of pixels
99-
# to describe our image.
100-
npix = 64
101-
fovxy = μas2rad(2000.0)
102-
103-
# To define the image model we need to specify both the grid we will be using and the
104-
# FT algorithm we will use, in this case the NFFT which is the most efficient.
105-
grid = imagepixels(fovxy, fovxy, npix, npix)
106-
85+
# For this tutorial we decided to image a very compact AGN. Thus, we will use a small FOV for a 15 GHz
86+
# observation. Namely, we will use a 5000 μas FOV with 64x64 pixels.
87+
nx = 64
88+
ny = 64
89+
fovx = μas2rad(1_000)
90+
fovy = fovx*ny/nx
91+
grid = imagepixels(fovx, fovy, nx, ny, μas2rad(150.0), -μas2rad(150.0))
10792

10893
# Now we need to specify our image prior. For this work we will use a Gaussian Markov
10994
# Random field prior
110-
using VLBIImagePriors, Distributions
11195

11296
# Since we are using a Gaussian Markov random field prior we need to first specify our `mean`
113-
# image. For this work we will use a symmetric Gaussian with a FWHM of 50 μas
97+
# image. For this work we will use a symmetric Gaussian with a FWHM equal to the approximate
98+
# beamsize of the array. This models the fact that we expect the AGN core to be compact.
11499
fwhmfac = 2 * sqrt(2 * log(2))
115-
mpr = modify(Gaussian(), Stretch(3*beamsize(dlcamp) / fwhmfac))
100+
mpr = modify(Gaussian(), Stretch(beamsize(dlcamp) / 4 / fwhmfac))
116101
imgpr = intensitymap(mpr, grid)
117102
# To momdel the power spectrum we also need to construct our execution plan for the given grid.
118103
# This will be used to construct the actual correlated realization of the RF given some initial
@@ -121,21 +106,24 @@ pl = StationaryRandomFieldPlan(grid)
121106
skymeta = (; mimg=imgpr./sum(imgpr), pl);
122107

123108

124-
# Now we can finally form our image prior. For this we use a heirarchical prior where the
125-
# direct log-ratio image prior is a Gaussian Markov Random Field. The correlation length
126-
# of the GMRF is a hyperparameter that is fit during imaging. We pass the data to the prior
127-
# to estimate what the maximumal resolutoin of the array is and prevent the prior from allowing
128-
# correlation lengths that are much small than the telescope beam size. Note that this GMRF prior
129-
# has unit variance. For more information on the GMRF prior see the [`corr_image_prior`](@ref) doc string.
109+
# For the stationary random field prior we also need to define the *noise* prior. Luckily
110+
# VLBIImagePriors provides a helper function to do this for us.
130111
cprior = std_dist(pl)
131112

113+
# For the coefficients of the spectral expansion we will use a uniform prior between 0.1 and
114+
# 4 times the maximum dimension of the image. This prior is rather uninformative and
115+
# allows for a wide range of power spectra. Additionally, we truncate the expansion at order 2
116+
# for simplicity in this tutorial.
117+
using Distributions
118+
ρs = ntuple(Returns(Uniform(0.1, 2 * max(size(grid)...))), 3)
119+
132120
# Putting everything together the total prior is then our image prior, a prior on the
133121
# standard deviation of the MRF, and a prior on the fractional flux of the Gaussian component.
134-
= Uniform(0.1, 4 * max(size(grid)...))
135-
prior = (
122+
#
123+
prior = (;
136124
c = cprior,
137-
ρs = ntuple(Returns(dρ), 2),
138-
σimg = truncated(Normal(0.0, 0.5); lower=0.0),
125+
ρs = ρs,
126+
σimg = Exponential(2.0),
139127
fb = Uniform(0.0, 1.0),
140128
)
141129

@@ -158,49 +146,44 @@ post = VLBIPosterior(skym, dlcamp, dcphase)
158146
# functionality a user first needs to import `Optimization.jl` and the optimizer of choice.
159147
# In this tutorial we will use Optiizations LBFGS optimizer.
160148
# We also need to import Enzyme to allow for automatic differentiation.
161-
using Optimization
162-
xopt, sol = comrade_opt(
163-
post, Optimization.LBFGS();
164-
maxiters = 2000, initial_params = prior_sample(rng, post)
165-
);
149+
using Optimization, OptimizationLBFGSB, OptimizationOptimisers
150+
# tpost = asflat(post)
151+
xopt, sol = comrade_opt(post, Adam(); maxiters = 5000)
166152

167153
using CairoMakie
168154
using DisplayAs #hide
169-
g = refinespatial(grid, 2)
170-
# img = intensitymap(skymodel(post, xopt), g)
171-
img = skymodel(post, xopt).model.img
172-
fig = imageviz(img, size = (600, 500));
155+
# The image we actually fit is a continuous object so we can easily refine the image
156+
# to produce a higher resolution rendering.
157+
# Here we refine the image by a factor of 3 in each dimension.
158+
g = refinespatial(grid, 3)
159+
# Now to produce the intensity map we just do
160+
imgmap = intensitymap(skymodel(post, xopt), g)
161+
fig = imageviz(imgmap, colorscale=log10, colorrange=(1e-8, 1e-4), size = (650, 500));
173162
DisplayAs.Text(DisplayAs.PNG(fig)) #hide
174163

175164

176-
# First we will evaluate our fit by plotting the residuals
165+
# To see how well the MAP estimate fits the data we can plot the residuals.
177166
res = Comrade.residuals(post, xopt)
178167
fig = Figure(; size = (800, 300))
179168
plotfields!(fig[1, 1], res[1], :uvdist, :res);
180169
plotfields!(fig[1, 2], res[2], :uvdist, :res);
181170
fig |> DisplayAs.PNG |> DisplayAs.Text
182171

183-
# Now let's plot the MAP estimate.
184172

185-
186-
# That doesn't look great. This is pretty common for the sparse EHT data. In this case the
187-
# MAP often drastically overfits the data, producing a image filled with artifacts. In addition,
188-
# we note that the MAP itself is not invariant to the model parameterization. Namely, if we
189-
# changed our prior to use a fully centered parameterization we would get a very different image.
190-
# Fortunately, these issues go away when we sample from the posterior, and construct expectations
191-
# of the posterior, like the mean image.
173+
# Overall, the image looks reasonable but the MAP has a slightly high reduced chi-square. Note that
174+
# since we are fitting with an image prior the MAP may actually have a higher reduced chi-square
175+
# than the MLE since the prior may pull the solution away from the MLE. The MAP however, is not
176+
# a robust estimator of the image statistics. For high dimensional problems like imaging it is often
177+
# not representative of the entire image posterior. For this reason Comrade's main goal is to sample
178+
# the posterior of the image given the data.
192179

193180

194181
# To sample from the posterior we will use HMC and more specifically the NUTS algorithm. For information about NUTS
195182
# see Michael Betancourt's [notes](https://arxiv.org/abs/1701.02434).
196-
# !!! note
197-
# For our `metric` we use a diagonal matrix due to easier tuning.
198-
#-
199183
using AdvancedHMC
200-
out = sample(rng, post, AdvancedHMC.NUTS(0.8), 1000 + 1000, n_adapts = 1000,
201-
saveto = DiskStore(name = joinpath(@__DIR__, "gausstest"), stride = 10),
202-
initial_params = xopt, restart=false);
203-
chain = load_samples(joinpath(@__DIR__, "gausstest"), 1:69*10)
184+
mc = sample(rng, post, AdvancedHMC.NUTS(0.8), 200 + 500, n_adapts = 200,
185+
initial_params = xopt, saveto=DiskStore(;stride=10, name="VLBA_2025"), restart=true);
186+
chain = load_samples(mc)
204187
# !!! warning
205188
# This should be run for longer!
206189
#-
@@ -209,60 +192,73 @@ chain = load_samples(joinpath(@__DIR__, "gausstest"), 1:69*10)
209192
# unable to assess uncertainty in their reconstructions.
210193
#
211194
# To explore our posterior let's first create images from a bunch of draws from the posterior
212-
msamples = skymodel.(Ref(post), chain[301:5:end]);
195+
msamples = skymodel.(Ref(post), chain[501:5:end]);
213196

214197
k = range(1 / size(grid)[1], π/2, length = 512)
215198
fig = Figure()
216199
ax = Axis(fig[1, 1], xscale = log10, yscale = log10)
217-
for i in 301:5:length(chain)
218-
lines!(ax, k, VLBIImagePriors.ampspectrum.(Ref(MarkovPS(chain.sky.ρs[i])), tuple.(k, 0)))
200+
for i in 501:10:length(chain)
201+
lines!(ax, k, VLBIImagePriors.ampspectrum.(Ref(MarkovPS(chain.sky.ρs[i].^2)), tuple.(k, 0)))
219202
end
220203
fig
221204

222205
# The mean image is then given by
223206
using StatsBase
224-
imgs = center_image.(parent.(VLBISkyModels.unmodified.(msamples)))
207+
gpl = refinespatial(grid, 3)
208+
imgs = intensitymap.(msamples, Ref(gpl))
225209
mimg = mean(imgs)
226210
simg = std(imgs)
227-
fig = Figure(; resolution = (700, 400));
228-
axs = [Axis(fig[i, j], xreversed = true, aspect = DataAspect()) for i in 1:2, j in 1:2]
229-
image!(axs[1, 1], mimg, colormap = :afmhot, ); axs[1, 1].title = "Mean"
230-
image!(axs[1, 2], simg ./ (max.(mimg, 1.0e-8)), colormap = :afmhot);axs[1, 2].title = "Std"
231-
image!(axs[2, 1], sample(imgs), colormap = :afmhot, );
232-
image!(axs[2, 2], sample(imgs), colormap = :afmhot, );
211+
fig = Figure(; size = (500, 300));
212+
crange = (5e-6, 5e-2)
213+
axs = [Axis(fig[i, j], xreversed = true, aspect = DataAspect()) for i in 1:1, j in 1:2]
214+
image!(axs[1, 1], mimg, colormap = :afmhot, colorscale=log10, colorrange=crange); axs[1, 1].title = "Mean"
215+
image!(axs[1, 2], simg ./ (max.(mimg, 1.0e-12)), colormap = :afmhot);axs[1, 2].title = "Fractional Uncertainty"
233216
hidedecorations!.(axs)
234217
fig |> DisplayAs.PNG |> DisplayAs.Text
235218

236-
gpl = imagepixels(μas2rad(100.0), μas2rad(100.0), 128, 128)
237-
pimgs = regrid.(imgs, Ref(gpl))
219+
# We can also compare the Comrade reconstruction to the CLEAN reconstruction of the same data.
220+
cleanf = Base.download("https://www.bu.edu/blazars/VLBA_GLAST/1308/1308+326Q.2021-03-19.IMAP.gz")
221+
# By default this will load the clean components with the beam defined in the FITS header.
222+
mcl = load_clean_components(cleanf)
223+
# We can also choose the load the clean components with a user-defined beam.
224+
mcl_50 = load_clean_components(cleanf, modify(Gaussian(), Stretch(beamsize(dlcamp) / 4 / fwhmfac)))
225+
226+
# Now we can produce the CLEAN images on the same grid as our Comrade reconstruction.
227+
cleanimg = intensitymap(mcl, gpl)
228+
cleanimg25 = intensitymap(mcl_50, gpl)
229+
230+
fig = Figure(; size = (900, 350));
231+
axs = [Axis(fig[1, j], xreversed = true, aspect = DataAspect()) for j in 1:3]
232+
image!(axs[1], mimg, colormap = :afmhot, colorscale=log10, colorrange=crange); axs[1].title = "Comrade Mean"
233+
image!(axs[2], max.(cleanimg, 1e-20), colormap = :afmhot, colorscale=log10, colorrange=crange); axs[2].title = "CLEAN (Nominal beam)"
234+
image!(axs[3], max.(cleanimg50, 1e-20), colormap = :afmhot, colorscale=log10, colorrange=crange); axs[3].title = "CLEAN (25% beam)"
235+
hidedecorations!.(axs)
236+
fig |> DisplayAs.PNG |> DisplayAs.Text
238237

239-
fig = Figure(;resolution=(600, 400))
238+
# From the plot you can see that the Comrade reconstruction is significantly superresolved compared
239+
# to the CLEAN reconstruction with the nominal beam. If we use a smaller beam for CLEAN we see
240+
# a reconstruction that is more similar to Comrade. However, unlike CLEAN Comrade automatically
241+
# infers the effective resolution from the data itself and does not require a restoring beam.
242+
243+
# Additionally, Comrade allows us to fully explore the distributions of images that are consistent
244+
# with the data. For example, we can plot a few random samples from the posterior to see the
245+
# variety of images that are consistent with the data.
246+
fig = Figure(;resolution=(800, 450))
240247
axs = [Axis(fig[i, j], xreversed = true, aspect = DataAspect()) for i in 1:2, j in 1:3]
241248
map(enumerate(axs)) do (i, ax)
242249
hidedecorations!(ax)
243-
image!(ax, pimgs[i], colormap = :afmhot)
244-
text!(ax, 0.05, 0.9, text="χ²= $(round(mean(chi2(post, chain[300:5:end][i]; reduce=true)); digits=2))", space=:relative, color=:white)
250+
image!(ax, sample(imgs), colormap = :afmhot, colorscale=log10, colorrange=crange)
251+
text!(ax, 0.05, 0.9, text="χ²= $(round(mean(chi2(post, chain[51:5:end][i]; reduce=true)); digits=2))", space=:relative, color=:white)
245252
end
253+
axcl = Axis(fig[1:2, 4], xreversed = true, aspect = DataAspect())
254+
hidedecorations!(axcl)
255+
image!(axcl, max.(cleanimg25, 1e-20), colormap = :afmhot, colorscale=log10, colorrange=crange)
256+
axcl.title = "CLEAN (25% beam)"
257+
Label(fig[0, 1:3], "Comrade Post. Samples", tellheight=true)
258+
rowgap!(fig.layout, 1, 0.0)
246259
fig
247260

248-
249-
# Now let's see whether our residuals look better.
250-
fig = Figure(; size = (800, 300))
251-
res = Comrade.residuals(post, chain[end])
252-
ax1, = baselineplot(fig[1, 1], res[1], :uvdist, :res, label = "MAP residuals", axis = (ylabel = "LCA Normalized Residuals", xlabel = "uvdist (Gλ)"))
253-
ax2, = baselineplot(fig[1, 2], res[2], :uvdist, :res, label = "MAP residuals", axis = (ylabel = "CP Normalized Residuals", xlabel = "uvdist (Gλ)"))
254-
ax1.title = "χ²ᵣ = $(chi2(post, chain[end]; reduce=true)[1])"
255-
ax2.title = "χ²ᵣ = $(chi2(post, chain[end]; reduce=true)[2])"
256-
for s in sample(chain[201:end], 10)
257-
rs = Comrade.residuals(post, s)
258-
baselineplot!(ax1, rs[1], :uvdist, :res, color = :grey, alpha = 0.2, label = "Posterior Draw")
259-
baselineplot!(ax2, rs[2], :uvdist, :res, color = :grey, alpha = 0.2, label = "Posterior Draw")
260-
end
261-
axislegend(ax1, merge = true)
262-
fig |> DisplayAs.PNG |> DisplayAs.Text
263-
264-
265-
# And viola, you have a quick and preliminary image of M87 fitting only closure products.
266-
# For a publication-level version we would recommend
267-
# 1. Running the chain longer and multiple times to properly assess things like ESS and R̂ (see [Geometric Modeling of EHT Data](@ref))
268-
# 2. Fitting gains. Typically gain amplitudes are good to 10-20% for the EHT not the infinite uncertainty closures implicitly assume
261+
# In summary, we have demonstrated how to use Comrade to reconstruct VLBA data of an AGN
262+
# using only closure quantities. Additionally, we have shown how to use a Markov Random Field
263+
# expansion to model the power spectrum of the AGN. This allows us to model more complex
264+
# structures in the AGN jet and infer the power spectrum directly from the data.

0 commit comments

Comments
 (0)