346 lines
11 KiB
Go
346 lines
11 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package stats
|
||
|
||
import (
|
||
"math"
|
||
"math/big"
|
||
"testing"
|
||
|
||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
)
|
||
|
||
// The hidden Markov machinery against exact referents. The model
|
||
// probabilities are rational, so a short sequence's likelihood and
|
||
// its most likely path are computed exactly by enumerating every
|
||
// path, and the recursions are compared against that enumeration, not
|
||
// against quoted figures.
|
||
|
||
// exactModel holds one small model's parameters as rationals.
|
||
type exactModel struct {
|
||
initial []float64
|
||
transition []float64 // 2×2
|
||
emission []float64 // 2×2
|
||
}
|
||
|
||
// ratRow converts a float row to rationals.
|
||
func ratRow(vals []float64) []*big.Rat {
|
||
out := make([]*big.Rat, len(vals))
|
||
for i, v := range vals {
|
||
out[i] = big.NewRat(int64(v*1e15), 1e15)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// exactPathProbability evaluates one path's joint probability with
|
||
// the observations it explains.
|
||
func exactPathProbability(m *exactModel, observations, path []int) *big.Rat {
|
||
initial := ratRow(m.initial)
|
||
trans := make([][]*big.Rat, 2)
|
||
for k := range 2 {
|
||
trans[k] = ratRow(m.transition[k*2 : (k+1)*2])
|
||
}
|
||
emis := make([][]*big.Rat, 2)
|
||
for k := range 2 {
|
||
emis[k] = ratRow(m.emission[k*2 : (k+1)*2])
|
||
}
|
||
total := new(big.Rat).Set(initial[path[0]])
|
||
total.Mul(total, emis[path[0]][observations[0]])
|
||
for t := 1; t < len(path); t++ {
|
||
total.Mul(total, trans[path[t-1]][path[t]])
|
||
total.Mul(total, emis[path[t]][observations[t]])
|
||
}
|
||
return total
|
||
}
|
||
|
||
func TestHiddenMarkovAgainstExactEnumeration(t *testing.T) {
|
||
m := &exactModel{
|
||
initial: []float64{0.6, 0.4},
|
||
transition: []float64{0.7, 0.3, 0.2, 0.8},
|
||
emission: []float64{0.9, 0.1, 0.25, 0.75},
|
||
}
|
||
model, err := NewHiddenMarkovModel(m.initial, m.transition, m.emission)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
observations := []int{0, 0, 1, 0, 1, 1, 0, 1}
|
||
// Every path over the sequence, exactly.
|
||
bestProb := new(big.Rat)
|
||
totalProb := new(big.Rat)
|
||
var bestPath []int
|
||
path := make([]int, len(observations))
|
||
var walk func(t int)
|
||
walk = func(t int) {
|
||
if t == len(path) {
|
||
p := exactPathProbability(m, observations, path)
|
||
totalProb.Add(totalProb, p)
|
||
if p.Cmp(bestProb) > 0 {
|
||
bestProb.Set(p)
|
||
bestPath = append([]int(nil), path...)
|
||
}
|
||
return
|
||
}
|
||
for _, s := range []int{0, 1} {
|
||
path[t] = s
|
||
walk(t + 1)
|
||
}
|
||
}
|
||
walk(0)
|
||
// The forward recursion's likelihood against the exact total.
|
||
_, ll, err := model.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
wantLL, _ := new(big.Float).SetRat(totalProb).Float64()
|
||
if math.Abs(ll-math.Log(wantLL)) > 1e-12 {
|
||
t.Fatalf("the forward likelihood = %.16f, want the exact %.16f", ll, math.Log(wantLL))
|
||
}
|
||
// Viterbi against the exact best path.
|
||
states, pathProb, err := model.Viterbi(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
wantBest, _ := new(big.Float).SetRat(bestProb).Float64()
|
||
if math.Abs(pathProb-math.Log(wantBest)) > 1e-12 {
|
||
t.Fatalf("Viterbi's log probability = %.16f, want the exact %.16f", pathProb, math.Log(wantBest))
|
||
}
|
||
for step := range states {
|
||
if states[step] != bestPath[step] {
|
||
t.Fatalf("Viterbi's path %v leaves the exact best path %v at step %d", states, bestPath, step)
|
||
}
|
||
}
|
||
// The smoothing's last row must coincide with filtering's: both
|
||
// see the whole sequence by then.
|
||
filtered, _, err := model.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
smoothed, _, err := model.Smooth(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for k := range filtered[len(observations)-1] {
|
||
if filtered[len(observations)-1][k] != smoothed[len(observations)-1][k] {
|
||
t.Fatalf("the last filtered posterior %v disagrees with the smoothed one %v",
|
||
filtered[len(observations)-1], smoothed[len(observations)-1])
|
||
}
|
||
if smoothed[len(observations)-1][k] < 0 || smoothed[len(observations)-1][k] > 1 {
|
||
t.Fatalf("the smoothed posterior %g is not a probability", smoothed[len(observations)-1][k])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHiddenMarkovSingleStateLikelihood(t *testing.T) {
|
||
// One state: the model is an independent-symbol law and the
|
||
// likelihood is the product of the emissions, by hand.
|
||
model, err := NewHiddenMarkovModel([]float64{1}, []float64{1}, []float64{0.25, 0.75})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
observations := []int{1, 1, 0, 1, 1, 1, 0, 1, 1}
|
||
_, ll, err := model.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := 0.0
|
||
for _, o := range observations {
|
||
if o == 0 {
|
||
want += math.Log(0.25)
|
||
} else {
|
||
want += math.Log(0.75)
|
||
}
|
||
}
|
||
if math.Abs(ll-want) > 1e-12 {
|
||
t.Fatalf("the single-state likelihood = %.16f, want %.16f", ll, want)
|
||
}
|
||
path, _, err := model.Viterbi(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, s := range path {
|
||
if s != 0 {
|
||
t.Fatalf("the single-state decode left the only state: %v", path)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHiddenMarkovScalingSurvivesLongSequences(t *testing.T) {
|
||
// A thousand-step sequence under a sticky model: the raw forward
|
||
// probabilities underflow long before the end, and the scaled
|
||
// recursions must answer a finite likelihood and rows that stay
|
||
// distributions.
|
||
model, err := NewHiddenMarkovModel(
|
||
[]float64{0.5, 0.5},
|
||
[]float64{0.99, 0.01, 0.02, 0.98},
|
||
[]float64{0.8, 0.2, 0.3, 0.7})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
observations := make([]int, 1000)
|
||
for t := range observations {
|
||
observations[t] = (t * 7 / 3) % 2
|
||
}
|
||
filtered, ll, err := model.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if math.IsInf(ll, 0) || math.IsNaN(ll) {
|
||
t.Fatalf("the long sequence's likelihood = %g", ll)
|
||
}
|
||
for step, row := range filtered {
|
||
total := 0.0
|
||
for _, v := range row {
|
||
total += v
|
||
}
|
||
if math.Abs(total-1) > 1e-9 {
|
||
t.Fatalf("the filtered row at %d sums to %g", step, total)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHiddenMarkovFitRecovery(t *testing.T) {
|
||
// Baum-Welch on a sequence drawn from a known model: the fitted
|
||
// likelihood must clear the known model's own, the fit must
|
||
// converge, and a repeat run must be bit-identical.
|
||
truth, err := NewHiddenMarkovModel(
|
||
[]float64{0.65, 0.35},
|
||
[]float64{0.8, 0.2, 0.15, 0.85},
|
||
[]float64{0.85, 0.15, 0.3, 0.7})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
observations := hmmSimulate(t, truth, 400, 20260925)
|
||
_, truthLL, err := truth.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fit, err := FitHiddenMarkovModel(core.NewGenerator(7), observations, 2, 2)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !fit.Converged {
|
||
t.Fatalf("the fit did not converge (%d iterations)", fit.Iterations)
|
||
}
|
||
if fit.LogLikelihood < truthLL {
|
||
t.Fatalf("the fitted likelihood %.6f sits below the truth's %.6f, expectation maximisation failed to climb",
|
||
fit.LogLikelihood, truthLL)
|
||
}
|
||
again, err := FitHiddenMarkovModel(core.NewGenerator(7), observations, 2, 2)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if again.LogLikelihood != fit.LogLikelihood || again.Iterations != fit.Iterations {
|
||
t.Fatal("two identical fits disagreed")
|
||
}
|
||
for i := range fit.Model.Initial {
|
||
if fit.Model.Initial[i] != again.Model.Initial[i] ||
|
||
fit.Model.Transition[i] != again.Model.Transition[i] ||
|
||
fit.Model.Emission[i] != again.Model.Emission[i] {
|
||
t.Fatal("two identical fits returned different parameters")
|
||
}
|
||
}
|
||
// The fitted model answers the same likelihood through Forward as
|
||
// the fit reported.
|
||
_, ll, err := fit.Model.Forward(observations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ll != fit.LogLikelihood {
|
||
t.Fatalf("the fitted model's likelihood %.16f differs from the fit's report %.16f", ll, fit.LogLikelihood)
|
||
}
|
||
}
|
||
|
||
// hmmSimulate draws an observation sequence from a model through the
|
||
// house generator, by inverse transform on the cumulative rows.
|
||
func hmmSimulate(t *testing.T, model *HiddenMarkovModel, length int, seed int64) []int {
|
||
t.Helper()
|
||
g := core.NewGenerator(seed)
|
||
states := len(model.Initial)
|
||
symbols := len(model.Emission) / states
|
||
draw := func(row []float64) int {
|
||
u := g.Unit()
|
||
total := 0.0
|
||
for i, v := range row {
|
||
total += v
|
||
if u < total {
|
||
return i
|
||
}
|
||
}
|
||
return len(row) - 1
|
||
}
|
||
state := draw(model.Initial)
|
||
out := make([]int, length)
|
||
for t := range length {
|
||
out[t] = draw(model.Emission[state*symbols : (state+1)*symbols])
|
||
state = draw(model.Transition[state*states : (state+1)*states])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func TestHiddenMarkovRefusals(t *testing.T) {
|
||
model, err := NewHiddenMarkovModel([]float64{0.6, 0.4}, []float64{0.7, 0.3, 0.2, 0.8}, []float64{0.9, 0.1, 0.25, 0.75})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := model.Forward(nil); err == nil {
|
||
t.Fatal("an empty sequence was accepted")
|
||
}
|
||
if _, _, err := model.Forward([]int{0, 2, 1}); err == nil {
|
||
t.Fatal("an out-of-range symbol was accepted")
|
||
}
|
||
if _, err := NewHiddenMarkovModel([]float64{0.6, 0.5}, []float64{0.7, 0.3, 0.2, 0.8}, []float64{0.9, 0.1, 0.25, 0.75}); err == nil {
|
||
t.Fatal("an initial distribution summing over 1 was accepted")
|
||
}
|
||
if _, err := NewHiddenMarkovModel([]float64{0.6, 0.4}, []float64{0.7, 0.3, 0.2}, []float64{0.9, 0.1, 0.25, 0.75}); err == nil {
|
||
t.Fatal("a short transition matrix was accepted")
|
||
}
|
||
if _, err := NewHiddenMarkovModel([]float64{0.6, 0.4}, []float64{0.7, 0.3, 0.2, 0.8}, []float64{0.9, 0.1, 0.25}); err == nil {
|
||
t.Fatal("a ragged emission matrix was accepted")
|
||
}
|
||
if _, err := NewHiddenMarkovModel([]float64{1.2, -0.2}, []float64{0.7, 0.3, 0.2, 0.8}, []float64{0.9, 0.1, 0.25, 0.75}); err == nil {
|
||
t.Fatal("a probability outside [0, 1] was accepted")
|
||
}
|
||
if _, err := FitHiddenMarkovModel(nil, []int{0, 1}, 2, 2); err == nil {
|
||
t.Fatal("a nil generator was accepted")
|
||
}
|
||
if _, err := FitHiddenMarkovModel(core.NewGenerator(1), []int{0, 1, 5}, 2, 2); err == nil {
|
||
t.Fatal("an out-of-range training symbol was accepted")
|
||
}
|
||
var nilModel *HiddenMarkovModel
|
||
if _, _, err := nilModel.Forward([]int{0}); err == nil {
|
||
t.Fatal("a nil model was accepted")
|
||
}
|
||
}
|
||
|
||
func TestHiddenMarkovZeroProbabilitySequence(t *testing.T) {
|
||
// A structural zero in the emissions: the only state never emits
|
||
// symbol 1, so a sequence holding it has probability zero and no
|
||
// posterior exists. The recursions must refuse rather than divide
|
||
// the zero normaliser into NaN posteriors.
|
||
model, err := NewHiddenMarkovModel([]float64{1}, []float64{1}, []float64{1, 0})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := model.Forward([]int{1}); err == nil {
|
||
t.Fatal("a zero-probability sequence was accepted by Forward")
|
||
}
|
||
if _, _, err := model.Smooth([]int{1}); err == nil {
|
||
t.Fatal("a zero-probability sequence was accepted by Smooth")
|
||
}
|
||
// A sequence that dies one step in: the first symbol is live, the
|
||
// second is not, and the refusal is the same.
|
||
if _, _, err := model.Forward([]int{0, 1}); err == nil {
|
||
t.Fatal("a sequence that dies at step 1 was accepted by Forward")
|
||
}
|
||
// The live half of the same model still answers: the zero emission
|
||
// silences nothing a legal sequence needs.
|
||
filtered, ll, err := model.Forward([]int{0, 0})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ll != 0 || filtered[0][0] != 1 {
|
||
t.Fatalf("the certain sequence answered (%g, %v), want (0, [1])", ll, filtered[0])
|
||
}
|
||
}
|