diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce8330..9003018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 longer exhausts its passes on a matrix it should have declared converged. +**Statistics.** + +- `FitHiddenMarkovModel` fits a single-observation sequence instead of + crashing the process: a transition row with no evidence keeps its + previous estimate rather than dividing by zero. + ## [1.0.0] - 2026-09-03 The initial release of Tensor, a scientific computing library in pure diff --git a/stats/hmm.go b/stats/hmm.go index f0cfb39..9ebda8d 100644 --- a/stats/hmm.go +++ b/stats/hmm.go @@ -524,6 +524,15 @@ func hmmReestimate(model *HiddenMarkovModel, observations []int, gamma, xi [][]f 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) } diff --git a/stats/hmm_test.go b/stats/hmm_test.go index 3efcce8..b3ae976 100644 --- a/stats/hmm_test.go +++ b/stats/hmm_test.go @@ -343,3 +343,35 @@ func TestHiddenMarkovZeroProbabilitySequence(t *testing.T) { t.Fatalf("the certain sequence answered (%g, %v), want (0, [1])", ll, filtered[0]) } } + +func TestHiddenMarkovFitSingleObservation(t *testing.T) { + // One observation carries emission and initial evidence but no + // transition evidence: the re-estimation divided the zero count into + // NaN rows, the constructor refused them, and the sweep then called + // a method on the nil model and crashed the process. The fit must + // answer with a valid model whose transitions keep their starting + // estimate. + g := core.NewGenerator(11) + res, err := FitHiddenMarkovModel(g, []int{0}, 2, 2) + if err != nil { + t.Fatalf("FitHiddenMarkovModel on one observation: %v", err) + } + if res.Model == nil { + t.Fatal("FitHiddenMarkovModel on one observation returned no model") + } + if math.IsNaN(res.LogLikelihood) || math.IsInf(res.LogLikelihood, 0) { + t.Fatalf("log likelihood = %g, want a finite value", res.LogLikelihood) + } + for k := range 2 { + sum := 0.0 + for _, v := range res.Model.Transition[k*2 : k*2+2] { + if math.IsNaN(v) || v < 0 { + t.Fatalf("transition row %d holds %g, want probabilities", k, v) + } + sum += v + } + if math.Abs(sum-1) > 1e-9 { + t.Fatalf("transition row %d sums to %g, want 1", k, sum) + } + } +}