Prior Predictive Checks

The prior predictive distribution of a Bayesian model is what you get by sampling parameters directly from the priors and calculating where the model would place the data. For example, if sampling from relative astrometry, the prior predictive model is the distribution of (simulated) astrometry points corresponding to orbits drawn from the prior. For radial velocity data, these would be simulated RV points based on an RV curve drawn from the priors.

To generate a prior predictive distribution, one first needs to create a model. We will use the model and sample data from the Fit Astrometry tutorial:

using Octofitter
using CairoMakie
using PairPlots
using Distributions

astrom_dat = Table(;
    epoch= [50000.,50120,50240,50360,50480,50600,50720,50840,],
    ra = [-505.764,-502.57,-498.209,-492.678,-485.977,-478.11,-469.08,-458.896,],
    dec = [-66.9298,-37.4722,-7.92755,21.6356, 51.1472, 80.5359, 109.729, 138.651,],
    σ_ra = fill(50.0, 8),
    σ_dec = fill(50.0, 8),
    cor = fill(0.0, 8)
)

A = Body(
    name="A",
    variables=@variables begin
        mass ~ truncated(Normal(1.2, 0.1), lower=0.1)  # M⊙
    end
)

b = Body(
    name="b",
    about=A,
    variables=@variables begin
        a ~ truncated(Normal(10, 4), lower=0, upper=100)
        e ~ Uniform(0.0, 0.5)
        i ~ Sine()
        ω ~ UniformCircular()
        Ω ~ UniformCircular()
        θ ~ UniformCircular()
        epoch = 50420.0  # reference epoch for θ. Choose an MJD date near your data.
    end
)

astrom_obs = RelAstromObs(astrom_dat; target=b, ref=A, name="relastrom")

sys = System(
    name="Tutoria",
    bodies=[A, b],
    observations=[astrom_obs],
    variables=@variables begin
        plx ~ truncated(Normal(50.0, 0.02), lower=0.1)
    end
)
[ Info: [Tutoria] observing_geometry = false (auto): worst accumulated bias 0.00151σ, 66.1× inside the 0.1σ limit — over 300 prior draws (seed 0xc70f177e5000001)
[ Info: [Tutoria] barycentric_lighttime = false (auto): changes no prediction at all — over 300 prior draws (seed 0xc70f177e5000001)
Note

θ is the planet's position angle at the reference epoch, so θ + epoch fixes the orbital phase. Both are orbital-element keywords in their own right, and PlanetOrbits does the conversion to periastron passage for you.

We can now draw one sample from the prior. generate_from_params returns a whole new system whose observations hold simulated data — one entry per observation in the original system, in the same order:

prior_draw_system = generate_from_params(sys)
prior_draw_astrometry = prior_draw_system.observations[1]
RelAstromObs "relastrom"  b vs A
Table with 6 columns and 8 rows:
     epoch    ra        dec       σ_ra  σ_dec  cor
   ┌──────────────────────────────────────────────
 1 │ 50000.0  -324.528  -321.731  50.0  50.0   0.0
 2 │ 50120.0  -354.887  -307.104  50.0  50.0   0.0
 3 │ 50240.0  -383.568  -291.023  50.0  50.0   0.0
 4 │ 50360.0  -410.546  -273.647  50.0  50.0   0.0
 5 │ 50480.0  -435.807  -255.126  50.0  50.0   0.0
 6 │ 50600.0  -459.346  -235.595  50.0  50.0   0.0
 7 │ 50720.0  -481.162  -215.18   50.0  50.0   0.0
 8 │ 50840.0  -501.261  -193.996  50.0  50.0   0.0
Looking an observation up by name

The simulated observations come back in the order they were listed. If you have several and would rather look one up by name than by position:

only(filter(o -> Octofitter.likelihoodname(o) == "relastrom",
            collect(prior_draw_system.observations)))

And plot the generated astrometry:

Makie.scatter(prior_draw_astrometry.table.ra, prior_draw_astrometry.table.dec,color=:black, axis=(;autolimitaspect=1,xreversed=true))
Example block output

We can repeat this many times to get a feel for our chosen priors in the domain of our data:

using Random
Random.seed!(1)


fig = Figure()
ax = Axis(
    fig[1,1], xlabel="ra offset [mas]", ylabel="dec offset [mas]",
    xreversed=true,
    aspect=1
)
for i in 1:50
    prior_draw_system = generate_from_params(sys)
    prior_draw_astrometry = prior_draw_system.observations[1]
    Makie.scatter!(
        ax,
        prior_draw_astrometry.table.ra,
        prior_draw_astrometry.table.dec,
        color=Makie.cgrad(:turbo)[i/50],
    )
end


Makie.errorbars!(ax,astrom_dat.ra,astrom_dat.dec,astrom_dat.σ_dec,color=:black,linewidth=3)
Makie.errorbars!(ax,astrom_dat.ra,astrom_dat.dec,astrom_dat.σ_ra,direction=:x,color=:black,linewidth=3)

fig
Example block output

The heavy black crosses are our actual data, while the colored ones are simulations drawn from our priors. Notice that our real data lies at a greater separation than most draws from the prior? That might mean the priors could be tweaked.

Noiseless versus noisy draws

By default generate_from_params returns the noiseless model prediction — exactly where the model says the planet is, with the original uncertainties carried over untouched. That is the right choice when you want to see the spread the priors alone produce, which is what the figure above shows.

For a true prior predictive distribution — draws from p(ỹ) = ∫ p(ỹ | θ) p(θ) dθ, including measurement noise — pass add_noise=true. Each observation then adds a draw from its own noise model, including any jitter the parameters specify:

Random.seed!(2)

fig = Figure()
ax = Axis(
    fig[1,1], xlabel="ra offset [mas]", ylabel="dec offset [mas]",
    xreversed=true, aspect=1
)
for i in 1:50
    draw = generate_from_params(sys, drawfrompriors(sys); add_noise=true)
    tbl = draw.observations[1].table
    Makie.scatter!(ax, tbl.ra, tbl.dec, color=Makie.cgrad(:turbo)[i/50], markersize=6)
end
Makie.errorbars!(ax,astrom_dat.ra,astrom_dat.dec,astrom_dat.σ_dec,color=:black,linewidth=3)
Makie.errorbars!(ax,astrom_dat.ra,astrom_dat.dec,astrom_dat.σ_ra,direction=:x,color=:black,linewidth=3)
fig
Example block output

Here drawfrompriors(sys) is the parameter draw and generate_from_params is the data draw; calling generate_from_params(sys) with no parameters does both in one step.

Sampling the prior directly

Sometimes what you want is not simulated data but the prior over parameters, in the same chain format octofit produces — so that you can run it through octoplot, octocorner, or your own diagnostics side by side with a posterior.

prior_only_model builds that model for you. It replaces every data likelihood with a BlankLikelihood that keeps the same name and the same @variables block, so the resulting chain lines up column for column with a chain from the full model:

prior_model = Octofitter.LogDensityModel(prior_only_model(sys))
prior_chain = octofit(prior_model, iterations=2000, adaptation=1000, verbosity=0)
octocorner(prior_model, prior_chain, small=true)
Example block output
Note

prior_only_model keeps prior-shaped terms — the ~ lines in a @variables block, and the UnitLengthPrior behind each UniformCircular. That is deliberate: they reshape the prior rather than adding data, so a "prior only" model is the one that still has them. prior_only_model(sys; exclude_all=true) drops those as well, which is for evidence bookkeeping (see Bayesian evidence) and not for inference — without that term a UniformCircular variable's (x, y) pair is free to wander onto the origin, where the angle is undefined.