Extending Modeling
Each family is a catalog of architectures crossed with training methods, and the two grow independently: a new architecture works with every method of its family, a new method with every architecture. Most additions are one class registered under one name, which then becomes a value you can write in a YAML. The patterns below are the whole API; the shipped recipes are written the same way.
Each family is a model catalog crossed with training methods; the two grow independently.
modeling
├── llm
│ ├── Model Catalog transformer, hybrid <- add an architecture
│ │ └── heads readout, pointer <- add a decision head
│ └── Training Method sft, instruction, dpo, grpo <- add a method
│ └── decision sft, cispo
├── embedding
│ ├── Model Catalog bi_encoder
│ └── Training Method contrastive
├── tts one recipe, trained by the Piper program; `model.quality` picks its size
└── wakeword one recipe: openWakeWord's dnn head on its frozen features
A new architecture in a catalog
Subclass the family's backbone and register it; every recipe of the family can then name it in model.architecture.
@LLM_BACKBONES.register("moe") # modeling/llm/models/moe.py; import it in models/__init__.py
class MoEBackbone(LLMBackbone):
loader = AutoModelForCausalLM # the transformers class that builds it
frozen = ("router",) # parameter-name fragments training must not update
adapter_targets = ("q_proj", "v_proj") # layers LoRA adapts by default
markers = Markers(...) # only for decision recipes: seven single tokens of its tokenizer
An embedding architecture (for example a cross-encoder) subclasses EmbeddingBackbone the same way and overrides
what differs, such as pool.
A new decision head
A head owns the prompt it reads and turns hidden states into one score per option. The tokens that mark state,
question and options come from the architecture (LLMBackbone.markers), so a head works on any backbone that
defines them.
@HEADS.register("my_head") # modeling/llm/models/heads/my_head.py; import it in heads/__init__.py
class MyHead(DecisionHead):
def option_block(self, options): ... # how the options appear in the prompt
def scores(self, hidden, layouts): ... # (rows, options) scores from (rows, tokens, hidden) states
A new training method in a family
A method says what a row must contain, how a batch becomes a loss and how the result is measured. Then one config class and one registered pipeline make it a recipe.
class MyMethod(LLMMethod): # modeling/llm/methods/my_method.py
class Config(BaseModel): # the YAML's `method:` section
strength: float = 1.0
def check(self, row): ... # raise ValueError for a row this method cannot use
def loss(self, backbone, rows): ... # differentiable scalar for one batch
def evaluate(self, backbone, rows, batch_size, progress): ... # metrics the validation bounds can name
class MyConfig(LLMConfig): # modeling/llm/config.py
method_class = MyMethod
method: MyMethod.Config = Field(default_factory=MyMethod.Config)
@recipe # modeling/llm/pipeline.py
class MyPipeline(LLMPipeline):
kind = "llm_my_method"
config_class = MyConfig
prepare(backbone, rows, workdir) runs once before training with the untouched base weights (DPO caches its
reference margins there). Helpers shared by LLM methods live in modeling/llm/methods/base.py.
A new family
A package with models/, methods/, a TuningConfig subclass that sets backbones to its catalog, and a
TuningPipeline subclass; import it in modeling/__init__.py. The data, train and validate stages and the training
loop are inherited.
Reusing sampled groups (llm_grpo, llm_decision_cispo)
Sampling is the expensive part of both methods. method.iterations takes that many optimizer steps on each sampled
group before sampling again. From the second step on the policy has moved away from the one that produced the
samples, and method.clip bounds how far: GRPO clips the policy ratio to 1 +- clip (PPO's surrogate), CISPO caps the
importance weight of each reasoning token at 1 + clip. llm_grpo also takes method.beta, a KL penalty towards
the base model; it needs training.adapter, because the base model is then the same weights with the adapter
switched off and no second model is loaded.
method: {group_size: 4, iterations: 2, clip: 0.2, beta: 0.05}
training: {adapter: {name: lora, r: 16, alpha: 32}}
A reward for llm_grpo
@REWARDS.register("json_valid") # modeling/llm/methods/grpo.py
def json_valid(completion: str, row: dict, params: dict) -> float: ...
A recipe with its own stages
When training does not fit the shared loop (for example an external trainer run as a child process), a recipe
brings its own config class, stages and pipeline class; users only see a new value for pipeline.recipe.
class MyConfig(ModelingConfig): # modeling/<recipe>/config.py
model: MyModelConfig
class MyTrain(TrainStage[MyConfig]): # modeling/<recipe>/stages.py
sections = ("seed", "model") # config sections that decide the result
def train(self, workdir, inputs): # write into workdir; must be safe to run again after a crash
...
return {"checkpoint": str(workdir / "model")}
class MyValidate(ValidateStage[MyConfig]):
def measure(self, workdir, inputs): # metrics on held-out data; bounds and report are handled for you
return {"accuracy": ...}
@recipe # modeling/<recipe>/pipeline.py; import it in modeling/__init__.py
class MyPipeline(ModelingPipeline[MyConfig]):
kind = "my_recipe"
config_class = MyConfig
stage_classes = (MyTrain, MyValidate) # execution order; a stage may only require earlier ones
Inside a stage, self.progress.update(done, total, note) drives the progress bar.
Building blocks a new recipe reuses
A data, evaluation or modeling recipe is safe to rerun because its stages write through these. Use them rather than writing the step again, so the guarantee holds in one place:
| Need | Use | Where |
|---|---|---|
| Write a file whole or not at all | with atomic_path(path) as temporary: | core/utils/files.py |
| Build a checkpoint directory whole or not at all | with staged_directory(workdir / "checkpoint") as staging: | core/utils/files.py |
| Rows between stages | read_jsonl, write_jsonl (atomic); the data channel's read_rows also reads a missing file as empty | core/utils/files.py, data/rows.py |
| Per-item results that survive a crash | cached(directory, key), keep(directory, key, row) under a natural key(...) | data/rows.py |
| Hold out validation rows | held_out_positions(count, seed, share): a row's side depends only on the seed and its position | modeling/stages.py |
| Load a model for one block | with backbone.loaded(device, checkpoint): releases it afterwards, also on failure | modeling/tuning/backbone.py |
A name: key in the config | a Registry; in the config validator REGISTRY.check(*names) gives the standard error | core/registry.py |
| Local files a config reads | local_paths(entries, keys) in paths(), so the API can confine them | modeling/config.py |
| Recordings named by rows | audio.recordings(source), audio.signatures(sources) for the fingerprint | modeling/tts/audio.py |
Source: README.md. This page is generated; edit the source and run npm run sync.