Hide code cell source

import matplotlib.pyplot as plt
%matplotlib inline
import matplotlib_inline
matplotlib_inline.backend_inline.set_matplotlib_formats('svg')
import seaborn as sns

import urllib.request
import os
import jax
from jax import lax, tree, grad, jit, vmap, value_and_grad, hessian
import jax.scipy.stats as jstats
import jax.numpy as jnp
import jax.random as jr
import equinox as eqx
import optax
from jax.scipy.linalg import expm
import pandas as pd
from functools import partial

jax.config.update("jax_enable_x64", True)
colors = sns.color_palette()
key = jr.PRNGKey(0)

def download(
    url : str,
    local_filename : str = None
):
    """Download a file from a url.
    
    Arguments
    url            -- The url we want to download.
    local_filename -- The filemame to write on. If not
                      specified 
    """
    if local_filename is None:
        local_filename = os.path.basename(url)
    urllib.request.urlretrieve(url, local_filename)

Example: The Catalysis Problem Using Variational Inference#

Let’s do variational inference for the catalysis problem, using the data reported by Tsilifis et al. (2016).

Model for data generation#

The model was described in detail in the earlier example of the classical approach to inverse problems. We’ll briefly summarize it here.

Catalysis dynamics#

Recall that we have modeled the production of various chemicals in a catalytic reaction with the following ODE:

\[\begin{split} \begin{aligned} \dot{z} &= A(k)z \\ z(0) &= (500,0,0,0,0,0) \in \mathbb{R}^6 \end{aligned} \end{split}\]

where \(z \in \mathbb{R}^6\) are the concentrations of each chemical. The linear dynamics \(A\) depend on some (unknown) kinetic rates \(k \in \mathbb{R}_+^5\) as

\[\begin{split} A(k) = \left(\begin{array}{cccccc} -k_1 & 0 & 0 & 0 & 0 & 0\\ k_1 & -(k_2+k_4+k_5) & 0 & 0 & 0 & 0\\ 0 & k_2 & -k_3 & 0 & 0 & 0\\ 0 & 0 & k_3 & 0 & 0 & 0\\ 0 & k_4 & 0 & 0 & 0 & 0\\ 0 & k_5 & 0 & 0 & 0 & 0 \end{array}\right)\in\mathbb{R}^{6\times 6}. \end{split}\]

Inverse problem setup#

We want to infer the rates, \(k\), from data. For numerical stability, let’s work with the scaled quantities \(\tilde{z} = \frac{z}{500}, \tilde t = \frac{t}{180}\), and \(\tilde k = 180k\) so that our ODE becomes

\[\begin{split} \begin{aligned} \dot{\tilde z} &= A(\tilde k) \tilde z \\ \tilde z(0) &= (1,0,0,0,0,0) \in \mathbb{R}^6, \end{aligned} \end{split}\]

The dot now denotes differentiation with respect to \(\tilde t\). The concentration scale cancels from this linear ODE. We’ll denote its solution by \(\tilde z(\tilde t; \tilde k)\). The full probabilistic model is

\[\begin{split} \begin{aligned} \log \tilde k &\sim \mathcal{N}(0, \gamma^2 I) &\quad \text{Lognormal prior on rates} \\ \tilde \sigma^2 &\sim \text{Exp}(\lambda) &\quad \text{Exponential prior on noise variance} \\ Y_{ij} &\sim \mathcal{N}\Big(\tilde z_{s_i}\big(\tilde t_j; \tilde k\big), \tilde \sigma^2\Big) &\quad \text{Independent Gaussian measurements} \end{aligned} \end{split}\]

where \(\gamma=2\), \(\lambda=1/0.005=200\) is the exponential rate (so the prior mean of \(\tilde\sigma^2\) is \(0.005\)), and \(Y \in \mathbb{R}^{5 \times 6}\) contains the scaled observed concentrations. The species indices are \((s_1,\ldots,s_5)=(1,2,4,5,6)\), in the order nitrate, nitrite, nitrogen, ammonia, and nitrous oxide. We observe them at 6 equally spaced times \(\mathbf{\tilde t} = \left( \frac{1}{6}, \frac{1}{3}, \frac{1}{2}, \frac{2}{3}, \frac{5}{6}, 1 \right)\).

Finally, let’s follow the common practice of mapping all random variables to a standard Gaussian random vector \(x \in \mathbb{R}^6\). To this end, let \(T\) be the transformation such that

\[ T(x) = (\tilde k, ~ \tilde\sigma^2) \]

and

\[ x \sim \mathcal{N}(0, I). \]

Our goal is now to use variational inference to characterize the posterior

\[\begin{split} p(x|Y) \propto \underbrace{p(Y|x) p(x)}_{\substack{\text{generative model} \\ \text{for the} \\ \text{catalysis dataset}}}. \end{split}\]

The transformation uses \(\tilde k_i=\exp(\gamma x_i)\) and \(\tilde\sigma^2=-\log\Phi(-x_6)/\lambda\), where \(\Phi\) is the standard normal cumulative distribution function. These formulas give exactly the stated priors. Physical rates and noise variance are recovered as \(k=\tilde k/180\) and \(\sigma^2=500^2\tilde\sigma^2\). From now on, we’ll call \(x\) the model parameters. Let’s import the data and define the solver:

Hide code cell source

data_path = Path('../../data/catalysis.csv')
if not data_path.exists():
    url = 'https://raw.githubusercontent.com/PredictiveScienceLab/advanced-scientific-machine-learning/refs/heads/main/book/data/catalysis.csv'
    download(url)
    data_path = Path('catalysis.csv')
catalysis_data = pd.read_csv(data_path)
catalysis_data = catalysis_data[catalysis_data['Time'] > 0.0].reset_index(drop=True)
t_obs = jnp.array(catalysis_data['Time'].values, dtype=float)
Y_obs = jnp.array(catalysis_data[['NO3', 'NO2', 'N2', 'NH3', 'N2O']].values.T, dtype=float)
t_scale = 180.0
z_scale = 500.0

t_obs_scaled = t_obs / t_scale
Y_obs_scaled = Y_obs / z_scale

z0_scaled = jnp.array([500.0, 0.0, 0.0, 0.0, 0.0, 0.0])/z_scale
model_kwargs = dict(
    z0=z0_scaled,
    y=Y_obs_scaled,
    t=t_obs_scaled,
    gamma=2.0,
    lambda_=1/0.005,
)

# Define the linear system
def A(k):
    """
    Return the matrix of the dynamical system.
    """
    # jax.debug.print('k = {k}', k=k)
    res = jnp.zeros((6, 6))
    res = res.at[0, 0].set(-k[0])
    res = res.at[1, 0].set(k[0])
    res = res.at[1, 1].set(-(k[1] + k[3] + k[4]))
    res = res.at[2, 1].set(k[1])
    res = res.at[2, 2].set(-k[2])
    res = res.at[3, 2].set(k[2])
    res = res.at[4, 1].set(k[3])
    res = res.at[5, 1].set(k[4])
    return res

@jit
def solve_catalysis(t, k, z0):
    """Evaluate the exact linear-system solution at the requested scaled times."""
    matrix = A(k)
    return vmap(lambda ti: expm(ti * matrix, max_squarings=32) @ z0)(t)

As a reminder, here is what the data look like:

Hide code cell source

# Plotting with noise
fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_standard"])

# Define labels and colors
labels = ['NO3-', 'NO2-', 'N2', 'NH3', 'N2O', 'X']
data_cols = ['NO3', 'NO2', 'N2', 'NH3', 'N2O']
model_cols = [0, 1, 3, 4, 5, 2]
species_styles = ['-', '--', '-.', ':', (0, (5, 2)), (0, (3, 1, 1, 1))]
species_markers = ['o', 's', '^', 'D', 'v', 'P']

# Plot experimental data
for i, col in enumerate(data_cols):
    ci = 500.0 if data_cols[i] == 'NO3' else 0.0
    ax.plot(jnp.hstack([0.0, t_obs]), jnp.hstack([ci, jnp.array(catalysis_data[col])]),
            color='black', linestyle=species_styles[i], marker=species_markers[i],
            markerfacecolor='white', label=f'Data {labels[i]}')

ax.set_ylim(0, 600)
ax.set_xlabel('Time (min)')
ax.set_ylabel(r'Concentration (mmol L$^{-1}$)')
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.20), ncol=3)
finalize_axes(keep_box=False)
plt.show()
Observed concentrations of five chemical species versus time with distinct line styles and markers.

Here is the negative log posterior, up to an additive constant independent of \(x\). With \(m=30\) scalar observations and residual sum of squares \(R(x)\), its likelihood contribution is

\[ -\log p(Y\mid x)=\frac{R(x)}{2\tilde\sigma^2}+\frac{m}{2}\log\tilde\sigma^2+\text{constant}. \]

The logarithmic term must be retained because we infer the noise variance.

def T(x, gamma, lambda_):
    """Transforms a standard normal random vector into the parameters of the model."""
    # Get reaction rates
    k = jnp.exp(x[:-1]*gamma)

    # Get measurement noise variance
    sigma2 = -jax.scipy.special.log_ndtr(-x[-1]) / lambda_

    return k, sigma2

# Negative log posterior
def minus_log_post(x, z0, y, t, gamma, lambda_):
    """Negative log posterior of the catalysis model."""
    
    # Negative log prior
    prior = 0.5*jnp.sum(x**2)

    # Get reaction rates and measurement noise variance
    k, sigma2 = T(x, gamma, lambda_)

    # Simulate physical process
    states = solve_catalysis(t, k, z0)
    obs_states = jnp.hstack([states[:, :2], states[:, 3:]])  # We don't observe the third component

    # Negative log likelihood
    likelihood = (0.5 * jnp.sum((obs_states.T - y)**2) / sigma2
                  + 0.5 * y.size * jnp.log(sigma2))

    # Total negative log posterior
    posterior = likelihood + prior
    
    return posterior

# Check the likelihood against independent Normal log-density evaluations.
for check_x in (jnp.zeros(6), jnp.array([0.2, -0.3, 0.1, 0.4, -0.2, -1.0]),
                jnp.array([-0.2, 0.3, 0.2, -0.1, 0.1, 1.0])):
    check_k, check_sigma2 = T(check_x, model_kwargs['gamma'], model_kwargs['lambda_'])
    check_states = solve_catalysis(t_obs_scaled, check_k, z0_scaled)
    check_mean = check_states[:, jnp.array([0, 1, 3, 4, 5])].T
    reference_nlp = (-jstats.norm.logpdf(Y_obs_scaled, check_mean, jnp.sqrt(check_sigma2)).sum()
                     - jstats.norm.logpdf(check_x).sum())
    omitted_constant = 0.5 * (Y_obs_scaled.size + check_x.size) * jnp.log(2 * jnp.pi)
    assert jnp.allclose(minus_log_post(check_x, **model_kwargs) + omitted_constant,
                        reference_nlp, atol=1e-10)
print('Normalized-likelihood checks passed for all three test points.')
Normalized-likelihood checks passed for all three test points.

Numerical reliability#

This linear system has the solution \(\tilde z(\tilde t)=\exp(\tilde t A(\tilde k))\tilde z(0)\), where the matrix exponential propagates the initial state. We evaluate that solution directly, avoiding time-step failures when the reaction rates differ greatly. The implementation also supports automatic differentiation.

A numerical failure does not establish that a parameter has low posterior probability. Silently discarding failed evaluations can change the variational objective. We therefore stop on nonfinite values and check the fitted guide’s trajectories and gradients for finite values. Finite values alone do not establish posterior accuracy; the Gaussian guide remains an approximation.

Full-rank multivariate Gaussian guide#

Let’s choose the guide to be a multivariate normal distribution, i.e.,

\[ q_\phi(x) = \mathcal{N}(x| \mu_\phi, \Sigma_\phi). \]

where \(\phi \in \mathbb{R}^{27}\) are the guide parameters (and \(x\) are the model parameters). The mean, \(\mu\), of the guide is parameterized as

\[ \mu_\phi = ( \phi_1 \ldots \phi_6 ) \in \mathbb{R}^6 \]

We’ll represent the covariance matrix, \(\Sigma_\phi\), with its Cholesky decomposition

\[ \Sigma_\phi = L_\phi L_\phi^T \]

and we’ll parameterize the Cholesky factor \(L_\phi\) as

\[\begin{split} L_\phi = \begin{pmatrix} \exp(\phi_7) & 0 & 0 & 0 & 0 & 0 \\ \phi_{13} & \exp(\phi_8) & 0 & 0 & 0 & 0 \\ \phi_{14} & \phi_{15} & \exp(\phi_9) & 0 & 0 & 0 \\ \phi_{16} & \phi_{17} & \phi_{18} & \exp(\phi_{10}) & 0 & 0 \\ \phi_{19} & \phi_{20} & \phi_{21} & \phi_{22} & \exp(\phi_{11}) & 0 \\ \phi_{23} & \phi_{24} & \phi_{25} & \phi_{26} & \phi_{27} & \exp(\phi_{12})\end{pmatrix} \end{split}\]

Parameterizing this way ensures the covariance matrix is positive definite. We now have a parameterized guide \(q_\phi\), where \(\phi\) fully specifies a multivariate normal distribution. Here it is:

class FullRankGaussianGuide(eqx.Module):
    """Class that represents a multivariate normal guide with variational parameters phi."""
    guide_params: jnp.ndarray
    num_model_params: int = eqx.field(static=True, default=None)

    def __post_init__(self):
        if self.guide_params.shape[0] != self.get_num_guide_params(self.num_model_params):
            raise ValueError("The length of phi is not consistent with the number of parameters.")
        
    def logprob(self, model_params):
        """The log probability density of the guide."""
        return jax.scipy.stats.multivariate_normal.logpdf(model_params, self.mu, self.Sigma)
    
    def sample(self, key, num_samples):
        """Samples from the guide."""
        return jr.multivariate_normal(key, self.mu, self.Sigma, shape=(num_samples,))
    
    def forward(self, epsilon):
        """Transforms a multivariate normal sample to a sample from the guide, as per the reparameterization trick."""
        return self.mu + jnp.dot(self.L, epsilon)

    @property
    def mu(self):
        """The mean of the guide."""
        return self.guide_params[:self.num_model_params]

    @property
    def Sigma(self):
        """The covariance of the guide."""
        L = self.L
        return jnp.dot(L, L.T)
    
    @property
    def L(self):
        """The Cholesky decomposition of the covariance of the guide."""
        # The diagonal
        ell = jnp.exp(self.guide_params[self.num_model_params:2*self.num_model_params])
        L = jnp.diag(ell)

        # The lower triangular part
        L = L.at[jnp.tril_indices(self.num_model_params, -1)].set(self.guide_params[2*self.num_model_params:])
        return L

    @classmethod
    def from_mean_covariance(cls, mu, Sigma):
        """Constructs a guide with a given mean and covariance. Useful for initializing phi to a reasonable value."""
        L = jnp.linalg.cholesky(Sigma)
        ell = jnp.diag(L)
        tri = L[jnp.tril_indices(L.shape[0], -1)]
        guide_params = jnp.hstack([mu, jnp.log(ell), tri])
        return cls(guide_params, mu.shape[0])
    
    @staticmethod
    def get_num_guide_params(num_model_params):
        """Return the number of guide parameters there are."""
        num_mu_params = num_model_params
        num_L_params = num_model_params*(num_model_params + 1)//2
        return num_mu_params + num_L_params

# Test
_q_test = FullRankGaussianGuide(guide_params=jnp.arange(27, dtype=float), num_model_params=6)
assert jnp.all(jnp.isclose(_q_test.guide_params, FullRankGaussianGuide.from_mean_covariance(_q_test.mu, _q_test.Sigma).guide_params))

The FullRankGaussianGuide represents a Gaussian distribution with a full covariance matrix. To create a full-covariance Gaussian guide for 6 model parameters \(x\), we need 27 guide parameters \(\phi\) (6 for the mean and 21 for the covariance):

q = FullRankGaussianGuide(guide_params=jnp.ones(27), num_model_params=6)
q
FullRankGaussianGuide(guide_params=f64[27], num_model_params=6)

Now that we’ve initialized the guide \(q_\phi\), we can sample from it:

key, subkey = jr.split(key)
q.sample(subkey, num_samples=10)  # (num_samples, num_model_params)
Array([[-2.8079978 ,  3.49208962,  2.72967246,  2.2010525 ,  2.52837572,
         3.52938305],
       [ 3.34280187,  5.18826966, -0.80306875,  4.75388514,  0.75643109,
         3.13978265],
       [-0.48645701,  1.57155862,  1.76209927,  0.93533241,  3.58083797,
         3.06905721],
       [ 6.25103529,  7.26271346,  8.3119131 ,  6.28970767,  8.18207679,
         7.91590921],
       [ 2.94317353,  7.92587719,  4.90180311,  8.08421901,  7.18992512,
         8.8179723 ],
       [ 0.41155856, -0.63177211, -4.01889174,  0.27839716, -3.08971887,
        -4.33044549],
       [ 4.76008462,  2.67552553,  1.09574005,  0.36490992,  1.95640742,
         3.65516029],
       [ 3.43887376,  3.11580037,  1.24335608,  0.36927497,  0.11001628,
        -1.24456638],
       [ 2.88484058,  0.32565137,  5.67480696,  5.50366196,  9.81397252,
         3.34967173],
       [ 1.41480539, -1.5567134 , -0.43071873, -4.09773821, -1.01897203,
        -3.778782  ]], dtype=float64)

We can also compute the log probability of some \(x\):

q.logprob(jnp.zeros(6))  # log probability of the model parameters
Array(-11.62587234, dtype=float64)

We can also transform a standard Gaussian random sample \(\epsilon\) to a sample from the guide \(x=g_\phi(\epsilon)\). This is the reparameterization trick, and it is implemented by the forward method. See Basics of Variational Inference for more details.

# Sample from standard Gaussian
key, key1 = jr.split(key)
epsilon = jr.normal(key1, shape=(10_000, 6))  # (num_samples, num_model_params)

# Transform to the guide samples
guide_samples = vmap(q.forward)(epsilon)  # (num_samples, num_model_params)

Hide code cell source

fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_standard"])
bins = jnp.linspace(-10, 10, 50)
_, bins, _ = ax.hist(epsilon[:, 0], bins=bins, density=True, histtype='step', color='black', linestyle='-', label='Standard Gaussian')
ax.hist(guide_samples[:, 0], bins=bins, density=True, histtype='step', color='black', linestyle='--', label='Transformed guide')

ax.legend(loc='lower center', bbox_to_anchor=(0.5, 1.02), ncol=1)
ax.set_xlabel('First coordinate')
ax.set_yticks([])
finalize_axes(keep_box=False)
array([<Axes: xlabel='First coordinate'>], dtype=object)
Histograms compare standard-normal samples with samples transformed through the variational guide.

Finally, we can set the guide to a particular mean and covariance. Let’s initialize to a standard Gaussian:

\[ {q_\phi}_\text{init}(x) = \mathcal{N}(x|0, I) \]
# Set the initial guide to a standard Gaussian
q_init = FullRankGaussianGuide.from_mean_covariance(mu=jnp.zeros(6), Sigma=jnp.eye(6))

Okay, we now have a better feel for what FullRankGaussianGuide is. We’ll be using each of the showcased methods (sample, logprob, and forward) as part of our VI implementation.

First, though, let’s plot a few concentration-vs-time trajectories, sampled from our (untrained) guide.

(And coincidentally, these are also samples of our model prior because we had carefully defined the transform \(T\) so that \(x \sim \mathcal{N}(0, I)\).)

Hide code cell source

def plot_guide_samples(q, num_samples, key):
    times_scaled = jnp.linspace(0.0, t_obs_scaled[-1], 1000)
    x_samples = jax.random.multivariate_normal(key, q.mu, q.Sigma, shape=(num_samples,))
    k_samples = vmap(partial(T, gamma=model_kwargs['gamma'], lambda_=model_kwargs['lambda_']))(x_samples)[0]

    # Compute the samples with noise
    z_samples_scaled = jnp.array([solve_catalysis(times_scaled, k, model_kwargs['z0']) for k in k_samples])

    times = times_scaled*t_scale
    z_samples = z_samples_scaled*z_scale

    # Compute the median model with noise
    median_models_noise = jnp.median(z_samples, axis=0)

    # Plotting with noise
    fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_standard"])

    # Define labels and colors
    labels = ['NO3-', 'NO2-', 'N2', 'NH3', 'N2O', 'X']
    data_cols = ['NO3', 'NO2', 'N2', 'NH3', 'N2O']
    model_cols = [0, 1, 3, 4, 5, 2]
    species_styles = ['-', '--', '-.', ':', (0, (5, 2)), (0, (3, 1, 1, 1))]
    species_markers = ['o', 's', '^', 'D', 'v', 'P']

    # Plot experimental data
    for i, col in enumerate(data_cols):
        ax.plot(t_obs, catalysis_data[col], linestyle='none', color='black',
                marker=species_markers[i], markerfacecolor='white',
                label=f'Data {labels[i]}')

    # Plot the mean models with noise
    for i, col in enumerate(model_cols):
        ax.plot(times, median_models_noise[:, col], color='black',
                linestyle=species_styles[i], label=f'Median {labels[i]}')

    for i in range(min(num_samples, 12)):
        for j, col in enumerate(model_cols):
            ax.plot(times, z_samples[i, :, col], color='0.65',
                    linestyle=species_styles[j], linewidth=0.45, alpha=0.25)
    ax.set_ylim(0, 600)
    ax.set_xlabel('Time (min)')
    ax.set_ylabel(r'Concentration (mmol L$^{-1}$)')
    ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.20), ncol=2)
    finalize_axes(keep_box=False)
    plt.show()
key, subkey = jr.split(key)
plot_guide_samples(q=q_init, num_samples=50, key=subkey)
Prior-guide predictive concentration trajectories for six chemical species.

The prior seems reasonable. If we didn’t have the data, any of these trajectories could be plausible.

Maximizing the ELBO#

The goal of variational inference is to find optimal parameters \(\phi\) so that the guide is as close as possible to the true posterior \(p(x|Y).\)

We do this by maximizing the ELBO with respect to \(\phi\). See Basics of Variational Inference for a review of this construction. Here, we’ll just focus on the code implementation:

# Minimizing the negative ELBO is equivalent to maximizing the ELBO.
def neg_elbo(guide_params, epsilon, model_kwargs, num_model_params):
    """The integrand of the negative reparameterized ELBO, f(g_phi(xi)).
    
    Parameters
    ----------
    guide_params : jnp.ndarray
        The guide parameters. (Denoted as ϕ in the notes.)
    epsilon : jnp.ndarray
        The standard Gaussian sample to be transformed to a guide sample.
    model_kwargs : dict
        The keyword arguments for the model.
    num_model_params : int
        The number of model parameters. (The dimension of x in the notes.)

    Returns the negative ELBO.
    """
    q = FullRankGaussianGuide(guide_params=guide_params, num_model_params=num_model_params)
    x = q.forward(epsilon)
    return minus_log_post(x, **model_kwargs) + q.logprob(x)

We are now ready to do variational inference:

def step(carry, _, optim, batch_size, model_kwargs, num_model_params):
    """A single optimization step."""
    guide_params, opt_state, key = carry
    
    # Sample latent variable ϵ
    key, subkey = jr.split(key)
    epsilon = jr.normal(subkey, shape=(batch_size, num_model_params))

    # Compute ELBO gradient estimate
    neg_elbo_val, neg_elbo_grad = tree.map(
        lambda x: jnp.mean(x, axis=0), 
        vmap(value_and_grad(neg_elbo), in_axes=(None, 0, None, None))(guide_params, epsilon, model_kwargs, num_model_params)
    )

    # Update variational parameters phi
    updates, opt_state = optim.update(neg_elbo_grad, opt_state)
    guide_params = optax.apply_updates(guide_params, updates)

    return (guide_params, opt_state, key), neg_elbo_val

# Setup
schedule = optax.exponential_decay(2e-2, transition_steps=5000, decay_rate=0.6)
optim = optax.adam(schedule)
step_frozen_args = jit(partial(step, optim=optim, batch_size=32, model_kwargs=model_kwargs, num_model_params=6))
key, subkey = jr.split(key)
vi_state = (q_init.guide_params, optim.init(q_init.guide_params), subkey)
neg_elbo_vals = []
print_every = 100
num_iter = 20000

# Run variational inference loop
for i in range(num_iter):
    vi_state, neg_elbo_val = step_frozen_args(vi_state, None)
    if not bool(jnp.isfinite(neg_elbo_val)) or not bool(jnp.all(jnp.isfinite(vi_state[0]))):
        raise FloatingPointError('Nonfinite VI step; diagnose the numerical failure before continuing.')
    neg_elbo_vals.append(neg_elbo_val)
    if (i + 1) % print_every == 0:
        print(f"Iteration {(i + 1):4d}/{num_iter:4d} \t Negative ELBO: {jnp.mean(jnp.array(neg_elbo_vals[-print_every:])):.2f}")

# Extract optimized guide parameters and ELBO values
phi_opt, _, _ = vi_state
neg_elbo_vals = jnp.array(neg_elbo_vals)

Hide code cell output

Iteration  100/20000 	 Negative ELBO: 241.55
Iteration  200/20000 	 Negative ELBO: -43.82
Iteration  300/20000 	 Negative ELBO: -56.87
Iteration  400/20000 	 Negative ELBO: -62.59
Iteration  500/20000 	 Negative ELBO: -64.27
Iteration  600/20000 	 Negative ELBO: -65.67
Iteration  700/20000 	 Negative ELBO: -66.94
Iteration  800/20000 	 Negative ELBO: -67.56
Iteration  900/20000 	 Negative ELBO: -68.30
Iteration 1000/20000 	 Negative ELBO: -68.93
Iteration 1100/20000 	 Negative ELBO: -69.85
Iteration 1200/20000 	 Negative ELBO: -70.73
Iteration 1300/20000 	 Negative ELBO: -71.41
Iteration 1400/20000 	 Negative ELBO: -72.16
Iteration 1500/20000 	 Negative ELBO: -73.00
Iteration 1600/20000 	 Negative ELBO: -73.98
Iteration 1700/20000 	 Negative ELBO: -74.75
Iteration 1800/20000 	 Negative ELBO: -75.62
Iteration 1900/20000 	 Negative ELBO: -76.35
Iteration 2000/20000 	 Negative ELBO: -77.22
Iteration 2100/20000 	 Negative ELBO: -78.15
Iteration 2200/20000 	 Negative ELBO: -78.67
Iteration 2300/20000 	 Negative ELBO: -79.33
Iteration 2400/20000 	 Negative ELBO: -80.17
Iteration 2500/20000 	 Negative ELBO: -80.72
Iteration 2600/20000 	 Negative ELBO: -81.08
Iteration 2700/20000 	 Negative ELBO: -81.63
Iteration 2800/20000 	 Negative ELBO: -82.26
Iteration 2900/20000 	 Negative ELBO: -82.68
Iteration 3000/20000 	 Negative ELBO: -82.81
Iteration 3100/20000 	 Negative ELBO: -83.18
Iteration 3200/20000 	 Negative ELBO: -83.40
Iteration 3300/20000 	 Negative ELBO: -83.77
Iteration 3400/20000 	 Negative ELBO: -83.79
Iteration 3500/20000 	 Negative ELBO: -83.89
Iteration 3600/20000 	 Negative ELBO: -83.98
Iteration 3700/20000 	 Negative ELBO: -84.15
Iteration 3800/20000 	 Negative ELBO: -84.20
Iteration 3900/20000 	 Negative ELBO: -84.38
Iteration 4000/20000 	 Negative ELBO: -84.41
Iteration 4100/20000 	 Negative ELBO: -84.53
Iteration 4200/20000 	 Negative ELBO: -84.52
Iteration 4300/20000 	 Negative ELBO: -84.64
Iteration 4400/20000 	 Negative ELBO: -84.56
Iteration 4500/20000 	 Negative ELBO: -84.74
Iteration 4600/20000 	 Negative ELBO: -84.76
Iteration 4700/20000 	 Negative ELBO: -84.77
Iteration 4800/20000 	 Negative ELBO: -84.83
Iteration 4900/20000 	 Negative ELBO: -84.87
Iteration 5000/20000 	 Negative ELBO: -84.86
Iteration 5100/20000 	 Negative ELBO: -84.88
Iteration 5200/20000 	 Negative ELBO: -84.80
Iteration 5300/20000 	 Negative ELBO: -84.86
Iteration 5400/20000 	 Negative ELBO: -84.90
Iteration 5500/20000 	 Negative ELBO: -84.95
Iteration 5600/20000 	 Negative ELBO: -84.97
Iteration 5700/20000 	 Negative ELBO: -84.96
Iteration 5800/20000 	 Negative ELBO: -84.97
Iteration 5900/20000 	 Negative ELBO: -84.97
Iteration 6000/20000 	 Negative ELBO: -85.01
Iteration 6100/20000 	 Negative ELBO: -84.98
Iteration 6200/20000 	 Negative ELBO: -84.95
Iteration 6300/20000 	 Negative ELBO: -85.01
Iteration 6400/20000 	 Negative ELBO: -84.93
Iteration 6500/20000 	 Negative ELBO: -85.01
Iteration 6600/20000 	 Negative ELBO: -85.03
Iteration 6700/20000 	 Negative ELBO: -85.04
Iteration 6800/20000 	 Negative ELBO: -85.06
Iteration 6900/20000 	 Negative ELBO: -85.00
Iteration 7000/20000 	 Negative ELBO: -85.04
Iteration 7100/20000 	 Negative ELBO: -85.07
Iteration 7200/20000 	 Negative ELBO: -84.98
Iteration 7300/20000 	 Negative ELBO: -85.02
Iteration 7400/20000 	 Negative ELBO: -85.03
Iteration 7500/20000 	 Negative ELBO: -85.05
Iteration 7600/20000 	 Negative ELBO: -85.03
Iteration 7700/20000 	 Negative ELBO: -85.06
Iteration 7800/20000 	 Negative ELBO: -85.04
Iteration 7900/20000 	 Negative ELBO: -85.01
Iteration 8000/20000 	 Negative ELBO: -85.06
Iteration 8100/20000 	 Negative ELBO: -84.98
Iteration 8200/20000 	 Negative ELBO: -85.05
Iteration 8300/20000 	 Negative ELBO: -85.02
Iteration 8400/20000 	 Negative ELBO: -85.07
Iteration 8500/20000 	 Negative ELBO: -85.03
Iteration 8600/20000 	 Negative ELBO: -85.05
Iteration 8700/20000 	 Negative ELBO: -85.06
Iteration 8800/20000 	 Negative ELBO: -85.04
Iteration 8900/20000 	 Negative ELBO: -85.05
Iteration 9000/20000 	 Negative ELBO: -85.04
Iteration 9100/20000 	 Negative ELBO: -85.06
Iteration 9200/20000 	 Negative ELBO: -85.05
Iteration 9300/20000 	 Negative ELBO: -85.07
Iteration 9400/20000 	 Negative ELBO: -85.01
Iteration 9500/20000 	 Negative ELBO: -85.07
Iteration 9600/20000 	 Negative ELBO: -85.03
Iteration 9700/20000 	 Negative ELBO: -85.03
Iteration 9800/20000 	 Negative ELBO: -85.07
Iteration 9900/20000 	 Negative ELBO: -85.04
Iteration 10000/20000 	 Negative ELBO: -85.07
Iteration 10100/20000 	 Negative ELBO: -85.05
Iteration 10200/20000 	 Negative ELBO: -85.05
Iteration 10300/20000 	 Negative ELBO: -85.06
Iteration 10400/20000 	 Negative ELBO: -85.05
Iteration 10500/20000 	 Negative ELBO: -85.08
Iteration 10600/20000 	 Negative ELBO: -85.04
Iteration 10700/20000 	 Negative ELBO: -85.04
Iteration 10800/20000 	 Negative ELBO: -85.08
Iteration 10900/20000 	 Negative ELBO: -85.07
Iteration 11000/20000 	 Negative ELBO: -85.09
Iteration 11100/20000 	 Negative ELBO: -85.06
Iteration 11200/20000 	 Negative ELBO: -85.05
Iteration 11300/20000 	 Negative ELBO: -85.07
Iteration 11400/20000 	 Negative ELBO: -85.09
Iteration 11500/20000 	 Negative ELBO: -85.08
Iteration 11600/20000 	 Negative ELBO: -85.02
Iteration 11700/20000 	 Negative ELBO: -85.09
Iteration 11800/20000 	 Negative ELBO: -85.05
Iteration 11900/20000 	 Negative ELBO: -85.05
Iteration 12000/20000 	 Negative ELBO: -85.06
Iteration 12100/20000 	 Negative ELBO: -85.06
Iteration 12200/20000 	 Negative ELBO: -85.03
Iteration 12300/20000 	 Negative ELBO: -85.05
Iteration 12400/20000 	 Negative ELBO: -85.11
Iteration 12500/20000 	 Negative ELBO: -85.12
Iteration 12600/20000 	 Negative ELBO: -85.09
Iteration 12700/20000 	 Negative ELBO: -85.07
Iteration 12800/20000 	 Negative ELBO: -85.06
Iteration 12900/20000 	 Negative ELBO: -85.11
Iteration 13000/20000 	 Negative ELBO: -85.08
Iteration 13100/20000 	 Negative ELBO: -85.08
Iteration 13200/20000 	 Negative ELBO: -85.09
Iteration 13300/20000 	 Negative ELBO: -85.09
Iteration 13400/20000 	 Negative ELBO: -85.06
Iteration 13500/20000 	 Negative ELBO: -85.08
Iteration 13600/20000 	 Negative ELBO: -85.09
Iteration 13700/20000 	 Negative ELBO: -85.09
Iteration 13800/20000 	 Negative ELBO: -85.08
Iteration 13900/20000 	 Negative ELBO: -85.07
Iteration 14000/20000 	 Negative ELBO: -85.11
Iteration 14100/20000 	 Negative ELBO: -85.11
Iteration 14200/20000 	 Negative ELBO: -85.09
Iteration 14300/20000 	 Negative ELBO: -85.13
Iteration 14400/20000 	 Negative ELBO: -85.05
Iteration 14500/20000 	 Negative ELBO: -85.10
Iteration 14600/20000 	 Negative ELBO: -85.07
Iteration 14700/20000 	 Negative ELBO: -85.12
Iteration 14800/20000 	 Negative ELBO: -85.09
Iteration 14900/20000 	 Negative ELBO: -85.06
Iteration 15000/20000 	 Negative ELBO: -85.12
Iteration 15100/20000 	 Negative ELBO: -85.07
Iteration 15200/20000 	 Negative ELBO: -85.11
Iteration 15300/20000 	 Negative ELBO: -85.12
Iteration 15400/20000 	 Negative ELBO: -85.10
Iteration 15500/20000 	 Negative ELBO: -85.05
Iteration 15600/20000 	 Negative ELBO: -85.10
Iteration 15700/20000 	 Negative ELBO: -85.09
Iteration 15800/20000 	 Negative ELBO: -85.10
Iteration 15900/20000 	 Negative ELBO: -85.10
Iteration 16000/20000 	 Negative ELBO: -85.08
Iteration 16100/20000 	 Negative ELBO: -85.14
Iteration 16200/20000 	 Negative ELBO: -85.10
Iteration 16300/20000 	 Negative ELBO: -85.09
Iteration 16400/20000 	 Negative ELBO: -85.12
Iteration 16500/20000 	 Negative ELBO: -85.13
Iteration 16600/20000 	 Negative ELBO: -85.12
Iteration 16700/20000 	 Negative ELBO: -85.10
Iteration 16800/20000 	 Negative ELBO: -85.12
Iteration 16900/20000 	 Negative ELBO: -85.10
Iteration 17000/20000 	 Negative ELBO: -85.08
Iteration 17100/20000 	 Negative ELBO: -85.10
Iteration 17200/20000 	 Negative ELBO: -85.14
Iteration 17300/20000 	 Negative ELBO: -85.12
Iteration 17400/20000 	 Negative ELBO: -85.11
Iteration 17500/20000 	 Negative ELBO: -85.08
Iteration 17600/20000 	 Negative ELBO: -85.10
Iteration 17700/20000 	 Negative ELBO: -85.14
Iteration 17800/20000 	 Negative ELBO: -85.09
Iteration 17900/20000 	 Negative ELBO: -85.10
Iteration 18000/20000 	 Negative ELBO: -85.12
Iteration 18100/20000 	 Negative ELBO: -85.12
Iteration 18200/20000 	 Negative ELBO: -85.13
Iteration 18300/20000 	 Negative ELBO: -85.10
Iteration 18400/20000 	 Negative ELBO: -85.12
Iteration 18500/20000 	 Negative ELBO: -85.09
Iteration 18600/20000 	 Negative ELBO: -85.13
Iteration 18700/20000 	 Negative ELBO: -85.12
Iteration 18800/20000 	 Negative ELBO: -85.13
Iteration 18900/20000 	 Negative ELBO: -85.13
Iteration 19000/20000 	 Negative ELBO: -85.14
Iteration 19100/20000 	 Negative ELBO: -85.10
Iteration 19200/20000 	 Negative ELBO: -85.13
Iteration 19300/20000 	 Negative ELBO: -85.13
Iteration 19400/20000 	 Negative ELBO: -85.14
Iteration 19500/20000 	 Negative ELBO: -85.11
Iteration 19600/20000 	 Negative ELBO: -85.10
Iteration 19700/20000 	 Negative ELBO: -85.12
Iteration 19800/20000 	 Negative ELBO: -85.14
Iteration 19900/20000 	 Negative ELBO: -85.11
Iteration 20000/20000 	 Negative ELBO: -85.12

Hide code cell source

fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_standard"])
sliding_window = 20
elbo_vals_averaged = jnp.convolve(neg_elbo_vals, jnp.ones(sliding_window)/sliding_window, mode='valid')
iters_averaged = jnp.arange(neg_elbo_vals.shape[0])[:elbo_vals_averaged.shape[0]]
ax.plot(iters_averaged, elbo_vals_averaged, color='black')
ax.set_xlabel('Iteration')
ax.set_ylabel('Negative ELBO')
# The negative ELBO can be negative; use a linear ordinate.
finalize_axes(keep_box=False)
array([<Axes: xlabel='Iteration', ylabel='Negative ELBO'>], dtype=object)
Smoothed negative ELBO decreases during variational optimization.

The optimized guide gives a Gaussian approximation in the transformed coordinates. A stable objective and plausible trajectories are useful checks, but they do not establish that the guide captures every feature of the posterior.

Let us plot concentration trajectories sampled from the fitted guide:

q = FullRankGaussianGuide(guide_params=phi_opt, num_model_params=6)
key, subkey = jr.split(key)
plot_guide_samples(q=q, num_samples=50, key=subkey)

# Diagnose numerical evaluations under the final guide using an independent key.
check_draws = q.sample(jr.key(728), num_samples=1024)
check_value_grad = jit(vmap(value_and_grad(lambda x: minus_log_post(x, **model_kwargs))))
check_values, check_gradients = check_value_grad(check_draws)
assert jnp.all(jnp.isfinite(check_values))
assert jnp.all(jnp.isfinite(check_gradients))
check_rates, check_variances = vmap(partial(T, gamma=model_kwargs['gamma'],
                                          lambda_=model_kwargs['lambda_']))(check_draws)
print('Finite posterior values and gradients:', len(check_draws), '/', len(check_draws))
print('Guide median physical rates (1/min):', jnp.median(check_rates, axis=0) / t_scale)
print('Guide median noise SD (mmol/L):', jnp.median(jnp.sqrt(check_variances)) * z_scale)

# Compare the matrix-exponential implementation with SciPy at selected guide draws.
from scipy.linalg import expm as scipy_expm
check_errors = []
for check_k in check_rates[:16]:
    computed = solve_catalysis(t_obs_scaled, check_k, z0_scaled)
    reference = np.stack([scipy_expm(float(ti) * np.asarray(A(check_k))) @ np.asarray(z0_scaled)
                          for ti in t_obs_scaled])
    check_errors.append(float(np.max(np.abs(np.asarray(computed) - reference))))
    assert jnp.allclose(computed.sum(axis=1), 1.0, atol=1e-11)
    assert jnp.min(computed) >= -1e-12
assert max(check_errors) < 1e-11
print('Maximum solution difference from SciPy:', max(check_errors))
Posterior-guide predictive concentration trajectories for six chemical species.
Finite posterior values and gradients: 1024 / 1024
Guide median physical rates (1/min): [0.02166137 0.02956234 0.02106632 0.00180764 0.0046783 ]
Guide median noise SD (mmol/L): 12.806537443467752
Maximum solution difference from SciPy: 8.298917109073045e-15

The trajectories can be compared with the measurements. The following heatmap shows correlations between the six transformed coordinates under the fitted Gaussian guide:

Hide code cell source

std = jnp.sqrt(jnp.diag(q.Sigma))
correlation = q.Sigma / jnp.outer(std, std)
assert jnp.allclose(jnp.diag(correlation), 1.0)
fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_tall"])
sns.heatmap(correlation, ax=ax, cmap='Greys', vmin=-1, vmax=1,
            annot=True, fmt='.2f', annot_kws={'fontsize': 7},
            cbar_kws={'label': 'Correlation'}, square=True)
ax.set_xticklabels([rf'$x_{i}$' for i in range(1, 7)])
ax.set_yticklabels([rf'$x_{i}$' for i in range(1, 7)], rotation=0)
plt.show()
Numerically annotated grayscale heat map of correlations among the six transformed model coordinates under the Gaussian guide.

Exercises#

  • Try playing with the prior.

    • Increase the prior mean \(1/\lambda\) of the scaled measurement noise variance from \(0.005\) to \(0.1\) (lambda_ = 1/0.1). How does this affect the posterior approximation? What happens if you decrease the prior mean instead?

    • Increase the standard deviation \(\gamma\) of the prior log-rate parameter to 3 (gamma = 3). Does this change the posterior approximation? What about if you decrease \(\gamma\)?

  • Play with the starting point. Start variational inference from different points by modifying q_init. Does the optimization algorithm always converge to the same solution?