Migrating from v6 to v7
This guide is for you if you wrote nodes and rules against ReactiveMP v6, maintain a package that does, or build graphs with the engine by hand. If you write models with RxInfer, read RxInfer's Migration from v5 to v6 instead: it covers @model, infer and the standard nodes a model uses.
ReactiveMP v7 moves nodes and rules out of the engine into packages of their own. Most of a port is mechanical, and this guide gives each mechanical translation as a before/after pair, in the order a port happens. The v6 half of a pair starts with # v6 and does not run. The v7 half runs when these docs are built, on small nodes declared on this page. If you port code mechanically, as a person or a tool, follow the Checklist for a mechanical port at the end.
For the concepts behind the pairs, read Your first node, the keyword reference and the glossary.
What moved where
v6, in ReactiveMP | v7 |
|---|---|
| variables, factor nodes, messages, marginals, the free energy | ReactiveMP, the engine |
@node, @rule, @marginalrule, @average_energy, @call_rule, rule lookup | MessagePassingRulesBase |
@test_rules, @test_marginalrules | MessagePassingRulesTestUtils |
| the standard nodes and their rules | StandardMessagePassingRules |
| the approximation methods | MessagePassingRulesApproximations |
| the Delta node | DeltaMessagePassingRules |
| every other node | a package of its own (Node packages) |
Loading a package is enough for the engine to find its rules. These names moved with their packages:
| v6 | v7 |
|---|---|
Unscented, UT, UnscentedTransform, Linearization, GaussHermiteCubature, ghcubature, AbstractApproximationMethod, approximation_name, approximation_short_name | the same names, exported by MessagePassingRulesApproximations: Unscented, Linearization, GaussHermiteCubature, ghcubature, AbstractApproximationMethod, approximation_name, approximation_short_name |
DeltaFn, CVIProjection, CVISamplingStrategy, FullSampling, MeanBased | the same names, exported by DeltaMessagePassingRules: DeltaFn, CVIProjection, CVISamplingStrategy, FullSampling, MeanBased; CVIProjection's rules load with ExponentialFamilyProjection |
DeltaFnNode, the Delta node's own node type | none: a Delta node is an ordinary FactorNode of DeltaFn, which folds its constant and data inputs into its function (Static inputs) |
Autoregressive, an alias of AR | the same alias, exported by AutoregressiveMessagePassingRules |
GaussianMixture, an alias of NormalMixture | the same alias, exported by StandardMessagePassingRules |
Flow's compile, nr_params, getlayers | the same names, exported by FlowMessagePassingRules |
Flow's getmodel(meta), getapproximation(meta) | FlowMessagePassingRules.getmodel(algo) and FlowMessagePassingRules.getmethod(algo) on a FlowApproximation, public and called qualified |
Declaring a node
A node is declared with @define_factor_node, keyword by keyword. Aliases are written on the interface, and the first interface is the output, as before.
# v6
@node MyGaussian Stochastic [out, (μ, aliases = [mean]), (τ, aliases = [precision])]using MessagePassingRulesBase, BayesBase, ExponentialFamily, Distributions
import MessagePassingRulesBase: annotate!, getannotation
struct MyGaussian end
@define_factor_node(
node = MyGaussian,
type = Stochastic,
interfaces = [:out, (:μ, aliases = [:mean]), (:τ, aliases = [:precision])],
)A node with a variable number of edges of one kind, which v6 wrote by hand with ManyOf and a node type of its own, declares an interface group, :in...; see Groups. The checks a node's constructor made become keywords of the declaration: matched_groups, min_group_length and factorisation (keyword reference).
Message update rules
@rule becomes @define_message_update_rule. The node, the target and the inputs are keywords. The inputs are typed like the old arguments: the message m_x becomes m[:x], and the marginal q_x becomes q[:x]. The body is a lambda over the slots it needs. Marginalisation is gone: a rule belongs to an algorithm, the node's default unless it says otherwise.
# v6
@rule MyGaussian(:out, Marginalisation) (m_μ::PointMass, m_τ::PointMass) = begin
return NormalMeanPrecision(mean(m_μ), mean(m_τ))
end
@rule MyGaussian(:out, Marginalisation) (q_μ::Any, q_τ::Any) = NormalMeanPrecision(mean(q_μ), mean(q_τ))@define_message_update_rule(
node = MyGaussian, target = :out,
args = (m[:μ]::PointMass, m[:τ]::PointMass),
body = (args) -> NormalMeanPrecision(mean(args.m[:μ]), mean(args.m[:τ])),
)
@define_message_update_rule(
node = MyGaussian, target = :out,
args = (q[:μ]::Any, q[:τ]::Any),
body = (args) -> NormalMeanPrecision(mean(args.q[:μ]), mean(args.q[:τ])),
)
getresult(call_message_update_rule(MyGaussian, :out; q = (μ = NormalMeanVariance(1.0, 1.0), τ = GammaShapeRate(2.0, 1.0))))ExponentialFamily.NormalMeanPrecision{Float64}(μ=1.0, w=2.0)A joint marginal, q_out_μ in v6, is q[:out, :μ], its members in interface order:
# v6
@rule MyGaussian(:τ, Marginalisation) (q_out_μ::Any,) = begin
m, V = mean_cov(q_out_μ)
return GammaShapeRate(3 / 2, (V[1, 1] - V[1, 2] - V[2, 1] + V[2, 2] + abs2(m[1] - m[2])) / 2)
end@define_message_update_rule(
node = MyGaussian, target = :τ,
args = (q[:out, :μ]::Any,),
body = (args) -> begin
m, V = mean_cov(args.q[:out, :μ])
GammaShapeRate(3 / 2, (V[1, 1] - V[1, 2] - V[2, 1] + V[2, 2] + abs2(m[1] - m[2])) / 2)
end,
)
getresult(call_message_update_rule(MyGaussian, :τ; clusters = ((:out, :μ) => MvNormalMeanCovariance([1.0, 0.0], [1.0 0.0; 0.0 1.0]),)))ExponentialFamily.GammaShapeRate{Float64}(a=1.5, b=1.5)A rule that took meta::MyMeta names its algorithm instead, algorithm = MyMeta, and reads the value from its algo slot. The algorithm is a subtype of DefaultAlgorithmExtension, which keeps the default's rules for every target it has none for:
# v6
struct MyScale
factor::Float64
end
@rule MyGaussian(:μ, Marginalisation) (m_out::PointMass, m_τ::PointMass, meta::MyScale) = begin
return NormalMeanPrecision(mean(m_out), meta.factor * mean(m_τ))
endstruct MyScale <: DefaultAlgorithmExtension
factor::Float64
end
@define_message_update_rule(
node = MyGaussian, target = :μ, algorithm = MyScale,
args = (m[:out]::PointMass, m[:τ]::PointMass),
body = (algo, args) -> NormalMeanPrecision(mean(args.m[:out]), algo.factor * mean(args.m[:τ])),
)
getresult(call_message_update_rule(MyGaussian, :μ; m = (out = PointMass(1.0), τ = PointMass(2.0)), algorithm = MyScale(0.5)))ExponentialFamily.NormalMeanPrecision{Float64}(μ=1.0, w=1.0)What else meta held is covered in meta: algorithm or context service.
Marginal rules
@marginalrule becomes @define_marginal_update_rule. Its target is the cluster's members as a tuple rather than a joined name: :out_μ becomes (:out, :μ). A result that factorises into independent blocks, which v6 returned as a NamedTuple, is a FactorizedCluster, each block labelled with the members it covers.
# v6
@marginalrule MyGaussian(:out_μ) (m_out::PointMass, m_μ::NormalMeanPrecision, q_τ::Any) = begin
return (out = m_out, μ = prod(ClosedProd(), NormalMeanPrecision(mean(m_out), mean(q_τ)), m_μ))
end@define_marginal_update_rule(
node = MyGaussian, target = (:out, :μ),
args = (m[:out]::PointMass, m[:μ]::NormalMeanPrecision, q[:τ]::Any),
body = (args) -> FactorizedCluster(
(:out,) => args.m[:out],
(:μ,) => prod(ClosedProd(), NormalMeanPrecision(mean(args.m[:out]), mean(args.q[:τ])), args.m[:μ]),
),
)
getresult(call_marginal_update_rule(MyGaussian, (:out, :μ); m = (out = PointMass(1.0), μ = NormalMeanPrecision(0.0, 1.0)), q = (τ = PointMass(1.0),)))FactorizedCluster((:out,) => BayesBase.PointMass{Float64}(1.0), (:μ,) => ExponentialFamily.NormalWeightedMeanPrecision{Float64}(xi=1.0, w=2.0))Average energies
@average_energy becomes @define_average_energy, with the same argument syntax as the rules.
# v6
@average_energy MyGaussian (q_out::Any, q_μ::Any, q_τ::Any) = begin
return (log(2π) - mean(log, q_τ) + mean(q_τ) * (var(q_out) + var(q_μ) + abs2(mean(q_out) - mean(q_μ)))) / 2
end@define_average_energy(
node = MyGaussian,
args = (q[:out]::Any, q[:μ]::Any, q[:τ]::Any),
body = (args) -> (log(2π) - mean(log, args.q[:τ]) + mean(args.q[:τ]) * (var(args.q[:out]) + var(args.q[:μ]) + abs2(mean(args.q[:out]) - mean(args.q[:μ])))) / 2,
)
getresult(call_average_energy(MyGaussian; q = (out = PointMass(1.0), μ = PointMass(0.0), τ = PointMass(1.0))))1.4189385332046727Log scales
A message's log scale is part of the message, not an annotation: it is the scalar with message = exp(logscale) · distribution (see Log scales). A rule declares it with the logscale keyword instead of writing it in its body:
@logscale vbecomeslogscale = vfor a constant;logscale = (args) -> …computes it from the inputs;logscale = from_bodytakes it from the body, which returnswith_logscale(result, v).
A rule that reads the log scales its inputs arrived with, which v6 read from the raw messages tuple, declares reads_logscale = true and reads args.logscale.m[:x] (keyword reference).
# v6
@rule MyBernoulli(:p, Marginalisation) (m_out::PointMass,) = begin
@logscale -log(2)
return Beta(1 + mean(m_out), 2 - mean(m_out))
endstruct MyBernoulli end
@define_factor_node(node = MyBernoulli, type = Stochastic, interfaces = [:out, :p])
@define_message_update_rule(
node = MyBernoulli, target = :p,
args = (m[:out]::PointMass,),
logscale = -log(2),
body = (args) -> Beta(1 + mean(args.m[:out]), 2 - mean(args.m[:out])),
)
result = call_message_update_rule(MyBernoulli, :p; m = (out = PointMass(1.0),))
getresult(result), getlogscale(result)(Distributions.Beta{Float64}(α=2.0, β=1.0), -0.6931471805599453)The engine tracks log scales with the activation option logscales = true, which replaces LogScaleAnnotations. RxInfer's infer(...; logscales = true) replaces annotations = LogScaleAnnotations(), and getlogscale(getannotations(q)) becomes getlogscale(q).
A rule that declares no log scale does not make inference fail. Its message's log scale is an UndefinedLogScale saying why, which propagates through products. Only a rule or a user that needs the number gets an error. v6's fallback, which set zero whenever every input was a point mass, is gone, since it was not right for every rule; the rules it covered declare their log scale.
Groups
A variable number of edges of one kind, ManyOf in v6, is an interface group. A rule towards a member names it (:in, k). Its inputs select members: m[:in...] takes all of them, m[:in][k] the target's own, and m[:in][!k] all but it. A group arrives as a tuple in member order, with nothing for a member the rule does not take. The node needs no node type, factornode or activate! of its own, and no {N} parameter: the number of members is the group's length. Groups builds such a node step by step.
# v6
@rule MySum{N}(:out, Marginalisation) (m_in::ManyOf{N, NormalMeanVariance},) where {N} = begin
return NormalMeanVariance(sum(mean, m_in), sum(var, m_in))
endstruct MySum end
@define_factor_node(node = MySum, type = Deterministic, interfaces = [:out, :in...])
@define_message_update_rule(
node = MySum, target = :out,
args = (m[:in...]::NormalMeanVariance,),
body = (args) -> NormalMeanVariance(sum(mean, args.m[:in]), sum(var, args.m[:in])),
)
@define_message_update_rule(
node = MySum, target = (:in, k),
args = (m[:out]::NormalMeanVariance, m[:in][!k]::NormalMeanVariance),
body = (args) -> begin
others = [m for m in args.m[:in] if m !== nothing]
NormalMeanVariance(mean(args.m[:out]) - sum(mean, others), var(args.m[:out]) + sum(var, others))
end,
)
getresult(call_message_update_rule(MySum, (:in, 1); m = (out = NormalMeanVariance(3.0, 1.0), in = (nothing, NormalMeanVariance(2.0, 1.0)))))ExponentialFamily.NormalMeanVariance{Float64}(μ=1.0, v=2.0)A group may be empty where the node declares min_group_length = 0. A joint may hold some of a group's members with other interfaces. It is keyed with the members, (:out, (:T, 1)), and read as q[:out, (:T, 1)]. A joint holding every member of a group names the group once, so with one member, T₁ alone, the joint over out, in and it is (:out, :in, :T): a rule written for (:out, :in, (:T, 1)) is valid but never runs in that graph. v6 handed such joints to hand-written rule methods under mangled names, q_out_T1, which the rule parsed. A rule that takes whatever inputs the factorisation delivers, as those did, is written with default in its arguments. It walks its inputs by key with MessagePassingRulesBase.rule_inputs: an interface by its name, a member as (:T, k), a joint by its key.
# v6
function ReactiveMP.rule(::Type{<:MyTensor}, ::Val{:out}, ::Marginalisation, mnames, messages, qnames, marginals, meta, annotations, node)
# split names such as `:in_T1` on `_` to find which edges each input covers
endstruct MyTensor end
@define_factor_node(node = MyTensor, type = Stochastic, interfaces = [:out, :in, :T...], min_group_length = 0)
@define_message_update_rule(
node = MyTensor, target = :out, args = (default,),
body = (args) -> sum(last, MessagePassingRulesBase.rule_inputs(MyTensor, args.q)),
)
# Under q(out) q(in, T1) q(T2): the joint by its key, and the second member of `T`.
getresult(call_message_update_rule(MyTensor, :out; clusters = ((:in, (:T, 1)) => 0.5,), q = (T = (nothing, 1.0),)))1.5A rule with typed inputs for the same node, target and algorithm is more specific than one with default, and wins wherever its inputs fit; the default rule takes the rest. A model adds a rule for a packaged node this way, for inputs the package's default rule does not handle as it needs:
@define_message_update_rule(node = MyTensor, target = :out, args = (q[:in]::Float64,), body = (args) -> -args.q[:in])
# The typed rule fits, so it wins; with a joint the `default` rule still answers.
getresult(call_message_update_rule(MyTensor, :out; q = (in = 2.0,))),
getresult(call_message_update_rule(MyTensor, :out; clusters = ((:in, (:T, 1)) => 0.5,), q = (T = (nothing, 1.0),)))(-2.0, 1.5)A node with a group and its own dependencies
A node that v6 wrote as an engine node type, with its own factornode, functional dependencies and activate!, such as a gate that passes on the input a switch selects, is a declaration in v7. The group holds the inputs, the algorithm declares what each rule reads, and the joint over the inputs, which a deterministic node's free energy takes, is a FactorizedCluster when its blocks are independent:
# v6
struct GateNode{N} <: AbstractFactorNode
out::NodeInterface
switch::NodeInterface
inputs::NTuple{N, IndexedNodeInterface}
end
# … a `factornode` method, functional dependencies, `collect_latest_messages`, `activate!` …struct MyGate end # out = inputs[switch]
struct MyGateMP <: MessagePassingRulesBase.AbstractAlgorithm end
@define_factor_node(
node = MyGate, type = Deterministic,
interfaces = [:out, :switch, :inputs...],
algorithm = MyGateMP,
dependencies = [
:out => (m[:inputs...], q[:switch]),
:switch => (m[:out], m[:inputs...]),
(:inputs, k) => (m[:out], q[:switch]),
],
)
# Towards `out`: the inputs' messages weighted by the switch.
@define_message_update_rule(
node = MyGate, target = :out,
args = (m[:inputs...]::NormalMeanVariance, q[:switch]::Categorical),
body = (args) -> BayesBase.MixtureDistribution(collect(args.m[:inputs]), probs(args.q[:switch])),
)
# Towards `switch`: how well each input explains `out`'s message, from the log scale of their product.
@define_message_update_rule(
node = MyGate, target = :switch,
args = (m[:out]::NormalMeanVariance, m[:inputs...]::NormalMeanVariance),
body = (args) -> begin
logweights = [BayesBase.compute_logscale(prod(ClosedProd(), args.m[:out], input), args.m[:out], input) for input in args.m[:inputs]]
weights = exp.(logweights .- maximum(logweights))
Categorical(weights ./ sum(weights))
end,
)
# Towards input k: `out`'s message raised to the probability that the switch selects k.
@define_message_update_rule(
node = MyGate, target = (:inputs, k),
args = (m[:out]::NormalMeanVariance, q[:switch]::Categorical),
body = (args) -> begin
ξ, w = weightedmean_precision(args.m[:out])
z = probs(args.q[:switch])[k]
NormalWeightedMeanPrecision(z * ξ, z * w)
end,
)
# The joint over the switch and the inputs: their messages, independent.
@define_marginal_update_rule(
node = MyGate, target = (:switch, :inputs),
args = (m[:out]::Any, m[:switch]::Categorical, m[:inputs...]::NormalMeanVariance),
body = (args) -> FactorizedCluster((:switch,) => args.m[:switch], (:inputs,) => BayesBase.FactorizedJoint(args.m[:inputs])),
)
inputs = (NormalMeanVariance(0.0, 1.0), NormalMeanVariance(5.0, 1.0))
getresult(call_message_update_rule(MyGate, :switch; m = (out = NormalMeanVariance(4.5, 1.0), inputs = inputs)))Distributions.Categorical{Float64, Vector{Float64}}(support=Base.OneTo(2), p=[0.006692850924284856, 0.9933071490757153])The joint's key names the group once, (:switch, :inputs), since it holds every member. A rule that needs v6's per-node schedule declares it this way; one that reads what the default scheme delivers declares nothing, or default beside the inputs it adds.
meta: algorithm or context service
meta served two purposes, and each has its own place.
- What a rule computes, such as an approximation method, is an algorithm: a type
struct MyMethod <: AbstractAlgorithm end, possibly with fields, that the rule names withalgorithm = MyMethodand receives in itsalgoslot. A direct subtype ofAbstractAlgorithmstands alone; a subtype ofDefaultAlgorithmExtensioninherits the default's rules, asMyScaleabove does. The node's user chooses the algorithm per node, as they chose the meta.DeltaMeta(method = Unscented())isDeltaApproximation(method = Unscented()), andDeltaMeta(method = Linearization(), inverse = f⁻¹)isDeltaApproximation(method = Linearization(), inverse = f⁻¹). A node with its own algorithm builds one step by step. - How a rule computes it numerically, such as a matrix correction or a random number generator, is a context service (service). The rule declares it with
ctx = (:matrix_correction,)and reads it asmatrix_correction(ctx, default)orctx.rng. A generator that lived in a meta is given to the nodes as the activation optioncontext,(rng = …,)(Services: the context).
A default_meta becomes the rule's default: the node's algorithm keyword for a default algorithm, and the default of matrix_correction(ctx, default) for a default correction.
# v6
struct MySampling
rng::AbstractRNG
end
@rule MyBernoulli(:out, Marginalisation) (q_p::Beta, meta::MySampling) = begin
return Bernoulli(mean(rand(meta.rng, q_p, 100)))
endusing Random
@define_message_update_rule(
node = MyBernoulli, target = :out,
args = (q[:p]::Beta,), ctx = (:rng,),
body = (ctx, args) -> Bernoulli(mean(rand(ctx.rng, args.q[:p], 100))),
)
getresult(call_message_update_rule(MyBernoulli, :out; q = (p = Beta(2.0, 2.0),), ctx = MessagePassingRulesBase.RuleContext(rng = Xoshiro(1))))Distributions.Bernoulli{Float64}(p=0.4898895531197639)The engine supplies rng, node and matrix_correction, and the activation option context adds to them or overrides them (see RuleContext).
Functional dependencies
v6's functional dependencies become declared dependencies. The default scheme, which gives a rule the messages in its own cluster and the marginals of the others, needs no declaration.
| v6 | v7 |
|---|---|
DefaultFunctionalDependencies | nothing: the default |
a node with its own functional_dependencies | an algorithm of the node's own, with dependencies = [...] on @define_factor_node |
RequireMessageFunctionalDependencies, RequireMarginalFunctionalDependencies, RequireEverythingFunctionalDependencies | for one model, a DefaultAlgorithmExtension with its own dependencies (@define_dependencies); for a node, its own algorithm |
RequireMarginalFunctionalDependencies(a = nothing), keeping the default and adding q(a) | :a => (default, q[:a]), with default alone for the other targets (@define_dependencies) |
RxInfer's where { dependencies = RequireMessageFunctionalDependencies(in = d) }, a start for the message on this node's own edge | RxInfer's where { initial_messages = (in = d,) }, the activation option initial_messages (Initial messages); @initialization μ(x) = d starts the messages on every edge of x instead, which is not the same |
RxInfer's where { dependencies = … }, otherwise | choosing the node's algorithm |
A target's inputs are subscribed to in the order they are declared, which is the update schedule in variational message passing.
Calling rules
@call_rule returned the message, or a tuple with its add-ons under an option. Every call returns a RuleResult, the same shape whatever the rule. getresult(result) is the message and getlogscale(result) its log scale. getrule, MessagePassingRulesBase.getalgorithm and the other getters say what produced it.
| v6 | v7 |
|---|---|
@call_rule Node(:out, Marginalisation) (m_x = …,) | getresult(call_message_update_rule(Node, :out; m = (x = …,))), or @call_message_update_rule |
@call_rule … (…, meta = x) | call_message_update_rule(…; algorithm = x) |
@call_rule typeof(f)(:out, Marginalisation) (…), for a function node | the node is the function itself, call_message_update_rule(f, :out; …); typeof(f) is an ArgumentError saying so |
@call_marginalrule | call_marginal_update_rule, @call_marginal_update_rule |
score(AverageEnergy(), Node, Val{…}(), marginals, meta) | call_average_energy(Node; q = …) |
| a rule calling another rule | the same: the rule calls the other one, forwarding its ctx (Delegating to another rule) |
The calls for the standard nodes that a model author meets, such as @call_rule typeof(+)(:out, Marginalisation) (…), are in RxInfer's migration guide.
Delegating to another rule
A rule may compute its message with another node's rule, a packaged one included, as a v6 rule did with @call_rule. It calls the other rule with the inputs it builds and forwards its own context, so the other rule sees the engine's services and the model's:
# v6
@rule MyShiftedSum(:out, Marginalisation) (m_in::ManyOf{N, NormalMeanVariance},) where {N} = begin
s = @call_rule MySum(:out, Marginalisation) (m_in = m_in,)
return NormalMeanVariance(mean(s) + 1.0, var(s))
endstruct MyShiftedSum end
@define_factor_node(node = MyShiftedSum, type = Deterministic, interfaces = [:out, :in...])
@define_message_update_rule(
node = MyShiftedSum, target = :out,
args = (m[:in...]::NormalMeanVariance,),
body = (ctx, args) -> begin
s = getresult(call_message_update_rule(MySum, :out; m = (in = args.m[:in],), ctx))
NormalMeanVariance(mean(s) + 1.0, var(s))
end,
)
getresult(call_message_update_rule(MyShiftedSum, :out; m = (in = (NormalMeanVariance(1.0, 1.0), NormalMeanVariance(2.0, 3.0)),)))ExponentialFamily.NormalMeanVariance{Float64}(μ=4.0, v=4.0)The keyword call builds its arguments from named tuples, which allocates. Where that matters, the positional form allocates nothing: message_passing_rule(MySum, Target(:out), DefaultAlgorithm(), RuleArgs(m = (in = args.m[:in],)), ctx), and message_passing_marginalrule and message_passing_average_energy for the other kinds. The other rule runs under the algorithm the call names, the node's default unless algorithm says otherwise.
These helpers and extension points have new names or homes:
| v6 | v7 |
|---|---|
to_marginal(d) | public_equivalent(d), a method a package adds for its working types |
getnodefn(node), getnode() in a rule | getnodefn(ctx.node, target), ctx.node |
nodefunction(node, meta, Val(:out)); a known inverse as nodefunction(node, meta, (Val(:in), k)) | getnodefn(ctx.node, Target(:out)); an inverse is the algorithm's, read from algo |
ReactiveMP.rank1update, mul_trace, negate_inplace!, mul_inplace!, v_a_vT | MessagePassingRulesBase's math helpers: add_outer, trace_product, negate!!, scale!!, scaled_outer; public, not exported |
ReactiveMP.approximate(method, f, (d,)), a distribution | approximate(method, f, (mean(d),), (cov(d),)), the mean and covariance, from MessagePassingRulesApproximations |
StandardBasisVector(n, k), as in softdot(x, StandardBasisVector(n, k), γ) | AutoregressiveMessagePassingRules.StandardBasisVector(n, k), public in that package: the rules' products with it read one entry, so it gives a dense one-hot vector's messages faster |
ReactiveMP.MatrixCorrectionTools, and a correction given as a node's meta, *() -> ClampSingularValues(…) | the registered package MatrixCorrectionTools; the correction is the service matrix_correction of the activation option context, (matrix_correction = ClampSingularValues(…),), which reaches every rule of the node that reads it |
diageye | the same, MessagePassingRulesBase.diageye, exported by StandardMessagePassingRules |
NodeFunctionRuleFallback() as the engine's rulefallback | the same, from MessagePassingRulesBase, as the activation option rulefallback (Rule fallbacks); its message is a NodeFunctionLogPdf |
RuleMethodError, MarginalRuleMethodError | RuleNotFoundError, for every kind of rule |
Testing rules
Table tests move to MessagePassingRulesTestUtils and take keywords. A case is the inputs of one call paired with the expected result.
| v6 | v7 |
|---|---|
@test_rules [opts] Node(:out, Marginalisation) [(input = (m_x = …,), output = d), …] | @test_message_update_rule(node = Node, target = :out, cases = [(m = (x = …,),) => d, …], opts…) |
@test_marginalrules [opts] Node(:out_μ) [(input = …, output = d), …] | @test_marginal_update_rule(node = Node, target = (:out, :μ), cases = […], opts…) |
| an average energy checked by hand | @test_average_energy(node = Node, cases = [(q = …,) => value, …]) |
the options atol, rtol, check_type_promotion (default false) | the same keywords; check_type_promotion defaults to true |
the option extra_float_types | float_types, with the same default, (Float32, Float64, BigFloat) |
| nothing | check_rule_coverage, which fails a suite that leaves a rule untested |
What cannot be translated mechanically
These need a person who knows what the rule means.
- Rules reading the raw
messagesormarginalstuple, often for their annotations or log scales. Each use has to be matched to a named input, toann.m/ann.qor toargs.logscale.m, which depends on what the rule meant by the index. - Rules building graph objects, such as a
randomvarfor a product with a log scale. The log scale of a product of two distributions isBayesBase.compute_logscaleof it, asMixture's rule towards its switch computes it; anything else has no counterpart. metaused as mutable workspace, such as a cache filled across calls. A rule is pure unless it sayspure = false. State belongs to an algorithm that declares itself impure, and whether that is right depends on the model. Working memory that carries nothing between calls is the rule'sscratch. State that several nodes share, as one meta given to them did, is one algorithm object given to each (below).
State shared by several nodes
An algorithm is any value, so a mutable one holds state, and every node given the same object reads and writes the same state: in RxInfer, where { algorithm = shared } on each node, or one @algorithm block naming them. It declares itself impure, which the purity audit then reports for every rule under it. Keeping the state consistent is the algorithm's job, as it was the meta's: the engine computes each message when a variable needs it, in the order of the update schedule, and promises no other order between the nodes.
# v6
mutable struct MyTally
calls::Int
end
@rule MyTallyA(:out, Marginalisation) (q_in::Any, meta::MyTally) = (meta.calls += 1; q_in)
@rule MyTallyB(:out, Marginalisation) (q_in::Any, meta::MyTally) = (meta.calls += 1; q_in)mutable struct MyTally <: MessagePassingRulesBase.AbstractAlgorithm
calls::Int
end
MessagePassingRulesBase.ispure(::Type{<:MyTally}) = false
struct MyTallyA end
struct MyTallyB end
@define_factor_node(node = MyTallyA, type = Stochastic, interfaces = [:out, :in])
@define_factor_node(node = MyTallyB, type = Stochastic, interfaces = [:out, :in])
for node in (MyTallyA, MyTallyB)
@eval @define_message_update_rule(
node = $node, target = :out, algorithm = MyTally,
args = (q[:in]::Any,),
body = (algo, args) -> (algo.calls += 1; args.q[:in]),
)
end
shared = MyTally(0)
getresult(call_message_update_rule(MyTallyA, :out; q = (in = 1.0,), algorithm = shared))
getresult(call_message_update_rule(MyTallyB, :out; q = (in = 2.0,), algorithm = shared))
shared.calls2Verifying a port
Check a ported rule two ways:
- A table of cases,
@test_message_update_rule, with values derived by hand or taken from the old tests. - Where the inputs allow,
@verify_message_update_rule, which checks a message against the node's definition.
Both test the rule against what is true, not against the old implementation, so they keep holding when a later release changes a number on purpose.
Building a graph by hand
The engine's calls keep their shape. A factorisation names interfaces instead of positions, and the activation options are keywords. Getting started builds a whole graph this way.
# v6
node = factornode(NormalMeanVariance, [(:out, y), (:μ, x), (:v, v)], ((1,), (2,), (3,)))
activate!(node, FactorNodeActivationOptions(meta, DefaultFunctionalDependencies(), nothing, nothing, nothing, nothing))using ReactiveMP, MessagePassingRulesBase, BayesBase, ExponentialFamily, Distributions
import ReactiveMP: activate!, FactorNodeActivationOptions
include(joinpath(pkgdir(ReactiveMP), "docs", "nodes.jl")) # declares the node `Gaussian`
x, y, v = randomvar(label = :x), datavar(label = :y), constvar(1.0)
node = factornode(Gaussian, [(:out, y), (:μ, x), (:v, v)], ((:out,), (:μ,), (:v,)))FactorNode Gaussian (stochastic)
out ── y data
μ ── x random
v ── 1.0 constant
clusters: (out) (μ) (v)activate!(x, RandomVariableActivationOptions())
activate!(y, DataVariableActivationOptions())
activate!(node, FactorNodeActivationOptions(; logscales = true))factornodetakes its factorisation by interface name,((:out, :μ), (:v,)), where v6 took positions,((1, 2), (3,)). A group member is(:in, k)(Creating a node).ReactiveMP.FactorNodeActivationOptionswas positional,(metadata, dependencies, postprocessor, annotations, rulefallback, callbacks). It takes the keywordsalgorithm,postprocessor,annotations,callbacks,diagnostics,context,rulefallback,logscalesandinitial_messages, each with a default, and a positional form of the first four,(algorithm, postprocessor, annotations, callbacks).metadatabecomesalgorithm, anddependenciesbecomes the algorithm's declaration (Activation options).bethe_free_energy(Float64, nodes, variables)is the stream of the free energy of an activated graph. v6 left the sum to its caller, which combined each node'sscore(T, FactorBoundFreeEnergy(), node, meta, postprocessors)and each variable'sVariableBoundEntropy, as RxInfer'sBetheFreeEnergydid (Computing the free energy). The node's score takes the node's algorithm in the place ofmeta.factorisation,localmarginalsandlocalmarginalnames, which v6 exported without a method, are gone.ReactiveMP.getlocalclusters(node)andReactiveMP.get_node_local_marginalsgive a node's clusters.AverageEnergyis gone too:call_average_energycomputes an average energy.
Features without a v6 form
These have no v6 name to translate. The left column says what served the purpose in v6, if anything.
| v6 | v7 |
|---|---|
| nothing | in-place rules, inplace = true with preallocate, which write the result into a buffer (keyword reference) |
meta as a cache | scratch, working memory the engine keeps per outbound stream and the rule writes before it reads (scratch) |
| nothing | pure, a rule's declared purity, which overrides its algorithm's ispure (keyword reference) |
RequireMessageFunctionalDependencies(in = d) as a start | initial_messages on @define_factor_node, set where the graph sets none (keyword reference) |
DeltaFnNode's handling of constants and data | static_inputs = :fold on any deterministic node (Static inputs) |
| the factorisation alone | free_energy_partition on @define_dependencies, the clusters an algorithm's free energy splits into (keyword reference) |
| nothing | which_message_update_rule, which_marginal_update_rule and which_average_energy, which resolve a rule without running it |
| nothing | rule_coverage, a node's rules by target and algorithm, and check_rules, which checks rules against their nodes' declarations |
RuleMethodError's "Possible fix, define:" | the not-found report of a RuleNotFoundError: the closest rules, why each does not fit, and a what to try line |
| nothing | rule_not_found_hint, a sentence a node's package adds to that report |
| nothing | ReactiveMP.EngineDiagnostics, the activation option diagnostics: audits of purity, in-place coverage and scratch reuse (Diagnostics) |
| nothing | callback events for a node's marginal rule calls and for every term of the free energy, ReactiveMP.AfterMarginalRuleCallEvent, ReactiveMP.AfterFactorBoundFreeEnergyEvent and ReactiveMP.AfterVariableBoundEntropyEvent with their "before" events, and the rule of every event after a rule call: the rule that ran, the fallback or nothing (Callbacks, Tracing the free energy) |
Node packages
The nodes that are not standard have a package each, loaded next to the engine: loading it is enough for the engine to find its rules. Their v6 meta is the node's own algorithm:
| v6 | v7 |
|---|---|
GaussianCoupling | GaussianCouplingMessagePassingRules, no algorithm of its own |
Probit, ProbitMeta(p) | StandardMessagePassingRules, ProbitEP(; p = 32) |
GCV, GCVMetadata(GaussHermiteCubature(n)) | GCVMessagePassingRules, GCVApproximation(; method = GaussHermiteCubature(n)) |
AR, ConjugateAR, ARMeta(form, order, stype) | AutoregressiveMessagePassingRules, ARVMP(form, order, stype) with ARsafe() or ARunsafe() |
SoftDot (softdot) | SoftDotMessagePassingRules, no algorithm of its own |
ContinuousTransition (CTransition), CTMeta(f) or ContinuousTransitionMeta(f) | ContinuousTransitionMessagePassingRules, CTVMP(f) |
BinomialPolya, BinomialPolyaMeta(n, rng) | PolyaMessagePassingRules, BinomialPolyaApproximation(; samples = n), drawing from the engine's generator |
MultinomialPolya, MultinomialPolyaMeta(points) | PolyaMessagePassingRules, MultinomialPolyaApproximation(; points) |
BIFM, BIFMHelper, BIFMMeta(A, B, C) | BIFMMessagePassingRules, BIFMSmoother(A, B, C) |
Flow, FlowMeta(model, approximation) | FlowMessagePassingRules, FlowApproximation(model; method = approximation) |
DiscreteTransition | DiscreteTransitionMessagePassingRules, no algorithm of its own |
- Probit declared
RequireMessageFunctionalDependencies(in = NormalMeanPrecision(0, 100)). Its algorithm declares that the rule towardsinreads the message on its own edge, and the node declares that message's start, set only where the model sets none. A model that chose another initial message keeps it. v6's rules without expectation propagation run underDefaultAlgorithm(). - GCV's
ExponentialLinearQuadraticis exported by its package, which also holds the rules that letNormalMeanVarianceandNormalMeanPrecisiontake one onout. - AR and ConjugateAR declare no algorithm, as v6 had no default
ARMeta. A model gives each nodeARVMP(...), and without one no rule is found. ConjugateAR's marginal overwalone is the engine's product of its messages, as for any single interface. - SoftDot does not need the AR package; loading its own package is enough.
- ContinuousTransition declares no algorithm, as v6 had no default
CTMeta: a model gives each nodeCTVMP(f). Its rule towardsastill readsq(a), asRequireMarginalFunctionalDependencies(a = nothing)made it, through its declaration (@define_dependencies). As in v6, the node sets no initialq(a); a model initialises it. - BinomialPolya and MultinomialPolya read the message on their weights' own edge, which v6 required each model to ask for with
where { dependencies = RequireMessageFunctionalDependencies(β = …) }. The nodes declare it; drop thedependenciesand initialise the message instead,μ(β) = …in RxInfer's@initialization. Their package is GPL-3 licensed, through PolyaGammaHybridSamplers. - BIFM keeps nothing between calls. v6's
BIFMMetawas a cache its rules shared, so they had to run in a set order and each node needed a meta of its own.BIFMSmootheris an ordinary value that nodes may share, and the order the posteriors are subscribed in does not matter.BIFMMeta(A, B, C, μu, Σu)has no counterpart: the input's statistics come from its message. - Flow's models, layers and
PermutationMatrixlive in its package.ReactiveMP.forward(model, x)and its siblings areFlowMessagePassingRules.forward, public and unexported. Building a model that draws takes a generator first,compile(rng, model),PermutationMatrix(rng, dim), and without one draws from the task's as v6 did.Unscented()needs no dimension. - DiscreteTransition's interfaces are
out,in,aand the groupT, which may be empty. v6 tookDiscreteTransition(out, in, a, t1, t2)positionally and aliased the extra argumentsT1,T2; they are the members(:T, 1),(:T, 2), and a joint overoutandt1alone is keyed(:out, (:T, 1)). Every factorisation v6 handled is handled, with one rule per target, none per factorisation. A marginal rule's observed members come asFactorizedClusterblocks,(:out,) => …, (:in, (:T, 2)) => …, where v6 returned(out = …, in_T2 = …); one inside a joint over the whole groupTstays in the joint as a one-hot axis. v6 ignored the node'smeta, and it has no algorithm. Its rules clamp probabilities into[tiny, huge]relative toa's own values, where v6 did it after normalising the whole tensor, so entries near1e-9differ from v6's. - A distribution value as a prior,
x ~ dford = Beta(4.0, 8.0)orTruncated(…), was v6'sStandaloneDistributionNode, an engine node type. It is Standard'sStandaloneDistribution, an ordinary nodeout ~ dwithda constant: the message towardsoutisd, and the free-energy termKL(q ‖ d), as before. It is stochastic, so a model'sReactiveMP.sdtype(::StandaloneDistributionNode) = Stochastic(), which some v6 models added, is deleted.
Behaviour that changed
The rules of the standard nodes follow naive variational message passing where v6 did not, and fix errors v6 had. A result that differs from v6's for these nodes is expected:
- Variational rules take a variance's contribution as
1/E[1/v]and a covariance's asE[Σ⁻¹]⁻¹(NormalMeanVariance, MvNormalMeanCovariance), where v6 tookE[v]andE[Σ]. - Average energies of Gamma and GammaInverse compute
E[x]·E[1/θ]andθ·E[1/x], where v6 divided expectations. - Wishart's variational rule towards
outusesE[S⁻¹]; MvNormalGamma's includes the covariance ofμin the rate; MvNormalWeightedMeanPrecision's marginal labels its blocks correctly. - Arithmetic:
+and-fix sign errors for weighted-mean normals and for-'s marginal;*'s sampled messages towards a factor drop a spurious weight, its log scale towardsinis-d·log|a|, and a product that would needin * Afor a matrix operand has no rule. v6's*rules towardsinwith their arguments reversed, reachable only through@call_rule, are gone. - Categorical's log scale towards
pfrom a Categoricalq(out)isΣₖ log Γ(qₖ + 1) - log K!, the logarithm ofB(q + 1), the integral of the messagep ↦ ∏ₖ pₖ^qₖ. v6 gave-log K!, right only for a one-hotq(out), so the evidence of a model with a Categorical whoseq(out)is not one-hot changes. For a one-hotq(out)nothing changes. +takes any number of terms, its interfacesoutand the groupin: v6'sin1andin2are(:in, 1)and(:in, 2),call_message_update_rule(+, (:in, 2); m = (out = …, in = (…, nothing)))and((:in, 1), x)in a hand-built graph.a + b + cis one node, where v6 had no rule for it, and so is 6.6'sManyPlus, which v7 does not have. The joint of the terms withoutknown has a rule, so the free energy of a model observing a sum is defined.-keepsin1andin2.- When a rule fires. A rule runs once every input has refreshed since its last run. v6 relaxed this for marginal inputs whenever every one of them was still initial, which started factorisations whose clusters wait on each other (RxInfer#344), but also let rules re-fire each other on initial values alone before any data arrived, without bound. v7 relaxes it, after a run on initial marginals alone, once for any update and every time a value computed from data arrives (
ReactiveMP.MarginalRelaxation), which starts them as well. A model with every marginal initialised can therefore converge elsewhere than on v6, so far always to a free energy as low or lower, with far fewer rule calls; v6's mixtures and Delta wired their own inputs and were never relaxed, so models with them can differ the most. - Mixture has no average energy: the free energy of a model with one is an error, not zero.
- NormalMixture and GammaMixture take no type parameter: v6's
NormalMixture{N}andGammaMixture{N}areNormalMixtureandGammaMixture, and the number of components is the length of their groups. *runs underMultiplicationSampling(; samples = 3000), its default algorithm: its three rules that sample drawsamplesvalues from the engine's generator, where v6 always drew 3000.- MatrixNormal's average energy takes a point mass or a MatrixNormal for
q(out)andq(M), whose second moments it uses; v6 took any type and dropped the second moments of anything but a MatrixNormal. MatrixNormalWishart's is exact for known parameters, where v6 tookf(E[x])forE[f(x)]. - MvNormalWeightedMeanPrecision's average energy takes a Wishart
q(Λ), not only a point mass. - Probit's average energy is finite for a wide
q(in), where v6's underflowed at far cubature points and returned Inf or NaN. - ContinuousTransition's average energies are the closed form; v6's were wrong in three terms, so a model's free energy changes with them. Its rules towards
aandWkeep the offset of an affine or nonlinearf, such as a rotation, which v6 dropped; forreshapenothing changes. - The Pólya nodes' average energies are corrected: BinomialPolya's is the expectation of
softplus(xᵀβ), where v6 took it at the mean, and MultinomialPolya's is right for a Multinomialq(x)with more than one trial. A binomial regression's free energy is higher than v6's. BinomialPolya's sampling takes a univariateβtoo, each draw one sample, where v6 took all the draws as one. - DiscreteTransition normalises its five-interface belief-propagation message towards
T3with a DirichletCollectionq(a)over the whole tensor, as every other rule of the node, where v6 normalised overoutonly. It also takes what v6 failed on: a Bernoulliinorout, the energy of a joint over three or more axes with a DirichletCollectionq(a), and that of a non-squareq(out, in)with a point-massq(a). A point-massAis used as given; v6's belief-propagation rules clamped it to at most one, which changed nothing for a probability tensor. v6's fifty or so rules specialised to a number of interfaces are gone; they computed the generic contraction, and four of its five-interface rules towardsT2, which read their own edge's message, never matched. - The free energy of a model with BIFM raises an error naming the node, where v6's failed with an infinite node bound.
ARunsafe's jointq(y, x)is correct: v6's disagreed withARsafeeven for an AR(1), and threw for a multivariate AR.ARsafeis unchanged.- AR's mean-field message towards
γincludestr(Vθ Vx)in the expected squared residualE[(y₁ - θᵀx)²], as its structured message and its average energy do. v6's left it out, so itsq(γ)had a smaller rate, a larger expected precision, wheneverθandxwere both uncertain. A model underMeanField()with an AR node gives a differentq(γ), and a differentq(θ)andq(x)through it (ReactiveMP.jl#681). AdditiveCouplingLayerwithpartition_dim > 1works, NICE's two halves among them; v6's threw when evaluated, and its Jacobians were wrong for blocks.- ConjugateAR under
Univariateworks: an AR(1) on scalars,ARVMP(Univariate, 1, stype), where v6'sARMeta(Univariate, 1, stype)raised aMethodError.Multivariateof order 1 gives the same results on one-dimensional vectors. - ContinuousTransition's structured message towards
ytakes the uncertainty ofainto account, as its message towardsxand its jointq(y, x)do. v6's pushed the message onxthroughE[A]alone, so its message was too wide and its mean off wheneverq(a)was uncertain. A model underq(y, x) q(a) q(W)gives different posteriors fora,Wand the states (ReactiveMP.jl#681, #682). Withaknown, nothing changes. - The Delta node takes three methods:
Unscented(),Linearization()and, onceusing ExponentialFamilyProjectionloads its rules,CVIProjection(). v6's other methods are gone, with no replacement:CVIandProdCVI,LaplaceApproximation,ImportanceSamplingApproximation,GaussLaguerreQuadratureand the spherical-radial cubature.DeltaApproximationrefuses any other method with an error that lists these three and, forCVIProjectionwithout its package, says which package to load.CVIProjectionhas norngfield: it samples from the generator the engine gives the rule, and its joint rule, which keeps its result as the next proposal, is impure. With several inputs, that rule projects them in turn, each against the others' latest projections, where v6 used the previous proposal for all: its results differ from v6's, and on a posterior with several modes it settles on one where v6's alternated. - The Delta node's joint marginal over its inputs is right for a function with more outputs than inputs, and so is the message towards an input when no inverse is given. Its smoothing step,
smoothRTS, conditions on the message fromoutwithout inverting the forward covariance ofout, which a linearisation of such a function makes singular: three distances of a position in the plane vary in two directions only. v6's inverted it, and its marginal was wrong and asymmetric, with FastCholesky warning that a matrix is not symmetric. Where that covariance is invertible, the results are the same.
A random variable's outbound messages change under a form constraint on messages:
FormConstraintCheckLastapplies once to each outbound message, as documented, where v6 applied it to every partial product the variable's equality chain caches as well: for inbound messagesμ₁ … μ₄, the message to the second connection isf(μ₁ μ₃ μ₄), where v6's wasf(f(μ₁) f(μ₃ f(μ₄))). A constraint that changes the distribution, such asμ(x) :: PointMassFormConstraint()in RxInfer, gives a different result; one that only checks it, such as RxInfer's check that the form is supported, runs once per message. The callbacks see oneReactiveMP.BeforeProductOfMessagesEventand oneReactiveMP.AfterProductOfMessagesEventper outbound message, and one pair of two-message product events fewer per recomputation, since a cached partial product is not computed again.FormConstraintCheckEachis unchanged.
Two changes in the engine concern code that reads annotations or traces rule calls:
- A message or marginal that nothing may annotate shares one frozen, empty
ReactiveMP.AnnotationDict, where v6 gave each its own: one from a mapping without annotation processors whose rule does not take theannslot, say. Writing to it,annotate!(getannotations(message), …), or to theannotationsof a callback event, is anArgumentError. An annotation processor and a rule taking theannslot get a fresh one, as before. - Span ids still pair a "before" event with its "after" event, but come from a counter salted once per session, not from
uuid4(). ReactiveMP.AfterMessageRuleCallEventhas a fieldrule, beforespan_id: what gave the message. A handler that reads fields by name is unaffected; code that builds the event positionally, as a test might, passes it.
Removed
These v6 names have no counterpart: Marginalisation, MomentMatching, the functional dependency types, the per-node node types (NormalMixtureNode and its alias GaussianMixtureNode, GammaMixtureNode, MixtureNode; the checks their constructors made, at least two components, as many of each kind, a mean-field factorisation, are the matched_groups, min_group_length and factorisation of @define_factor_node), and the approximation methods with no remaining consumer (CVI, ProdCVI, Adam and its update!, ForwardDiffGrad, LaplaceApproximation and laplace, ImportanceSamplingApproximation, GaussLaguerreQuadrature, srcubature), with the Optimisers extension that served ProdCVI. MvNormalMeanPrecision's two marginal rules for BIFM's TerminalProdArgument messages are gone too: only the free energy of a BIFM model reached them, and it is not supported.
The engine does not define these internal helpers, none of which it used: skip_clamped and skip_clamped_and_initial (skip_initial stays), KLDivergence and its score method (BayesBase.kldivergence computes the divergence directly), dropproxytype, other_clusters, getinboundinterfaces, interfaceindices, ReactiveMP.hasfield (which shadowed Base.hasfield), split_underscored_symbol, fields, swapped, the interface's tag, and the macro helpers other than @proxy_methods. The exported skipindex and its SkipIndexIterator are gone as well: nothing used them; (x for (i, x) in enumerate(xs) if i != k) or a deleteat! copy does the same. The v5 stubs AddonLogScale and AddonMemory, which only raised an error pointing to their replacements, are gone too: use the logscales = true option and InputArgumentsAnnotations.
Checklist for a mechanical port
Follow this checklist when you port code by rote, or have a tool port it for you. A rule that compiles and returns a plausible distribution can still be wrong, and nothing downstream catches it.
- Read first: the macros of MessagePassingRulesBase,
@define_factor_node,@define_message_update_ruleand@define_dependencies, then the pairs of this guide. - Translate only what a pair covers. Each construct in the code you port must match a pair in this guide. Anything else, and everything in What cannot be translated mechanically, is a question for the person who owns the code. Stop and ask; do not guess.
- Never guess which inputs a rule consumes, whether an input is a message or a marginal, the order of interfaces, or what
metaheld: all of these change the result. - Verify every rule you port as Verifying a port describes: a table of cases, and a comparison with the old rule while it is available.
- Stop when a rule reads raw message tuples, builds graph objects or keeps state in
meta, or when a verification fails and you cannot explain the difference.