Training Pipeline Plan

Implementation Status Grid

Step

Pipeline Task

Step Function

Lib Function

CFFI Binding

Status

Features

step_extract_features

✅

features.extract_features()

✅ pstrain_fe_create

✅ Done

Flat

step_flat

✅

flat.init_flat_model()

✅ pstrain_flat_*, pstrain_init_gau

✅ Done

CI Train

step_ci_1g

✅

bw.BWTrainer

✅ pstrain_bw_*

✅ Done

CI Split

step_ci_2g/4g/8g

✅

split.split_gaussians()

✅ pstrain_inc_comp

✅ Done

CD Untied

step_cd_untied

✅

mdef.create_untied_mdef()

✅ pstrain_mdef_gen_untied

✅ Done

Make Quests

step_questions

✅

dtree.make_quests()

✅ pstrain_make_quests

✅ Done

Trees

step_trees

✅

dtree.build_tree()

✅ pstrain_build_tree

✅ Done

Prune

step_prune_trees

✅

dtree.prune_tree()

✅ pstrain_prune_tree

✅ Done

Tiestate

step_cd_1g

✅

dtree.tie_states()

✅ pstrain_tie_states

✅ Done

Init CD

step_cd_1g

✅

dtree.init_mixw()

✅ pstrain_init_mixw

✅ Done

CD Train

step_cd_2g/4g/8g

✅

bw.BWTrainer

✅ pstrain_bw_*

✅ Done

CD Split

step_cd_2g/4g/.../32g

✅

split.split_gaussians()

✅ pstrain_inc_comp

✅ Done

Package

package_ci_8g/cd_8g/cd_32g

✅

package.package_model()

N/A

✅ Done

Legend

  • ✅ Done - Fully implemented and tested

  • ⏳ Wire up - CFFI binding exists, need step function to orchestrate

  • ❌ Missing - Not yet implemented

Key Insight: CFFI Bindings Are Complete

All core C functions are already wrapped via CFFI:

pstrain/lib/
├── bw.py           ✅ BWTrainer class: pstrain_bw_init, pstrain_bw_process_utt,
│                      pstrain_bw_normalize, pstrain_bw_save, pstrain_bw_get_stats
├── split.py        ✅ split_gaussians(): pstrain_inc_comp
│                   ✅ kmeans(), kmeans_init(): pstrain_kmeans, pstrain_kmeans_init
├── dtree.py        ✅ build_tree(): pstrain_build_tree
│                   ✅ tie_states(): pstrain_tie_states
│                   ✅ make_quests(): pstrain_make_quests
│                   ✅ prune_tree(): pstrain_prune_tree
├── mdef.py         ✅ create_ci_mdef(): pstrain_mdef_gen_ci
│                   ✅ create_untied_mdef(): pstrain_mdef_gen_untied
│                   ✅ create_alltriphones_mdef(): pstrain_mdef_gen_alltriphones
├── flat.py         ✅ init_flat_model(): pstrain_flat_tmat, pstrain_flat_mixw,
│                      pstrain_init_gau, pstrain_norm_gau
├── features.py     ✅ FeatureExtractor: pstrain_fe_create
├── agg_seg.py      ✅ agg_seg(): pstrain_agg_seg
├── commands.py     ✅ core command paths: param_cnt, map_adapt, kdtree, delint
├── param_cnt.py    ✅ contained, declared capability (not pipeline-wired)
├── map_adapt.py    ✅ contained, declared capability (not pipeline-wired)
├── kdtree.py       ✅ contained, declared capability (not pipeline-wired)
├── delint.py       ✅ contained, declared capability (not pipeline-wired)
└── _cffi/io.py     ✅ read_gau, write_gau, read_mixw, write_mixw,
                       read_tmat, write_tmat, read_dnom, write_dnom

What’s Missing: Step Orchestration

The CFFI bindings provide low-level operations. We need higher-level “step” functions that orchestrate these into complete training steps:

Example: CI Training Step

The CFFI binding exists (BWTrainer), but we need a step function:

# pstrain/lib/steps/ci_hmm.py - needs implementation

def run_ci_training(
    model_dir: Path,
    output_dir: Path,
    features_dir: Path,
    train_fileids: Path,
    transcription: Path,
    dictionary: Path,
    n_iter: int = 10,
    convergence_ratio: float = 0.001,
) -> TrainingResult:
    """Run CI HMM training using BWTrainer.

    This orchestrates the existing CFFI bindings:
    1. Load model from model_dir
    2. For each iteration:
       a. Create BWTrainer with current model
       b. For each utterance in train_fileids:
          - Load features from features_dir
          - Look up transcript from transcription
          - Call trainer.process_utterance()
       c. Call trainer.normalize()
       d. Call trainer.save() to output_dir
       e. Check convergence
    3. Return final stats
    """
    from pstrain.lib.bw import BWTrainer, BWConfig

    trainer = BWTrainer(
        mdef_path=model_dir / "mdef",
        means_path=model_dir / "means",
        vars_path=model_dir / "variances",
        mixw_path=model_dir / "mixture_weights",
        tmat_path=model_dir / "transition_matrices",
    )

    # Set dictionary for text-based processing
    trainer.set_dict(dictionary, filler_dict)

    # Process each utterance
    for fileid in read_fileids(train_fileids):
        features = load_features(features_dir / f"{fileid}.mfc")
        transcript = get_transcript(transcription, fileid)
        trainer.process_utterance_text(features, transcript)

    # Normalize and save
    trainer.normalize()
    trainer.save(output_dir / "means", ...)

    return trainer.get_stats()

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                              USER INTERFACE                                  │
│  pstrain build cd-8g --config wideband                                          │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                           PIPELINE RUNNER                                    │
│  pstrain.lib.pipeline.runner.Pipeline: resolves DAG from file inputs/outputs    │
│  pstrain.lib.pipeline.tasks.TARGETS:   target definitions (ci-1g, cd-8g, ...)   │
│  etc/configs.yaml:                 named configs (wideband, telephone, ...) │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                           STEP FUNCTIONS                                     │
│  pstrain.lib.steps.ci_hmm.run_ci_training()                                     │
│  pstrain.lib.steps.cd_hmm.run_cd_training()                                     │
│  pstrain.lib.steps.trees.build_trees()                                          │
│  Orchestrate multiple CFFI calls into complete training steps               │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                           LIBRARY FUNCTIONS (CFFI)                           │
│  pstrain.lib.bw.BWTrainer    pstrain.lib.split.split_gaussians()                    │
│  pstrain.lib.dtree.*         pstrain.lib.mdef.*    pstrain.lib.flat.*                   │
│  All already wrapped via CFFI - no shell-outs needed                        │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                           C LIBRARY (libpstrainc)                                │
│  pstrain_bw_*, pstrain_inc_comp, pstrain_build_tree, pstrain_tie_states, pstrain_make_quests   │
│  pstrain_prune_tree, pstrain_mdef_gen_*, pstrain_flat_*, pstrain_init_gau, pstrain_norm_gau    │
└─────────────────────────────────────────────────────────────────────────────┘

What’s Complete ✅

CFFI Bindings (All Done)

  • [x] Feature extraction (pstrain_fe_create)

  • [x] Baum-Welch training (pstrain_bw_*)

  • [x] Gaussian splitting (pstrain_inc_comp)

  • [x] Flat model init (pstrain_flat_*, pstrain_init_gau, pstrain_norm_gau)

  • [x] Mdef generation (pstrain_mdef_gen_*)

  • [x] Decision trees (pstrain_build_tree, pstrain_make_quests, pstrain_prune_tree)

  • [x] State tying (pstrain_tie_states)

  • [x] Model I/O (read_gau, write_gau, read_mixw, etc.)

  • [x] K-means (pstrain_kmeans, pstrain_kmeans_init)

  • [x] Parameter counting (contained capability and core param_cnt command)

  • [x] Segment aggregation (pstrain_agg_seg)

  • [x] MAP adaptation (contained capability and core map_adapt command)

  • [x] KD-tree building (contained capability and core kdtree command)

  • [x] Deleted interpolation (contained capability and core delint command)

Orchestration Layer

  • [x] Pipeline runner with all tasks registered (pstrain.lib.pipeline)

  • [x] Dependency resolution (runner topo-sorts from inputs/outputs)

  • [x] Named configs (etc/configs.yaml: wideband, telephone, etc.)

  • [x] Target definitions (targets.yaml: ci-1g through cd-32g)

  • [x] Shared output paths (same config → same outputs)

  • [x] CLI (pstrain build cd-8g --config wideband)

  • [x] Dry-run support (pstrain --dry-run build cd-8g)

Working Steps

  • [x] step_extract_features - Feature extraction via CFFI

  • [x] step_flat - Flat model initialization via CFFI

What Needs Implementation ⏳

Complete Training Pipeline (execution order)

User Setup (manual):
  0. pstrain split                    # Create train/test fileids (optional)

Feature Extraction:
  1. step_extract_features        # ✅ DONE - MFCC extraction

Flat Model:
  2. step_flat                    # ✅ DONE - Initial uniform model

CI Training:
  3. step_ci_1g                   # ⏳ BW training on flat model
  4. step_ci_2g                   # ⏳ Split 1→2 Gaussians, BW
  5. step_ci_4g                   # ⏳ Split 2→4 Gaussians, BW
  6. step_ci_8g                   # ⏳ Split 4→8 Gaussians, BW

CD Untied:
  7. step_cd_untied               # ⏳ Generate triphone mdef, BW

Decision Trees:
  8. step_make_quests             # ⏳ Generate phonetic questions
  9. step_build_trees             # ⏳ Build decision trees
 10. step_prune_trees             # ⏳ Prune to target senones

State Tying:
 11. step_tie_states              # ⏳ Create tied mdef

CD Training:
 12. step_cd_1g                   # ⏳ BW on tied model
 13. step_cd_2g                   # ⏳ Split 1→2 Gaussians, BW
 14. step_cd_4g                   # ⏳ Split 2→4 Gaussians, BW
 15. step_cd_8g                   # ⏳ Split 4→8 Gaussians, BW
 16. step_cd_16g                  # ⏳ Split 8→16 Gaussians, BW
 17. step_cd_32g                  # ⏳ Split 16→32 Gaussians, BW

Step Functions to Implement (total order)

Many pipeline tasks share the same step function:

#

Step Function

File

CFFI Wrapper

Used By

1

run_bw_training()

steps/train.py

BWTrainer

ci-1g→8g, cd-untied, cd-1g→32g

2

run_gaussian_split()

steps/split.py

split_gaussians()

ci-2g→8g, cd-2g→32g

3

run_cd_untied_setup()

steps/cd_untied.py

create_untied_mdef()

cd-untied

4

run_make_quests()

steps/trees.py

make_quests()

step 8

5

run_build_trees()

steps/trees.py

build_tree()

step 9

6

run_prune_trees()

steps/trees.py

prune_tree()

step 10

7

run_tie_states()

steps/tiestate.py

tie_states()

step 11

Implementation order: 1 → 2 → 3 → 4 → 5 → 6 → 7

After implementing #1 and #2: pstrain build ci-8g works After implementing #3-#7: pstrain build cd-8g works

Existing CFFI wrappers (all done):

  • pstrain.lib.bw.BWTrainer - BW training

  • pstrain.lib.split.split_gaussians() - Gaussian splitting

  • pstrain.lib.mdef.create_untied_mdef() - Triphone mdef generation

  • pstrain.lib.dtree.make_quests() - Question generation

  • pstrain.lib.dtree.build_tree() - Tree building

  • pstrain.lib.dtree.prune_tree() - Tree pruning

  • pstrain.lib.dtree.tie_states() - State tying

Each step function is ~50-100 lines of orchestration code.


Step 0: Train/Test Split — ✅ DONE

Library: pstrain.lib.corpus.train_test_split. Pipeline: registered as the split task in pstrain/lib/pipeline/tasks.py (target name split).

  • Input: etc/all.transcription.

  • Outputs: experiments/{experiment}/etc/{train,test}.{fileids,transcription}.

  • Default split: 95% train, seed 42. Override via a split: block in etc/configs.yaml.

  • A complete pre-existing set of the four output files is instead treated as an authoritative external split. It is validated for exact ordered pairing, disjoint and complete corpus membership, transcript equality, and audio presence, then preserved byte-for-byte.

The CLI (pstrain split) and the pipeline task both call the same library function. The pipeline’s feature-extraction fan-out is keyed on audio_fileids() (corpus-wide, derived from audio/*.wav), so the extract tasks and the split task are parallel branches in the DAG that join at the model tasks. A content-hash sidecar distinguishes untouched automatically generated files from supplied or subsequently edited files. The pipeline runner’s completion-marker suffix hashes the task name, not file contents; split files are therefore explicit inputs to the validation task and its sentinel is an explicit input to downstream consumers, so an edit invalidates the protected work by mtime.


Step 1: BW Training (steps/train.py) — FIRST PRIORITY

Generic BW training that works for both CI and CD models:

# pstrain/lib/steps/train.py (~80 lines)

def run_bw_training(
    model_dir: Path,
    output_dir: Path,
    features_dir: Path,
    train_ctl: Path,
    transcription: Path,
    dictionary: Path,
    filler_dict: Path,
    n_iter: int = 10,
    convergence_ratio: float = 0.001,
) -> dict:
    """Run BW training iterations.

    Uses:
      - pstrain.lib.bw.BWTrainer (already implemented)
      - pstrain.lib.transcription.read_transcription()
      - pstrain.lib._cffi.io.read_mfc() or similar

    Returns:
      {"iterations": n, "final_likelihood": x, "converged": bool}
    """
    from pstrain.lib.bw import BWTrainer

    prev_likelihood = float("-inf")

    for i in range(n_iter):
        trainer = BWTrainer(
            mdef_path=model_dir / "mdef",
            means_path=model_dir / "means",
            vars_path=model_dir / "variances",
            mixw_path=model_dir / "mixture_weights",
            tmat_path=model_dir / "transition_matrices",
        )
        trainer.set_dict(str(dictionary), str(filler_dict))

        # Process all utterances
        for fileid in read_ctl(train_ctl):
            features = load_features(features_dir / f"{fileid}.mfc")
            transcript = lookup_transcript(transcription, fileid)
            trainer.process_utterance_text(features, transcript)

        trainer.normalize()
        trainer.save(output_dir / "means", output_dir / "variances",
                     output_dir / "mixture_weights", output_dir / "transition_matrices")

        stats = trainer.get_stats()
        if check_convergence(stats.avg_log_prob, prev_likelihood, convergence_ratio):
            return {"iterations": i + 1, "converged": True, ...}

        prev_likelihood = stats.avg_log_prob
        model_dir = output_dir  # next iteration reads from output

    return {"iterations": n_iter, "converged": False, ...}

Enables: ci-1g target, and all subsequent training

Forward pruning can occasionally remove the transcript’s final state. The live training driver treats that native status as recoverable: it retries the utterance exactly once with a forward beam widened by retry_beam_factor (default 1e10, so 1e-90 becomes 1e-100), restores the stage beam, and only then includes a second failure in the normal skip count and limit. Other Baum-Welch failures are never retried. retry_beam_factor may instead be an ascending list of factors, each relative to the stage beam: the rungs run in order, the first success ends the ladder, and each rung’s attempts and recoveries are reported per pass, in the stage summary, and in telemetry. An utterance whose audio is too short for its transcript runs no rung; that is measured once per failure, before the first rung.

BASIS / deliberate deviation: upstream SphinxTrain has no retry and silently skips an utterance when forward pruning loses the final state. The wider beam changes that utterance’s lattice and posteriors for the iteration, but recovers training evidence that upstream discards. Pstrain therefore permits a bounded retry (one by default, or the configured rungs), and accumulator counts come only from a successful pass; the failed passes contribute no counts.


Step 2: Gaussian Split (steps/split.py)

Split Gaussians to double density (works for CI and CD):

# pstrain/lib/steps/split.py (~40 lines)

def run_split(
    input_model_dir: Path,
    output_model_dir: Path,
) -> None:
    """Double Gaussian density.

    Uses:
      - pstrain.lib.split.split_gaussians() (already implemented)

    Also copies mdef, tmat (unchanged) to output.
    """
    from pstrain.lib.split import split_gaussians
    import shutil

    output_model_dir.mkdir(parents=True, exist_ok=True)

    # Split means, variances, mixture weights
    split_gaussians(
        in_mean_path=input_model_dir / "means",
        in_var_path=input_model_dir / "variances",
        in_mixw_path=input_model_dir / "mixture_weights",
        out_mean_path=output_model_dir / "means",
        out_var_path=output_model_dir / "variances",
        out_mixw_path=output_model_dir / "mixture_weights",
    )

    # Copy unchanged files
    shutil.copy(input_model_dir / "mdef", output_model_dir / "mdef")
    shutil.copy(input_model_dir / "transition_matrices",
                output_model_dir / "transition_matrices")

Enables: ci-2g, ci-4g, ci-8g (and later cd-2g, etc.)


Step 3: CD Untied (steps/cd_untied.py)

Generate triphone mdef and train untied CD model:

# pstrain/lib/steps/cd_untied.py (~60 lines)

def run_cd_untied(
    ci_model_dir: Path,
    output_dir: Path,
    features_dir: Path,
    train_ctl: Path,
    transcription: Path,
    dictionary: Path,
    filler_dict: Path,
    phoneset: Path,
) -> None:
    """Create and train untied CD model.

    Uses:
      - pstrain.lib.mdef.create_untied_mdef() (already implemented)
      - run_bw_training() from step 1
    """
    from pstrain.lib.mdef import create_untied_mdef

    # 1. Generate untied triphone mdef from transcripts
    create_untied_mdef(
        phone_list=phoneset,
        dict_path=dictionary,
        filler_dict_path=filler_dict,
        transcript_path=transcription,
        output_path=output_dir / "mdef",
    )

    # 2. Initialize parameters from CI model (expanded for triphones)
    init_cd_params_from_ci(ci_model_dir, output_dir)

    # 3. Train with BW
    run_bw_training(output_dir, output_dir, features_dir, ...)

Enables: cd-untied intermediate model


Step 4: Build Trees (steps/trees.py)

Generate questions, build trees, prune to target senones:

# pstrain/lib/steps/trees.py (~80 lines)

def run_build_trees(
    model_dir: Path,
    output_dir: Path,
    phoneset: Path,
    n_senones: int,
) -> None:
    """Build and prune decision trees.

    Uses:
      - pstrain.lib.dtree.make_quests() (already implemented)
      - pstrain.lib.dtree.build_tree() (already implemented)
      - pstrain.lib.dtree.prune_tree() (already implemented)
    """
    from pstrain.lib.dtree import make_quests, build_tree, prune_tree

    # 1. Generate phonetic questions
    make_quests(
        mdef_path=model_dir / "mdef",
        mixw_path=model_dir / "mixture_weights",
        mean_path=model_dir / "means",
        var_path=model_dir / "variances",
        output_path=output_dir / "questions",
    )

    # 2. Build tree for each phone and state
    phones = read_phoneset(phoneset)
    for phone in phones:
        for state in range(n_states):
            build_tree(
                mdef_path=model_dir / "mdef",
                mixw_path=model_dir / "mixture_weights",
                pset_path=output_dir / "questions",
                output_path=output_dir / f"{phone}.{state}.tree",
                phone=phone,
                state=state,
            )

    # 3. Prune to target senones
    prune_tree(
        mdef_path=model_dir / "mdef",
        pset_path=output_dir / "questions",
        input_tree_dir=output_dir,
        output_tree_dir=output_dir / "pruned",
        n_seno_target=n_senones,
    )

Enables: Decision trees for state clustering


Step 5: Tie States (steps/tiestate.py)

Apply trees to create tied mdef and initialize tied model:

# pstrain/lib/steps/tiestate.py (~50 lines)

def run_tie_states(
    untied_model_dir: Path,
    trees_dir: Path,
    output_dir: Path,
    phoneset: Path,
) -> None:
    """Apply decision trees to create tied model.

    Uses:
      - pstrain.lib.dtree.tie_states() (already implemented)
    """
    from pstrain.lib.dtree import tie_states

    # 1. Create tied mdef by applying trees
    tie_states(
        input_mdef_path=untied_model_dir / "mdef",
        output_mdef_path=output_dir / "mdef",
        tree_dir=trees_dir / "pruned",
        pset_path=trees_dir / "questions",
        allphones=True,
    )

    # 2. Initialize tied model parameters from untied
    init_tied_params(untied_model_dir, output_dir)

Enables: cd-1g (tied CD model), then training + splitting for cd-2g…cd-32g

Implementation Order

Phase 1: CI Pipeline (Already have CFFI, need step functions)
├── Implement steps/ci_hmm.py - orchestrate BWTrainer
├── Implement steps/ci_split.py - orchestrate split_gaussians
├── Wire tasks in pstrain/lib/pipeline/tasks.py to call step functions
├── Test: pstrain build ci-8g produces working model
└── Validate: decode with PocketSphinx

Phase 2: CD Pipeline (Already have CFFI, need step functions)
├── Implement steps/cd_untied.py - triphone mdef + BW
├── Implement steps/trees.py - questions + trees + prune
├── Implement steps/tiestate.py - apply trees
├── Test: pstrain build cd-8g works end-to-end
└── Validate: cd-8g achieves target WER

File Locations

pstrain/
├── lib/
│   ├── bw.py           ✅ BWTrainer CFFI wrapper
│   ├── split.py        ✅ split_gaussians() CFFI wrapper
│   ├── dtree.py        ✅ build_tree(), tie_states(), make_quests() CFFI
│   ├── mdef.py         ✅ create_ci_mdef(), create_untied_mdef() CFFI
│   ├── flat.py         ✅ init_flat_model() CFFI
│   ├── features.py     ✅ FeatureExtractor CFFI
│   ├── _cffi/          ✅ All CFFI bindings
│   └── steps/
│       ├── ci_hmm.py   ⏳ Orchestrate CI training
│       ├── ci_split.py ⏳ Orchestrate CI split
│       ├── cd_untied.py⏳ Orchestrate CD untied
│       ├── trees.py    ⏳ Orchestrate tree building
│       └── tiestate.py ⏳ Orchestrate state tying
├── lib/pipeline/
│   ├── runner.py       ✅ Task, Pipeline, staleness, executor
│   ├── context.py      ✅ PipelineContext + config loading
│   └── tasks.py        ✅ All tasks + TARGETS registry
├── etc/
│   └── configs.yaml    ✅ Named configs
└── cli/
    └── build.py        ✅ Build command (drives the runner)

Success Criteria

  • [ ] pstrain build ci-1g trains a working CI model

  • [ ] pstrain build ci-8g trains CI with Gaussian splitting

  • [ ] pstrain build cd-8g trains full CD pipeline

  • [ ] Models decode with PocketSphinx

  • [ ] cd-8g on CMU Arctic: WER < 10%

  • [ ] Training time: < 30 min for cd-8g on Arctic

  • [ ] No shell-outs - everything via CFFI