End-to-End Pipelines
End-to-End Pipelines
Tier 6: pipeline configs wire together every component (model, optimizer, loss, data) into a complete experiment config with a
@L.taskentry point. They are the top of the example curriculum and demonstrate the full laco composition model.
All source files live under sources/laco/examples/pipelines/.
1. pipelines/mnist_train.py: MNIST training pipeline
Wires the Tier-1 cnn_classifier model together with an Adam optimizer, CrossEntropyLoss, a torchvision MNIST dataset, and a DataLoader, all as config nodes. A @L.task-decorated function provides the training loop entry point.
Full source
import laco.language as L
from laco.examples.cnn_classifier import make_cnn_classifier
from torch import nn, optim
from torch.utils.data import DataLoader
from torchvision import transforms
from torchvision.datasets import MNIST
__all__ = ["model", "optimizer", "loss", "dataset", "loader", "train", "hps"]
@L.params
class hps:
data_root: str = "./data"
batch_size: int = 64
learning_rate: float = 1e-3
num_workers: int = 0
in_channels: int = 1
base_channels: int = 32
num_stages: int = 3
num_classes: int = 10
model = make_cnn_classifier(
in_channels=hps.in_channels,
base_channels=hps.base_channels,
num_stages=hps.num_stages,
num_classes=hps.num_classes,
)
optimizer = L.partial(optim.Adam)(lr=hps.learning_rate)
loss = L.call(nn.CrossEntropyLoss)()
_transform = L.call(transforms.Compose)(
L.List(
L.call(transforms.ToTensor)(),
L.call(transforms.Normalize)(mean=L.List(0.1307), std=L.List(0.3081)),
)
)
dataset = L.call(MNIST)(
root=hps.data_root,
train=True,
download=True,
transform=_transform,
)
loader = L.call(DataLoader)(
dataset=dataset,
batch_size=hps.batch_size,
shuffle=True,
num_workers=hps.num_workers,
)
train = L.Dict(
model=model,
optimizer=optimizer,
loss=loss,
loader=loader,
)
@L.task
def task(
model: nn.Module,
optimizer: optim.Optimizer,
loss: nn.Module,
loader: DataLoader,
num_steps: int = 1,
) -> None:
opt = optimizer(model.parameters())
model.train()
loader_iter = iter(loader)
for step in range(num_steps):
try:
images, labels = next(loader_iter)
except StopIteration:
loader_iter = iter(loader)
images, labels = next(loader_iter)
opt.zero_grad()
out = model(images)
loss_val = loss(out, labels)
loss_val.backward()
opt.step()
if __name__ == "__main__":
import laco
cfg = laco.load(__file__ + "#train")
task(cfg)
Annotated walkthrough
@L.params class hps
All scalar hyperparameters in one flat namespace. The pipeline owns all of them, including model architecture parameters (in_channels, base_channels, etc.), so overrides are uniform regardless of whether the hps affect the model, optimizer, or data loader.
model = make_cnn_classifier(...)
Imports the factory from laco.examples.cnn_classifier and calls it with hps values. This is the standard way to compose Tier-1 building blocks into a pipeline: the model config node is identical to what cnn_classifier.py produces directly; the pipeline just supplies different hps.
optimizer = L.partial(optim.Adam)(lr=hps.learning_rate)L.partial for the optimizer: the factory receives model.parameters() inside @L.task, not here. This is the correct pattern for any object that needs live tensors.
loss = L.call(nn.CrossEntropyLoss)()L.call with no arguments (()) at the end constructs the loss config with all defaults. The trailing () is the keyword-argument call, required even when empty.
L.List(...) for the transform pipelinetransforms.Compose takes a Python list. L.List(node, node, ...) is the laco list literal: it produces a config list that instantiates each element before passing it to Compose. Nested structures (mean=L.List(0.1307)) work the same way.
dataset = L.call(MNIST)(..., transform=_transform)
The dataset config references the _transform config node. During instantiation, laco resolves _transform first (producing a Compose object), then passes it as the transform argument to MNIST.__init__.
loader = L.call(DataLoader)(dataset=dataset, ...)
The loader config references the dataset config node. Instantiation order is resolved automatically: dataset is fully instantiated before it is passed to DataLoader.
train = L.Dict(model=model, optimizer=optimizer, loss=loss, loader=loader)L.Dict assembles a named bundle. This is the pipeline root: loading "...#train" returns this dict, and laco.instantiate(train_cfg) instantiates all four components in dependency order. The #train fragment selector targets this node specifically.
@L.task def task(...)
The entry point. @L.task marks a function as a laco task: when invoked with a config dict (e.g. the instantiated train bundle), laco maps config keys to function parameters by name, instantiating any nodes that have not been instantiated yet. num_steps: int = 1 is a task-local parameter that can be overridden from the CLI.
Inside task, optimizer arrives as a partial (returned by L.partial); it is called with model.parameters() to produce the actual Adam instance.
if __name__ == "__main__":, self-loading pattern
cfg = laco.load(__file__ + "#train")
task(cfg)
The file loads itself as a config, selects the train bundle, and calls task. This means the file can be run directly (python -m laco.examples.pipelines.mnist_train) or loaded as a config from another file: the same source code serves both roles.
Load and inspect
import laco
# Load the full pipeline config
cfg = laco.load("configs://examples/pipelines/mnist_train.py")
print(list(cfg.keys()))
# ['hps', 'model', 'optimizer', 'loss', 'dataset', 'loader', 'train']
# Load only the training bundle
train_cfg = laco.load("configs://examples/pipelines/mnist_train.py#train")
print(list(train_cfg.keys()))
# ['model', 'optimizer', 'loss', 'loader']
# Instantiate everything in the bundle
train = laco.instantiate(train_cfg)
# train.model → nn.Sequential (the CNN)
# train.optimizer → functools.partial wrapping Adam
# train.loss → nn.CrossEntropyLoss()
# train.loader → DataLoader (dataset instantiated as part of this step)
Override demo
import laco
# Tune learning rate and batch size
cfg = laco.load(
"configs://examples/pipelines/mnist_train.py",
"hps.learning_rate=5e-4",
"hps.batch_size=128",
)
# Shrink model for a smoke run
cfg = laco.load(
"configs://examples/pipelines/mnist_train.py",
"hps.base_channels=8",
"hps.num_stages=1",
"hps.num_classes=10",
)
train = laco.instantiate(cfg.train)
Run from the command line
# Smoke run (1 step, no MNIST download required for model/optimizer/loss):
python -m laco.examples.pipelines.mnist_train
# Five steps:
python -m laco.examples.pipelines.mnist_train num_steps=5
# Tune hyperparameters:
python -m laco.examples.pipelines.mnist_train \
hps.learning_rate=5e-4 \
hps.batch_size=128 \
num_steps=10
What this demonstrates
- Importing a Tier-1 config factory (
make_cnn_classifier) into a pipeline L.Listfor list-valued constructor arguments (transforms.Compose)L.Dictas the pipeline root: enables#trainfragment selection@L.taskentry point: maps config keys to typed function parametersL.partialfor objects that need live tensors at call time (optimizer)- Self-loading pattern:
laco.load(__file__ + "#train")for direct execution
2. pipelines/clm_finetune.py: Causal-LM fine-tuning pipeline
Wires together a Qwen3-architecture language model (built via make_qwen3), a HuggingFace tokenizer, a HuggingFace dataset slice, an AdamW optimizer partial, and a linear learning-rate scheduler partial. Demonstrates how laco handles non-PyTorch components (HF datasets, transformers) alongside the standard torch stack.
Key source patterns
import laco.language as L
from laco.examples.models.qwen3 import make_qwen3
from datasets import load_dataset
from torch import optim
from torch.optim import lr_scheduler
from transformers import AutoTokenizer
@L.params
class hps:
tokenizer_name: str = "hf-internal-testing/tiny-random-Qwen2ForCausalLM"
dataset_name: str = "wikitext"
dataset_config: str = "wikitext-2-raw-v1"
dataset_split: str = "train[:100]"
learning_rate: float = 5e-5
weight_decay: float = 0.01
warmup_steps: int = 100
vocab_size: int = 151_936
hidden_size: int = 1024
num_layers: int = 28
num_heads: int = 16
num_kv_heads: int = 8
intermediate_size: int = 3072
model = make_qwen3(
vocab_size=hps.vocab_size,
hidden_size=hps.hidden_size,
num_layers=hps.num_layers,
num_heads=hps.num_heads,
num_kv_heads=hps.num_kv_heads,
intermediate_size=hps.intermediate_size,
)
tokenizer = L.call(AutoTokenizer.from_pretrained)(
pretrained_model_name_or_path=hps.tokenizer_name,
)
dataset = L.call(load_dataset)(
path=hps.dataset_name,
name=hps.dataset_config,
split=hps.dataset_split,
)
optimizer_partial = L.partial(optim.AdamW)(
lr=hps.learning_rate,
weight_decay=hps.weight_decay,
)
scheduler_partial = L.partial(lr_scheduler.LinearLR)(
start_factor=1e-6,
end_factor=1.0,
total_iters=hps.warmup_steps,
)
train = L.Dict(
model=model,
tokenizer=tokenizer,
dataset=dataset,
optimizer_partial=optimizer_partial,
scheduler_partial=scheduler_partial,
)
What this additionally demonstrates (beyond MNIST)
L.call(AutoTokenizer.from_pretrained): any callable (including class methods and module-level functions) as a config targetL.call(load_dataset): HuggingFacedatasetsAPI as a first-class config node- Two-partial pattern:
optimizer_partialandscheduler_partialare both deferred factories, because the scheduler also needs the optimizer object (itself needingmodel.parameters()) vocab_sizeas anhpsfield that is both a model architecture parameter and a tokenizer-derived value: the pipeline owns the alignment between the two
Pipeline patterns
The following conventions apply to all Tier-6 pipeline files.
1. Use L.Dict as the pipeline root
train = L.Dict(
model=model,
optimizer=optimizer,
loss=loss,
loader=loader,
)
L.Dict produces a named mapping as the pipeline root. This enables:
- Fragment selection:
laco.load("...#train")returns only the training bundle - Clean instantiation:
laco.instantiate(train_cfg)instantiates all components - Partial loading: a downstream config can embed
trainas a sub-tree
2. Use L.partial for objects that need runtime tensors
# Correct: optimizer receives model.parameters() inside @L.task
optimizer = L.partial(optim.Adam)(lr=hps.learning_rate)
# Wrong: model.parameters() does not exist at config-build time
# optimizer = L.call(optim.Adam)(params=model.parameters(), ...)
Optimizers and LR schedulers always use L.partial. The partial is called inside the task function after the model has been instantiated.
3. Annotate the @L.task entry point with types
@L.task
def task(
model: nn.Module,
optimizer: optim.Optimizer,
loss: nn.Module,
loader: DataLoader,
num_steps: int = 1,
) -> None:
...
@L.task uses the parameter names to match config keys. Type annotations are used for static checking and are not enforced at runtime by laco. Parameters with default values (like num_steps) can be overridden from the CLI without appearing in the config root.
4. Use the self-loading pattern for direct execution
if __name__ == "__main__":
import laco
cfg = laco.load(__file__ + "#train")
task(cfg)
__file__ resolves to the absolute path of the current module. Appending "#train" selects the train bundle. This pattern allows the file to be both a laco config (importable by other pipelines) and a runnable script.
5. Import config factories from sibling files with absolute-style imports
# Pipeline imports the factory, not the instantiated model
from laco.examples.cnn_classifier import make_cnn_classifier
from laco.examples.models.qwen3 import make_qwen3
model = make_cnn_classifier(in_channels=hps.in_channels, ...)
Always import the factory function, not the module-level model variable. The module-level variable is built with that module's own hps defaults; the factory lets the pipeline supply its own.