A Julia frontend for writing and composing Stan models. Write a model once in Julia syntax; StanBlocks transpiles it to Stan source and — through BridgeStan — hands you a differentiable log-density you can sample or optimise directly. It is a deliberately modest promise: a frontend, not a replacement for Stan. The generated Stan code is part of the product, meant to be read.
using StanBlocks
import PosteriorDB, StanLogDensityProblems, JSON
# Data from PosteriorDB
pdb = PosteriorDB.database()
post = PosteriorDB.posterior(pdb, "earnings-earn_height")
(;earn, height) = (;Dict([Symbol(k)=>v for (k, v) in pairs(PosteriorDB.load(PosteriorDB.dataset(post)))])...)
# The whole model — no `data`/`parameters`/`model` blocks to write by hand
earn_height_model = @slic begin
beta ~ flat(;n=2)
sigma ~ flat(;lower=0.)
earn ~ normal(beta[1]+beta[2]*to_vector(height), sigma)
end
earn_height_posterior = earn_height_model(; earn, height) # bind data
println(stan_code(earn_height_posterior)) # read the Stan
earn_height_problem = stan_instantiate(earn_height_posterior) # compile + differentiatestanc never sees a block you wrote: activity analysis decides what is
data, transformed data, parameters, transformed parameters, model, or
generated quantities, and type/shape/constraint inference fills in every
declaration — for model bodies and user-defined functions (arguments, body,
and return type alike).
StanBlocks is one small compiler with a wide surface. The feature atlas is the canonical works / does-not-work matrix; the highlights:
Activity analysis & inference — automatic block placement; inferred types, shapes, and constraints; a single dependency graph evaluated at several execution times (data, transformed data, parameters, …, generated quantities).
Composition — the reason for a frontend — anonymous sub-models, named
typed-positional sub-models, and post-hoc Base.merge variants that graft new
statements onto an existing model. Cross-validation is a first-class post-hoc
variant (the cv flag), with correct density-taint handling.
Structured data & compiler-owned plate loops — first-class ragged data and
ragged constrained parameters, EachCol / EachRow dense views, and plate
loops over independent per-cell parameters (scalar, fixed-vector, and selected
ragged/constrained cells).
Functions — positional defaults, keyword arguments, varargs, and
higher-order functions (map, broadcasted, sum, …); à-la-Julia multiple
dispatch (on base type + dimensionality, on dimensionality alone, or on a
higher-order function argument via f(::typeof(g)) = …); automatic shape
extraction (f(x::vector[n]) = … makes n available in the body); @deffun
for deterministic control-flow programs; @inline UDFs with caller-scope
mutation; @stan_assert; and return_type_of(f, args...) for transpile-time
type/shape queries.
Closures — see below; a headline capability, especially for scientific models.
Distributions — author your own distribution triad (_lpdf / _lpdfs /
_rng, opted into ~ sampling with @lhs/@lpxf); distribution
higher-order functions weighted, truncated, censored, and
interval_censored; and fused GLM families (normal_id, bernoulli_logit,
poisson_log, neg_binomial_2_log).
Scientific-computing surface — ODE solvers (ode_rk45, ode_bdf, …),
Torsten-style pharmacometrics
signatures, Gaussian-process helpers, and reduce_sum for within-chain
parallelism.
Generated quantities, for free — automatic pointwise log-likelihood and posterior-predictive draws, plus automatic imputation of partly-missing continuous outcomes (Turing-style).
Reflection & ergonomics — executable model descriptors with derived
fit / predict / log-likelihood operations; user-defined types (@usertype,
NamedTuples, ragged carriers); user-written Julia macros expand transparently
inside @slic / @deffun bodies (@views, @., @inbounds, …); and
approximate automatic formatting à la
Blue.
Closures ship, and they are what make scientific models — ODEs above all — read like ordinary Julia:
closure_model = @slic (; ts, yobs) begin
lambda ~ exponential(1)
y = ode_rk45(
(t, state) -> -lambda * state,
[1.0], 0.0, to_array_1d(ts),
)
yobs ~ normal(y[1][1], 0.05)
endThe closure (t, state) -> -lambda * state is lifted into a generated Stan
function. Captured data and parameters become explicit trailing arguments, and
likelihood activity follows through those captures — so lambda stays a live
parameter of the model even though it is only referenced from inside the closure
handed to the solver. No runtime function object ever reaches Stan.
StanBlocks descends from SlicStan: the
same destination (clean Stan), a different route. One traced model is lowered by
four compiler verbs — capture, forward!, backward!, and
distribute! — into a single dependency graph, then emitted (via
Base.show) at each execution time. Downstream, the compiled log-density is
differentiated with Mooncake.
Almost anything expressible in Julia can be transpiled to Stan. Whether you should is a separate question: unless Stan is meaningfully faster than Julia + Mooncake for your model, staying in Julia keeps many advantages.
See the user guide for @deffun,
@inline UDFs, sub-models, posterior pointwise likelihood / predictive draws,
the full built-in catalog, and a regression-DSL-scale worked example.
The boundary is intentional and explicit: @slic is flat declarative code,
deterministic control flow belongs in @deffun, and plate is an
independent-cell parameter loop rather than a general scan. Not supported:
- ordinary
for/if/while/&&/||/ ternary / comprehensions in a@slicbody — move them into a@deffun, or use a vectorised form; - a scan whose cell
iconsumes celli-1; - matrix-valued
platecells and cell-to-cell dependencies; - general 3-D-and-higher Julia containers;
- missing predictors or discrete outcomes; ragged integer observations;
target +=in a UDF, and top-level control flow / mutability;- auto-transpiling Julia functions not defined via
@deffun; - full Julia-runtime parity for Stan's probability / RNG / ODE / parallel built-ins.
See the authoring support guide for the full works / does-not-work matrix.
@deffun definitions are Stan-only by default. Add @juliacompat to an
eligible deterministic, bodyful bare-symbol definition to also install one
callable Julia method from the user-facing signature, while keeping the same
Stan lowering; @stanonly is the explicit opt-out marker. Signature-only /
type-token glue, qualified extensions of existing functions, and
probability / RNG / ODE-family definitions (*_lpdf, *_lpmf, *_rng,
ode_*, …) remain Stan-only.
for / while / if / && / || / ternary / comprehensions are not allowed
in @slic bodies. Move that logic into a @deffun body, or use a vectorised
form.