The @slic macro
Historical design document This page preserves the original @slic design overview, including its future-tense proposals. See Authoring and feature support and the Feature atlas for the current executable surface. :::
Reading the original proposal today
This document is useful because it states the design goals before the compiler surface settled. The table below separates those goals from today's spelling:
| Original theme | Current status |
|---|---|
| Reusable model components | Shipped through anonymous and named @slic submodels; inputs are rebound with kwargs and results compose through ~. |
| Post-hoc adjustment | Base.merge(model, quote ... end) replaces statements by their bound name; Base.merge(model, (; x=value)) removes matching statements and fixes those names as data. |
| Parameter pinning | Shipped for data/input rebinding with model(; name=value); it is not a general deconditioning operator. |
| Leave-X-out support | Shipped through StanBlocks.stan.maybecv on the held-out dimension/input, with qualifier propagation deciding what is redrawn. |
| Activity analysis | Shipped: statements and prior-only quantities are distributed to the earliest valid Stan block, including generated quantities. |
| Compound declarations and inferred constraints | Shipped for sampling declarations and explicit bare typed parameters; constraints may be inferred from distribution kwargs. |
| Arbitrary Julia control flow in models | Deliberately not shipped. @slic stays straight-line; loops and branches belong in typed @deffun helpers or compiler primitives such as plate. |
| Extensible tracing | Shipped internally through typed tracing and @defsig/@deffun; custom compiler passes are not a stable public plugin API. |
The sections below remain in their original voice. Treat “will support” as a historical proposal and use the links above for the executable, current contract.
Supporting Bayesian Workflow
Code up your model once - simplify, extend and use in multiple contexts efficiently.
Modularity
The aim is to make it easy to iterate on the statistical model.
Reuse model components
Like Turing.jl's submodels (where's the documentation for this?) or SlicStan's functions that declare parameters (first example in the paper).
Getting tired of always coding up the same hierarchical priors? @slic will support reusing model components:
hierarchical_prior = @slic begin
location ~ std_normal()
scale ~ std_lognormal()
x ~ normal(location, scale; n)
return x
end
hierarchical_model = @slic begin
obs_location ~ hierarchical_prior(;n)
obs_scale ~ std_lognormal()
obs ~ normal(obs_location, obs_scale)
endAlternative syntax proposals are appreciated!
Post-hoc model component adjustment
Ever wanted to switch out one prior for another, but didn't want to implement this functionality when coding up your first exploratory model? @slic will support switching out arbitrary model components:
"My first, exploratory model to check that things work"
pooled_model = @slic begin
obs_location ~ std_normal()
obs_scale ~ std_lognormal()
obs ~ normal(obs_location, obs_scale)
end
"Equivalent to the previous implementation of `hierarchical_model` above"
hierarchical_model = Base.merge(pooled_model, quote
obs_location ~ hierarchical_prior(;n)
end)Alternative syntax proposals are appreciated!
Post-hoc model component pinning
Ever wanted to pin hierarchical scale parameters? Use an explicit fixed-value merge:
"The `hierarchical_model` with the hierarchical scale parameter fixed to 1."
semihierarchical_model = Base.merge(
hierarchical_model,
(; obs_location_scale=1.0),
)This removes the matching top-level sampling or assignment statement and binds the supplied value as model data. In contrast, hierarchical_model(; obs_location_scale=1.0) is ordinary conditioning: the sampling statement remains and contributes its likelihood. Structural quoted merges and fixed bindings can be combined as Base.merge(model, quote ... end, (; x=value)).
Leave-X-out cross-validation support
Getting tired of reimplementing parts of your model to perform e.g. leave-one-subject-out cross-validation? @slic will support automatic model rewrites to perform e.g. automatic leave-one-subject-out cross-validation from the same model implementation that you have used to sample from the posterior:
hierarchical_prior = @slic begin
location ~ std_normal()
scale ~ std_lognormal()
x ~ normal(location, scale; n)
return x
end
hierarchical_model = @slic begin
n = maximum(subject_idx)
obs_intercept ~ hierarchical_prior(;n)
obs_slope ~ hierarchical_prior(;n)
obs_location = obs_intercept + dot(covariates, obs_slope)
obs_scale ~ std_lognormal()
obs[subject_idx] ~ normal(obs_location, obs_scale)
end
samples = nuts_draws(hierarchical_model(;subject_idx, covariates, obs))
"Alternative 1: Constructing just the CV model"
cv_model = cv(
hierarchical_model;
subject_idx=held_out_subject_idx,
covariates=held_out_covariates,
obs=held_out_obs
)
"Alternative 2: Constructing the CV model and computing the necessary quantities"
cv_info = cv(
samples;
subject_idx=held_out_subject_idx,
covariates=held_out_covariates,
obs=held_out_obs
)In the above example, the hierarchical parameters will be fixed and reused from samples, while the subject specific parameters will be automatically resampled inedependently for each draw to compute the likelihood. Determining which parameters get fixed/resued and which ones get resampled can be automatically determined by tracing through the model.
Alternative syntax proposals are appreciated!
Computational efficiency
The aim is to make it easy to write computationally efficient code.
Supports activity analysis to avoid redundant computation
Like SlicStan, @slic will support automatically determining whether model components are
data - passed to the model,
transformed data - need to be computed only once per model instantiation/conditioning/deconditioning,
parameters - contribute to the posterior (MCMC-)dimension and potentially need to be transformed appropriately,
transformed parameters - have to be computed every gradient evaluation, because they do affect the likelihood,
generated quantities - have to be computed only once per sample and can be sampled independently, because they do not affect the likelihood.
This allows for the natural model specification in a single place, while not sacrificing any performance.
In the below model,
if we do not specify anything, every model component will be sampled independently,
if we specify only
obs_locationorobs_scale, every other model component will still be sampled independently, andif we specify only
obs, bothobs_locationandobs_scalebecome parameters, andobs_likelihoodandobs_predictionget automatically added as generated quantities.
@slic begin
obs_location ~ std_normal()
obs_scale ~ std_lognormal()
obs ~ normal(obs_location, obs_scale)
endProvides efficient primitives
The most efficient way to compute intermediate functional quantities will depend on the type, shape and potentially activity of the arguments passed to the function. We provide efficient primitives which explot this, e.g.:
normal_lpdf(y::AbstractVector, location::AbstractVector, scale::AbstractVector) = "Do something which requires computing log.(scale)."
normal_lpdf(y::AbstractVector, location::AbstractVector, scale::Real) = "Do something which only requires computing log(scale) once."Avoids unnecessary allocations
Like Stan, @slic models will use matrix expressions to keep the memory footprint low. This will (should) be more efficient than using something like a bump allocator and just-allocate-and-reuse away.
Expressiveness
The aim is to make it easy to write correct code.
Compound declare-distribute statements
Like Turing.jl's unified way of defining parameters, unlike Stan's split-across-blocks way of defining parameters.
Automatically inferred constraints
Like Turing.jl: a parameter's constraints are automatically inferred from the support of its prior - but can of course be adjusted:
@slic begin
"Unconstrained"
obs_intercept ~ std_normal()
"Constrained to be positive"
obs_scale ~ std_lognormal()
"Constrained to be positive"
obs_slope ~ std_normal(;lower=0.)
obs ~ normal(obs_intercept + obs_slope * x, obs_scale)
endAutomatically inferred - but optionally constrained - types
Like Turing.jl: the type of any model expression can be specified, but it need not be specified.
Supports tracing through a lot of Julia syntax
We will support tracing through a lot of common Julia syntax, like broadcasting, generators, maps and do blocks.
Supports opaquely handling arbitrary Julia syntax
While tracing will be limited to a subset of Julia syntax, "unknown" user defined functions will simply not be traced recursively. We will not allow these opaque functions to introduce model parameters, and we will "assume the worst" for the activity analysis.
Extendability
The aim is to make it easy to extend @slic's functionality.
Custom tracing passes
The data/parameter/generated-quantities activity analysis comes out of two passes through the model (one forward, one reverse).
The cross-validation activity analysis comes out of another pass through the model.
There is nothing stopping us from allowing additional or alternative passes, and there should not be anything stopping you from implementing these custom passes.
One possible custom pass would e.g. translate the @slic model to a Stan program.
The complete standalone prototype that accompanied this design document is preserved as slicppl.jl.