Source code for mixle.represent.learned_segment
"""Learned segmentation -- infer WHERE the tokens are, instead of cutting at fixed positions.
A fixed segmenter cuts every N bytes / every patch; but the *right* boundaries depend on the data. This fits an
HMM over a finer atomic stream and cuts where the latent state changes: the boundaries are chosen by
maximum likelihood, so the segmentation is *inferred* -- the objective-coupled tokenizer. A run of same-state
atoms becomes one variable-length token, pooled into a fixed feature vector for the embedding (a bag-of-symbols
histogram for a discrete atomic stream, the mean vector for a continuous one).
``LearnedSegmenter`` wraps any atomic :class:`~mixle.represent.segment.Segmenter`, is ``fit`` on example raws, and
then plugs into the representation pipeline exactly like a fixed segmenter -- so tokenization is a *model* you
train, reusing mixle's HMM inference, not a hand-set rule.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import numpy as np
from mixle.represent.segment import Segmenter
[docs]
class LearnedSegmenter(Segmenter):
"""Cut a raw object into variable-length tokens at HMM state changes over its atomic units (pooled to features)."""
discrete = False # segments are pooled feature vectors, whatever the atomic stream was
def __init__(self, atomic: Segmenter, n_states: int = 4, *, max_its: int = 30, seed: int = 0) -> None:
self.atomic = atomic
self.n_states = int(n_states)
self.max_its = int(max_its)
self.seed = int(seed)
self.hmm: Any = None
self.feat: int | None = None # dimension of a pooled segment feature
[docs]
def fit(self, raws: Sequence[Any]) -> LearnedSegmenter:
"""Fit the boundary HMM on example raws -- the segmentation is learned to maximize their likelihood."""
import mixle.stats as st
from mixle.inference import optimize
seqs = [self.atomic.segment(r) for r in raws]
if self.atomic.discrete:
self.feat = int(getattr(self.atomic, "num_categories", 1 + max(int(s.max()) for s in seqs if len(s))))
est = st.HiddenMarkovEstimator([st.CategoricalEstimator() for _ in range(self.n_states)])
data = [[int(v) for v in s] for s in seqs]
else:
self.feat = int(seqs[0].shape[1])
est = st.HiddenMarkovEstimator([st.DiagonalGaussianEstimator(dim=self.feat) for _ in range(self.n_states)])
data = [[np.asarray(v, dtype=np.float64) for v in s] for s in seqs]
self.hmm = optimize(data, est, max_its=self.max_its, rng=np.random.RandomState(self.seed), out=None)
return self
[docs]
def segment(self, raw: Any) -> np.ndarray:
if self.hmm is None:
raise RuntimeError("call fit(...) before segment(...)")
atoms = self.atomic.segment(raw)
if len(atoms) == 0:
return np.zeros((1, self.feat or 1), dtype=np.float32)
if self.atomic.discrete:
obs = [int(v) for v in atoms]
else:
obs = [np.asarray(v, dtype=np.float64) for v in atoms]
path = np.asarray(self.hmm.viterbi(obs))
cuts = np.flatnonzero(np.diff(path)) + 1 # boundaries where the latent state changes
runs = np.split(np.arange(len(path)), cuts)
return np.stack([self._pool(atoms, run) for run in runs]).astype(np.float32)
def _pool(self, atoms: np.ndarray, run: np.ndarray) -> np.ndarray:
if self.atomic.discrete:
return np.bincount(atoms[run].astype(int), minlength=self.feat or 1).astype(np.float64) / len(run)
return atoms[run].mean(axis=0)