Files
tensor/stats/hmm.go
T

566 lines
18 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Hidden Markov models over discrete observations: the filtered and
// smoothed state posteriors, the most likely state path and the
// Baum-Welch fit of the parameters. Every computation runs on the
// scaled recursions, where each sweep's normaliser absorbs the
// likelihood of what has been seen, so a long sequence cannot
// underflow the way the raw forward probabilities would; the log
// likelihood itself is the sum of the normalisers' logarithms. The
// decoding walks in log space with the lowest index winning ties, and
// the fit draws its starting point from the house generator, so the
// whole surface is deterministic for a given generator state and
// sequence.
// HiddenMarkovModel is a hidden Markov model over a set of hidden
// states and a set of observation symbols. The fields hold the
// distribution parameters in row-major order: the rows of Transition
// are the conditionals P(next = j | current = k) and the rows of
// Emission the conditionals P(symbol = m | state = k). The
// constructors validate every row as a distribution, so a model value
// is always safe to evaluate.
type HiddenMarkovModel struct {
// Initial is the distribution over states at the first step.
Initial []float64
// Transition is the states×states matrix of one-step
// probabilities, row-major.
Transition []float64
// Emission is the states×symbols matrix of observation
// probabilities, row-major.
Emission []float64
}
// HiddenMarkovFitResult carries the Baum-Welch fit of a hidden Markov
// model.
type HiddenMarkovFitResult struct {
// Model is the fitted model.
Model *HiddenMarkovModel
// LogLikelihood is the log likelihood of the training sequence
// under the fitted model.
LogLikelihood float64
// Iterations counts the Baum-Welch sweeps taken; Converged reports
// whether the log likelihood settled under the tolerance before
// the budget ran out.
Iterations int
Converged bool
}
// hmmMaxIterations caps the Baum-Welch sweeps, with the same
// reporting convention the other fits carry.
const hmmMaxIterations = 500
// hmmTolerance is the convergence tolerance on the log likelihood:
// the sweeps stop once it moves by less than hmmTolerance scaled by
// 1 + |log likelihood|. Baum-Welch's ascent crawls along a flat ridge
// long before its parameters settle to the last digit, and the
// tolerance stops the sweep where the objective's remaining climb is
// immaterial rather than where the parameters stop moving.
const hmmTolerance = 1e-6
// hmmFloor is the probability floor the re-estimation applies before
// renormalising: a parameter driven to exactly zero would silence a
// state or a symbol forever, and the floor keeps every path alive at
// a width no plausible parameter sits under.
const hmmFloor = 1e-12
// NewHiddenMarkovModel validates and copies the parameters of a
// hidden Markov model: Initial of the length of the state set,
// Transition with one row per state over the states, Emission with
// one row per state over the symbols. Every entry must be finite and
// in [0, 1] and every row must sum to 1 within 1e-9; a model that
// failed any of these would silently weight impossible paths later.
func NewHiddenMarkovModel(initial, transition, emission []float64) (*HiddenMarkovModel, error) {
const name = "NewHiddenMarkovModel"
states := len(initial)
if states < 1 {
return nil, base.Errf("%s: the model needs at least one state", name)
}
if len(transition) != states*states {
return nil, base.Errf("%s: the transition matrix holds %d entries, want %d for %d states",
name, len(transition), states*states, states)
}
if len(emission) == 0 || len(emission)%states != 0 {
return nil, base.Errf("%s: the emission matrix holds %d entries, not a whole number of rows for %d states",
name, len(emission), states)
}
if err := hmmCheckRow(name, "the initial distribution", initial); err != nil {
return nil, err
}
for k := range states {
if err := hmmCheckRow(name, "the transition rows", transition[k*states:(k+1)*states]); err != nil {
return nil, err
}
}
symbols := len(emission) / states
for k := range states {
if err := hmmCheckRow(name, "the emission rows", emission[k*symbols:(k+1)*symbols]); err != nil {
return nil, err
}
}
return &HiddenMarkovModel{
Initial: append([]float64(nil), initial...),
Transition: append([]float64(nil), transition...),
Emission: append([]float64(nil), emission...),
}, nil
}
// hmmCheckRow validates one distribution row: finite, inside [0, 1],
// summing to 1 within 1e-9.
func hmmCheckRow(name, what string, row []float64) error {
total := 0.0
for _, v := range row {
if math.IsNaN(v) || math.IsInf(v, 0) {
return base.Errf("%s: %s hold the non-finite value %g", name, what, v)
}
if v < 0 || v > 1 {
return base.Errf("%s: %s hold the probability %g outside [0, 1]", name, what, v)
}
total += v
}
if math.Abs(total-1) > 1e-9 {
return base.Errf("%s: %s sum to %g, not 1", name, what, total)
}
return nil
}
// hmmStates returns the model's state count.
func (m *HiddenMarkovModel) hmmStates() int { return len(m.Initial) }
// hmmSymbols returns the model's symbol count.
func (m *HiddenMarkovModel) hmmSymbols() int { return len(m.Emission) / max(1, len(m.Initial)) }
// hmmWidth returns the model's symbol count from its own fields, the
// shape the recursions read the emission rows with.
func (m *HiddenMarkovModel) hmmWidth() int { return len(m.Emission) / len(m.Initial) }
// hmmCheckSequence validates an observation sequence against the
// model's symbol set.
func hmmCheckSequence(name string, observations []int, symbols int) error {
if len(observations) == 0 {
return base.Errf("%s: the observation sequence is empty", name)
}
for t, o := range observations {
if o < 0 || o >= symbols {
return base.Errf("%s: observation %d is symbol %d, outside the model's %d symbols", name, t, o, symbols)
}
}
return nil
}
// hmmForward runs the scaled forward recursion: alpha[t] is the
// P(state, observations 0..t) vector normalised by its own total, and
// the totals accumulate into the log likelihood. The returned rows
// are fresh. A normaliser of exactly zero means the sequence has
// probability zero under the model, the structural zeros the
// parameters carry having silenced every state at some step: the
// posteriors are undefined there and the recursion refuses rather
// than divide the zero into NaNs.
func (m *HiddenMarkovModel) hmmForward(observations []int) ([][]float64, []float64, error) {
const name = "HiddenMarkovModel.Forward"
states := m.hmmStates()
width := m.hmmWidth()
alpha := make([][]float64, len(observations))
scales := make([]float64, len(observations))
row := make([]float64, states)
for k := range states {
row[k] = m.Initial[k] * m.Emission[k*width+observations[0]]
}
total := 0.0
for _, v := range row {
total += v
}
if total == 0 {
return nil, nil, base.Errf("%s: the observation sequence has probability zero under the model", name)
}
scales[0] = total
for k := range states {
row[k] /= total
}
alpha[0] = row
for t := 1; t < len(observations); t++ {
next := make([]float64, states)
sym := observations[t]
for j := range states {
total := 0.0
for k := range states {
total += row[k] * m.Transition[k*states+j]
}
next[j] = total * m.Emission[j*width+sym]
}
total := 0.0
for _, v := range next {
total += v
}
if total == 0 {
return nil, nil, base.Errf("%s: the observation sequence has probability zero under the model at step %d", name, t)
}
scales[t] = total
for j := range states {
next[j] /= total
}
alpha[t] = next
row = next
}
return alpha, scales, nil
}
// hmmBackward runs the scaled backward recursion against the forward
// scales, so the product alpha·beta normalises directly into the
// smoothed posteriors.
func (m *HiddenMarkovModel) hmmBackward(observations []int, scales []float64) [][]float64 {
states := m.hmmStates()
width := m.hmmWidth()
beta := make([][]float64, len(observations))
last := make([]float64, states)
for k := range states {
last[k] = 1
}
beta[len(observations)-1] = last
for t := len(observations) - 2; t >= 0; t-- {
row := make([]float64, states)
sym := observations[t+1]
for k := range states {
total := 0.0
for j := range states {
total += m.Transition[k*states+j] * m.Emission[j*width+sym] * beta[t+1][j]
}
row[k] = total / scales[t+1]
}
beta[t] = row
}
return beta
}
// Forward runs the scaled forward recursion over the observation
// sequence and returns the filtered state posteriors, one row of
// state probabilities per step, with the sequence's log likelihood
// P(observations | model). Refuses a nil model, an empty sequence,
// any observation outside the model's symbol set, and a sequence of
// probability zero under the model, whose structural zero an emission
// or transition carries leaves the filtered posteriors undefined.
func (m *HiddenMarkovModel) Forward(observations []int) (filtered [][]float64, logLikelihood float64, err error) {
const name = "HiddenMarkovModel.Forward"
if m == nil {
return nil, 0, base.Errf("%s: the model is nil", name)
}
if err := hmmCheckSequence(name, observations, m.hmmSymbols()); err != nil {
return nil, 0, err
}
alpha, scales, ferr := m.hmmForward(observations)
if ferr != nil {
return nil, 0, ferr
}
return alpha, hmmLogLikelihood(scales), nil
}
// Smooth runs the forward-backward recursions over the observation
// sequence and returns the smoothed state posteriors P(state at t |
// all observations), one row per step, with the sequence's log
// likelihood. The refusals are Forward's own.
func (m *HiddenMarkovModel) Smooth(observations []int) (smoothed [][]float64, logLikelihood float64, err error) {
const name = "HiddenMarkovModel.Smooth"
if m == nil {
return nil, 0, base.Errf("%s: the model is nil", name)
}
if err := hmmCheckSequence(name, observations, m.hmmSymbols()); err != nil {
return nil, 0, err
}
alpha, scales, ferr := m.hmmForward(observations)
if ferr != nil {
return nil, 0, ferr
}
beta := m.hmmBackward(observations, scales)
gamma := make([][]float64, len(observations))
for t := range observations {
row := make([]float64, len(m.Initial))
total := 0.0
for k := range row {
row[k] = alpha[t][k] * beta[t][k]
total += row[k]
}
for k := range row {
row[k] /= total
}
gamma[t] = row
}
return gamma, hmmLogLikelihood(scales), nil
}
// hmmLogLikelihood folds the forward scales into the sequence's log
// likelihood.
func hmmLogLikelihood(scales []float64) float64 {
total := 0.0
for _, s := range scales {
total += math.Log(s)
}
return total
}
// Viterbi decodes the most likely state path through the observation
// sequence in log space, breaking ties toward the lowest state index,
// and returns the path with its log probability
// log P(path, observations | model). The refusals are Forward's own.
func (m *HiddenMarkovModel) Viterbi(observations []int) (states []int, logProbability float64, err error) {
const name = "HiddenMarkovModel.Viterbi"
if m == nil {
return nil, 0, base.Errf("%s: the model is nil", name)
}
if err := hmmCheckSequence(name, observations, m.hmmSymbols()); err != nil {
return nil, 0, err
}
n := m.hmmStates()
width := m.hmmWidth()
scores := make([]float64, n)
for k := range n {
scores[k] = math.Log(m.Initial[k]) + math.Log(m.Emission[k*width+observations[0]])
}
back := make([][]int, len(observations))
back[0] = make([]int, n)
for t := 1; t < len(observations); t++ {
next := make([]float64, n)
prev := make([]int, n)
sym := observations[t]
for j := range n {
best := math.Inf(-1)
arg := 0
for k := range n {
candidate := scores[k] + math.Log(m.Transition[k*n+j])
if candidate > best {
best = candidate
arg = k
}
}
next[j] = best + math.Log(m.Emission[j*width+sym])
prev[j] = arg
}
scores = next
back[t] = prev
}
best := math.Inf(-1)
arg := 0
for k := range n {
if scores[k] > best {
best = scores[k]
arg = k
}
}
path := make([]int, len(observations))
path[len(observations)-1] = arg
for t := len(observations) - 1; t > 0; t-- {
path[t-1] = back[t][path[t]]
}
return path, best, nil
}
// FitHiddenMarkovModel fits a hidden Markov model of the given state
// and symbol counts to the observation sequence by Baum-Welch
// expectation maximisation. The starting point draws every
// distribution row from the flat Dirichlet through the house
// generator, so the fit is deterministic for the generator state;
// expectation maximisation climbs to a local maximum of the
// likelihood, and a different generator state may land on a different
// one.
//
// The sweeps stop once the log likelihood moves by less than
// hmmTolerance relative, within hmmMaxIterations sweeps; Converged
// names which happened. The re-estimation floors every parameter at
// hmmFloor and renormalises the rows, so no symbol or transition is
// silenced outright by one sweep. A nil generator, a state or symbol
// count below one, an empty sequence or an observation outside the
// symbol set is an error.
func FitHiddenMarkovModel(g *core.Generator, observations []int, states, symbols int) (*HiddenMarkovFitResult, error) {
const name = "FitHiddenMarkovModel"
if g == nil {
return nil, base.Errf("%s: the generator is nil", name)
}
if states < 1 {
return nil, base.Errf("%s: the model needs at least one state, got %d", name, states)
}
if symbols < 1 {
return nil, base.Errf("%s: the model needs at least one symbol, got %d", name, symbols)
}
if err := hmmCheckSequence(name, observations, symbols); err != nil {
return nil, err
}
initial := hmmDrawRow(g, states)
transition := make([]float64, 0, states*states)
for range states {
transition = append(transition, hmmDrawRow(g, states)...)
}
emission := make([]float64, 0, states*symbols)
for range states {
emission = append(emission, hmmDrawRow(g, symbols)...)
}
model, err := NewHiddenMarkovModel(initial, transition, emission)
if err != nil {
return nil, err
}
prev := math.Inf(-1)
converged := false
iterations := hmmMaxIterations
for iter := 1; iter <= hmmMaxIterations; iter++ {
logLik, gamma, xi, serr := model.hmmSweep(observations)
if serr != nil {
return nil, serr
}
move := logLik - prev
prev = logLik
if iter > 1 && math.Abs(move) <= hmmTolerance*(1+math.Abs(logLik)) {
// The model has not moved since the sweep above, so the
// log likelihood in hand is the fitted model's own.
converged = true
iterations = iter
break
}
model = hmmReestimate(model, observations, gamma, xi)
}
if !converged {
// The budget ran out after a re-estimation: the reported
// likelihood is the final model's own, not its predecessor's.
logLik, _, _, serr := model.hmmSweep(observations)
if serr != nil {
return nil, serr
}
prev = logLik
}
return &HiddenMarkovFitResult{
Model: model,
LogLikelihood: prev,
Iterations: iterations,
Converged: converged,
}, nil
}
// hmmDrawRow draws one distribution row of the given width from the
// flat Dirichlet through the generator: the normalised exponentials
// of uniform draws, the classic construction of the simplex's uniform
// distribution.
func hmmDrawRow(g *core.Generator, width int) []float64 {
row := make([]float64, width)
total := 0.0
for i := range width {
weight := -math.Log(max(g.Unit(), 1e-300))
row[i] = weight
total += weight
}
for i := range row {
row[i] /= total
}
return row
}
// hmmSweep runs the forward-backward pass at the model's current
// parameters: the log likelihood, the smoothed posteriors gamma and
// the pairwise posteriors xi the M step reads. xi[t] is the
// states×states row-major table P(state at t, state at t+1 |
// observations) for the step from t to t+1.
func (m *HiddenMarkovModel) hmmSweep(observations []int) (float64, [][]float64, [][]float64, error) {
states := m.hmmStates()
width := m.hmmWidth()
alpha, scales, ferr := m.hmmForward(observations)
if ferr != nil {
return 0, nil, nil, ferr
}
beta := m.hmmBackward(observations, scales)
logLik := hmmLogLikelihood(scales)
gamma := make([][]float64, len(observations))
for t := range observations {
row := make([]float64, states)
total := 0.0
for k := range states {
row[k] = alpha[t][k] * beta[t][k]
total += row[k]
}
for k := range states {
row[k] /= total
}
gamma[t] = row
}
xi := make([][]float64, len(observations)-1)
for t := range xi {
table := make([]float64, states*states)
total := 0.0
sym := observations[t+1]
for k := range states {
for j := range states {
v := alpha[t][k] * m.Transition[k*states+j] * m.Emission[j*width+sym] * beta[t+1][j]
table[k*states+j] = v
total += v
}
}
for i := range table {
table[i] /= total
}
xi[t] = table
}
return logLik, gamma, xi, nil
}
// hmmReestimate applies one Baum-Welch M step: the counts the
// posteriors carry become the new rows, floored and renormalised.
func hmmReestimate(model *HiddenMarkovModel, observations []int, gamma, xi [][]float64) *HiddenMarkovModel {
states := model.hmmStates()
symbols := model.hmmSymbols()
initial := make([]float64, states)
copy(initial, gamma[0])
transition := make([]float64, states*states)
for t := range xi {
for i, v := range xi[t] {
transition[i] += v
}
}
for k := range states {
den := 0.0
for j := range states {
den += transition[k*states+j]
}
for j := range states {
transition[k*states+j] = math.Max(transition[k*states+j]/den, hmmFloor)
}
hmmRenormalise(transition[k*states : (k+1)*states])
}
emission := make([]float64, states*symbols)
for t, row := range gamma {
sym := observations[t]
for k := range states {
emission[k*symbols+sym] += row[k]
}
}
for k := range states {
den := 0.0
for s := range symbols {
den += emission[k*symbols+s]
}
for s := range symbols {
emission[k*symbols+s] = math.Max(emission[k*symbols+s]/den, hmmFloor)
}
hmmRenormalise(emission[k*symbols : (k+1)*symbols])
}
// The floors and renormalisations above keep every row a valid
// distribution, so the constructor's refusals cannot fire here.
fitted, _ := NewHiddenMarkovModel(initial, transition, emission)
return fitted
}
// hmmRenormalise scales one distribution row back to sum 1.
func hmmRenormalise(row []float64) []float64 {
total := 0.0
for _, v := range row {
total += v
}
for i := range row {
row[i] /= total
}
return row
}