EXAMPLES

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.task entry 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

Source: sources/laco/examples/pipelines/mnist_train.py

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.List for list-valued constructor arguments (transforms.Compose)
  • L.Dict as the pipeline root: enables #train fragment selection
  • @L.task entry point: maps config keys to typed function parameters
  • L.partial for 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

Source: sources/laco/examples/pipelines/clm_finetune.py

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 target
  • L.call(load_dataset): HuggingFace datasets API as a first-class config node
  • Two-partial pattern: optimizer_partial and scheduler_partial are both deferred factories, because the scheduler also needs the optimizer object (itself needing model.parameters())
  • vocab_size as an hps field 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 train as 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.