Notebook

Pipeline Configs

Prerequisites: 01.why-laco.ipynb through 06.nested-configs-and-containers.ipynb.

Dependencies: torch, torchvision. Real MNIST download cells are marked. Skip or mock them if offline.

The previous notebooks focused on a single component: one model, one optimizer, one schema. Real training pipelines wire many such components together: model, optimizer, loss function, dataset, and data loader, all addressable from a single config file.

This notebook introduces:

  1. The progression from a single-component config to a full pipeline bundle.
  2. L.Dict: the pipeline root that groups named components.
  3. Relative imports between config files.
  4. The complete pipelines/mnist_train.py example annotated line by line.
  5. Override grammar for pipeline configs.
  6. A composition diagram showing how source files relate.
  7. The __file__-loading pattern used by runnable pipeline modules.
import laco
import laco.language as L
from omegaconf import OmegaConf
from torch import nn, optim
from torch.utils.data import DataLoader

Section 1: From Single-Component to Pipeline

This section traces the natural progression. Each step exposes a new need.

# ============================================================
# Step 1 — A single model config
# ============================================================
model_cfg = laco.load("configs://examples/typed/mlp.py#model")
print("=== Single model ===")
# We have a recipe for one component — great.
print(list(OmegaConf.to_container(model_cfg, resolve=False).keys())[:3], "...")
Output
=== Single model ===
['_target_', '_args_', '_convert_'] ...
# ============================================================
# Step 2 — Model + optimizer as two separate objects
# ============================================================
from laco.examples.typed.linear_regression import (
    model as lr_model_cfg,
    optimizer as lr_optim_cfg,
    OptimGroup,
)

# The two exports are *different kinds* of node:
#   - model     : a fully-formed DictConfig recipe (it has a _target_)
#   - optimizer : L.chosen(OptimGroup) — a *slot reference* into a typed
#                 group (an interpolation like '${optimgroup}'), not a
#                 standalone recipe, so it has no _target_ of its own.
print("model type        :", type(lr_model_cfg).__name__)
print("model _target_    :", lr_model_cfg._target_)   # type: ignore[union-attr]  # noqa: LACO001
print("optimizer type    :", type(lr_optim_cfg).__name__)
print("optimizer ref     :", str(lr_optim_cfg))
print("chosen variant    :", OptimGroup.sgd._target_)  # what the slot resolves to

# Problem: they are two separate objects with no shared namespace.
# A training loop would call laco.load() twice, with different fragment paths.
# There is no way to load *all components at once* or serialize the whole pipeline.
Output
model type        : DictConfig
model _target_    : torch.nn.Linear
optimizer type    : _SlotRef
optimizer ref     : ${optimgroup}
chosen variant    : torch.optim.SGD
# ============================================================
# Step 3 — Bundle them into one tree
# ============================================================
# The model recipe references '${schema.*}' interpolations, and the optimizer
# is a group slot. To dump/instantiate a *self-contained* bundle, gather the
# components together with a concrete `schema` node so the references resolve
# (laco.load does this bundling for you when it reads a whole file; here we do
# it by hand for an isolated fragment).
from laco.examples.typed.linear_regression import (
    model as lr_model_cfg,
    OptimGroup,
)

train_bundle = OmegaConf.create({
    "schema": {"in_features": 4, "out_features": 1, "bias": True},
    "model": lr_model_cfg,
    "optimizer": OptimGroup.sgd,   # the concrete optimizer the slot resolves to
})

print("Bundle keys:", list(OmegaConf.to_container(train_bundle, resolve=False).keys()))
print("\n--- bundle dump (schema lets the model's interpolations resolve) ---")
print(laco.dump(train_bundle))
Output
Bundle keys: ['schema', 'model', 'optimizer']

--- bundle dump (schema lets the model's interpolations resolve) ---
_laco_: 1
model: {_convert_: all, _target_: torch.nn.Linear, bias: '${schema.bias}', in_features: '${schema.in_features}',
  out_features: '${schema.out_features}'}
optimizer: {_convert_: all, _partial_: true, _target_: torch.optim.SGD, lr: 0.01,
  momentum: 0.9}
schema: {bias: true, in_features: 4, out_features: 1}

L.Dict is a plain DictConfig wrapper: it does not add any _target_ key of its own. After laco.load("pipeline.py#train") the bundle config can be inspected, serialized, or passed to a training loop that calls laco.instantiate(cfg.model), laco.instantiate(cfg.optimizer, model.parameters()), etc.


Section 2: L.Dict, The Pipeline Root

L.Dict accepts keyword arguments whose values are config nodes and produces a single DictConfig that holds all of them under their keyword names. This lets a downstream caller get all components from a single fragment:

# Build a minimal pipeline bundle inline (no actual MNIST)
model_cfg   = L.call(nn.Linear)(in_features=784, out_features=10)
optimizer_cfg = L.partial(optim.Adam)(lr=1e-3)
loss_cfg    = L.call(nn.CrossEntropyLoss)()

pipeline = L.Dict(
    model=model_cfg,
    optimizer=optimizer_cfg,
    loss=loss_cfg,
)

print("Top-level keys:", list(OmegaConf.to_container(pipeline, resolve=False).keys()))

# Each sub-node is a fully self-contained DictConfig recipe
print("\nmodel node:")
print(laco.dump(pipeline.model))   # type: ignore[union-attr]  # noqa: LACO001
Output
Top-level keys: ['_target_', '_convert_', 'model', 'optimizer', 'loss']

model node:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, in_features: 784, out_features: 10}

# Instantiate only what you need — no need to materialise the whole pipeline
model_obj = laco.instantiate(pipeline.model)        # type: ignore[union-attr]  # noqa: LACO001
loss_obj  = laco.instantiate(pipeline.loss)         # type: ignore[union-attr]  # noqa: LACO001

print("model:", model_obj)
print("loss: ", loss_obj)
Output
model: Linear(in_features=784, out_features=10, bias=True)
loss:  CrossEntropyLoss()

The #train fragment pattern

In a real config file you expose the bundle under a stable name:

# pipeline.py
model     = L.call(MyModel)(...)
optimizer = L.partial(optim.Adam)(lr=1e-3)
loss      = L.call(nn.CrossEntropyLoss)()

train = L.Dict(model=model, optimizer=optimizer, loss=loss)

Then a caller loads laco.load("pipeline.py#train") and gets a single DictConfig containing all three components. Individual components are still accessible via laco.load("pipeline.py#model"). The two access patterns coexist.


Section 3: Relative Imports in Config Files

Config files are regular Python modules. This means they can import from each other using the standard from package.module import name pattern.

The MNIST pipeline imports a factory from the CNN classifier example:

# The import at the top of mnist_train.py:
#
#   from laco.examples.cnn_classifier import make_cnn_classifier
#
# This imports a Python *function* (not a DictConfig node).
# The function returns a DictConfig tree when called, so calling it inside
# the pipeline file is like inlining the config construction.

from laco.examples.cnn_classifier import make_cnn_classifier

# Calling the factory with explicit arguments returns a DictConfig tree
cnn_cfg = make_cnn_classifier(
    in_channels=1,
    base_channels=16,
    num_stages=2,
    num_classes=10,
)

print("type:", type(cnn_cfg))
print("_target_:", cnn_cfg._target_)  # noqa: LACO001
Output
type: <class 'omegaconf.dictconfig.DictConfig'>
_target_: torch.nn.Sequential

Why a factory function instead of a module-level model attribute?

The cnn_classifier.py module exposes both:

  • model: a pre-built DictConfig node with default hps values.
  • make_cnn_classifier(**kwargs): a factory that accepts explicit values.

The pipeline uses the factory because it needs to pass values from its own @L.params class hps, which are interpolation references, not literal integers. A factory function called with those references builds the DictConfig tree with the right interpolation strings already in place.

# How the pipeline threads interpolations through the factory:
@L.params
class pipeline_hps:
    in_channels:   int = 1
    base_channels: int = 32
    num_stages:    int = 3
    num_classes:   int = 10

# pipeline_hps.in_channels is NOT the integer 1 —
# it is the interpolation string '${pipeline_hps.in_channels}'
# (or however laco names the params namespace)
model_with_refs = make_cnn_classifier(
    in_channels=pipeline_hps.in_channels,
    base_channels=pipeline_hps.base_channels,
    num_stages=pipeline_hps.num_stages,
    num_classes=pipeline_hps.num_classes,
)

# The resulting DictConfig stores the interpolations, not literal values.
# To resolve them we bundle the params node alongside the model (calling
# pipeline_hps() materialises the namespace as a plain dict). Overriding
# pipeline_hps.num_classes=5 on this bundle would then propagate everywhere.
bundle = OmegaConf.create({"pipeline_hps": pipeline_hps(), "model": model_with_refs})
head_lines = [l for l in laco.dump(bundle).splitlines() if "num_classes" in l]
print("head out_features references:", head_lines)
Output
head out_features references: ["          out_features: '${pipeline_hps.num_classes}'}", 'pipeline_hps: {base_channels: 32, in_channels: 1, num_classes: 10, num_stages: 3}']

Section 4: Full Walkthrough, pipelines/mnist_train.py

This section reads through the complete pipeline config, annotating every section. The source is at sources/laco/examples/pipelines/mnist_train.py.

# ============================================================
# Section A — Hyperparameters
# ============================================================
# @L.params creates a DictConfig namespace whose attributes are interpolation
# strings. Every other config node in the file can reference these via
# '${hps.batch_size}' etc.

from torchvision.datasets import MNIST
from torchvision import transforms

@L.params
class hps:
    data_root:     str   = "./data"
    batch_size:    int   = 64
    learning_rate: float = 1e-3
    num_workers:   int   = 0
    # Architecture hyperparameters — shared with the model factory
    in_channels:   int   = 1
    base_channels: int   = 32
    num_stages:    int   = 3
    num_classes:   int   = 10

print("hps type:", type(hps))
print("hps.batch_size (runtime):", repr(hps.batch_size))  # noqa: LACO001
Output
hps type: <class 'laco.language.ParamsWrapper'>
hps.batch_size (runtime): '${hps.batch_size}'
# ============================================================
# Section B — Model
# ============================================================
# make_cnn_classifier is called with interpolation references.
# The factory returns a DictConfig tree where every architecture
# dimension is a '${hps.*}' string — overriding hps.num_stages=2
# at load time changes the depth of the CNN without re-running this file.

model = make_cnn_classifier(
    in_channels=hps.in_channels,
    base_channels=hps.base_channels,
    num_stages=hps.num_stages,
    num_classes=hps.num_classes,
)
print("model _target_:", model._target_)  # noqa: LACO001
Output
model _target_: torch.nn.Sequential
# ============================================================
# Section C — Optimizer and loss
# ============================================================
# L.partial produces a "partial constructor" node (_partial_: true).
# At instantiation time you still pass model.parameters() — the optimizer
# cannot be fully instantiated without the model weights.
optimizer = L.partial(optim.Adam)(lr=hps.learning_rate)

# L.call produces a full constructor node — CrossEntropyLoss takes no
# required arguments, so it can be instantiated directly.
loss = L.call(nn.CrossEntropyLoss)()

print("optimizer _partial_:", optimizer._partial_)   # noqa: LACO001
print("loss _target_      :", loss._target_)          # noqa: LACO001
Output
optimizer _partial_: True
loss _target_      : torch.nn.CrossEntropyLoss
# ============================================================
# Section D — Data pipeline
# ============================================================
# L.List is a DictConfig list wrapper — it holds an ordered sequence of
# config nodes. transforms.Compose accepts a list of transforms, so we
# build the entire transform pipeline as a config tree.

_transform = L.call(transforms.Compose)(
    L.List(
        L.call(transforms.ToTensor)(),
        L.call(transforms.Normalize)(
            mean=L.List(0.1307),   # single-channel mean
            std=L.List(0.3081),    # single-channel std
        ),
    )
)

print("transform _target_:", _transform._target_)  # noqa: LACO001

# Dataset: MNIST with root, split, download, and the transform node above.
# 'download=True' is a literal bool — not an interpolation; it is part of the recipe.
dataset = L.call(MNIST)(
    root=hps.data_root,
    train=True,
    download=True,
    transform=_transform,
)

# DataLoader wraps the dataset — note that 'dataset=' receives the DictConfig
# recipe for MNIST, not an instantiated Dataset object. Hydra will
# recursively instantiate nested nodes automatically.
loader = L.call(DataLoader)(
    dataset=dataset,
    batch_size=hps.batch_size,
    shuffle=True,
    num_workers=hps.num_workers,
)

print("loader _target_  :", loader._target_)   # noqa: LACO001
print("dataset _target_ :", loader.dataset._target_)  # noqa: LACO001  # nested!
Output
transform _target_: torchvision.transforms.Compose
loader _target_  : torch.utils.data.DataLoader
dataset _target_ : torchvision.datasets.MNIST
# ============================================================
# Section E — The train bundle
# ============================================================
# L.Dict collects all components under a single DictConfig root.
# This is the fragment a training script loads with:
#   cfg = laco.load("configs://examples/pipelines/mnist_train.py#train")

train = L.Dict(
    model=model,
    optimizer=optimizer,
    loss=loss,
    loader=loader,
)

print("train bundle keys:", list(OmegaConf.to_container(train, resolve=False).keys()))
Output
train bundle keys: ['_target_', '_convert_', 'model', 'optimizer', 'loss', 'loader']
# Load the actual file from the examples package (does not instantiate anything)
full_cfg = laco.load("configs://examples/pipelines/mnist_train.py")

print("Top-level fragments:", list(OmegaConf.to_container(full_cfg, resolve=False).keys()))
Output
Top-level fragments: ['hps', 'model', 'optimizer', 'loss', 'dataset', 'loader', 'train']
# Load just the train bundle fragment
train_cfg = laco.load("configs://examples/pipelines/mnist_train.py#train")
print("train bundle keys:", list(OmegaConf.to_container(train_cfg, resolve=False).keys()))
print()

# model and optimizer are immediately available as sub-configs
print("=== model ===")
print(laco.dump(train_cfg.model))   # type: ignore[union-attr]  # noqa: LACO001
Output
train bundle keys: ['_target_', '_convert_', 'model', 'optimizer', 'loss', 'loader']

=== model ===
_args_:
- _convert_: all
  _target_: laco.language.OrderedDict.target
  items:
  - - stem
    - _args_:
      - {_convert_: all, _target_: torch.nn.Conv2d, bias: false, in_channels: '${hps.in_channels}',
        kernel_size: 3, out_channels: '${hps.base_channels}', padding: 1}
      - {_convert_: all, _target_: torch.nn.BatchNorm2d, num_features: '${hps.base_channels}'}
      - {_convert_: all, _target_: torch.nn.ReLU, inplace: true}
      _convert_: all
      _target_: torch.nn.Sequential
  - - stages
    - _args_:
        _convert_: all
        _target_: laco.ops.repeat
        num: ${hps.num_stages}
        src:
          _args_:
          - {_convert_: all, _target_: torch.nn.Conv2d, bias: false, in_channels: '${hps.base_channels}',
            kernel_size: 3, out_channels: '${hps.base_channels}', padding: 1}
          - {_convert_: all, _target_: torch.nn.BatchNorm2d, num_features: '${hps.base_channels}'}
          - {_convert_: all, _target_: torch.nn.ReLU, inplace: true}
          - {_convert_: all, _target_: torch.nn.MaxPool2d, kernel_size: 2, stride: 2}
          _convert_: all
          _target_: torch.nn.Sequential
      _convert_: all
      _target_: torch.nn.Sequential
  - - head
    - _args_:
      - {_convert_: all, _target_: torch.nn.AdaptiveAvgPool2d, output_size: 1}
      - {_convert_: all, _target_: torch.nn.Flatten}
      - {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.base_channels}',
        out_features: '${hps.num_classes}'}
      _convert_: all
      _target_: torch.nn.Sequential
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

# The hps namespace is also addressable as its own fragment
hps_cfg = laco.load("configs://examples/pipelines/mnist_train.py#hps")
print("=== hps ===")
print(laco.dump(hps_cfg))
Output
=== hps ===
{_laco_: 1, base_channels: 32, batch_size: 64, data_root: ./data, in_channels: 1,
  learning_rate: 0.001, num_classes: 10, num_stages: 3, num_workers: 0}

The @L.task decorator (preview)

The mnist_train.py file also defines:

@L.task
def task(
    model: nn.Module,
    optimizer: optim.Optimizer,
    loss: nn.Module,
    loader: DataLoader,
    num_steps: int = 1,
) -> None:
    ...

@L.task is a decorator that turns the function into a callable that accepts a single DictConfig (the train bundle) and instantiates the arguments by name before calling the underlying function. This is covered in 09.tasks-and-app-loop.ipynb; for now, note that it is what powers python -m laco.examples.pipelines.mnist_train num_steps=2.


Section 5: Override Grammar Deep-Dive

Overrides follow the Hydra dotpath syntax: key.subkey=value. Laco threads them through to OmegaConf merge semantics.

# Basic scalar override — change batch_size
cfg_bs128 = laco.load(
    "configs://examples/pipelines/mnist_train.py#hps",
    "hps.batch_size=128",
)
print(f"batch_size: {cfg_bs128.batch_size}")   # noqa: LACO001
Output
batch_size: 128
# Float override — scientific notation is supported
cfg_lr = laco.load(
    "configs://examples/pipelines/mnist_train.py#hps",
    "hps.learning_rate=5e-4",
)
print(f"learning_rate: {cfg_lr.learning_rate}")  # noqa: LACO001
Output
learning_rate: 0.0005
# Multiple overrides in a single call
cfg_multi = laco.load(
    "configs://examples/pipelines/mnist_train.py#hps",
    "hps.batch_size=256",
    "hps.num_stages=2",
    "hps.base_channels=16",
)
print(f"batch_size={cfg_multi.batch_size}  "
      f"num_stages={cfg_multi.num_stages}  "
      f"base_channels={cfg_multi.base_channels}")  # noqa: LACO001
Output
batch_size=256  num_stages=2  base_channels=16
# Show what changed between base and overridden configs
base_hps = laco.load("configs://examples/pipelines/mnist_train.py#hps")
ovrd_hps = laco.load(
    "configs://examples/pipelines/mnist_train.py#hps",
    "hps.batch_size=128",
    "hps.learning_rate=5e-4",
)

for key in ("batch_size", "learning_rate", "num_stages"):
    before = OmegaConf.select(base_hps, key)
    after  = OmegaConf.select(ovrd_hps, key)
    changed = "<-- changed" if before != after else ""
    print(f"  {key:<18} {before!r:>12}{after!r:<12} {changed}")
Output
  batch_size                   64  →  128          <-- changed
  learning_rate             0.001  →  0.0005       <-- changed
  num_stages                    3  →  3            

URL-style overrides

Laco also accepts overrides embedded in the URL string using ?key=value syntax, which is convenient when the full config path is stored in a single variable:

# URL-style: ?key=value before the # fragment separator
cfg_url = laco.load(
    "configs://examples/pipelines/mnist_train.py"
    "?hps.batch_size=32&hps.learning_rate=2e-3"
    "#hps"
)
print(f"batch_size={cfg_url.batch_size}  learning_rate={cfg_url.learning_rate}")  # noqa: LACO001
Output
batch_size=32  learning_rate=0.002

Override grammar summary

SyntaxMeaning
key=valueSet a scalar value
key.subkey=valueSet a nested scalar
+key=valueAppend a new key (Hydra + prefix)
~keyRemove a defaults-list entry (Hydra ~ prefix)
?key=valueURL-style inline override (before #)
?key=v1&key2=v2Multiple URL-style overrides

Section 6: Config Composition

How the two source files relate, and how the pipeline bundle is assembled at compose time.

cnn_classifier.py declares @L.params class hps and make_cnn_classifier(**kw), which builds L.call(nn.Sequential, root=True)(stem, stages, head) and exposes it as model = make_cnn_classifier(...).

mnist_train.py imports make_cnn_classifier from cnn_classifier.py and declares:

NameBuilt with
hps@L.params
modelmake_cnn_classifier(...), threaded with hps.* references
optimizerL.partial(Adam)(...)
lossL.call(CrossEntropyLoss)()
datasetL.call(MNIST)(...)
loaderL.call(DataLoader)(...)

All five are collected into train = L.Dict(model=model, optimizer=optimizer, loss=loss, loader=loader). Loading "...mnist_train.py#train" returns a single DictConfig with keys model, optimizer, loss, loader — the whole pipeline in one fragment.


Section 7: __file__-Loading and Runnable Pipeline Modules

A config file that defines a @L.task entry point can be run directly as a module:

python -m laco.examples.pipelines.mnist_train num_steps=2

The canonical pattern for making a config file self-runnable is to use Python's __file__ sentinel:

# From the bottom of mnist_train.py:
#
#   if __name__ == "__main__":
#       import laco
#       cfg = laco.load(__file__ + "#train")
#       task(cfg)
#
# Why __file__ instead of "configs://examples/pipelines/mnist_train.py"?
#
# __file__ is the absolute path on disk.  laco.load() accepts both:
#   - a "configs://" URL (resolved against the package search path)
#   - a raw filesystem path (resolved directly)
# Using __file__ makes the script relocatable — it works even if the module
# is installed in a virtualenv, editable-installed, or symlinked.

import laco.examples.pipelines.mnist_train as _mt_module
print("Module file:", _mt_module.__file__)
Output
Module file: /nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/examples/pipelines/mnist_train.py
# Load via filesystem path (same as __file__ + "#train" from inside the module)
import importlib.resources, pathlib

module_path = pathlib.Path(_mt_module.__file__)
cfg_via_file = laco.load(str(module_path) + "#hps")
print("Loaded via __file__ path — batch_size:", cfg_via_file.batch_size)  # noqa: LACO001
Output
Loaded via __file__ path — batch_size: 64

configs:// URL vs filesystem path

FormExampleWhen to use
configs:// URLconfigs://examples/pipelines/mnist_train.pyReferences into the installed laco package's examples; stable across installs
Filesystem pathstr(__file__) + "#fragment"Self-loading from within the config file itself; works even outside the package
Relative path"./my_config.py"Local project configs not installed in any package

The configs:// scheme is the recommended form for referencing library examples. The __file__ form is the recommended form for self-running modules.


Section 8: Instantiating a Full Pipeline Bundle

With all pieces in place, here is how a training script consumes the pipeline config:

# Load the train bundle
train_cfg = laco.load(
    "configs://examples/pipelines/mnist_train.py#train",
    "hps.base_channels=16",   # smaller model for faster instantiation in this notebook
    "hps.num_stages=2",
)

# Instantiate each component individually
model_obj     = laco.instantiate(train_cfg.model)      # type: ignore[union-attr]  # noqa: LACO001
optimizer_fn  = laco.instantiate(train_cfg.optimizer)  # type: ignore[union-attr]  # noqa: LACO001
loss_obj      = laco.instantiate(train_cfg.loss)       # type: ignore[union-attr]  # noqa: LACO001

# optimizer is a partial — call it with model.parameters()
opt = optimizer_fn(model_obj.parameters())

print("model     :", type(model_obj).__name__)
print("optimizer :", type(opt).__name__)
print("loss      :", type(loss_obj).__name__)

# Verify model works with a dummy forward pass
import torch
dummy = torch.randn(2, 1, 28, 28)  # batch=2, channels=1, 28x28
out = model_obj(dummy)
print("output shape:", out.shape)   # should be [2, 10]
Output
model     : Sequential
optimizer : Adam
loss      : CrossEntropyLoss
output shape: torch.Size([2, 10])

Section 9: Nested Instantiation, DataLoader with Nested MNIST

Recursive instantiation is inherited from Hydra: when a config node contains a nested node with a _target_, laco.instantiate will build the inner object first and pass it to the outer constructor automatically.

# Inspect the loader config — it contains a nested dataset node
loader_cfg = laco.load("configs://examples/pipelines/mnist_train.py#loader")
print("=== loader config ===")
print(laco.dump(loader_cfg))
Output
=== loader config ===
_convert_: all
_laco_: 1
_target_: torch.utils.data.DataLoader
batch_size: ${hps.batch_size}
dataset:
  _convert_: all
  _target_: torchvision.datasets.MNIST
  download: true
  root: ${hps.data_root}
  train: true
  transform:
    _args_:
    - _convert_: all
      _target_: laco.language.List.target
      items:
      - {_convert_: all, _target_: torchvision.transforms.ToTensor}
      - _convert_: all
        _target_: torchvision.transforms.Normalize
        mean:
          _convert_: all
          _target_: laco.language.List.target
          items: [0.1307]
        std:
          _convert_: all
          _target_: laco.language.List.target
          items: [0.3081]
    _convert_: all
    _target_: torchvision.transforms.Compose
num_workers: ${hps.num_workers}
shuffle: true

# The nesting depth: loader → dataset → transform → [ToTensor, Normalize]
# laco.instantiate(loader_cfg) would:
#   1. Build Normalize(mean=[0.1307], std=[0.3081])
#   2. Build ToTensor()
#   3. Build Compose([ToTensor(), Normalize(...)])
#   4. Build MNIST(root=..., transform=Compose(...))
#   5. Build DataLoader(dataset=MNIST(...), batch_size=64, ...)
#
# This cell is *illustrative* — MNIST download would be triggered.
# Uncomment the line below in an environment with internet access:

# loader_obj = laco.instantiate(loader_cfg)
# print("loader:", loader_obj)

print("(Skipped — would download MNIST.  Run in a connected environment to verify.)")
Output
(Skipped — would download MNIST.  Run in a connected environment to verify.)

Summary

ConceptWhat it does
L.Dict(k=v, ...)Groups named config nodes into a single DictConfig root; accessed via #fragment
L.List(n1, n2, ...)An ordered DictConfig list of nodes; used for sequences like transform pipelines
L.partial(T)(**kw)A "partial constructor" node; instantiation returns functools.partial(T, **kw), which requires further arguments (e.g. model.parameters())
Factory import patternA config file imports a factory function from another module and calls it with interpolation references to thread @L.params values through
configs:// URLResolves against the installed package's config root; stable across environments
__file__ loadingA config module loads itself by path; makes the module self-runnable as python -m ...
Recursive instantiationlaco.instantiate(cfg) builds nested objects bottom-up automatically

Putting it all together:

  1. Use @L.params to collect scalar hyperparameters in one place.
  2. Use L.call / L.partial to build lazy construction nodes for every component.
  3. Thread @L.params values through factory functions to keep the graph parametric.
  4. Collect everything into L.Dict(model=..., optimizer=..., ...) as the train bundle.
  5. Load with laco.load("...#train") and instantiate each component as needed.

Next: 09.tasks-and-app-loop.ipynb covers @L.task, the laco run CLI, and how to structure a project so experiments are fully reproducible and launch-able from the command line.