TL;DR — Implement a unigram tokenizer from scratch using the Expectation-Maximization algorithm and Viterbi decoding, providing a concrete portfolio piece that showcases algorithmic depth, probabilistic modeling, and production‑ready Python.

Tokenization is the first bottleneck in any NLP pipeline, and a hand‑crafted unigram model forces you to confront the same probability‑estimation and dynamic‑programming problems that power modern subword tokenizers like SentencePiece and BPE‑level systems. In this guide you will build a minimal yet functional tokenizer in pure Python, train it with the EM algorithm, and decode new text with Viterbi segmentation. The result is a self‑contained project that demonstrates systems‑level thinking, algorithmic rigor, and the ability to ship reusable code.

Why This Project Stands Out on a CV

  • Algorithmic depth – You will implement the Expectation‑Maximization algorithm for maximum‑likelihood estimation of subword probabilities, a technique used in Gaussian mixture models and hidden Markov models.
  • Dynamic programming – The Viterbi algorithm for optimal segmentation is a classic DP pattern that appears in speech recognition, bioinformatics, and decoding of convolutional codes.
  • Production‑ready code – The project includes type hints, unit tests, a CLI, and a serialization layer, showing you can move beyond notebooks to maintainable libraries.
  • Relevant domains – Subword tokenization underpins large language models (LLMs), machine translation, and speech‑to‑text pipelines, making the skill directly applicable to roles at companies like OpenAI, Meta AI, or Hugging Face.
  • Signal for hiring managers – A from‑scratch implementation signals that you understand the mathematical foundations (probability, information theory) and can translate them into efficient, testable Python code.

Architecture Overview

The tokenizer is composed of five loosely coupled components:

  1. Corpus Loader – Reads plain text or JSON lines, normalizes whitespace, and yields a stream of sentences.
  2. Vocabulary Seed – Generates an initial set of subword candidates (e.g., all characters plus common bigrams) to bootstrap the EM algorithm.
  3. EM Trainer – Iteratively estimates subword probabilities using the Expectation‑Maximization algorithm; each iteration consists of an E‑step (compute expected counts via Viterbi) and an M‑step (update probabilities).
  4. Segmenter – Given a trained model, uses Viterbi decoding to produce the most likely segmentation of a new string.
  5. Persistence – Serializes the learned vocabulary and probability table to JSON or a compact binary format for later reuse.

A simple text diagram of the flow:

[Raw Text] → Corpus Loader → Vocabulary Seed → EM Trainer → Model File
                         ↓
                    Segment (Viterbi)
                         ↓
                  Tokenized Output

Each component can be swapped out (e.g., a different initialization strategy or a more compact serialization format) without rewriting the whole pipeline, a principle borrowed from micro‑service architecture.

Building It Step by Step

Step 1: Project Scaffolding

Create a directory unigram_tokenizer/ with the following layout:

unigram_tokenizer/
├── __init__.py
├── corpus.py
├── vocab.py
├── trainer.py
├── segmenter.py
├── utils.py
└── cli.py

Initialize a pyproject.toml using Poetry or Flit to manage dependencies and packaging.

Step 2: Corpus Loader

# corpus.py
import json
from pathlib import Path
from typing import Iterator, List

def load_sentences(path: Path) -> Iterator[List[str]]:
    """Yield tokenized sentences from a JSON Lines file."""
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            record = json.loads(line)
            # Assume each record has a 'text' field
            text: str = record["text"]
            # Simple whitespace tokenization for demonstration
            yield text.split()

Step 3: Vocabulary Seed

# vocab.py
from collections import Counter
from typing import List, Set

def seed_vocabulary(sentences: Iterator[List[str]], max_vocab: int = 32_000) -> Set[str]:
    """Create an initial subword candidate set."""
    char_counter: Counter = Counter()
    for tokens in sentences:
        for token in tokens:
            # Add each character as a candidate
            char_counter.update(token)
            # Optionally add common bigrams
            for i in range(len(token) - 1):
                bigram = token[i:i+2]
                char_counter[bigram] += 1
    # Take the most frequent candidates
    most_common = char_counter.most_common(max_vocab)
    vocab = {pair[0] for pair in most_common}
    # Ensure all single characters are present
    for ch in set("".join(char_counter.elements())):
        vocab.add(ch)
    return vocab

Step 4: EM Trainer

The core of the tokenizer is the EM algorithm. We maintain a probability distribution P[subword] over the vocabulary.

# trainer.py
import math
from collections import defaultdict
from typing import Dict, List, Tuple
import numpy as np
from .vocab import seed_vocabulary
from .segmenter import viterbi_segment

def initialize_probs(vocab: Set[str]) -> Dict[str, float]:
    """Assign uniform probabilities."""
    n = len(vocab)
    return {s: 1.0 / n for s in vocab}

def e_step(sentences: List[List[str]], probs: Dict[str, float]) -> Dict[str, float]:
    """Compute expected counts using Viterbi segmentation."""
    expected_counts: Dict[str, float] = defaultdict(float)
    total_tokens = 0
    for tokens in sentences:
        for token in tokens:
            # Find best segmentation
            seg, _ = viterbi_segment(token, probs)
            for sub in seg:
                expected_counts[sub] += 1.0
                total_tokens += 1
    # Convert counts to expected frequencies
    for sub in expected_counts:
        expected_counts[sub] /= total_tokens
    return expected_counts

def m_step(expected_counts: Dict[str, float]) -> Dict[str, float]:
    """Update probabilities from expected counts."""
    total = sum(expected_counts.values())
    return {sub: count / total for sub, count in expected_counts.items()}

def train_em(sentences: List[List[str]], vocab: Set[str], iterations: int = 10) -> Dict[str, float]:
    probs = initialize_probs(vocab)
    for it in range(iterations):
        expected = e_step(sentences, probs)
        probs = m_step(expected)
        # Optional: print perplexity
        # perplexity = math.exp(-sum(p * math.log(p) for p in probs.values()))
    return probs

Step 5: Viterbi Segmentation

# segmenter.py
from typing import List, Tuple, Dict
import math

def viterbi_segment(token: str, probs: Dict[str, float]) -> Tuple[List[str], float]:
    """Return the most likely segmentation and its log‑probability."""
    n = len(token)
    # dp[i] = (log_prob, best_previous_index, best_subword)
    dp = [(float('-inf'), -1, '')] * (n + 1)
    dp[0] = (0.0, -1, '')
    for i in range(1, n + 1):
        for j in range(i):
            sub = token[j:i]
            if sub in probs:
                log_p = math.log(probs[sub])
                if dp[j][0] + log_p > dp[i][0]:
                    dp[i] = (dp[j][0] + log_p, j, sub)
    # Backtrack
    seg = []
    i = n
    while i > 0:
        _, j, sub = dp[i]
        seg.append(sub)
        i = j
    seg.reverse()
    return seg, dp[n][0]

Step 6: Persistence

# utils.py
import json
from pathlib import Path
from typing import Dict

def save_model(probs: Dict[str, float], path: Path) -> None:
    """Serialize the probability table to JSON."""
    with path.open("w", encoding="utf-8") as f:
        json.dump(probs, f, ensure_ascii=False, indent=2)

def load_model(path: Path) -> Dict[str, float]:
    """Load a previously saved probability table."""
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)

Step 7: CLI

# cli.py
import argparse
from pathlib import Path
from .corpus import load_sentences
from .vocab import seed_vocabulary
from .trainer import train_em
from .utils import save_model
from .segmenter import viterbi_segment

def main():
    parser = argparse.ArgumentParser(description="Train a unigram tokenizer")
    parser.add_argument("corpus", type=Path, help="Path to JSON Lines corpus")
    parser.add_argument("--output", type=Path, default=Path("model.json"), help="Output model file")
    parser.add_argument("--vocab-size", type=int, default=32_000, help="Maximum vocabulary size")
    parser.add_argument("--iterations", type=int, default=10, help="EM iterations")
    args = parser.parse_args()

    sentences = list(load_sentences(args.corpus))
    vocab = seed_vocabulary(iter(sentences), max_vocab=args.vocab_size)
    probs = train_em(sentences, vocab, iterations=args.iterations)
    save_model(probs, args.output)
    print(f"Model saved to {args.output}")

if __name__ == "__main__":
    main()

Running and Testing It

  1. Install the package in editable mode:

    pip install -e .
    
  2. Prepare a small corpus (e.g., data.jsonl):

    {"text": "the quick brown fox jumps over the lazy dog"}
    {"text": "machine learning is fun"}
    
  3. Train the tokenizer:

    python -m unigram_tokenizer.cli data.jsonl --output model.json --vocab-size 500 --iterations 5
    
  4. Segment a new sentence using a quick script:

    from utils import load_model
    from segmenter import viterbi_segment
    
    probs = load_model("model.json")
    sentence = "quick brown fox"
    for token in sentence.split():
        seg, logp = viterbi_segment(token, probs)
        print(token, "->", seg, "logP:", logp)
    
  5. Validate that the segmenter produces reasonable subwords (e.g., "quick" → ["quick"] or ["qu", "ick"] depending on learned probabilities). You can also compute the average log‑likelihood on a held‑out set to verify convergence.

Extending It: Your Roadmap to Senior-Level

  1. Binary serialization with Protocol Buffers – Replace JSON with a compiled .proto schema; this reduces model size by ~60 % and enables fast C++/Rust inference in production pipelines.
  2. Horizontal scaling via Ray – Distribute the E‑step across multiple workers; each worker processes a shard of the corpus, dramatically cutting training time for large datasets.
  3. Observability with Prometheus – Export metrics such as per‑iteration perplexity, memory usage, and segmentation latency; visualize them in Grafana to detect overfitting or convergence stalls.
  4. Fault‑tolerant checkpointing – Periodically persist intermediate probability tables to cloud storage (e.g., GCS or S3) so that a node failure does not discard hours of EM progress.
  5. Benchmarking harness – Integrate with MLPerf Inference to compare tokenization speed and perplexity against SentencePiece and BPE baselines.
  6. REST API with FastAPI – Expose the segmenter as an HTTP endpoint, enabling micro‑service architectures where tokenization is decoupled from model serving.

Key Takeaways

  • Implementing EM and Viterbi from scratch solidifies understanding of probabilistic modeling and dynamic programming.
  • A modular, testable Python package signals production readiness to hiring managers.
  • The project can be extended with serialization, distributed training, and observability to match real‑world NLP infrastructure.
  • It directly maps to roles in LLM, speech, and translation teams where subword tokenization is critical.
  • Use the provided CLI and test harness to demonstrate end‑to‑end functionality in interviews.

Further Reading