Probabilistic Machine Learning

Learning hidden Markov models: the Baum-Welch algorithm

Seong-Hwan Jun

Department of Biostatistics and Computational Biology, University of Rochester Medical Center

Monday, again

The computations Wednesday builds on, copied from Monday.

The joint distribution

Chain rule along the arrows:

\[ p(z_{1:T}, y_{1:T} \mid \theta) = \underbrace{p(z_1)}_{\pi} \; \prod_{t=2}^{T} \underbrace{p(z_t \mid z_{t-1})}_{A} \; \prod_{t=1}^{T} \underbrace{p(y_t \mid z_t)}_{\phi}. \]

Set beside the mixture model,

\[ p(z_{1:N}, y_{1:N} \mid \theta) = \prod_{i=1}^{N} p(z_i) \, p(y_i \mid z_i), \]

the only new object is the middle product. Everything we know about emissions carries over untouched.

The forward variable

\[ \alpha_t(k) \;:=\; p(y_{1:t}, \, z_t = k). \]

The joint probability of everything seen up to time \(t\), together with being in state \(k\) at time \(t\).

Write \(B_t(k) := p(y_t \mid z_t = k)\) for the emission probability of what was seen at \(t\). Then

\[ \begin{aligned} \alpha_t(k) &= \sum_{j} p(y_{1:t}, z_{t-1}=j, z_t=k) \\ &= \sum_j \underbrace{p(y_{1:t-1}, z_{t-1}=j)}_{\alpha_{t-1}(j)} \; \underbrace{p(z_t=k \mid z_{t-1}=j)}_{A_{jk}} \; \underbrace{p(y_t \mid z_t=k)}_{B_t(k)} \end{aligned} \]

using the Markov property and the locality of emissions. So

\[ \boxed{\;\alpha_t(k) = B_t(k) \sum_{j=1}^{K} \alpha_{t-1}(j) \, A_{jk}\;} \]

The forward recursion, end to end

\[ \begin{aligned} \textbf{initialize:} \quad & \alpha_1(k) = \pi_k \, B_1(k) \\ \textbf{recurse:} \quad & \alpha_t(k) = B_t(k) \sum_{j} \alpha_{t-1}(j) A_{jk}, \qquad t = 2, \ldots, T \\ \textbf{terminate:} \quad & p(y_{1:T}) = \sum_{k} \alpha_T(k). \end{aligned} \]

  • \(T\) steps, each a \(K \times K\) matrix-vector product: \(O(TK^2)\).
  • Question 1, the likelihood, is already answered, and we have not touched a single path.
  • In matrix form, with \(\alpha_t\) and \(B_t\) as row vectors of length \(K\): \(\alpha_t = (\alpha_{t-1} A) \odot B_t\), where \(\odot\) is the elementwise product, \((u \odot v)_k = u_k v_k\).

Numerical stability

\(\alpha_t(k)\) is a joint probability of \(t\) observations. It decays geometrically and underflows within a few hundred steps.

The standard fix is to normalize as we go. Define \(c_t := p(y_t \mid y_{1:t-1})\) and

\[ \hat{\alpha}_t(k) := \frac{\alpha_t(k)}{p(y_{1:t})} = p(z_t = k \mid y_{1:t}). \]

  • \(\hat{\alpha}_t\) is the filtering distribution: an interpretable quantity that sums to one, rather than a vanishing number.
  • The recursion becomes predict, then update: \(\hat{\alpha}_t \propto (\hat{\alpha}_{t-1} A) \odot B_t\), and the normalizer at step \(t\) is exactly \(c_t\).
  • The normalizers give the likelihood for free: \(\;\log p(y_{1:T}) = \sum_{t=1}^{T} \log c_t\).
  • The alternative is to run everything in log space with logsumexp, as we did for the mixture model.

The backward variable

\[ \beta_t(k) \;:=\; p(y_{t+1:T} \mid z_t = k). \]

The probability of everything after \(t\), given the state at \(t\). Condition on \(z_{t+1}\) and use the same two independences:

\[ \begin{aligned} \beta_t(k) &= \sum_{j} p(z_{t+1} = j \mid z_t = k) \; p(y_{t+1} \mid z_{t+1} = j) \; p(y_{t+2:T} \mid z_{t+1} = j) \\ &= \sum_{j} A_{kj} \, B_{t+1}(j) \, \beta_{t+1}(j). \end{aligned} \]

\[ \begin{aligned} \textbf{initialize:} \quad & \beta_T(k) = 1 \qquad \text{(nothing comes after } T\text{)} \\ \textbf{recurse:} \quad & \beta_t(k) = \sum_{j} A_{kj} \, B_{t+1}(j) \, \beta_{t+1}(j), \qquad t = T-1, \ldots, 1. \end{aligned} \]

Same cost, run right to left. Note \(\beta\) is a conditional likelihood, not a distribution over states: it need not sum to one, and it needs the same rescaling as \(\alpha\).

Putting the two passes together

Because \(y_{1:t}\) and \(y_{t+1:T}\) are conditionally independent given \(z_t\):

\[ p(y_{1:T}, z_t = k) = \underbrace{p(y_{1:t}, z_t=k)}_{\alpha_t(k)} \cdot \underbrace{p(y_{t+1:T} \mid z_t = k)}_{\beta_t(k)} \]

Smoothed marginals, the posterior over each state:

\[ \gamma_t(k) := p(z_t = k \mid y_{1:T}) = \frac{\alpha_t(k)\,\beta_t(k)}{p(y_{1:T})}. \]

Pairwise marginals, the posterior over each transition:

\[ \xi_t(j,k) := p(z_{t-1}=j, z_t=k \mid y_{1:T}) = \frac{\alpha_{t-1}(j) \, A_{jk} \, B_t(k) \, \beta_t(k)}{p(y_{1:T})}. \]

Exercise: derive \(\xi\), and write it up

On paper first, then typed up in a Quarto document. This is the week’s lab exercise.

  1. Starting from the definition \(\xi_t(j,k) = p(z_{t-1}=j, z_t=k \mid y_{1:T})\), show that \[ \xi_t(j,k) = \frac{\alpha_{t-1}(j) \, A_{jk} \, B_t(k) \, \beta_t(k)}{p(y_{1:T})}. \]
  2. Sum \(\xi_t(j,k)\) over \(j\) and show that you recover \(\gamma_t(k)\).
  3. Repeat part 1 with the rescaled quantities \(\hat\alpha_{t-1}(j) = p(z_{t-1}=j \mid y_{1:t-1})\) and \(\hat\beta_t(k) = \beta_t(k) / p(y_{t+1:T} \mid y_{1:t})\), and show that \[ \xi_t(j,k) = \frac{\hat\alpha_{t-1}(j) \, A_{jk} \, B_t(k) \, \hat\beta_t(k)}{c_t}, \qquad c_t = p(y_t \mid y_{1:t-1}). \]

Goals

  • Generate rolls from a casino you designed, and compare their likelihood under two models with the forward algorithm.
  • Write down the complete data log likelihood of an HMM and its maximizer when the states are known.
  • Show that the expected complete data log likelihood depends on the posterior only through \(\gamma\) and \(\xi\), and hence that forward-backward is the whole E-step.
  • Derive the M-step for \(\pi\), \(A\) and two emission families, and recognize them in the code.
  • Run Baum-Welch on your rolls and judge whether it recovered your choices.
  • Read the results critically: restarts, local optima, sample size, and priors.

Your own casino

Monday’s code, which you will need

setup: imports, colours, a table helper, the data
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import norm

from IPython.display import Markdown

def md_table(df, index=False):
    """Show a DataFrame as a markdown table so it matches the hand-written tables."""
    cell = lambda v: str(v).replace("|", "\\|")
    head = ([cell(df.index.name or "")] if index else []) + [cell(c) for c in df.columns]
    rows = [([cell(i)] if index else []) + [cell(v) for v in r]
            for i, r in zip(df.index, df.astype(object).to_numpy())]
    lines = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
    lines += ["| " + " | ".join(r) + " |" for r in rows]
    return Markdown("\n".join(lines))

sns.set_style("whitegrid")
INK, M1, M2, M3 = "#12355b", "#2a6f97", "#bb3e03", "#4c956c"

faithful = sns.load_dataset("geyser")
kind = faithful["kind"].to_numpy()
dur = faithful["duration"].to_numpy()

casino = pd.read_csv("data/casino.csv")
x_cas, y_cas = casino["x"].to_numpy(), casino["y"].to_numpy()
def forward(logB, pi, A):
    T, K = logB.shape
    B = np.exp(logB)
    alpha, c = np.zeros((T, K)), np.zeros(T)
    a = pi * B[0]
    c[0] = a.sum(); alpha[0] = a / c[0]
    for t in range(1, T):
        a = (alpha[t-1] @ A) * B[t]
        c[t] = a.sum(); alpha[t] = a / c[t]
    return alpha, c

def backward(logB, A, c):
    T, K = logB.shape
    B = np.exp(logB)
    beta = np.zeros((T, K)); beta[-1] = 1.0
    for t in range(T-2, -1, -1):
        beta[t] = (A @ (B[t+1] * beta[t+1])) / c[t+1]
    return beta

def forward_backward(logB, pi, A):
    alpha, c = forward(logB, pi, A)
    beta = backward(logB, A, c)
    B = np.exp(logB)
    gamma = alpha * beta
    xi = alpha[:-1, :, None] * A[None] * (B[1:] * beta[1:])[:, None, :] / c[1:, None, None]
    return gamma, xi, np.log(c).sum(), alpha

Design your casino

Three numbers are yours to choose. Everything in this lecture runs on them.

  • \(p_6\): the probability that the loaded die shows a six. The other five faces share the rest equally.
  • \(a_{FF}\): the probability that the dealer keeps the fair die from one roll to the next.
  • \(a_{LL}\): the probability that the dealer keeps the loaded die.
def make_casino(p_six, stay_fair, stay_loaded):
    pi = np.array([0.5, 0.5])
    A = np.array([[stay_fair, 1 - stay_fair],
                  [1 - stay_loaded, stay_loaded]])
    B = np.vstack([np.ones(6) / 6,
                   np.r_[np.repeat((1 - p_six) / 5, 5), p_six]])
    return pi, A, B

pi_cas, A_cas, B_cas = make_casino(p_six=0.5, stay_fair=0.9, stay_loaded=0.8)   # my choices
print("A =\n", A_cas, "\nB =\n", np.round(B_cas, 3))
A =
 [[0.9 0.1]
 [0.2 0.8]] 
B =
 [[0.167 0.167 0.167 0.167 0.167 0.167]
 [0.1   0.1   0.1   0.1   0.1   0.5  ]]

Mine are the numbers behind Monday’s rolls. Pick something different: a barely loaded die, a dealer who almost never switches, a dealer who switches every roll. Each teaches something different, and we compare notes at the end.

Simulate it

def simulate_casino(T, pi, A, B, seed):
    rng = np.random.default_rng(seed)
    z, y = np.zeros(T, dtype=int), np.zeros(T, dtype=int)
    z[0] = rng.choice(2, p=pi); y[0] = rng.choice(6, p=B[z[0]]) + 1
    for t in range(1, T):
        z[t] = rng.choice(2, p=A[z[t-1]]); y[t] = rng.choice(6, p=B[z[t]]) + 1
    return z, y

z_sim, y_sim = simulate_casino(300, pi_cas, A_cas, B_cas, seed=1)
print("rolls :", "".join(str(r) for r in y_sim[:70]))
print("die   :", "".join("L" if s else "." for s in z_sim[:70]))
print(f"{(y_sim == 6).mean():.0%} sixes overall, {(y_sim[z_sim == 1] == 6).mean():.0%} while the loaded die was in use")
rolls : 6633145332266361564162455551631524633166221122134534323162556442246666
die   : L...........LL........................................LLLLLLLLL...LLLL
26% sixes overall, 47% while the loaded die was in use

Keep z. You will not show it to the algorithm, but you will need it to grade the algorithm’s answers.

The likelihood of your rolls

You have the rolls, and you have the \(\theta\) that produced them. Compute \(\log p(y_{1:T} \mid \theta)\).

  • No code on this slide. You have forward from Monday and the slide Numerical stability; work out what to call and what to do with what comes back.
  • Wrap it in a function of (y, pi, A, B). You will call it many times today.
my casino, my rolls :  -530.56

Mine, for reference. Yours will differ, and it should be negative and of a similar size, a few hundred nats for 300 rolls.

Now under one fair die

Same rolls, a different story: every roll is an independent draw from six equally likely faces. No dealer, no hidden state. Compute \(\log p(y_{1:T})\) under this model.

  • Two routes. Write down the closed form, or notice that this is a one-state HMM and feed forward a \(1 \times 1\) transition matrix. They must agree.
one fair die :  -537.53
my casino    :  -530.56
difference   :     6.96 nats, or 0.023 per roll

Seven nats: my rolls are about \(e^7 \approx 1000\) times more probable under the two-dice story than under one fair die. That is 0.023 nats per roll: on average a roll is only about 2% more probable under the two-dice story than under a fair die. No single roll gives the casino away; three hundred of them together do.

How well did yours hide?

Code
rows = []
for p6 in (0.25, 0.5, 0.9):
    pi_, A_, B_ = make_casino(p6, 0.9, 0.8)
    _, y_ = simulate_casino(300, pi_, A_, B_, seed=3)
    rows.append({"p_6": p6, "one fair die": round(300 * np.log(1/6), 1),
                 "the casino": round(loglik_hmm(y_, pi_, A_, B_), 1),
                 "difference (nats)": round(loglik_hmm(y_, pi_, A_, B_) - 300 * np.log(1/6), 1)})
md_table(pd.DataFrame(rows))
p_6 one fair die the casino difference (nats)
0.25 -537.5 -537.8 -0.2
0.5 -537.5 -528.2 9.3
0.9 -537.5 -475.9 61.6
  • A die that shows a six a quarter of the time is invisible in 300 rolls: the one-die model fits the rolls just as well. A die that shows a six nine times in ten is unmissable.
  • Compare with your neighbour. Whoever chose the smaller \(p_6\) has the harder problem, and everything that follows, learning included, will be harder for them.

EM algorithm (Baum-Welch)

Review of EM

For any distribution \(q\) over the hidden variables, Week 3 gave us

\[ \ell(\theta) = \mathcal{L}(q, \theta) + \text{KL}\big( q \,\|\, p(z_{1:T} \mid y_{1:T}, \theta) \big), \qquad \mathcal{L}(q, \theta) = \mathbb{E}_q\big[\log p(z_{1:T}, y_{1:T} \mid \theta)\big] + H(q). \]

Nothing in the derivation used the mixture structure, so the recipe is unchanged:

  • E-step: maximize \(\mathcal{L}\) over \(q\). The maximizer is the posterior, \(q = p(z_{1:T} \mid y_{1:T}, \theta^{(s)})\), and the bound becomes tight.
  • M-step: maximize \(\mathcal{L}\) over \(\theta\). The entropy drops out, leaving \(Q(\theta \mid \theta^{(s)}) = \mathbb{E}_q[\log p(z_{1:T}, y_{1:T} \mid \theta)]\).

The difference is what \(q\) is. In Week 3 it was \(N\) independent distributions over \(K\) labels, \(N(K-1)\) numbers. Now it is a single distribution over \(K^T\) paths. We cannot write it down as a table, and we should not try.

Derive the \(Q\) function for the HMM by writing out the expectation. Start from Monday’s joint, \(p(z_{1:T}, y_{1:T} \mid \theta) = \pi_{z_1} \prod_{t=2}^{T} A_{z_{t-1} z_t} \prod_{t=1}^{T} p(y_t \mid \phi_{z_t})\).

The complete data log likelihood

Take the log of Monday’s joint and write each factor with indicators:

\[ \begin{aligned} \log p(z_{1:T}, y_{1:T} \mid \theta) &= \sum_{k} 1[z_1 = k] \log \pi_k \\ &\quad + \sum_{t=2}^{T} \sum_{j, k} 1[z_{t-1} = j, z_t = k] \log A_{jk} \\ &\quad + \sum_{t=1}^{T} \sum_{k} 1[z_t = k] \log p(y_t \mid \phi_k). \end{aligned} \]

  • Three separate pieces: one for \(\pi\), one for \(A\), one for \(\phi\). They share no parameters.
  • Each piece is linear in the indicators. That is the property that made EM work for the mixture, and it survives the chain.

Now take the expectation

The E-step needs \(Q(\theta \mid \theta^{(s)}) = \mathbb{E}_q[\log p(z_{1:T}, y_{1:T} \mid \theta)]\) with \(q = p(z_{1:T} \mid y_{1:T}, \theta^{(s)})\).

Take the expectation of each line on the previous slide. What is \(\mathbb{E}_q\, 1[z_t = k]\)? What is \(\mathbb{E}_q\, 1[z_{t-1} = j, z_t = k]\)?

\(Q\) only needs marginals

Each line of the complete data log likelihood is linear in the indicators, so the expectation passes straight through to them:

\[ \begin{aligned} Q(\theta \mid \theta^{(s)}) &= \sum_{k} \underbrace{\mathbb{E}_q\, 1[z_1 = k]}_{\gamma_1(k)} \log \pi_k \\ &\quad + \sum_{t=2}^{T} \sum_{j, k} \underbrace{\mathbb{E}_q\, 1[z_{t-1} = j, z_t = k]}_{\xi_t(j, k)} \log A_{jk} \\ &\quad + \sum_{t=1}^{T} \sum_{k} \underbrace{\mathbb{E}_q\, 1[z_t = k]}_{\gamma_t(k)} \log p(y_t \mid \phi_k). \end{aligned} \]

The expectation of an indicator is a probability, so the posterior over \(K^T\) paths enters only through \(\gamma\) and \(\xi\): \(T\) vectors and \(T-1\) matrices, which forward-backward computes. That is why Monday’s algorithm returned those two objects and nothing else (slide 12: see def forward_backward).

The complete data estimators

Suppose for a moment that the states were known. Collect the indicators into counts, \(n_{jk} = \sum_{t \geq 2} 1[z_{t-1} = j, z_t = k]\) transitions from \(j\) to \(k\) and \(n_k = \sum_t 1[z_t = k]\) visits to \(k\), and maximize each piece on its own:

\[ \hat{\pi}_k = 1[z_1 = k], \qquad \hat{A}_{jk} = \frac{n_{jk}}{\sum_{k'} n_{jk'}}, \qquad \hat{\phi}_k = \operatorname{argmax}_{\phi} \sum_{t \,:\, z_t = k} \log p(y_t \mid \phi). \]

  • \(A\): divide the transition counts by the row totals. The constraint \(\sum_k A_{jk} = 1\) is what makes it a proportion rather than a raw count; we derive it properly in a moment.
  • \(\phi_k\): the ordinary MLE for the emission family, applied to the observations in state \(k\). For a categorical emission, symbol frequencies; for a Gaussian, the mean and variance.
  • \(\pi\): with one sequence there is exactly one draw from \(\pi\), so the estimate is a point mass on \(z_1\). Nothing more can be learned about \(\pi\) from a single sequence. With several sequences, it is the fraction that start in each state.

Expected counts

Compare \(Q\) with the complete data log likelihood term by term. Every count has become an expected count:

known states unknown states
\(1[z_1 = k]\) \(\gamma_1(k)\)
\(n_{jk} = \sum_{t \geq 2} 1[z_{t-1} = j, z_t = k]\) \(\bar{n}_{jk} = \sum_{t \geq 2} \xi_t(j, k)\)
\(n_k = \sum_t 1[z_t = k]\) \(\bar{n}_k = \sum_t \gamma_t(k)\)

These are the soft counts of Week 3, now including a soft count for every transition. \(\bar{n}_{jk} = 17.3\) means the data supply the equivalent of \(17.3\) observed \(j \to k\) transitions.

Exercise: the M-step for \(A\)

Maximize \(\sum_{j,k} \bar{n}_{jk} \log A_{jk}\) subject to \(\sum_k A_{jk} = 1\) for every \(j\). The rows separate, so fix \(j\) and add a Lagrange multiplier:

\[ \frac{\partial}{\partial A_{jk}} \Big[ \sum_{k'} \bar{n}_{jk'} \log A_{jk'} - \lambda_j \Big( \sum_{k'} A_{jk'} - 1 \Big) \Big] = \frac{\bar{n}_{jk}}{A_{jk}} - \lambda_j = 0 \;\;\Longrightarrow\;\; A_{jk} = \frac{\bar{n}_{jk}}{\lambda_j}. \]

Summing over \(k\) and using the constraint gives \(\lambda_j = \sum_k \bar{n}_{jk}\), so

\[ \boxed{\; A_{jk}^{(s+1)} = \frac{\sum_{t=2}^{T} \xi_t(j, k)}{\sum_{t=2}^{T} \sum_{k'} \xi_t(j, k')} \;} \]

Expected \(j \to k\) transitions, divided by expected departures from \(j\).

The same calculation with \(\gamma_1\) gives \(\pi_k^{(s+1)} = \gamma_1(k)\), and with the symbol counts gives the categorical emission update.

Exercise: the M-step for the emissions

The emission piece of \(Q\) is \(\sum_k \sum_t \gamma_t(k) \log p(y_t \mid \phi_k)\): a weighted log likelihood for each state, with \(\gamma_t(k)\) as the weight on \(y_t\).

For the casino the emissions are categorical on \(1, \ldots, 6\): \(p(y_t = m \mid z_t = k) = B_{km}\), so the piece for state \(k\) is

\[ \sum_{m=1}^{6} \Big( \sum_t \gamma_t(k) \, 1[y_t = m] \Big) \log B_{km}, \qquad \sum_m B_{km} = 1. \]

Derive the update for \(B_{km}\) with a Lagrange multiplier, exactly as for \(A\) on the previous slide. Where does the row constraint enter, and what plays the role of \(\bar{n}_{jk}\)?

\[ \boxed{\; B_{km}^{(s+1)} = \frac{\sum_t \gamma_t(k) \, 1[y_t = m]}{\bar{n}_k} \;} \]

Expected number of \(m\)’s emitted in state \(k\), divided by expected visits to \(k\). Whatever the emission family, the M-step is Week 3’s weighted MLE with \(\gamma\) in place of \(r\).

Mixture and HMM, side by side

mixture (Week 3) HMM (today)
hidden variables \(z_i\), independent \(z_t\), a Markov chain
posterior \(\prod_i p(z_i \mid y_i)\) \(p(z_{1:T} \mid y_{1:T})\), no factorization
E-step computes \(r_{ik}\), by Bayes’ theorem \(\gamma_t(k)\) and \(\xi_t(j,k)\), by forward-backward
E-step cost \(O(NK)\) \(O(TK^2)\)
M-step for weights \(\pi_k = \bar{n}_k / N\) \(A_{jk} = \bar{n}_{jk} / \sum_{k'} \bar{n}_{jk'}\)
M-step for emissions weighted MLE with \(r\) weighted MLE with \(\gamma\)

One row is new: the transition update, which needs the pairwise marginals. Everything else is the algorithm you implemented in Lab 3 with a different E-step plugged in.

The algorithm

Baum-Welch (Baum, Petrie, Soules and Weiss, 1970; seven years before Dempster, Laird and Rubin named the general recipe).

Initialize \(\theta^{(0)}\). Repeat until \(\ell\) stops increasing:

  1. E-step. Run forward-backward at \(\theta^{(s)}\). Record \(\ell(\theta^{(s)})\), \(\gamma\) and \(\xi\).
  2. M-step. \(\pi \leftarrow \gamma_1\); \(A_{jk} \leftarrow \sum_t \xi_t(j,k) \big/ \sum_{t,k'} \xi_t(j,k')\); \(\phi_k \leftarrow\) weighted MLE of the emission family with weights \(\gamma_t(k)\).

Cost per iteration: one forward-backward pass, \(O(TK^2)\), plus an M-step that is linear in \(T\). The guarantees are EM’s: \(\ell\) never decreases, and the iterates converge to a stationary point.

In the code on the next slides, the E-step is one call to forward_backward and the M-step is the three lines after it, one per piece of \(Q\).

The code you are given

class Categorical:
    """y_t | z_t = k ~ Categorical(B[k, :]) on the symbols 1, ..., M."""
    def __init__(self, M):
        self.M = M
    def init(self, y, K, rng):
        return dict(B=rng.dirichlet(np.full(self.M, 5.0), size=K))
    def logB(self, y, phi):
        return np.log(phi["B"][:, y - 1].T)
    def update(self, y, gamma):
        counts = np.column_stack([gamma[y == m].sum(0) for m in range(1, self.M + 1)])
        return dict(B=counts / counts.sum(1, keepdims=True))

def baum_welch(y, K, emission, seed=0, max_iter=500, tol=1e-6, init=None, prior_A=None):
    rng = np.random.default_rng(seed)
    if init is None:
        pi, A, phi = np.repeat(1/K, K), rng.dirichlet(np.full(K, 5.0), size=K), emission.init(y, K, rng)
    else:
        pi, A, phi = init
    trace, objective = [], []
    for it in range(max_iter):
        gamma, xi, ll, _ = forward_backward(emission.logB(y, phi), pi, A)    # E-step
        trace.append(ll)
        objective.append(ll if prior_A is None else ll + ((prior_A - 1) * np.log(A)).sum())
        if it > 0 and objective[-1] - objective[-2] < tol:
            break
        pi = gamma[0]                                                         # M-step
        n_trans = xi.sum(0)
        if prior_A is not None:                                               # MAP variant, see later
            n_trans = n_trans + prior_A - 1
        A = n_trans / n_trans.sum(1, keepdims=True)
        phi = emission.update(y, gamma)
    return dict(pi=pi, A=A, phi=phi, gamma=gamma, ll=ll, iters=it + 1, trace=np.array(trace))

The E-step is one call to Monday’s forward_backward; the M-step is the three lines after it. Categorical supplies a random start, the matrix \(\log B_t(k)\), and the update from the soft counts. Run it on your rolls.

Watch one run

Code
# Uncomment below to import the plotting library.
#import matplotlib.pyplot as plt

fit0 = baum_welch(y_cas, 2, Categorical(6), seed=0)

fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(fit0["trace"], marker="o", ms=3)
ax.set_xlabel("iteration"); ax.set_ylabel(r"$\ell(\theta^{(s)})$")
ax.set_title(f"{fit0['iters']} iterations (the cap); last increase {np.diff(fit0['trace'])[-1]:.1e} nats", loc="left", fontsize=13)
plt.tight_layout(); plt.show()

print(f"monotone: {np.all(np.diff(fit0['trace']) >= -1e-9)}")
print(f"final log likelihood {fit0['ll']:.2f}, versus {forward_backward(np.log(B_cas[:, y_cas-1].T), pi_cas, A_cas)[2]:.2f} at the true parameters")

monotone: True
final log likelihood -526.62, versus -530.56 at the true parameters

The likelihood never goes down. A big jump in the first few iterations, then a crawl that is still going when the iteration cap stops it, each step adding a fraction of a thousandth of a nat. Does yours look the same? Keep the shape in mind; we come back to it.

Ten restarts on my rolls

Code
fits_cas = [baum_welch(y_cas, 2, Categorical(6), seed=s) for s in range(10)]

def ordered(fit):
    """Relabel so that state 1 is the die with the higher probability of a six."""
    o = np.argsort(fit["phi"]["B"][:, 5])
    return fit["A"][np.ix_(o, o)], fit["phi"]["B"][o]

rows = []
for s, f in enumerate(fits_cas):
    A_o, B_o = ordered(f)
    rows.append({"seed": s, "iterations": f["iters"], "log likelihood": round(f["ll"], 2),
                 "A_00": round(A_o[0, 0], 2), "A_11": round(A_o[1, 1], 2),
                 "p(6 | state 0)": round(B_o[0, 5], 2), "p(6 | state 1)": round(B_o[1, 5], 2)})
md_table(pd.DataFrame(rows).sort_values("log likelihood", ascending=False))
seed iterations log likelihood A_00 A_11 p(6 | state 0) p(6 | state 1)
4 500 -525.83 0.63 0.02 0.24 0.25
1 500 -525.84 0.62 0.04 0.24 0.25
2 500 -525.84 0.63 0.02 0.24 0.25
5 500 -525.88 0.6 0.1 0.24 0.24
7 446 -526.54 0.92 0.74 0.16 0.53
8 372 -526.54 0.92 0.74 0.16 0.53
0 500 -526.62 0.6 0.64 0.0 0.46
6 338 -526.71 0.92 0.87 0.13 0.43
3 500 -526.94 0.75 0.63 0.06 0.5
9 500 -526.99 0.67 0.61 0.0 0.53

Several solutions, and most runs were still creeping when the cap stopped them. The ones that look like the casino, two sticky states and one die with a six near \(0.5\), sit about half a nat below the best.

Exercise: did it recover your values?

Code
best_cas = max(fits_cas, key=lambda f: f["ll"])
A_o, B_o = ordered(best_cas)
print("A:\n", np.round(A_o, 3))
print("B (rows: states, columns: faces 1..6):\n", np.round(B_o, 3))
print(f"\nlog likelihood {best_cas['ll']:.2f}   true parameters: {forward_backward(np.log(B_cas[:, y_cas-1].T), pi_cas, A_cas)[2]:.2f}")
print(f"agreement with the true states: {max((best_cas['gamma'].argmax(1) == x_cas).mean(), (best_cas['gamma'].argmax(1) != x_cas).mean()):.1%}")
A:
 [[0.632 0.368]
 [0.98  0.02 ]]
B (rows: states, columns: faces 1..6):
 [[0.024 0.122 0.179 0.218 0.222 0.236]
 [0.399 0.261 0.    0.031 0.058 0.251]]

log likelihood -525.83   true parameters: -530.56
agreement with the true states: 56.7%

Mine: a solution that explains these rolls better than the truth, by five nats, and looks like nothing I put in. Not a bug. Maximum likelihood explains this sample, and 300 weakly informative rolls do not pin down 13 parameters.

Run ten restarts on your rolls. Compare the best fit with your three numbers. Which came out closest, and does your \(p_6\) or your \(T\) explain what you see?

Old Faithful, learned from scratch

Gaussian emissions

The only thing that changes for Old Faithful. The M-step for a Gaussian family is the weighted mean and variance with weights \(\gamma_t(k)\), exactly Week 3’s update with \(\gamma\) in place of \(r\):

\[ \mu_k = \frac{\sum_t \gamma_t(k) \, y_t}{\bar{n}_k}, \qquad \sigma_k^2 = \frac{\sum_t \gamma_t(k) \, (y_t - \mu_k)^2}{\bar{n}_k}. \]

class Gaussian:
    """y_t | z_t = k ~ Normal(mu_k, sd_k^2)."""
    def init(self, y, K, rng):
        return dict(mu=rng.choice(y, K, replace=False), sd=np.repeat(y.std(), K))
    def logB(self, y, phi):
        return np.column_stack([norm.logpdf(y, m, s) for m, s in zip(phi["mu"], phi["sd"])])
    def update(self, y, gamma):
        n_bar = gamma.sum(0)
        mu = gamma.T @ y / n_bar
        sd = np.sqrt((gamma * (y[:, None] - mu)**2).sum(0) / n_bar)
        return dict(mu=mu, sd=sd)

The fit

Code
fits_of = [baum_welch(dur, 2, Gaussian(), seed=s) for s in range(10)]
hmm_of = max(fits_of, key=lambda f: f["ll"])
order = np.argsort(hmm_of["phi"]["mu"])              # state 0 = short, state 1 = long

print(f"iterations per restart : {[f['iters'] for f in fits_of]}")
print(f"log likelihoods        : {pd.Series(np.round([f['ll'] for f in fits_of], 2)).value_counts().to_dict()}")
print(f"mu = {np.round(hmm_of['phi']['mu'][order], 2)},  sd = {np.round(hmm_of['phi']['sd'][order], 2)}")
print("learned transition matrix (short, long):")
print(np.round(hmm_of["A"][np.ix_(order, order)], 3))
iterations per restart : [358, 17, 19, 14, 26, 17, 500, 30, 23, 20]
log likelihoods        : {-243.59: 8, -421.42: 2}
mu = [2.04 4.29],  sd = [0.26 0.41]
learned transition matrix (short, long):
[[0.062 0.938]
 [0.521 0.479]]

Compare with the table we built on Monday from the labels:

from to short to long
short 0.07 0.93
long 0.544 0.456

Baum-Welch never saw the labels. It recovered the transition structure from the durations alone, and eight of ten random starts reached this solution, most within a few dozen iterations.

The other two stopped at a likelihood of \(-421\) with two identical states, both at \(3.5\) minutes with a huge variance. Durations repeat in this data set, the initializer drew the same value twice, and a symmetric start is a fixed point of EM. Restarts are not optional.

Was the ordering worth it?

model parameters log likelihood agreement with kind
mixture (ignores order) 5 -276.4 97.4%
HMM (uses order) 7 -243.6 98.2%

Thirty three nats for two extra parameters, almost exactly the gain we predicted on Monday from the label sequence alone. The model found the same structure without supervision.

The mixture is the HMM with both rows of \(A\) forced equal, so this is a nested comparison and the likelihood ratio test from Monday applies.

The learned dynamics, unrolled

Code
fig, ax = plt.subplots(figsize=(13, 4))
ax.plot(dur, color="0.7", lw=1, zorder=1)
sc = ax.scatter(np.arange(len(dur)), dur, c=hmm_of["gamma"][:, order[1]], cmap="coolwarm", s=32, zorder=2,
                vmin=0, vmax=1)
ax.set_xlim(0, 80); ax.set_xlabel("eruption number"); ax.set_ylabel("duration (min)")
fig.colorbar(sc, ax=ax, pad=0.01, label=r"$\gamma_t(\mathrm{long})$ from the fitted HMM")
plt.tight_layout(); plt.show()

Same first eighty eruptions as Monday’s opening slide, coloured by the posterior instead of the recorded label. The ambiguous durations around three minutes are where the ordering helps: the mixture judges them by duration alone, and the HMM also asks what came before and after.

The casino, continued

More data

Simulate longer sequences from my casino and fit from a fixed, deliberately vague start: symmetric sticky \(A\), one fair die, one die mildly favouring six.

Code
start = (np.array([0.5, 0.5]), np.array([[0.8, 0.2], [0.2, 0.8]]),
         dict(B=np.vstack([np.ones(6) / 6, [.14, .14, .14, .14, .14, .30]])))

rows = []
for T, (z_T, y_T) in [(300, (x_cas, y_cas)), (1000, simulate_casino(1000, pi_cas, A_cas, B_cas, 11)), (5000, simulate_casino(5000, pi_cas, A_cas, B_cas, 11))]:
    f = baum_welch(y_T, 2, Categorical(6), init=start, max_iter=300)
    A_o, B_o = ordered(f)
    ll_true = forward_backward(np.log(B_cas[:, y_T-1].T), pi_cas, A_cas)[2]
    rows.append({"T": T, "iterations": f["iters"], "A_00": round(A_o[0, 0], 3), "A_11": round(A_o[1, 1], 3),
                 "p(6 | fair)": round(B_o[0, 5], 3), "p(6 | loaded)": round(B_o[1, 5], 3),
                 "log l(learned) - log l(truth)": round(f["ll"] - ll_true, 1)})
md_table(pd.DataFrame(rows))
T iterations A_00 A_11 p(6 | fair) p(6 | loaded) log l(learned) - log l(truth)
300 116 0.937 0.901 0.126 0.408 4.2
1000 125 0.973 0.863 0.181 0.577 8.3
5000 300 0.938 0.844 0.166 0.496 6.6
  • Truth: \(A_{00} = 0.9\), \(A_{11} = 0.8\), \(p(6 \mid \text{fair}) = 0.167\), \(p(6 \mid \text{loaded}) = 0.5\). The estimates approach it as \(T\) grows. Maximum likelihood is consistent; it is just not magic at \(T = 300\). If your casino was not recovered, try your own parameters with \(T = 5000\).
  • The learned likelihood beats the truth’s on every sequence. It always will on the data it was fitted to. That is what “maximum” means, and it is why the likelihood cannot be used to check whether the truth was found.

A prior fixes the small sample

We know more than the likelihood does: a dealer who switches dice on every roll is not the story. Put a Dirichlet prior on each row of \(A\) and maximize the posterior instead.

\[ A_{j, \cdot} \sim \text{Dirichlet}(\alpha_{j, \cdot}) \quad\Longrightarrow\quad A_{jk}^{(s+1)} = \frac{\bar{n}_{jk} + \alpha_{jk} - 1}{\sum_{k'} \big( \bar{n}_{jk'} + \alpha_{jk'} - 1 \big)}. \]

The pseudo-counts \(\alpha_{jk} - 1\) are added to the expected counts and nothing else changes. Week 2’s conjugacy, inside the M-step. EM is now maximizing \(\ell(\theta) + \log p(\theta)\), the monotonicity argument goes through unchanged, and that is the quantity the stopping rule must watch: \(\ell\) alone may dip.

What the prior did

Code
prior_A = np.array([[11, 2], [2, 11]])         # ten pseudo-transitions of persistence per state
fits_map = [baum_welch(y_cas, 2, Categorical(6), seed=s, prior_A=prior_A) for s in range(10)]
best_map = max(fits_map, key=lambda f: f["ll"])
A_o, B_o = ordered(best_map)
print(f"log likelihood by restart : {pd.Series(np.round([f['ll'] for f in fits_map], 1)).value_counts().to_dict()}")
print(f"iterations by restart     : {[f['iters'] for f in fits_map]}")
print(f"A (fair, loaded):\n{np.round(A_o, 3)}")
print(f"p(6 | fair) = {B_o[0, 5]:.3f},   p(6 | loaded) = {B_o[1, 5]:.3f}")
print(f"agreement with the true states: {max((best_map['gamma'].argmax(1) == x_cas).mean(), (best_map['gamma'].argmax(1) != x_cas).mean()):.1%}")
log likelihood by restart : {-526.4: 9, -526.7: 1}
iterations by restart     : [98, 117, 113, 171, 89, 132, 64, 170, 158, 139]
A (fair, loaded):
[[0.926 0.074]
 [0.099 0.901]]
p(6 | fair) = 0.120,   p(6 | loaded) = 0.398
agreement with the true states: 76.0%
  • All ten restarts now reach the same solution, nine of them to three decimals. It has the structure that generated the rolls: one fair die, one die that favours six, and a dealer who keeps each die for a stretch. The numbers are still off, \(p_6 \approx 0.4\) rather than \(0.5\) and \(A_{11} \approx 0.9\) rather than \(0.8\), but the story is the right one, which the unpenalized fit’s was not.
  • The alternating solution is not forbidden, only penalized. Switching dice on nearly every roll is exactly what ten pseudo-counts of persistence make implausible, so it no longer has the highest posterior.
  • The prior is weak: ten imaginary transitions per state against about 300 real ones. It does not pull \(A\) toward any particular value; it rules out one story.

Why the casino crawls and the geyser sprints

Code
f_geyser = fits_of[1]
f_casino = max(fits_cas, key=lambda f: f["ll"])
fig, axes = plt.subplots(1, 2, figsize=(14, 4.2))
for ax, f, name, col in [(axes[0], f_geyser, "Old Faithful", M2), (axes[1], f_casino, "casino, 300 rolls", M1)]:
    ax.plot(f["trace"] - f["trace"][-1], marker="o", ms=3, color=col)
    ax.set_yscale("symlog", linthresh=0.1)
    ax.set_xlabel("iteration"); ax.set_ylabel(r"$\ell(\theta^{(s)}) - \ell(\hat\theta)$")
    ax.set_title(f"{name}: {f['iters']} iterations", loc="left", fontsize=13)
plt.tight_layout(); plt.show()

print(f"average max_k gamma_t(k):  Old Faithful {f_geyser['gamma'].max(1).mean():.3f},   casino {f_casino['gamma'].max(1).mean():.3f}")

average max_k gamma_t(k):  Old Faithful 0.998,   casino 0.832
  • Week 3: EM converges linearly at a rate set by the fraction of missing information.
  • A duration nearly is a label: the posterior is almost certain at almost every \(t\), so the soft counts are almost the true counts and EM behaves like the one-shot complete data MLE.
  • A roll is barely a label. Even a six leaves the posterior in doubt, the soft counts change a little each round, and EM crawls. The algorithm is not slow; the data are uninformative.

Summary

Baum-Welch in three lines

  • The complete data log likelihood of an HMM is linear in the indicators \(1[z_t = k]\) and \(1[z_{t-1} = j, z_t = k]\), so its expectation under the posterior needs only \(\gamma\) and \(\xi\). That is why forward-backward is the E-step.
  • The M-step is counting with expected counts: transitions for \(A\), symbol or moment counts for the emissions.
  • Everything about EM carries over: monotone, local, sensitive to initialization, and cheap to regularize with conjugate pseudo-counts.

What to carry into Week 5

  • Forward-backward is variable elimination on a chain: sums pushed inward, intermediate results reused. The only property of the chain we used is that it has no cycles.
  • The same two passes on a tree are called belief propagation. Felsenstein’s pruning algorithm for phylogenies is the forward pass; the backward pass gives the posterior at every internal node.
  • EM on a tree is Baum-Welch with tree-shaped marginals: the E-step returns \(\gamma\) at every node and \(\xi\) on every edge. Nothing else changes.

Reading

  • Today. Bishop, Pattern Recognition and Machine Learning (2006), §13.2.1, Maximum likelihood for the HMM. MLaPP §17.5, Learning for HMMs, including §17.5.2 on Baum-Welch and §17.5.3 on initialization.
  • Next week: sum-product on trees. Bishop §8.4.1 to §8.4.4, Inference in graphical models, which builds the algorithm on factor graphs. For the same algorithm stated directly on a tree, Murphy, Probabilistic Machine Learning: Advanced Topics (2023), §9.2, Belief propagation on chains, and §9.3, Belief propagation on trees.