Migration Guide from version 5.x to 6.x

This guide explains how to migrate a model from RxInfer 5.x to 6.x. RxInfer 6.x runs on ReactiveMP v7, which moves every factor node and its rules out of the engine into rule packages. The main breaking changes for a model author are:

  1. Some nodes need their package loaded. RxInfer loads the standard nodes and the packages of Delta, AR, DiscreteTransition, GCV, GaussianCoupling and SoftDot; ContinuousTransition, Pólya, BIFM and Flow have packages that you load. The default optimiser of a continuous point mass needs using Optim.
  2. A node's meta is its algorithm: @meta becomes @algorithm and meta = becomes algorithm =, and several meta types are renamed.
  3. where { dependencies = … } is removed: a node declares what its rules read, and you set initial messages with @initialization.
  4. Log scales are part of a message: annotations = LogScaleAnnotations() becomes logscales = true.
  5. Nodes and rules are declared with new macros: @node, @rule, @marginalrule, @average_energy and @call_rule are replaced.

The sections below follow the order in which you meet these changes when you run an old script. A block that starts with # v6 shows the RxInfer 5.x code, which ran on ReactiveMP v6; the block after it shows the 6.x code and runs when this documentation is built.

Loading the node packages

RxInfer loads and re-exports ReactiveMP, MessagePassingRulesBase, MessagePassingRulesApproximations and the node packages most models use: StandardMessagePassingRules (the distributions, the arithmetic and logic nodes, the mixtures and Probit), DeltaMessagePassingRules (deterministic transformations with :=), AutoregressiveMessagePassingRules, DiscreteTransitionMessagePassingRules, GCVMessagePassingRules, GaussianCouplingMessagePassingRules and SoftDotMessagePassingRules. The other nodes live in packages that you load next to RxInfer, so that RxInfer depends on as little as possible:

NodePackage
ContinuousTransitionContinuousTransitionMessagePassingRules
BinomialPolya, MultinomialPolyaPolyaMessagePassingRules (GPL-3 licensed)
BIFM, BIFMHelperBIFMMessagePassingRules
FlowFlowMessagePassingRules

Each package is an ordinary Julia package: install it with ] add PolyaMessagePassingRules and load it with using. Loading it is enough for the engine to find the node's rules. A model with a node RxInfer loads needs nothing more than using RxInfer; one with an autoregressive state reads as follows:

using RxInfer

@model function ar_model(y)
    θ ~ Normal(mean = 0.0, precision = 1.0)
    γ ~ Gamma(shape = 1.0, rate = 1.0)
    x_prev ~ Normal(mean = 0.0, precision = 1.0)
    for i in eachindex(y)
        x[i] ~ AR(x_prev, θ, γ) where { algorithm = ARVMP(Univariate, 1, ARsafe()) }
        y[i] ~ Normal(mean = x[i], precision = 10.0)
        x_prev = x[i]
    end
end

result = infer(
    model = ar_model(),
    data = (y = [0.5, 0.4, 0.3, 0.25, 0.2],),
    constraints = @constraints(begin
        q(x_prev, x, θ, γ) = q(x_prev, x)q(θ)q(γ)
    end),
    initialization = @initialization(begin
        q(θ) = NormalMeanPrecision(0.0, 1.0)
        q(γ) = GammaShapeRate(1.0, 1.0)
    end),
    iterations = 10,
)

mean(result.posteriors[:θ][end])
0.22798610995530955

A node from a package that is not loaded is undefined: without using PolyaMessagePassingRules, the name BinomialPolya is an UndefVarError, and the error says which package to load (see Hints for removed names).

A node's meta is its algorithm

In ReactiveMP v7, the setting that chooses how a node computes its messages is the node's algorithm. RxInfer follows the new name, with the @algorithm macro:

RxInfer 5.xRxInfer 6.x
@meta begin … end@algorithm begin … end
infer(…; meta = …)infer(…; algorithm = …)
x ~ Node(…) where { meta = … }x ~ Node(…) where { algorithm = … }
DeltaMeta(method = …, inverse = …)DeltaApproximation(method = …, inverse = …)
ARMeta(form, order, stype)ARVMP(form, order, stype)
GCVMetadata(method)GCVApproximation(method = method)
CTMeta(f), ContinuousTransitionMeta(f)CTVMP(f)
FlowMeta(model, approximation)FlowApproximation(model; method = approximation)

meta, @meta and where { meta = … } still work in 6.x and print a deprecation warning. The renamed meta types are gone. ReactiveMP's node packages table lists the algorithms of the other nodes.

# v6
result = infer(
    model = square_model(),
    data = (y = 2.0,),
    meta = @meta(begin
        f() -> DeltaMeta(method = Linearization())
    end),
)
using RxInfer

f(x) = x^2

@model function square_model(y)
    x ~ Normal(mean = 1.0, variance = 1.0)
    z := f(x)
    y ~ Normal(mean = z, variance = 0.1)
end

result = infer(
    model = square_model(),
    data = (y = 2.0,),
    algorithm = @algorithm(begin
        f() -> DeltaApproximation(method = Linearization())
    end),
)

result.posteriors[:x]
NormalWeightedMeanPrecision{Float64}(xi=61.00000000000022, w=41.00000000000015)

A single node takes its algorithm in the model, with the same where clause that held its meta:

@model function square_model_unscented(y)
    x ~ Normal(mean = 1.0, variance = 1.0)
    z := f(x) where { algorithm = DeltaApproximation(method = Unscented()) }
    y ~ Normal(mean = z, variance = 0.1)
end

result = infer(model = square_model_unscented(), data = (y = 2.0,))

result.posteriors[:x]
NormalWeightedMeanPrecision{Float64}(xi=2.9047619051322306, w=2.9047619050767945)

A Delta node also accepts its approximation method alone, f() -> Linearization(), as in 5.x. See Algorithm specification and Deterministic nodes for the details.

where { dependencies = … } is removed

In 5.x a model could change which inputs a node's rules read, and start a message on the node's own edge, with where { dependencies = RequireMessageFunctionalDependencies(…) }. In 6.x a node declares what its rules read (dependencies), and you set an initial message with @initialization. A model that still uses the clause fails with an error that says so.

# v6
@model function binomial_model(X, n, y)
    β ~ MvNormalWeightedMeanPrecision(zeros(2), diageye(2))
    for i in eachindex(y)
        y[i] ~ BinomialPolya(X[i], n[i], β) where {
            dependencies = RequireMessageFunctionalDependencies(β = MvNormalWeightedMeanPrecision(zeros(2), diageye(2)))
        }
    end
end
using RxInfer, PolyaMessagePassingRules, StableRNGs

@model function binomial_model(X, n, y)
    β ~ MvNormalWeightedMeanPrecision(zeros(2), diageye(2))
    for i in eachindex(y)
        y[i] ~ BinomialPolya(X[i], n[i], β)
    end
end

rng = StableRNG(42)
X = [randn(rng, 2) for _ in 1:30]
n = fill(10, 30)
y = [rand(rng, Binomial(10, 1 / (1 + exp(x[2] - x[1])))) for x in X]

result = infer(
    model = binomial_model(),
    data = (X = X, n = n, y = y),
    initialization = @initialization(begin
        μ(β) = MvNormalWeightedMeanPrecision(zeros(2), diageye(2))
    end),
    iterations = 5,
)

mean(result.posteriors[:β][end])
2-element Vector{Float64}:
  1.0862137701003212
 -1.0911920646243995

BinomialPolya declares that its rule towards β reads the message on β's own edge, so the model only initializes that message. @initialization sets it on every edge of β. To set it on one node's edge only, the counterpart of the removed clause, give that node where { initial_messages = … }, keyed by the node's interface:

@model function binomial_model_per_node(X, n, y)
    β ~ MvNormalWeightedMeanPrecision(zeros(2), diageye(2))
    for i in eachindex(y)
        y[i] ~ BinomialPolya(X[i], n[i], β) where {
            initial_messages = (β = MvNormalWeightedMeanPrecision(zeros(2), diageye(2)),)
        }
    end
end

per_node = infer(model = binomial_model_per_node(), data = (X = X, n = n, y = y), iterations = 5)

mean(per_node.posteriors[:β][end])
2-element Vector{Float64}:
  1.0862137701003212
 -1.0911920646243995

A node's initial_messages takes precedence over the message its package declares for that edge and over @initialization on the same edge.

Log scales

A log scale is part of every message and marginal in ReactiveMP v7, not an annotation. The logscales option replaces LogScaleAnnotations, and you read a posterior's log scale with getlogscale directly.

# v6
result = infer(model = coin_model(), data = (y = y,), annotations = LogScaleAnnotations())
getlogscale(getannotations(result.posteriors[:θ]))
using RxInfer

@model function coin_model(y)
    θ ~ Beta(1.0, 1.0)
    y .~ Bernoulli(θ)
end

result = infer(model = coin_model(), data = (y = [1.0, 0.0, 1.0],), logscales = true)

getlogscale(result.posteriors[:θ])
-2.4849066497880004

In a model that belief propagation solves exactly, a posterior's log scale is the log evidence of the data: here log(1/12). options = (logscales = true,) is the same option.

Custom nodes and rules in scripts

A script that declares its own node uses the macros of MessagePassingRulesBase, which RxInfer re-exports. Each old macro has one counterpart:

RxInfer 5.xRxInfer 6.x
@node@define_factor_node
@rule@define_message_update_rule
@marginalrule@define_marginal_update_rule
@average_energy@define_average_energy
@call_rule@call_message_update_rule
@logscale v in a rule's bodythe rule's logscale = v keyword
Marginalisation, MomentMatchingremoved: a rule belongs to its node's default algorithm unless it names one with algorithm = …

The pairs below port a Bernoulli-like node. A node is declared keyword by keyword:

# v6
struct MyBernoulli end
@node MyBernoulli Stochastic [out, p]
using RxInfer

struct MyBernoulli end

@define_factor_node(node = MyBernoulli, type = Stochastic, interfaces = [:out, :p])

A message rule names its node, its target and its inputs as keywords. The inputs m_x and q_x become m[:x] and q[:x], a joint marginal q_x_y becomes q[:x, :y], and the body is a function of args. The log scale is a keyword of the rule:

# v6
@rule MyBernoulli(:p, Marginalisation) (q_out::PointMass,) = begin
    @logscale -log(2)
    return Beta(1 + mean(q_out), 2 - mean(q_out))
end
@define_message_update_rule(
    node = MyBernoulli, target = :p,
    args = (q[:out]::PointMass,),
    logscale = -log(2),
    body = (args) -> Beta(1 + mean(args.q[:out]), 2 - mean(args.q[:out])),
)

A marginal rule's target is the tuple of its members, (:out, :p) in place of :out_p. A result that factorizes into independent blocks is a FactorizedCluster in place of a NamedTuple:

# v6
@marginalrule MyBernoulli(:out_p) (m_out::PointMass, m_p::Beta) = begin
    return (out = m_out, p = prod(ClosedProd(), Beta(1 + mean(m_out), 2 - mean(m_out)), m_p))
end
@define_marginal_update_rule(
    node = MyBernoulli, target = (:out, :p),
    args = (m[:out]::PointMass, m[:p]::Beta),
    body = (args) -> FactorizedCluster(
        (:out,) => args.m[:out],
        (:p,) => prod(ClosedProd(), Beta(1 + mean(args.m[:out]), 2 - mean(args.m[:out])), args.m[:p]),
    ),
)

An average energy takes the same argument syntax as a rule:

# v6
@average_energy MyBernoulli (q_out::PointMass, q_p::Beta) = begin
    return -(mean(q_out) * mean(log, q_p) + (1 - mean(q_out)) * mean(mirrorlog, q_p))
end
@define_average_energy(
    node = MyBernoulli,
    args = (q[:out]::PointMass, q[:p]::Beta),
    body = (args) -> -(mean(args.q[:out]) * mean(log, args.q[:p]) + (1 - mean(args.q[:out])) * mean(mirrorlog, args.q[:p])),
)

The node works in a model as before:

@model function my_coin_model(y)
    p ~ Beta(1.0, 1.0)
    y .~ MyBernoulli(p)
end

result = infer(model = my_coin_model(), data = (y = [1.0, 0.0, 1.0],), free_energy = true)

result.posteriors[:p], result.free_energy
(Beta{Float64}(α=3.0, β=2.0), [2.484906649788])

A rule reads the services the engine provides, such as the random number generator, from its context; a node with a variable number of edges declares an interface group. ReactiveMP's porting guide for rule authors covers these, and Creating your own custom nodes walks through a node from the start.

Calling rules by hand

@call_rule becomes @call_message_update_rule. It takes keywords: the node, the target, and the messages m and marginals q by interface name. The interfaces of + are out and in, a group with one member per term, where v6 had in1 and in2; the messages on a group are a tuple. MessagePassingRulesBase.nodespec lists any node's interfaces:

using RxInfer

MessagePassingRulesBase.nodespec(+)
NodeSpec: +
in…out+deterministic
interfacealiases
out
ina group of any number of members
default algorithmDefaultAlgorithm()
static inputsnone
min group length2
definednodes/addition.jl:3
# v6
@call_rule typeof(+)(:out, Marginalisation) (m_in1 = NormalMeanVariance(1.0, 1.0), m_in2 = NormalMeanVariance(2.0, 1.0))
result = @call_message_update_rule(
    node = +, target = :out,
    m = (in = (NormalMeanVariance(1.0, 1.0), NormalMeanVariance(2.0, 1.0)),),
)
RuleResult: message of + towards :outbelief propagation
in[1]in[2]out+
Result
valueNormalMeanVariance{Float64}(μ=3.0, v=2.0)
typeNormalMeanVariance{Float64}
log scale0 (declared)
Inputs
edgevalue
in[1]mNormalMeanVariance{Float64}(μ=1.0, v=1.0)
in[2]mNormalMeanVariance{Float64}(μ=2.0, v=1.0)
Rule
declared inputsm[:in...]::Union{NormalDistributionsFamily{T}, GaussianDistributionsFamily{T}} where T
algorithmDefaultAlgorithm()
log scale0
definedaddition/out.jl:8
body
args->sum_of_messages(args.m[:in])
Other rules for this target (2)
addition/out.jl:4
✓algorithm DefaultAlgorithm
✓m[:in...]::Union{PointMass, NormalDistributionsFamily, GaussianDistributionsFamily} got Tuple{NormalMeanVariance{Float64}, NormalMeanVariance{Float64}}
addition/out.jl:6
✓algorithm DefaultAlgorithm
✓m[:in...]::Distribution got Tuple{NormalMeanVariance{Float64}, NormalMeanVariance{Float64}}

The call returns a RuleResult, which renders as a card: the node, the inputs it read, the message it computed, its log scale and the rule that ran. getresult returns the message itself, the value @call_rule returned:

getresult(result)
NormalMeanVariance{Float64}(μ=3.0, v=2.0)

The interfaces of * are out, A and in. A known gain is a point mass on A:

# v6
@call_rule typeof(*)(:out, Marginalisation) (m_A = PointMass(2.0), m_in = NormalMeanVariance(1.0, 1.0))
getresult(@call_message_update_rule(
    node = *, target = :out,
    m = (A = PointMass(2.0), in = NormalMeanVariance(1.0, 1.0)),
))
NormalMeanVariance{Float64}(μ=2.0, v=4.0)

A meta = … argument of @call_rule becomes algorithm = ….

The rule-not-found report

When no rule matches, inference stops with a RuleNotFoundError that explains itself. Belief propagation has no rule for the mean of a normal with an unknown precision:

using RxInfer

@model function normal_gamma_model(y)
    μ ~ Normal(mean = 0.0, variance = 1.0)
    τ ~ Gamma(shape = 1.0, rate = 1.0)
    y ~ Normal(mean = μ, precision = τ)
end

try
    infer(model = normal_gamma_model(), data = (y = 1.0,), disable_inference_error_hint = true)
catch err
    showerror(stdout, err)
end
RuleNotFoundError: no message rule for NormalMeanPrecision towards :μ under DefaultAlgorithm() takes the inputs (m[:τ]::GammaShapeRate{Float64}, q[:out]::PointMass{Float64})
  no rule consumes this set of inputs under this algorithm (no rule of this shape)
  what to try: the factorisation decides which inputs a rule receives (messages from the target's cluster, marginals of the other clusters): choose one that delivers the inputs a rule below takes (in RxInfer, `@constraints`), or define a rule for these inputs
  near misses:
    rule at ~/.julia/packages/GCVMessagePassingRules/kzsU3/src/gaussian_extension.jl:18
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::ExponentialLinearQuadratic  not provided
      ✗ m[:τ]::PointMass  got GammaShapeRate{Float64}
      ✗ q[:out]::PointMass{Float64}  provided but not consumed
    rule at ~/.julia/packages/GCVMessagePassingRules/kzsU3/src/gaussian_extension.jl:23
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::ExponentialLinearQuadratic  not provided
      ✗ q[:τ]::Any  not provided
      ✗ m[:τ]::GammaShapeRate{Float64}  provided but not consumed
      ✗ q[:out]::PointMass{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:1
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::PointMass  not provided
      ✗ m[:τ]::PointMass  got GammaShapeRate{Float64}
      ✗ q[:out]::PointMass{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:8
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::Union{UnivariateNormalDistributionsFamily{T}, UnivariateGaussianDistributionsFamily{T}} where T  not provided
      ✗ m[:τ]::PointMass  got GammaShapeRate{Float64}
      ✗ q[:out]::PointMass{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:18
      ✓ algorithm DefaultAlgorithm
      ✓ q[:out]::PointMass  got PointMass{Float64}
      ✗ q[:τ]::PointMass  not provided
      ✗ m[:τ]::GammaShapeRate{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:25
      ✓ algorithm DefaultAlgorithm
      ✓ q[:out]::Any  got PointMass{Float64}
      ✗ q[:τ]::Any  not provided
      ✗ m[:τ]::GammaShapeRate{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:31
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::PointMass  not provided
      ✗ q[:τ]::Any  not provided
      ✗ m[:τ]::GammaShapeRate{Float64}  provided but not consumed
      ✗ q[:out]::PointMass{Float64}  provided but not consumed
    rule at ~/.julia/packages/StandardMessagePassingRules/OeA3e/src/rules/normal_mean_precision/mean.jl:37
      ✓ algorithm DefaultAlgorithm
      ✗ m[:out]::Union{UnivariateNormalDistributionsFamily{T}, UnivariateGaussianDistributionsFamily{T}} where T  not provided
      ✗ q[:τ]::Any  not provided
      ✗ m[:τ]::GammaShapeRate{Float64}  provided but not consumed
      ✗ q[:out]::PointMass{Float64}  provided but not consumed

The first line names the node, the target, the algorithm and the inputs the engine offered. The what to try line suggests a fix, and each near miss marks which of a rule's inputs match. See Rule Not Found Error for how to read and resolve it.

New inference options

Two options of infer's options argument are new in 6.x.

  • diagnostics takes the engine's audits of the rules a model runs: check_everything_pure rejects a rule that declares itself impure, check_everything_inplace warns about a rule without an in-place form, and checked_buffers fills recycled memory with NaN before each reuse. Name the ones to switch on, diagnostics = (check_everything_pure = true,), or give a ReactiveMP.EngineDiagnostics itself.
  • context gives the rules their services, a NamedTuple merged over the engine's defaults: rng, the random number generator that sampling rules draw from, and matrix_correction, the correction rules apply to the matrices they build.
using StableRNGs

result = infer(
    model = coin_model(),
    data = (y = [1.0, 0.0, 1.0],),
    options = (
        diagnostics = (check_everything_pure = true,),
        context = (rng = StableRNG(42),),
    ),
)

result.posteriors[:θ]
Beta{Float64}(α=3.0, β=2.0)

The rulefallback option, options = (rulefallback = NodeFunctionRuleFallback(),), is unchanged: it gives a message only where no rule matches.

Hints for removed names

A name that ReactiveMP v7 removed or moved into a node package raises an UndefVarError with a hint that names its replacement:

using RxInfer

try
    eval(:(ARMeta(1)))
catch err
    showerror(stdout, err)
end
UndefVarError: `ARMeta` not defined in `Main.var"Main"`
Suggestion: check for spelling errors or missing imports.
Hint: `ARMeta` is from ReactiveMP v6; in v7, `ARMeta(form, order, stype)` is the algorithm `ARVMP(form, order, stype)`, from AutoregressiveMessagePassingRules. See RxInfer's migration guide from v5 to v6.

The same hint appears for @node, @rule, @call_rule, Marginalisation, the renamed meta types, LogScaleAnnotations, the functional dependency types, and every node of a package that is not loaded.