Files
2026-09-27 15:41:19 +02:00

581 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}
if math.IsInf(best, -1) {
// Every path has probability zero, the structural zero an
// emission or transition carries: the same refusal Forward makes,
// not a meaningless path beside a −Inf log probability.
return nil, 0, base.Errf("%s: the observation sequence has probability zero under the model", name)
}
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]
}
if den == 0 {
// No transition evidence reached this row, the single-
// observation sequence being the case in point: dividing the
// zero fills the row with NaN the constructor refuses, and
// the sweep after it crashed on the nil model that refusal
// left. The row keeps its previous estimate instead.
copy(transition[k*states:(k+1)*states], model.Transition[k*states:(k+1)*states])
continue
}
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
}