to_distribution and to_submodel

Both functions embed one probabilistic program in another. to_distribution converts a supported model representation into a distribution over its latent variables. to_submodel instead evaluates a DynamicPPL model and yields its return value while recording its latent variables separately.

Currently, to_distribution supports only Stan through BridgeStan; to_submodel accepts only DynamicPPL models.

Stan example

Load BridgeStan and pass Stan source to to_distribution. The first call for a given program and set of options requires a BridgeStan toolchain; identical calls reuse the cached distribution.

using AbstractPPL
using ADTypes: AutoForwardDiff
using BridgeStan, Distributions, DynamicPPL, LogDensityProblems
using ForwardDiff

const STAN = raw"""
parameters {
  real location;
  real<lower=0> scale;
  simplex[3] weights;
  ordered[2] cutpoints;
}
model {
  location ~ normal(0, 1);
  scale ~ lognormal(0, 1);
  weights ~ dirichlet(rep_vector(1, 3));
  cutpoints ~ normal(0, 2);
}
"""

@model function demo(stan, y)
    params ~ to_distribution(stan)
    return y ~ Normal(params[1], params[2])
end

ldf = LogDensityFunction(demo(STAN, 0.4))
u = zeros(LogDensityProblems.dimension(ldf))
prepared = AbstractPPL.prepare(
    AutoForwardDiff(), u -> LogDensityProblems.logdensity(ldf, u), u
)
logdensity, gradient = AbstractPPL.value_and_gradient!!(prepared, u)
(-8.239370568253582, [0.4, -0.8399999999999999, 0.0, 0.0, -0.25, 0.75])

The left-hand side receives Stan's constrained parameters as a flat vector in declaration order. In this example, params[1] is location and params[2] is scale. The data and seed keywords configure model construction; stanc_args and make_args configure the build. BridgeStan supplies no sampler, so InitFromPrior uses DynamicPPL's uniform initializer in unconstrained space.

API

DynamicPPL.to_distributionFunction
to_distribution(model)

Convert model to a distribution for use on the right-hand side of ~.

In variables ~ to_distribution(model), variables receives the latent variables represented by the resulting distribution. The concrete representation and density depend on the method for typeof(model). This differs from to_submodel, which assigns a wrapped model's return value to the left-hand side and records its latent variables separately.

source
DynamicPPL.to_submodelFunction
to_submodel(model::Model[, auto_prefix::Bool])

Wrap model for use on the right-hand side of ~.

In value ~ to_submodel(model), model is evaluated, its return value is assigned to value, and its latent variables are recorded separately in the surrounding trace. By default, their names are prefixed with the left-hand side: a latent variable x becomes value.x. This differs from to_distribution, which assigns the represented latent variables themselves to the left-hand side.

Conceptually, to_submodel(model) is a returned_value(model) wrapper: its value is the model's return value, not its latent variables.

Submodel is not a Distribution; it provides this tilde behavior but no standalone logpdf method.

Warning

Operations normally associated with left ~ right, such as condition, do not necessarily work with to_submodel.

Warning

Keep auto_prefix=true unless the wrapped model has been explicitly prefixed. Disabling automatic prefixing can make latent-variable names collide.

Arguments

  • model::Model: the model to wrap.
  • auto_prefix::Bool=true: whether to prefix the model's latent variables with the left-hand side of ~.

Examples

julia> using DynamicPPL, Distributions

julia> @model function demo1(x)
           x ~ Normal()
           return 1 + abs(x)
       end;

julia> @model function demo2(x, y)
            a ~ to_submodel(demo1(x))
            return y ~ Uniform(0, a)
       end;

When sampling from demo2(missing, 0.4), the latent variable x is prefixed with a, the left-hand side of the tilde:

julia> model = demo2(missing, 0.4);

julia> haskey(rand(model), @varname(a.x))
true

The variable a receives the return value of demo1 and can be used in subsequent lines, as in the definition of y above.

We can verify that the log joint probability of the model accumulated in vi is correct:

julia> accs = setacc!!(OnlyAccsVarInfo(), RawValueAccumulator(false));

julia> _, accs = init!!(model, accs, InitFromPrior(), UnlinkAll());

julia> x = get_raw_values(accs)[@varname(a.x)];

julia> getlogjoint(accs) ≈ logpdf(Normal(), x) + logpdf(Uniform(0, 1 + abs(x)), 0.4)
true

Without automatic prefixing

If auto_prefix=false, the submodel's latent-variable names are unchanged.

julia> @model function demo1(x)
           x ~ Normal()
           return 1 + abs(x)
       end;

julia> @model function demo2_no_prefix(x, z)
            a ~ to_submodel(demo1(x), false)
            return z ~ Uniform(-a, 1)
       end;

julia> model = demo2_no_prefix(missing, 0.4);

julia> haskey(rand(model), @varname(x))  # here we just use `x` instead of `a.x`
true

However, not using prefixing is generally not recommended as it can lead to variable name clashes unless one is careful. For example, if the same submodel is used multiple times in a model, not using prefixing will lead to variable name clashes.

One can manually specify a prefix using prefix(::Model, prefix_varname):

julia> @model function demo2(x, y, z)
            a ~ to_submodel(prefix(demo1(x), @varname(sub1)), false)
            b ~ to_submodel(prefix(demo1(y), @varname(sub2)), false)
            return z ~ Uniform(-a, b)
       end;

julia> model = demo2(missing, missing, 0.4);

julia> haskey(rand(model), @varname(sub1.x))
true

julia> haskey(rand(model), @varname(sub2.x))
true
source
DynamicPPL.prefixFunction
prefix(ctx::AbstractContext, vn::VarName)

Apply the prefixes in the context ctx to the variable name vn.

source
prefix(model::Model, x::VarName)
prefix(model::Model, x::Val{sym})
prefix(model::Model, x::Any)

Return model but with all random variables prefixed by x, where x is either:

  • a VarName (e.g. @varname(a)),
  • a Val{sym} (e.g. Val(:a)), or
  • for any other type, x is converted to a Symbol and then to a VarName. Note that this will introduce runtime overheads so is not recommended unless absolutely necessary.

Examples

julia> using DynamicPPL: prefix

julia> @model demo() = x ~ Dirac(1)
demo (generic function with 2 methods)

julia> rand(prefix(demo(), @varname(my_prefix)))
VarNamedTuple
└─ my_prefix => VarNamedTuple
                └─ x => 1

julia> rand(prefix(demo(), Val(:my_prefix)))
VarNamedTuple
└─ my_prefix => VarNamedTuple
                └─ x => 1
source