Inside-outside algorithm in Python
Estimates PCFG rule probabilities by expectation-maximization over parse chart sums.
- File
inside_outside_algorithm.py, 145 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/inside-outside-algorithm/. The authors are listed in prov/9H2A1G.jsonl.
"""The inside-outside algorithm: one expectation-maximization step for a PCFG.
The grammar is in Chomsky normal form. A rule is a pair (A, rhs) where rhs is
(B, C) with two nonterminals, (a,) with one terminal, or () for S -> eps.
The nonterminals are the left sides; S -> eps is allowed only when S is on
no right side. theta maps each rule to its probability, and the rules of each
nonterminal should sum to 1. The numbers may be floats or Fractions; with
Fractions every result is exact.
inside[i][j][A] is the probability that A derives a_{i+1} ... a_j, and
outside[i][j][A] is the probability that S derives a_1 ... a_i A a_{j+1} ... a_n.
"""
from fractions import Fraction
from typing import Sequence, Union
Num = Union[float, Fraction]
Rule = tuple[str, tuple[str, ...]]
Chart = list[list[dict[str, Num]]]
def _check(theta: dict[Rule, Num], start: str) -> set[str]:
nts = {a for a, _ in theta}
for (a, rhs), _ in theta.items():
if len(rhs) == 2 and not (rhs[0] in nts and rhs[1] in nts):
raise ValueError(f"binary rule {a} -> {rhs} needs two nonterminals")
if len(rhs) == 1 and rhs[0] in nts:
raise ValueError(f"unit rule {a} -> {rhs} is not in Chomsky normal form")
if len(rhs) == 0 and a != start:
raise ValueError("only the start symbol may have an empty rule")
if len(rhs) > 2:
raise ValueError(f"rule {a} -> {rhs} is too long")
if (start, ()) in theta and any(start in rhs for _, rhs in theta if len(rhs) == 2):
raise ValueError("S -> eps needs S on no right side")
return nts
def inside_chart(theta: dict[Rule, Num], start: str, w: Sequence[str]) -> Chart:
"""Inside probabilities for all spans of a nonempty w, shortest spans first."""
n = len(w)
binary = [(a, rhs[0], rhs[1], p) for (a, rhs), p in theta.items() if len(rhs) == 2]
inside: Chart = [[{} for _ in range(n + 1)] for _ in range(n + 1)]
for i in range(n): # @line L4
for (a, rhs), p in theta.items():
if len(rhs) == 1 and rhs[0] == w[i]:
inside[i][i + 1][a] = inside[i][i + 1].get(a, 0) + p
for length in range(2, n + 1): # @line L5
for i in range(n - length + 1):
j = i + length
cell = inside[i][j]
for k in range(i + 1, j):
left, right = inside[i][k], inside[k][j]
if not left or not right:
continue
for a, b, c, p in binary:
if b in left and c in right:
cell[a] = cell.get(a, 0) + p * left[b] * right[c]
return inside
def outside_chart(theta: dict[Rule, Num], start: str, inside: Chart, n: int) -> Chart:
"""Outside probabilities for all spans, longest spans first."""
binary = [(a, rhs[0], rhs[1], p) for (a, rhs), p in theta.items() if len(rhs) == 2]
outside: Chart = [[{} for _ in range(n + 1)] for _ in range(n + 1)]
outside[0][n][start] = 1 # @line L7
for length in range(n, 1, -1): # @line L8
for i in range(n - length + 1):
j = i + length
out = outside[i][j]
if not out:
continue
for k in range(i + 1, j):
left, right = inside[i][k], inside[k][j]
for a, b, c, p in binary:
if a in out and b in left and c in right: # @line L9
outside[i][k][b] = outside[i][k].get(b, 0) + out[a] * p * right[c]
outside[k][j][c] = outside[k][j].get(c, 0) + out[a] * p * left[b]
return outside
def expected_counts(theta: dict[Rule, Num], start: str,
w: Sequence[str]) -> tuple[Num, dict[Rule, Num]]:
"""Return (Z, c): Z is the probability of w, and c[r] is the expected number
of uses of rule r in a parse tree of w, given w. If Z = 0, c is empty."""
_check(theta, start)
n = len(w)
counts: dict[Rule, Num] = {}
if n == 0: # @line L3
z = theta.get((start, ()), 0)
if z:
counts[(start, ())] = 1
return z, counts
inside = inside_chart(theta, start, w)
z = inside[0][n].get(start, 0) # @line L6
if not z:
return z, counts
outside = outside_chart(theta, start, inside, n)
for (a, rhs), p in theta.items(): # @line L10
if len(rhs) != 2:
continue
b, c = rhs
total: Num = 0
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length
out = outside[i][j].get(a)
if not out:
continue
for k in range(i + 1, j):
lb, rc = inside[i][k].get(b), inside[k][j].get(c)
if lb and rc:
total += out * p * lb * rc
if total:
counts[(a, rhs)] = total / z
for i in range(n): # @line L11
for (a, rhs), p in theta.items():
if len(rhs) == 1 and rhs[0] == w[i]:
out = outside[i][i + 1].get(a)
if out:
counts[(a, rhs)] = counts.get((a, rhs), 0) + out * p / z
return z, counts
def em_step(theta: dict[Rule, Num], start: str,
corpus: Sequence[Sequence[str]]) -> tuple[dict[Rule, Num], list[Num]]:
"""One inside-outside step. Return the new probabilities and the
probability of each sentence under the old ones. Sentences of probability 0
are skipped; a nonterminal with no expected use keeps its old probabilities."""
total: dict[Rule, Num] = {r: 0 for r in theta} # @line L1
likelihoods: list[Num] = []
for w in corpus: # @line L2
z, counts = expected_counts(theta, start, w)
likelihoods.append(z)
for r, c in counts.items():
total[r] += c
by_lhs: dict[str, Num] = {}
for (a, _), c in total.items():
by_lhs[a] = by_lhs.get(a, 0) + c
new: dict[Rule, Num] = {}
for (a, rhs), c in total.items(): # @line L12
new[(a, rhs)] = c / by_lhs[a] if by_lhs[a] else theta[(a, rhs)]
return new, likelihoods # @line L13
test_inside_outside_algorithm.py 178 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/inside-outside-algorithm/. The authors are listed in prov/9H2A1G.jsonl.
"""Tests for the inside-outside algorithm against enumeration of all parse trees."""
import math
import random
from collections import Counter
from fractions import Fraction
import pytest
from inside_outside_algorithm import (em_step, expected_counts, inside_chart,
outside_chart)
EXAMPLE = {
("S", ("S", "S")): Fraction(1, 2),
("S", ("a",)): Fraction(1, 2),
}
def all_trees(theta, start, w):
"""Every parse tree of w, as (probability, Counter of rules, list of
(A, i, j) nodes). Plain enumeration over spans; only for short w."""
if not w:
p = theta.get((start, ()), 0)
return [(p, Counter({(start, ()): 1}), [])] if p else []
memo = {}
def trees(a, i, j):
key = (a, i, j)
if key in memo:
return memo[key]
out = []
for (lhs, rhs), p in theta.items():
if lhs != a or p == 0:
continue
if len(rhs) == 1 and j == i + 1 and rhs[0] == w[i]:
out.append((p, Counter({(lhs, rhs): 1}), [(a, i, j)]))
if len(rhs) == 2:
for k in range(i + 1, j):
for pl, cl, nl in trees(rhs[0], i, k):
for pr, cr, nr in trees(rhs[1], k, j):
out.append((p * pl * pr, cl + cr + Counter({(lhs, rhs): 1}),
[(a, i, j)] + nl + nr))
memo[key] = out
return out
return trees(start, 0, len(w))
def random_pcfg(rng, with_eps):
names = ["S", "A", "B"][: rng.randint(1, 3)]
inner = [x for x in names if x != "S"] if with_eps else names
weights = {}
for a in names:
rules = set()
rules.add((rng.choice("ab"),))
if inner:
for _ in range(rng.randint(0, 3)):
rules.add((rng.choice(inner), rng.choice(inner)))
if a == "S" and with_eps:
rules.add(())
if not inner:
rules.add(("b",))
for rhs in rules:
weights[(a, rhs)] = Fraction(rng.randint(1, 4))
theta = {}
for (a, rhs), wgt in weights.items():
tot = sum(v for (b, _), v in weights.items() if b == a)
theta[(a, rhs)] = wgt / tot
return theta
def test_example_from_the_page():
w = ["a", "a", "a"]
z, counts = expected_counts(EXAMPLE, "S", w)
assert z == Fraction(1, 16)
assert counts[("S", ("S", "S"))] == 2
assert counts[("S", ("a",))] == 3
inside = inside_chart(EXAMPLE, "S", w)
assert inside[0][2]["S"] == Fraction(1, 8)
outside = outside_chart(EXAMPLE, "S", inside, 3)
assert outside[0][1]["S"] == Fraction(1, 8)
assert outside[1][2]["S"] == Fraction(1, 8)
new, lik = em_step(EXAMPLE, "S", [w])
assert lik == [Fraction(1, 16)]
assert new[("S", ("S", "S"))] == Fraction(2, 5)
assert new[("S", ("a",))] == Fraction(3, 5)
def test_random_grammars_match_tree_enumeration():
rng = random.Random(20261106)
checked = 0
positive = 0
for _ in range(600):
with_eps = rng.random() < 0.3
theta = random_pcfg(rng, with_eps)
w = [rng.choice("ab") for _ in range(rng.randint(0, 5))]
trees = all_trees(theta, "S", w)
z_brute = sum(p for p, _, _ in trees)
z, counts = expected_counts(theta, "S", w)
assert z == z_brute
if z:
positive += 1
brute = Counter()
for p, c, _ in trees:
for r, m in c.items():
brute[r] += p * m / z
assert dict(brute) == counts
if w:
n = len(w)
inside = inside_chart(theta, "S", w)
outside = outside_chart(theta, "S", inside, n)
spans = Counter()
for p, _, nodes in trees:
for node in nodes:
spans[node] += p
for i in range(n):
for j in range(i + 1, n + 1):
for a in {x for x, _ in theta}:
got = inside[i][j].get(a, 0) * outside[i][j].get(a, 0)
assert got == spans[(a, i, j)]
else:
assert counts == {}
checked += 1
assert checked == 600
assert positive > 120
def test_em_never_lowers_the_likelihood():
rng = random.Random(99)
runs = 0
while runs < 60:
theta = {r: float(p) for r, p in random_pcfg(rng, False).items()}
corpus = [[rng.choice("ab") for _ in range(rng.randint(1, 6))] for _ in range(4)]
_, lik = em_step(theta, "S", corpus)
if any(z == 0 for z in lik):
continue
runs += 1
last = sum(math.log(z) for z in lik)
for _ in range(8):
theta, lik = em_step(theta, "S", corpus)
now = sum(math.log(z) for z in lik)
assert now >= last - 1e-9
last = now
sums = Counter()
for (a, _), p in theta.items():
sums[a] += p
for a, s in sums.items():
assert abs(s - 1) < 1e-9
def test_empty_sentence_and_zero_probability():
theta = {("S", ()): Fraction(1, 3), ("S", ("A", "A")): Fraction(2, 3), ("A", ("a",)): Fraction(1)}
z, counts = expected_counts(theta, "S", [])
assert z == Fraction(1, 3) and counts == {("S", ()): 1}
z, counts = expected_counts(theta, "S", ["a"])
assert z == 0 and counts == {}
new, lik = em_step(theta, "S", [[], ["a", "a"], ["b"]])
assert lik == [Fraction(1, 3), Fraction(2, 3), 0]
assert new[("S", ())] == Fraction(1, 2) and new[("A", ("a",))] == 1
def test_rejects_grammars_outside_the_normal_form():
with pytest.raises(ValueError):
expected_counts({("S", ("A",)): 1.0, ("A", ("a",)): 1.0}, "S", ["a"])
with pytest.raises(ValueError):
expected_counts({("S", ()): 0.5, ("S", ("S", "S")): 0.5}, "S", [])
def test_long_sentence_in_floats():
theta = {("S", ("S", "S")): 0.4, ("S", ("a",)): 0.6}
w = ["a"] * 60
z, counts = expected_counts(theta, "S", w)
assert z > 0
assert abs(counts[("S", ("a",))] - 60) < 1e-6
assert abs(counts[("S", ("S", "S"))] - 59) < 1e-6
What the next level needs
- the tests did not run in this build