Skip to main content

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:

NeedUseWhere
Write a file whole or not at allwith atomic_path(path) as temporary:core/utils/files.py
Build a checkpoint directory whole or not at allwith staged_directory(workdir / "checkpoint") as staging:core/utils/files.py
Rows between stagesread_jsonl, write_jsonl (atomic); the data channel's read_rows also reads a missing file as emptycore/utils/files.py, data/rows.py
Per-item results that survive a crashcached(directory, key), keep(directory, key, row) under a natural key(...)data/rows.py
Hold out validation rowsheld_out_positions(count, seed, share): a row's side depends only on the seed and its positionmodeling/stages.py
Load a model for one blockwith backbone.loaded(device, checkpoint): releases it afterwards, also on failuremodeling/tuning/backbone.py
A name: key in the configa Registry; in the config validator REGISTRY.check(*names) gives the standard errorcore/registry.py
Local files a config readslocal_paths(entries, keys) in paths(), so the API can confine themmodeling/config.py
Recordings named by rowsaudio.recordings(source), audio.signatures(sources) for the fingerprintmodeling/tts/audio.py

Source: README.md. This page is generated; edit the source and run npm run sync.