Notebook

Nested Configs and Containers

Prerequisites: 01.why-laco.ipynb through 04.hyperparameters-and-interpolation.ipynb.

What you will learn:

  • How L.call nodes nest to produce deep DictConfig trees
  • L.OrderedDict: named sub-modules for nn.Sequential
  • expand_args=True: unpacking a list as *args
  • L.repeat: generating replicated config structures without Python loops
  • The five container macros: L.List, L.Dict, L.Tuple, L.Set, L.OrderedDict
  • Full annotated walkthrough of mlp.py and cnn_classifier.py
  • Tree visualizations of nested config structure

Dependencies: torch, torch.nn, laco.

import laco
import laco.language as L
from omegaconf import OmegaConf
from torch import nn

print("torch version:", __import__('torch').__version__)
Output
torch version: 2.12.0+cu130

Section 1: Nesting L.call Nodes

Every L.call(T)(**kwargs) produces a DictConfig node with a _target_ key pointing to T. When you pass another L.call result as a keyword argument, you get a nested DictConfig tree: the entire model architecture expressed as data, not live objects.

This section builds up from a single layer to a two-layer sequential to illustrate how the nesting works.

# Flat: a single nn.Linear layer config.
# L.call(nn.Linear) returns a callable that, when called with kwargs,
# produces a DictConfig — NOT an nn.Linear object.
linear_cfg = L.call(nn.Linear)(in_features=128, out_features=64)

print("Type at runtime:", type(linear_cfg).__name__)
print()
print("YAML representation:")
print(laco.dump(linear_cfg))
Output
Type at runtime: DictConfig

YAML representation:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, in_features: 128, out_features: 64}

# Nested: nn.Sequential containing two sub-nodes.
# Positional arguments to L.call become the _args_ list in the DictConfig.
seq_cfg = L.call(nn.Sequential)(
    L.call(nn.Linear)(in_features=128, out_features=64),
    L.call(nn.ReLU)(),
)

print("Nested YAML (note the _args_ list containing two sub-nodes):")
print(laco.dump(seq_cfg))
Output
Nested YAML (note the _args_ list containing two sub-nodes):
_args_:
- {_convert_: all, _target_: torch.nn.Linear, in_features: 128, out_features: 64}
- {_convert_: all, _target_: torch.nn.ReLU}
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

# Instantiate: laco.instantiate walks the DictConfig tree recursively,
# calling each _target_ with its stored kwargs and args.
model = laco.instantiate(seq_cfg)
print("Instantiated model:", model)
print("Type:", type(model).__name__)
Output
Instantiated model: Sequential(
  (0): Linear(in_features=128, out_features=64, bias=True)
  (1): ReLU()
)
Type: Sequential

Key insight: the DictConfig tree is the blueprint. laco.instantiate is the builder that walks it. You can store the blueprint, serialize it, override it, merge it, and only build when you actually need the object.

The _args_ list in the YAML is how positional constructor arguments are represented in the config. For nn.Sequential, those positional args are the layer objects.


Section 2: L.OrderedDict, Named Sub-modules

nn.Sequential has a second constructor form: it accepts an OrderedDict to give each sub-module a name. Named sub-modules are very useful for feature extraction (you can ask PyTorch for model.linear, model.relu, etc.) and for readable debugging.

L.OrderedDict((name, config), ...) is the laco primitive for this pattern. It takes positional (key, value) pairs, not keyword arguments, because Python dicts do not guarantee insertion order in all contexts, and the ordered semantics matter for nn.Sequential.

# Build a three-layer sequential with named sub-modules.
model_cfg = L.call(nn.Sequential)(
    L.OrderedDict(
        ("linear", L.call(nn.Linear)(in_features=128, out_features=64)),
        ("relu",   L.call(nn.ReLU)()),
        ("head",   L.call(nn.Linear)(in_features=64,  out_features=10)),
    )
)

print("Config YAML:")
print(laco.dump(model_cfg))
Output
Config YAML:
_args_:
- _convert_: all
  _target_: laco.language.OrderedDict.target
  items:
  - - linear
    - {_convert_: all, _target_: torch.nn.Linear, in_features: 128, out_features: 64}
  - - relu
    - {_convert_: all, _target_: torch.nn.ReLU}
  - - head
    - {_convert_: all, _target_: torch.nn.Linear, in_features: 64, out_features: 10}
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

model = laco.instantiate(model_cfg)
print("Model:", model)
print()

# Named sub-modules are accessible by name:
print("Named children:")
for name, child in model.named_children():
    print(f"  model.{name} = {child}")
Output
Model: Sequential(
  (linear): Linear(in_features=128, out_features=64, bias=True)
  (relu): ReLU()
  (head): Linear(in_features=64, out_features=10, bias=True)
)

Named children:
  model.linear = Linear(in_features=128, out_features=64, bias=True)
  model.relu = ReLU()
  model.head = Linear(in_features=64, out_features=10, bias=True)

When to use L.OrderedDict vs positional args:

StyleUse when
Positional args (L.call(nn.Linear)(...), ...)Order is all that matters; no need for named access
L.OrderedDict(("name", cfg), ...)You want to access sub-modules by name (feature extraction, debugging, fine-tuning)

Section 3: expand_args=True

Sometimes you want to pass a list of items as *args (variadic positional arguments), not as a single positional argument containing the list. The expand_args=True flag on L.call signals this intent.

Without expand_args: the list is stored as a single _args_ entry: the list itself is the first positional argument.

With expand_args=True: the list is stored in _args_ and laco.instantiate unpacks it as constructor(*list_items) instead of constructor(list_items).

This is critical for nn.Sequential: it expects Sequential(layer1, layer2, ...), not Sequential([layer1, layer2, ...]).

# L.repeat returns a lazy DictConfig node (a deferred laco.ops.repeat call),
# NOT a Python list. The list of copies only materializes at instantiation.
# Passing this node directly to nn.Sequential as a single arg would be wrong;
# see expand_args below for the correct way to splat it into *args.
layers_node = L.repeat(3, L.call(nn.Linear)(in_features=64, out_features=64))
print("L.repeat(3, ...) node type:", type(layers_node).__name__)
print("Instantiated length:", len(laco.instantiate(layers_node)))
print()
Output
L.repeat(3, ...) node type: DictConfig
Instantiated length: 3

# With expand_args=True: the list is unpacked as *args at instantiation time.
# This is the correct way to build an nn.Sequential from L.repeat.
seq_cfg = L.call(nn.Sequential, expand_args=True)(
    L.repeat(3, L.call(nn.Linear)(in_features=64, out_features=64))
)

print("Config YAML (note expand_args in the node):")
print(laco.dump(seq_cfg))

model = laco.instantiate(seq_cfg)
print("Instantiated:", model)
print(f"Number of children: {len(list(model.children()))}")
Output
Config YAML (note expand_args in the node):
_args_:
  _convert_: all
  _target_: laco.ops.repeat
  num: 3
  src: {_convert_: all, _target_: torch.nn.Linear, in_features: 64, out_features: 64}
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

Instantiated: Sequential(
  (0): Linear(in_features=64, out_features=64, bias=True)
  (1): Linear(in_features=64, out_features=64, bias=True)
  (2): Linear(in_features=64, out_features=64, bias=True)
)
Number of children: 3

Section 4: L.repeat, Replicated Structures

L.repeat(n, item) produces a list of n deep-copies of item in the config tree. This is the idiomatic way to express "N identical blocks" without writing Python loops in your config files.

Because each copy is independent in the config tree, you can override individual copies after the fact: hps.num_layers=5 would regenerate the list with 5 copies.

# Three identical linear layers. L.repeat builds a lazy DictConfig node;
# instantiating it materializes the list of n deep-copies.
block_cfg = L.call(nn.Linear)(in_features=64, out_features=64)
repeated = L.repeat(3, block_cfg)

print("Type returned by L.repeat:", type(repeated).__name__)
print()
print("The L.repeat node as YAML (num + src blueprint):")
print(laco.dump(repeated))

# Instantiate to get the actual list, then index/iterate it.
layers = laco.instantiate(repeated)
print("Instantiated type:", type(layers).__name__)
print("Length:", len(layers))
print("First element:", layers[0])
Output
Type returned by L.repeat: DictConfig

The L.repeat node as YAML (num + src blueprint):
_convert_: all
_laco_: 1
_target_: laco.ops.repeat
num: 3
src: {_convert_: all, _target_: torch.nn.Linear, in_features: 64, out_features: 64}

Instantiated type: list
Length: 3
First element: Linear(in_features=64, out_features=64, bias=True)
# Build a deep network from L.repeat + expand_args.
deep_net_cfg = L.call(nn.Sequential, expand_args=True)(
    L.repeat(5, L.call(nn.Linear)(in_features=64, out_features=64))
)

deep_net = laco.instantiate(deep_net_cfg)
print("Deep net (5 linear layers):")
print(deep_net)
Output
Deep net (5 linear layers):
Sequential(
  (0): Linear(in_features=64, out_features=64, bias=True)
  (1): Linear(in_features=64, out_features=64, bias=True)
  (2): Linear(in_features=64, out_features=64, bias=True)
  (3): Linear(in_features=64, out_features=64, bias=True)
  (4): Linear(in_features=64, out_features=64, bias=True)
)
# L.repeat deep-copies its source, so each entry is an independent module.
# Instantiate the node to get the list, then inspect individual elements.
multi_cfg = L.repeat(3, L.call(nn.Linear)(in_features=64, out_features=64))
modules = laco.instantiate(multi_cfg)
print("Are copies distinct objects?", modules[0] is not modules[1])
print("Copy 0:", modules[0])
print("Copy 1:", modules[1])
Output
Are copies distinct objects? True
Copy 0: Linear(in_features=64, out_features=64, bias=True)
Copy 1: Linear(in_features=64, out_features=64, bias=True)

Section 5: Container Macros

laco provides five typed container macros. Each one looks like a Python constructor but produces a DictConfig node (the lie-typing contract, see the language module docstring). When you call laco.instantiate, the node is resolved into the actual Python container.

MacroStatic typeUse case
L.List(*items)list[T]Homogeneous list
L.Dict(**kwargs)dict[str, T]String-keyed mapping
L.Tuple(*items)tuple[...]Heterogeneous tuple
L.Set(*items)set[T]Unordered, deduplicated set
L.OrderedDict((k,v), ...)OrderedDict[str, T]Insertion-order mapping
# L.List — homogeneous list of items.
list_cfg = L.List(1, 2, 3)
print("L.List config type:", type(list_cfg).__name__)
result = laco.instantiate(list_cfg)
print("Instantiated:", result, "| type:", type(result).__name__)
Output
L.List config type: DictConfig
Instantiated: [1, 2, 3] | type: list
# L.Dict — string-keyed mapping via keyword arguments.
dict_cfg = L.Dict(a=1, b=2, c=3)
print("L.Dict config type:", type(dict_cfg).__name__)
result = laco.instantiate(dict_cfg)
print("Instantiated:", result, "| type:", type(result).__name__)
Output
L.Dict config type: DictConfig
Instantiated: {'a': 1, 'b': 2, 'c': 3} | type: dict
# L.Tuple — heterogeneous tuple.
# Uses PEP 646 TypeVarTuple so mypy/pyright can track element types.
tuple_cfg = L.Tuple(1, "hello", 3.14)
print("L.Tuple config type:", type(tuple_cfg).__name__)
result = laco.instantiate(tuple_cfg)
print("Instantiated:", result, "| type:", type(result).__name__)
Output
L.Tuple config type: DictConfig
Instantiated: (1, 'hello', 3.14) | type: tuple
# L.Set — deduplicated, unordered set.
set_cfg = L.Set(1, 2, 3, 2, 1)
print("L.Set config type:", type(set_cfg).__name__)
result = laco.instantiate(set_cfg)
print("Instantiated:", result, "| type:", type(result).__name__)
print("Duplicates were removed:", 2 in result and len(result) == 3)
Output
L.Set config type: DictConfig
Instantiated: {1, 2, 3} | type: set
Duplicates were removed: True
# L.OrderedDict — insertion-order preserved mapping.
import collections
od_cfg = L.OrderedDict(
    ("first",  10),
    ("second", 20),
    ("third",  30),
)
print("L.OrderedDict config type:", type(od_cfg).__name__)
result = laco.instantiate(od_cfg)
print("Instantiated:", result, "| type:", type(result).__name__)
print("Keys in insertion order:", list(result.keys()))
Output
L.OrderedDict config type: DictConfig
Instantiated: OrderedDict({'first': 10, 'second': 20, 'third': 30}) | type: OrderedDict
Keys in insertion order: ['first', 'second', 'third']
# Containers can hold nested L.call nodes — they instantiate recursively.
# Example: L.Dict of two nn.Linear configs.
layers_dict_cfg = L.Dict(
    encoder=L.call(nn.Linear)(in_features=128, out_features=64),
    decoder=L.call(nn.Linear)(in_features=64,  out_features=128),
)
layers = laco.instantiate(layers_dict_cfg)
print("Nested Dict instantiation:")
for k, v in layers.items():
    print(f"  {k}: {v}")
Output
Nested Dict instantiation:
  encoder: Linear(in_features=128, out_features=64, bias=True)
  decoder: Linear(in_features=64, out_features=128, bias=True)

Section 6: Full mlp.py Walkthrough

The mlp.py example puts together nearly every concept from the previous sections. Let's read through it line by line, then build and visualize the resulting config tree.

The full source

import inspect
from laco.examples import mlp

print(inspect.getsource(mlp))
Output
r"""Multilayer perceptron (MLP) example.

Smallest non-trivial laco config: stacks ``Linear → activation`` blocks via
``L.repeat`` and groups them with ``L.OrderedDict``.

Two ways to use:

- Load ``configs://examples/mlp.py#model`` and override hps.
- Import :func:`make_mlp` and pass explicit hyperparameters when composing
  into a larger pipeline.

For the rest of the curriculum (CNNs, transformers, vision/LLM models,
Lightning + Transformers + TensorDict integrations, end-to-end pipelines),
see the sibling files under ``laco/examples/``.
"""

import laco.language as L
from torch import nn

__all__ = ["model", "hps"]


@L.params
class hps:
    dim_in: int = 128
    dim_out: int = 128
    dim_hidden: int = 256
    num_layers: int = 3
    activation: type[nn.Module] = nn.ReLU


def make_mlp(*, dim_in, dim_out, dim_hidden, num_layers, activation):
    return L.call(nn.Sequential, root=True)(
        L.OrderedDict(
            (
                "input",
                L.call(nn.Sequential)(
                    L.call(nn.Linear)(
                        in_features=dim_in,
                        out_features=dim_hidden,
                    ),
                    L.call(activation)(),
                ),
            ),
            (
                "hidden",
                L.call(nn.Sequential, expand_args=True)(
                    L.repeat(
                        num_layers,
                        L.call(nn.Sequential)(
                            L.call(nn.Linear)(
                                in_features=dim_hidden,
                                out_features=dim_hidden,
                            ),
                            L.call(activation)(),
                        ),
                    ),
                ),
            ),
            (
                "output",
                L.call(nn.Sequential)(
                    L.call(nn.Linear)(
                        in_features=dim_hidden,
                        out_features=dim_out,
                    )
                ),
            ),
        )
    )


model = make_mlp(
    dim_in=hps.dim_in,
    dim_out=hps.dim_out,
    dim_hidden=hps.dim_hidden,
    num_layers=hps.num_layers,
    activation=hps.activation,
)

Line-by-line annotations

@L.params
class hps:
    dim_in: int = 128
    ...
    activation: type[nn.Module] = nn.ReLU

@L.params turns the class into a params wrapper: a thin proxy that makes attribute access produce OmegaConf interpolation strings (${hps.dim_in}) rather than the raw Python values. This lets make_mlp compose a config that refers to the hps values without hard-coding them.


def make_mlp(*, dim_in, dim_out, dim_hidden, num_layers, activation):
    return L.call(nn.Sequential, root=True)(

root=True marks this node as the root of the config tree. The YAML marker _root_: true is stored in the node so that laco.instantiate knows where to start. It has no effect on the tree structure itself.


        L.OrderedDict(
            ("input",  L.call(nn.Sequential)(linear, activation)),
            ("hidden", L.call(nn.Sequential, expand_args=True)(L.repeat(num_layers, block))),
            ("output", L.call(nn.Sequential)(linear_out)),
        )

L.OrderedDict gives each section a name. The hidden section uses expand_args=True + L.repeat to build num_layers identical blocks. The number of hidden layers can be overridden at load time without changing this function.


model = make_mlp(
    dim_in=hps.dim_in,     # expands to ${hps.dim_in}
    ...
)

The module-level model variable is the complete DictConfig tree for the default hyperparameters.

# Load the mlp config and inspect the full tree.
mlp_cfg = laco.load("configs://examples/mlp.py")

print("Top-level keys:", list(mlp_cfg.keys()))
print()
print("Full config as YAML:")
print(laco.dump(mlp_cfg))
Output
Top-level keys: ['hps', 'model']

Full config as YAML:
_laco_: 1
hps: {activation: !!python/name:torch.nn.modules.activation.ReLU '', dim_hidden: 256,
  dim_in: 128, dim_out: 128, num_layers: 3}
model:
  _args_:
  - _convert_: all
    _target_: laco.language.OrderedDict.target
    items:
    - - input
      - _args_:
        - {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_in}',
          out_features: '${hps.dim_hidden}'}
        - {_convert_: all, _target_: torch.nn.ReLU}
        _convert_: all
        _target_: torch.nn.Sequential
    - - hidden
      - _args_:
          _convert_: all
          _target_: laco.ops.repeat
          num: ${hps.num_layers}
          src:
            _args_:
            - {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_hidden}',
              out_features: '${hps.dim_hidden}'}
            - {_convert_: all, _target_: torch.nn.ReLU}
            _convert_: all
            _target_: torch.nn.Sequential
        _convert_: all
        _target_: torch.nn.Sequential
    - - output
      - _args_:
        - {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_hidden}',
          out_features: '${hps.dim_out}'}
        _convert_: all
        _target_: torch.nn.Sequential
  _convert_: all
  _target_: torch.nn.Sequential

# Instantiate the MLP and inspect its structure.
model_cfg = laco.load("configs://examples/mlp.py#model")
mlp_model = laco.instantiate(model_cfg)
print("Instantiated MLP:")
print(mlp_model)
print()
print("Named sections:")
for name, section in mlp_model.named_children():
    print(f"  {name}: {section}")
Output
Instantiated MLP:
Sequential(
  (input): Sequential(
    (0): Linear(in_features=128, out_features=256, bias=True)
    (1): ReLU()
  )
  (hidden): Sequential(
    (0): Sequential(
      (0): Linear(in_features=256, out_features=256, bias=True)
      (1): ReLU()
    )
    (1): Sequential(
      (0): Linear(in_features=256, out_features=256, bias=True)
      (1): ReLU()
    )
    (2): Sequential(
      (0): Linear(in_features=256, out_features=256, bias=True)
      (1): ReLU()
    )
  )
  (output): Sequential(
    (0): Linear(in_features=256, out_features=128, bias=True)
  )
)

Named sections:
  input: Sequential(
  (0): Linear(in_features=128, out_features=256, bias=True)
  (1): ReLU()
)
  hidden: Sequential(
  (0): Sequential(
    (0): Linear(in_features=256, out_features=256, bias=True)
    (1): ReLU()
  )
  (1): Sequential(
    (0): Linear(in_features=256, out_features=256, bias=True)
    (1): ReLU()
  )
  (2): Sequential(
    (0): Linear(in_features=256, out_features=256, bias=True)
    (1): ReLU()
  )
)
  output: Sequential(
  (0): Linear(in_features=256, out_features=128, bias=True)
)
# Override the number of hidden layers and hidden dimension.
# L.repeat uses the hps values at config-composition time, so we
# must pass the overrides to laco.load (which re-runs make_mlp with new hps).
cfg_wide = laco.load(
    "configs://examples/mlp.py",
    "hps.dim_hidden=512",
    "hps.num_layers=5",
    key="model",
)
wide_mlp = laco.instantiate(cfg_wide)
print("Overridden MLP (dim_hidden=512, num_layers=5):")
print(wide_mlp)
Output
Overridden MLP (dim_hidden=512, num_layers=5):
Sequential(
  (input): Sequential(
    (0): Linear(in_features=128, out_features=512, bias=True)
    (1): ReLU()
  )
  (hidden): Sequential(
    (0): Sequential(
      (0): Linear(in_features=512, out_features=512, bias=True)
      (1): ReLU()
    )
    (1): Sequential(
      (0): Linear(in_features=512, out_features=512, bias=True)
      (1): ReLU()
    )
    (2): Sequential(
      (0): Linear(in_features=512, out_features=512, bias=True)
      (1): ReLU()
    )
    (3): Sequential(
      (0): Linear(in_features=512, out_features=512, bias=True)
      (1): ReLU()
    )
    (4): Sequential(
      (0): Linear(in_features=512, out_features=512, bias=True)
      (1): ReLU()
    )
  )
  (output): Sequential(
    (0): Linear(in_features=512, out_features=128, bias=True)
  )
)

Section 7: cnn_classifier.py Pattern

The CNN classifier introduces a key idiom: the config factory function.

def _conv_block(in_ch, out_ch, *, pool: bool):
    layers = [
        L.call(nn.Conv2d)(...),
        L.call(nn.BatchNorm2d)(...),
        L.call(nn.ReLU)(inplace=True),
    ]
    if pool:
        layers.append(L.call(nn.MaxPool2d)(...))
    return L.call(nn.Sequential)(*layers)

_conv_block is a pure config factory: it takes plain Python values as arguments and returns a DictConfig node. It never instantiates any nn.Module. This keeps config construction cheap and side-effect-free.

The make_cnn_classifier function then uses L.repeat to replicate the block config num_stages times, and expand_args=True to pass the resulting list as *args to nn.Sequential.

from laco.examples import cnn_classifier
print(inspect.getsource(cnn_classifier))
Output
r"""CNN image classifier: introduces ``L.repeat`` for stacked conv stages.

Stem (Conv2d → BatchNorm2d → ReLU) lifts ``in_channels → base_channels``;
``num_stages`` identical Conv → BN → ReLU → MaxPool blocks then halve the
spatial size; a global-average-pool head produces logits.

Two ways to use this from a downstream config:

- Load ``configs://examples/cnn_classifier.py#model`` and override hps.
- Import :func:`make_cnn_classifier` and pass explicit hyperparameters —
  useful when composing into a larger pipeline.
"""

import laco.language as L
from torch import nn

__all__ = ["model", "hps"]


@L.params
class hps:
    in_channels: int = 3
    base_channels: int = 32
    num_stages: int = 3
    num_classes: int = 10


def _conv_block(in_ch, out_ch, *, pool: bool):
    layers = [
        L.call(nn.Conv2d)(
            in_channels=in_ch,
            out_channels=out_ch,
            kernel_size=3,
            padding=1,
            bias=False,
        ),
        L.call(nn.BatchNorm2d)(num_features=out_ch),
        L.call(nn.ReLU)(inplace=True),
    ]
    if pool:
        layers.append(L.call(nn.MaxPool2d)(kernel_size=2, stride=2))
    return L.call(nn.Sequential)(*layers)


def make_cnn_classifier(
    *,
    in_channels,
    base_channels,
    num_stages,
    num_classes,
):
    return L.call(nn.Sequential, root=True)(
        L.OrderedDict(
            (
                "stem",
                _conv_block(in_channels, base_channels, pool=False),
            ),
            (
                "stages",
                L.call(nn.Sequential, expand_args=True)(
                    L.repeat(
                        num_stages,
                        _conv_block(base_channels, base_channels, pool=True),
                    ),
                ),
            ),
            (
                "head",
                L.call(nn.Sequential)(
                    L.call(nn.AdaptiveAvgPool2d)(output_size=1),
                    L.call(nn.Flatten)(),
                    L.call(nn.Linear)(
                        in_features=base_channels,
                        out_features=num_classes,
                    ),
                ),
            ),
        )
    )


model = make_cnn_classifier(
    in_channels=hps.in_channels,
    base_channels=hps.base_channels,
    num_stages=hps.num_stages,
    num_classes=hps.num_classes,
)

# Demonstrate the _conv_block factory independently.
from laco.examples.cnn_classifier import _conv_block, make_cnn_classifier

block_without_pool = _conv_block(3, 32, pool=False)
block_with_pool    = _conv_block(32, 32, pool=True)

print("Conv block without pool:")
print(laco.dump(block_without_pool))
print("Conv block with MaxPool:")
print(laco.dump(block_with_pool))
Output
Conv block without pool:
_args_:
- {_convert_: all, _target_: torch.nn.Conv2d, bias: false, in_channels: 3, kernel_size: 3,
  out_channels: 32, padding: 1}
- {_convert_: all, _target_: torch.nn.BatchNorm2d, num_features: 32}
- {_convert_: all, _target_: torch.nn.ReLU, inplace: true}
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

Conv block with MaxPool:
_args_:
- {_convert_: all, _target_: torch.nn.Conv2d, bias: false, in_channels: 32, kernel_size: 3,
  out_channels: 32, padding: 1}
- {_convert_: all, _target_: torch.nn.BatchNorm2d, num_features: 32}
- {_convert_: all, _target_: torch.nn.ReLU, inplace: true}
- {_convert_: all, _target_: torch.nn.MaxPool2d, kernel_size: 2, stride: 2}
_convert_: all
_laco_: 1
_target_: torch.nn.Sequential

# Build the full CNN config and show the stages section.
cnn_full = laco.load("configs://examples/cnn_classifier.py")
print("Top-level keys:", list(cnn_full.keys()))
print()
print("Full CNN config YAML:")
print(laco.dump(cnn_full))
Output
Top-level keys: ['hps', 'model']

Full CNN config YAML:
_laco_: 1
hps: {base_channels: 32, in_channels: 3, num_classes: 10, num_stages: 3}
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
  _target_: torch.nn.Sequential

# The stages section is L.repeat(num_stages, conv_block_with_pool).
# Override num_stages to 5 to get a deeper network.
cnn_deep = laco.load(
    "configs://examples/cnn_classifier.py",
    "hps.num_stages=5",
    key="model",
)
cnn_model = laco.instantiate(cnn_deep)
print("Deep CNN (5 stages):")
print(cnn_model)
print()
print("Named sections:")
for name, child in cnn_model.named_children():
    child_count = len(list(child.children()))
    print(f"  {name}: {type(child).__name__} ({child_count} sub-modules)")
Output
Deep CNN (5 stages):
Sequential(
  (stem): Sequential(
    (0): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
    (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
    (2): ReLU(inplace=True)
  )
  (stages): Sequential(
    (0): Sequential(
      (0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    )
    (1): Sequential(
      (0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    )
    (2): Sequential(
      (0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    )
    (3): Sequential(
      (0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    )
    (4): Sequential(
      (0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (1): BatchNorm2d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    )
  )
  (head): Sequential(
    (0): AdaptiveAvgPool2d(output_size=1)
    (1): Flatten(start_dim=1, end_dim=-1)
    (2): Linear(in_features=32, out_features=10, bias=True)
  )
)

Named sections:
  stem: Sequential (3 sub-modules)
  stages: Sequential (5 sub-modules)
  head: Sequential (3 sub-modules)

The factory pattern in summary:

  1. Write a Python function that accepts plain hyperparameters and returns a DictConfig.
  2. Use L.call, L.repeat, L.OrderedDict, etc. inside it; never nn.Module(...) directly.
  3. Call the factory from the module level, binding hps.* values.
  4. laco.load executes the file, evaluates the factory, and returns the config tree.

This separation of config building from object construction is the core laco design principle.


Section 8: Config Tree Visualization

A picture is worth a thousand YAML lines. Here is the MLP config structure:

                nn.Sequential (root)
               /       |           \
           input     hidden (×3)   output
          /    \       |    \        |
    nn.Linear  act  nn.Linear  act  nn.Linear

Corresponding CNN tree

The CNN classifier has a similar but distinct structure:

                nn.Sequential (root)
              /       |           \
           stem    stages (×N)   head
           ...     L.repeat       |
                   / | \         ...
               blk blk blk

Summary

Primitives covered

PrimitivePurpose
L.call(T)(**kw)Lazy node: stores T + kwargs as DictConfig
L.call(T, root=True)(**kw)Same, marks the top of the instantiation tree
L.call(T, expand_args=True)(list)Unpack list as *args when instantiating
L.repeat(n, item)List of n independent deep-copies of item
L.OrderedDict((k, v), ...)Named, ordered mapping → collections.OrderedDict
L.List(*items)Homogeneous list
L.Dict(**kwargs)String-keyed dict
L.Tuple(*items)Heterogeneous tuple
L.Set(*items)Deduplicated set

The config factory pattern

  1. Write a Python function that returns a DictConfig (never an nn.Module).
  2. Accept hyperparameters as plain Python values (int, float, type, …).
  3. Use L.call, L.repeat, L.OrderedDict to compose the tree.
  4. Call the factory from the module level, binding hps.* values.
  5. Override at load time with laco.load(path, "hps.x=y").

Next steps

  • 07.typed-groups-and-schemas.ipynb covers config groups, defaults lists, and Hydra integration
  • 10.tracing.ipynb covers the tracing API: @L.configurable and L.trace