Bayesian Inference#

Reno provides a mechanism for taking a model with multiple probability distributions and running Bayesian inference based on one or more observed values in the system in order to produce posterior probability distributions.

This is done through the PyMC library - since math equations in Reno have the ability to produce a PyTensor equivalent, Reno can recreate the entire system dynamics model as a PyMC model along with the mechanisms to compute the full timeseries outputs, and then take advantage of PyMC’s samplers to approximate posterior distributions.

The value of doing this through Reno as opposed to directly through PyMC is due to a few reasons:

Running a Reno model with PyMC is very similar to a normal run by using the model’s .pymc() call. This acts similarly to the model’s __call__(), optionally taking any free variable/initial value conditions, plus some pymc-specific sampling arguments.

A normal “forward-run” of the model using PyMC, or running the simulations just based on prior probabilities can be done by specifying the compute_prior_only=True argument.

import reno

t = reno.TimeRef()
tub = reno.Model("tub", steps=30, doc="Model the amount of water in a bathtub based on a drain and faucet rate")
with tub:
    faucet, drain = reno.Flow(), reno.Flow()
    water_level = reno.Stock()

    faucet_off_time = reno.Variable(5, doc="Timestep to turn the faucet off in the simulation.")

    faucet >> water_level >> drain

    # the faucet should be some waterflow amount until the faucet is turned
    # off, so we use a piecewise operation to make a conditional based on time
    faucet.eq = reno.Piecewise([5, 0], [t < faucet_off_time, t >= faucet_off_time])

    drain.eq = reno.sin(t) + 2
    # the drain can't move negative water, and can't drain more than exists
    # in the tub
    drain.min = 0
    drain.max = water_level

    final_water_level = reno.Metric(water_level.timeseries[-1])
trace = tub.pymc(n=1000, faucet_off_time=reno.Normal(10, 5), compute_prior_only=True)

.pymc calls return Arviz InferenceData objects, which contain a .prior XArray dataset (very similar to what the normal Reno model call returns) and when not passing compute_prior_only=True a .posterior XArray dataset as well.

We can confirm the normal distribution on the variable by plotting it from the trace:

reno.plot_trace_refs(tub, [trace.prior], [tub.faucet_off_time])
../_images/tub_prior_faucet_dist.png

Bayesian inference comes in when we know some additional piece of data or have an observed value for a metric somewhere, and want to determine how the probability distributions change to produce that observed value. These are provided to the .pymc call through the observations argument, which takes a list of reno.ops.Observation instance. These effectively define a normal likelihood function around a metric, with specified observed mean and standard deviation.

As an example, suppose we observed that the final level in the tub was 12, with some allowance for uncertainty, and we want to find out what the likely cut off time for the faucet was. We run the .pymc function with an observation on the final_water_level metric:

trace = tub.pymc(
    n=1000,
    faucet_off_time=reno.Normal(10, 5),
    observations=[reno.Observation(tub.final_water_level, 2.0, [12.0])]
)

And observe the change from prior to posterior:

reno.plot_trace_refs(
    tub,
    {"prior": trace.prior, "post": trace.posterior},
    [tub.faucet_off_time, tub.faucet, tub.drain, tub.water_level],
    figsize=(10, 6)
)
../_images/tub_posteriors.png

Technical process#

Transpiling#