@@ -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
2326using Comrade
2427using LinearAlgebra
2528LinearAlgebra. 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.
2931using 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`
7972function 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
9383end
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.
11499fwhmfac = 2 * sqrt(2 * log(2 ))
115- mpr = modify(Gaussian(), Stretch(3 * beamsize(dlcamp) / fwhmfac))
100+ mpr = modify(Gaussian(), Stretch(beamsize(dlcamp) / 4 / fwhmfac))
116101imgpr = 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)
121106skymeta = (; 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.
130111cprior = 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- dρ = 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
167153using CairoMakie
168154using 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 ));
173162DisplayAs. 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.
177166res = Comrade. residuals(post, xopt)
178167fig = Figure(; size = (800 , 300 ))
179168plotfields!(fig[1 , 1 ], res[1 ], :uvdist, :res);
180169plotfields!(fig[1 , 2 ], res[2 ], :uvdist, :res);
181170fig |> 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- # -
199183using 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
214197k = range(1 / size(grid)[1 ], π/ 2 , length = 512 )
215198fig = Figure()
216199ax = 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 )))
219202end
220203fig
221204
222205# The mean image is then given by
223206using StatsBase
224- imgs = center_image.(parent.(VLBISkyModels. unmodified.(msamples)))
207+ gpl = refinespatial(grid, 3 )
208+ imgs = intensitymap.(msamples, Ref(gpl))
225209mimg = mean(imgs)
226210simg = 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"
233216hidedecorations!.(axs)
234217fig |> 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 ))
240247axs = [Axis(fig[i, j], xreversed = true , aspect = DataAspect()) for i in 1 : 2 , j in 1 : 3 ]
241248map(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)
245252end
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 )
246259fig
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