Monadic Programs¶
What is a MonadicProgram?¶
A
MonadicProgram
is a probabilistic program specified as a sequence of bind and let
steps. It defines a
ContinuousMorphism
from a domain to a codomain via monadic
composition
(Kleisli bind).
The program syntax mirrors probabilistic programming languages (Pyro, NumPyro, Stan):
program name : domain -> codomain
sample x_1 <- morphism_1
sample x_2 <- morphism_2(x_1)
let y = x_1 + x_2
observe z <- morphism_3(y)
return y
Each sample ... <- ... step draws from a conditional distribution
and binds the result. The observe keyword conditions the program on
an external observation.
Program structure¶
A program is an
nn.Module
that, when called, executes forward ancestral sampling:
from quivers.continuous import Draw, MonadicProgram
from quivers.continuous.families import ConditionalNormal
from quivers.continuous.spaces import Euclidean
from quivers.core.objects import FinSet
import torch
Unit = FinSet(name="Unit", cardinality=1)
R1 = Euclidean(name="R1", dim=1)
prior = ConditionalNormal(Unit, R1) # x ~ Normal(0, 1)
likelihood = ConditionalNormal(R1, R1) # y ~ Normal(x, 1)
program = MonadicProgram(
Unit,
R1,
steps=[
Draw(names=("x",), morphism=prior), # x <- prior
Draw(names=("y",), morphism=likelihood, args=("x",)), # y <- likelihood(x)
],
return_vars=("y",),
)
# Forward pass: sampling
samples = program.rsample(
torch.zeros(4, dtype=torch.long), sample_shape=torch.Size([100])
) # shape (100, 4, 1)
# Log joint: sum_i log p(z_i | pa(z_i)) given every bound variable
x_val = torch.randn(4, 1)
y_val = torch.randn(4, 1)
log_joint = program.log_joint(
torch.zeros(4, dtype=torch.long),
{"x": x_val, "y": y_val},
)
Bind steps¶
A bind step sample x <- f or sample x <- f(y, z) samples from a morphism,
optionally conditioned on previous variables.
Single bind:
Conditioned bind:
Destructuring tuple bind (stacked along feature dimension):
The variable names on the left side are bound in the environment.
An indexed bind sample v : A <- F(args) declares v as an
\(A\)-indexed plate of independent draws.
Let steps¶
Deterministic binding:
Supports literals, variable references, arithmetic, and the let-expression primitives; see DSL Programs and Let-Expressions for the full primitive list.
Observe keyword¶
Condition the program on an observation:
This marks y as conditioned. During inference, observations
clamp these variables to external values. An indexed-observe
observe r : N <- F(args) accumulates a batched likelihood over
the index set N, with the response buffer supplied via the
runtime observations dict.
Return statement¶
Specify the program output. Single or tuple:
The return value's shape determines the codomain. Tuples are bare-positional; the resulting product space's components are ordered by tuple position.
Domains and codomains¶
Domains can be:
- A single
FinSetorContinuousSpace. - A product of sets / spaces:
X * Y * Z. - Named parameters: the domain is the product, but variables can refer to sub-components.
Codomains are determined by the return statement shape.
rsample and log_joint¶
Two key operations on a compiled program:
rsample(x, sample_shape=(), observations=None)¶
Generate samples by executing the program:
x = torch.zeros(5, dtype=torch.long)
samples = program.rsample(x, sample_shape=torch.Size([1000]))
# shape: (1000, 5, codomain_dim)
Sequential ancestral sampling: each draw step samples, previous
draws are available to subsequent steps. observations is an
optional dict[str, torch.Tensor] clamping observed sites to
runtime data.
log_joint(x, intermediates)¶
Compute \(\log p(z_1, \ldots, z_k \mid x) = \sum_i \log p(z_i \mid \mathrm{pa}(z_i))\) given all bound-variable values:
x = torch.randn(5)
intermediates = {"z": z_value, "y": y_value} # every bound variable
log_pjoint = program.log_joint(x, intermediates)
log_joint is the core kernel summed across the program's draw /
plate-draw / observe steps, used inside
ELBO.forward
after the guide samples latents.
The observations dict¶
Indexed-observe steps (observe r : N <- F(args)) read their
response buffers from a runtime
observations: dict[str, torch.Tensor], keyed by the
observed-variable name. The dict is passed as the observations
kwarg to
MonadicProgram.rsample
and as the final positional argument to
ELBO.forward /
SVI.step:
observations = {
"cloze_resp": cloze_tensor, # shape (n_cloze_resp,)
"prop_resp": prop_tensor, # shape (n_prop_resp,)
}
samples = program.rsample(x, observations=observations)
loss = elbo(model, guide, x, observations)
There is no .qvr-level data block; the tensor sources live in
Python at the call site, and the keys must match the response
identifiers declared in the program body.
Named parameters¶
If the domain is a product, name the components via the params
argument so steps can reference them by name:
A = FinSet(name="A", cardinality=3)
B = FinSet(name="B", cardinality=4)
Z = FinSet(name="Z", cardinality=5)
program = MonadicProgram(
A * B,
Z,
steps=[
Draw(names=("x",), morphism=f, args=("a", "b")), # x <- f(a, b)
],
return_vars=("x",),
params=("a", "b"),
)
The program splits the product input along the feature axis at
runtime and binds each slice to the corresponding name in params.
Example: a simple model¶
import torch
from quivers.continuous import Draw, MonadicProgram
from quivers.continuous.families import (
ConditionalNormal,
ConditionalLogitNormal,
)
from quivers.continuous.spaces import Euclidean
from quivers.core.objects import FinSet
Unit = FinSet(name="Unit", cardinality=1)
R1 = Euclidean(name="R1", dim=1)
R2 = Euclidean(name="R2", dim=2)
prior_mu = ConditionalNormal(Unit, R1)
prior_sigma = ConditionalLogitNormal(Unit, R1)
likelihood = ConditionalNormal(R2, R1)
program = MonadicProgram(
Unit,
R1,
steps=[
Draw(names=("mu",), morphism=prior_mu),
Draw(names=("sigma",), morphism=prior_sigma),
Draw(names=("y",), morphism=likelihood, args=("mu", "sigma")),
],
return_vars=("y",),
)
# Use for inference
optimizer = torch.optim.Adam(program.parameters())
Destructuring binds¶
Extract multiple values from a tuple-returning sub-program:
program sub : X -> Y * Y
sample (a, b) <- some_morphism
return (a, b)
program main : X -> Z
sample (u, v) <- sub
sample w <- g(u, v)
return w
The pattern sample (u, v) <- sub destructures the output.
Observation clamping¶
During inference, the
condition()
function clamps observations:
from quivers.inference import condition
# Condition program on external observations
observed_y = torch.tensor([1.0, -0.5, 2.0])
conditioned = condition(program, {"y": observed_y})
# Trace under the conditioning: observed sites are clamped to the data
x = torch.zeros(3, dtype=torch.long)
tr = conditioned.trace(x)
Product domains and outputs¶
For multiple domain inputs, stack along the feature dimension:
The bare-identifier parameters x_val, y_val name the
projections of the product domain. Internally, the domain tensor
is reshaped to match.
Building programs in Python¶
Every program the DSL compiles is assembled from objects the
quivers.continuous package exports, so
a program can be built in Python without writing QVR source. The step
records are the contract:
| Record | Source form | Fields |
|---|---|---|
Draw |
sample x <- f(a, b) |
names, morphism, args |
Observe |
observe y <- f(a, b) |
names, morphism, args |
Let |
let w = expression |
name, value |
Score |
a marginalize block |
name, score |
A draw's args are binding names, or
Indexed
gathers such as mu[group]; None reads the program input. A let's
value is a constant, the name of an earlier binding, or a function
of the environment, which may declare the names it reads through
reading.
The morphisms are the ones the compiler emits: a fixed-parameter
family comes from a make_fixed_* factory such as
make_fixed_normal;
a family whose parameters are bindings is a
MixedInlineDistribution
over the stacked arguments; an indexed draw sample v : A <- F(...) is a
PlateDraw
of one draw per element of A; and an indexed observe is a
VectorisedObserve.
The program below is a hierarchical Normal model with one mean per group, built twice: once from source, and once from the records. The two score every value identically.
import torch
import torch.distributions as D
from quivers.continuous import (
Draw,
Euclidean,
Indexed,
MixedInlineDistribution,
MonadicProgram,
Observe,
PlateDraw,
ProductSpace,
VectorisedObserve,
make_fixed_halfnormal,
make_fixed_normal,
)
from quivers.core import FinSet, Unit
from quivers.dsl import loads
compiled = loads("""
object Group : FinSet 3
object Row : FinSet 6
object Val : Real 1
program model : Row -> Val
sample mu : Group <- Normal(loc=0.0, scale=1.0)
sample sigma <- HalfNormal(scale=1.0)
let m = mu[group]
observe y : Row <- Normal(loc=m, scale=sigma)
return y
export model
""").morphism
Row = FinSet(name="Row", cardinality=6)
Val = Euclidean(name="Val", dim=1)
likelihood = MixedInlineDistribution(
ProductSpace(components=(Val, Val)),
Val,
param_spec=[("var", 1), ("var", 1)],
dist_builder=lambda params: D.Normal(params[0], params[1]),
)
built = MonadicProgram(
Row,
Val,
steps=[
# sample mu : Group <- Normal(0, 1), one mean per group
Draw(
names=("mu",),
morphism=PlateDraw(3, make_fixed_normal(0.0, 1.0, Val), domain=Unit),
),
# sample sigma <- HalfNormal(1)
Draw(names=("sigma",), morphism=make_fixed_halfnormal(1.0, Val)),
# observe y : Row <- Normal(mu[group], sigma)
Observe(
names=("y",),
morphism=VectorisedObserve(likelihood, torch.zeros(6)),
args=(Indexed(name="mu", indices=("group",)), "sigma"),
),
],
return_vars=("y",),
)
values = {
"mu": torch.randn(3, 1),
"sigma": torch.rand(1, 1) + 0.5,
"y": torch.randn(6),
"group": torch.tensor([0, 0, 1, 1, 2, 2]),
}
x = torch.arange(6)
torch.testing.assert_close(compiled.log_joint(x, values), built.log_joint(x, values))
A group-level correlation structure follows the same pattern. A
PlateDraw over an LKJ prior on Cholesky factors draws one factor per
group, whether the prior is the inline family
(make_fixed_lkj_cholesky)
or the LKJCorrelationFactor
morphism:
from quivers.continuous import CholeskyFactor, make_fixed_lkj_cholesky
factors = PlateDraw(
3, make_fixed_lkj_cholesky(2.0, CholeskyFactor(name="L", dim=4)), domain=Unit
)
draw = factors.rsample(torch.zeros(1, dtype=torch.long)) # (3, 16): one factor per group
reference = D.LKJCholesky(4, torch.tensor(2.0)).log_prob(draw.reshape(3, 4, 4)).sum()
torch.testing.assert_close(factors.log_prob(torch.zeros(1), draw).squeeze(), reference)
Integration with the DSL¶
MonadicPrograms are the output of .qvr DSL compilation. The DSL
parser translates:
object X : FinSet 3
object Y : FinSet 4
program my_prog : X -> Y
sample mu <- LogitNormal(mu=0, sigma=1)
sample x <- Normal(loc=mu, scale=1)
return x
export my_prog
into a MonadicProgram instance that can be trained. The full DSL
syntax for programs is documented in
DSL Programs and Let-Expressions.
Where to next¶
- Hierarchical Programs: parametric templates for crossed random intercepts, monotone-spline coefficients, and the grouped marginalization construct for fibred discrete latents.
- Variational Inference: how programs feed into the variational training loop.