Notebook

Why Laco?

Prerequisites: Basic Python. Prior use of argparse or a YAML-based config loader such as yaml.safe_load is helpful.

This notebook traces the path from a plain argparse script to Laco. Each step shows what the previous tool cannot do.

No GPU or PyTorch required: all runnable cells use only the standard library or Laco itself. PyTorch is referenced in illustrative (non-executed) cells, marked with a # torch required comment.


Section 1: The Configuration Problem

Every machine-learning experiment starts with a script. Here is the canonical one: a training loop with a handful of hyperparameters exposed via argparse.

# Illustrative only — not executed as a runnable script here
# (would need torch, a dataset, etc.)

import argparse

def get_args():
    p = argparse.ArgumentParser()
    p.add_argument("--lr",          type=float, default=1e-3)
    p.add_argument("--batch_size",  type=int,   default=32)
    p.add_argument("--model",       type=str,   default="Linear")
    p.add_argument("--hidden_dim",  type=int,   default=256)
    p.add_argument("--epochs",      type=int,   default=10)
    return p.parse_args()

# --- main training loop ---
# args = get_args()
# model_class = getattr(torch.nn, args.model)  # hope the string is right!
# model = model_class(args.hidden_dim, num_classes)
# optimizer = torch.optim.SGD(model.parameters(), lr=args.lr)
# for epoch in range(args.epochs):
#     for x, y in DataLoader(dataset, batch_size=args.batch_size):
#         loss = criterion(model(x), y)
#         loss.backward()
#         optimizer.step()
#         optimizer.zero_grad()
print("Script stub defined (not run).")
Output
Script stub defined (not run).

This holds up until:

  • A colleague asks you to reproduce Run #42 from last month. What were the exact flags? Did you --lr 3e-4 or --lr 0.0003? Argparse doesn't save anything.
  • You want to sweep over learning rates. You write a Bash loop, and hope no flag gets silently ignored.
  • Your model grows. --hidden_dim now needs to be a list of layer widths. Argparse can do nargs='+', but then your CLI becomes python train.py --hidden_dim 256 128 64 --lr 1e-3, which is fragile and hard to read.
  • You want to swap the optimizer. That's a new argument, a new code branch, more flags…

Argparse pain points in one sentence: it has no serialization, no nesting, no lazy object construction, and no type checking beyond primitive conversions.


Section 2: The YAML Step

The natural next step: move configuration into a YAML file so it can be committed to version control and passed around.

import yaml

yaml_config = """
lr: 1.0e-3
batch_size: 32
model: Linear
hidden_dim: 256
epochs: 10
"""

cfg = yaml.safe_load(yaml_config)
print(cfg)
print(type(cfg))          # plain dict
print(type(cfg["lr"]))    # float — YAML parsed it
Output
{'lr': 0.001, 'batch_size': 32, 'model': 'Linear', 'hidden_dim': 256, 'epochs': 10}
<class 'dict'>
<class 'float'>

It is serializable, committable, diffable. But consider what the training script now looks like when it needs to actually build a model from the config:

# Illustrative — shows what you have to write by hand (torch not imported here)
source = '''
import yaml, torch

with open("config.yaml") as f:
    cfg = yaml.safe_load(f)

# No lazy construction — you have to manually wire up every class
model_class = getattr(torch.nn, cfg["model"])   # magic string lookup
model = model_class(cfg["hidden_dim"], num_classes)

# No autocomplete — cfg is just a dict[str, Any]
# Type error at runtime, not at write-time:
optimizer = torch.optim.SGD(model.parameters(), lr=cfg["lrr"])  # typo!
'''
print(source)
Output

import yaml, torch

with open("config.yaml") as f:
    cfg = yaml.safe_load(f)

# No lazy construction — you have to manually wire up every class
model_class = getattr(torch.nn, cfg["model"])   # magic string lookup
model = model_class(cfg["hidden_dim"], num_classes)

# No autocomplete — cfg is just a dict[str, Any]
# Type error at runtime, not at write-time:
optimizer = torch.optim.SGD(model.parameters(), lr=cfg["lrr"])  # typo!

Remaining YAML pain points:

ProblemDetail
No lazy constructionMust manually map strings to classes (getattr)
No IDE autocompletecfg is dict[str, Any], so there are no hints
Runtime-only errorsTypos in keys are silent until the experiment crashes
No nested objectsRepresenting SGD(lr=1e-3, momentum=0.9) requires custom parsing

The config file is now reproducible; the object graph construction is still manual.


Section 3: OmegaConf + Structured Configs

OmegaConf adds typed access and variable interpolation on top of YAML. With structured configs (dataclasses as schemas) you also get IDE autocomplete.

# Illustrative — requires omegaconf
source = '''
from dataclasses import dataclass, field
from omegaconf import OmegaConf, MISSING

@dataclass
class TrainingConfig:
    lr: float = 1e-3
    batch_size: int = 32
    model: str = MISSING       # must be provided
    hidden_dim: int = 256
    epochs: int = 10

# Merge schema defaults with a YAML override file
schema = OmegaConf.structured(TrainingConfig)
override = OmegaConf.load("config.yaml")
cfg = OmegaConf.merge(schema, override)

# Now cfg.lr gives type-checked float access — IDE knows the type!
# BUT: model construction still requires manual getattr:
model_class = getattr(torch.nn, cfg.model)   # still a magic string
model = model_class(cfg.hidden_dim, num_classes)
'''
print(source)
Output

from dataclasses import dataclass, field
from omegaconf import OmegaConf, MISSING

@dataclass
class TrainingConfig:
    lr: float = 1e-3
    batch_size: int = 32
    model: str = MISSING       # must be provided
    hidden_dim: int = 256
    epochs: int = 10

# Merge schema defaults with a YAML override file
schema = OmegaConf.structured(TrainingConfig)
override = OmegaConf.load("config.yaml")
cfg = OmegaConf.merge(schema, override)

# Now cfg.lr gives type-checked float access — IDE knows the type!
# BUT: model construction still requires manual getattr:
model_class = getattr(torch.nn, cfg.model)   # still a magic string
model = model_class(cfg.hidden_dim, num_classes)

What OmegaConf adds: typed attribute access, interpolation (${lr}), merge semantics.

What it still doesn't do: the @dataclass schema and the YAML file are two separate things that can silently diverge. Add a field to the dataclass and forget to add it to your YAML directory, and nothing errors until runtime. Lazy construction of arbitrary Python objects (nn.Linear, SGD, your custom loss…) is still not supported at the config level.


Section 4: PyTorch Lightning CLI

LightningCLI goes further: it generates a CLI and wires up the model and datamodule automatically, reading a YAML config.

# Illustrative — requires lightning
source = '''
from lightning.pytorch.cli import LightningCLI
from my_project.model import MyModel
from my_project.data import MyDataModule

# Entire training loop, CLI, and config loading in one line:
cli = LightningCLI(MyModel, MyDataModule)

# config.yaml:
# model:
#   class_path: my_project.model.MyModel
#   init_args:
#     hidden_dim: 256
# trainer:
#   max_epochs: 10
'''
print(source)
Output

from lightning.pytorch.cli import LightningCLI
from my_project.model import MyModel
from my_project.data import MyDataModule

# Entire training loop, CLI, and config loading in one line:
cli = LightningCLI(MyModel, MyDataModule)

# config.yaml:
# model:
#   class_path: my_project.model.MyModel
#   init_args:
#     hidden_dim: 256
# trainer:
#   max_epochs: 10

What LightningCLI adds: automatic CLI generation, class-path instantiation (class_path: my_project.model.MyModel).

Constraints:

  • Your model must subclass LightningModule; your data must be a LightningDataModule. Third-party or custom objects that don't fit this hierarchy are hard to compose.
  • Config composition is limited to what Lightning's parser understands: no arbitrary nesting of objects.
  • You're buying into the entire Lightning ecosystem; it's not a standalone config library.

Section 5: Full Hydra

Hydra is the first tool that solves lazy construction properly: a _target_ key in YAML tells Hydra which class to instantiate.

# Illustrative — requires hydra-core
source = '''
# conf/model/linear.yaml
# _target_: torch.nn.Linear
# in_features: 8
# out_features: 1

# conf/optimizer/sgd.yaml
# _target_: torch.optim.SGD
# lr: 1e-3
# momentum: 0.9

# conf/config.yaml
# defaults:
#   - model: linear
#   - optimizer: sgd

import hydra
from hydra.utils import instantiate
from omegaconf import DictConfig

@hydra.main(config_path="conf", config_name="config", version_base=None)
def train(cfg: DictConfig):
    model = instantiate(cfg.model)
    optimizer = instantiate(cfg.optimizer, params=model.parameters())
    # cfg.model is typed as DictConfig, NOT nn.Linear
    # cfg.optimizer.lr  ->  Any  (no static type)
    ...

if __name__ == "__main__":
    train()
'''
print(source)
Output

# conf/model/linear.yaml
# _target_: torch.nn.Linear
# in_features: 8
# out_features: 1

# conf/optimizer/sgd.yaml
# _target_: torch.optim.SGD
# lr: 1e-3
# momentum: 0.9

# conf/config.yaml
# defaults:
#   - model: linear
#   - optimizer: sgd

import hydra
from hydra.utils import instantiate
from omegaconf import DictConfig

@hydra.main(config_path="conf", config_name="config", version_base=None)
def train(cfg: DictConfig):
    model = instantiate(cfg.model)
    optimizer = instantiate(cfg.optimizer, params=model.parameters())
    # cfg.model is typed as DictConfig, NOT nn.Linear
    # cfg.optimizer.lr  ->  Any  (no static type)
    ...

if __name__ == "__main__":
    train()

What Hydra adds: composable config groups, _target_-based lazy instantiation, sweepers, multirun.

Remaining friction:

  • Config is split across a directory of YAML files with magic string group references (defaults: [model: linear]), so refactoring a class name requires hunting all YAML files.
  • Static types are lost: cfg.model is DictConfig, not nn.Linear. The IDE cannot autocomplete cfg.model.in_features.
  • The @hydra.main decorator changes how your script is run (subprocess isolation, working-directory changes), which is subtle to debug.

Section 6: hydra-zen

hydra-zen solves one big Hydra annoyance: instead of writing YAML by hand, you use Python builds() calls to generate config dataclasses.

# Illustrative — requires hydra-zen and torch
source = '''
from hydra_zen import builds, instantiate
from torch import nn

LinearConf = builds(nn.Linear, in_features=8, out_features=1)
cfg = LinearConf()  # an instance of the generated dataclass

# This is already better — no YAML file needed!
# BUT: the return type of builds() is type[Any].
# The IDE sees:  cfg : Any
# Not:           cfg : nn.Linear

model = instantiate(cfg)   # works at runtime
# model : Any              # IDE has no idea this is nn.Linear

# Nested composition:
ModelConf = builds(MyModel, encoder=builds(Encoder, depth=24))
# The nested encoder field is also typed Any
# cfg.encoder.depth  ->  AttributeError at write-time (no static type)
'''
print(source)
Output

from hydra_zen import builds, instantiate
from torch import nn

LinearConf = builds(nn.Linear, in_features=8, out_features=1)
cfg = LinearConf()  # an instance of the generated dataclass

# This is already better — no YAML file needed!
# BUT: the return type of builds() is type[Any].
# The IDE sees:  cfg : Any
# Not:           cfg : nn.Linear

model = instantiate(cfg)   # works at runtime
# model : Any              # IDE has no idea this is nn.Linear

# Nested composition:
ModelConf = builds(MyModel, encoder=builds(Encoder, depth=24))
# The nested encoder field is also typed Any
# cfg.encoder.depth  ->  AttributeError at write-time (no static type)

What hydra-zen adds: Python-first config construction, with no YAML directory required.

The one remaining gap: the return type of builds(T, ...)() is Any, not T. When configs are deeply nested, the IDE cannot follow the types across composition boundaries. You lose autocomplete precisely where you need it most: on the fields of the instantiated object.


Section 7: Enter Laco

Laco's key insight is called the lie-typing contract: the static return type declared by the config primitive intentionally lies to the type-checker, claiming to return the target type T while actually returning a DictConfig at runtime.

This is not a bug. It is a deliberate design choice that keeps deeply nested config composition tractable under Python's type system.

import laco.language as L

# Use stdlib int so this cell runs without torch
cfg = L.call(int)()      # static type (what pyright sees): int
                         # runtime type (what Python holds): DictConfig

print("type at runtime :", type(cfg))
print("_target_ key    :", cfg._target_)  # noqa: LACO001
Output
type at runtime : <class 'omegaconf.dictconfig.DictConfig'>
_target_ key    : builtins.int
Output
<cell-8>:4: LazyCallIntrospectionWarning: L.call(int, strict=True): cannot introspect target signature; strict-mode kwarg validation is disabled for this call. Pass `strict=False` explicitly to silence this warning.
  cfg = L.call(int)()      # static type (what pyright sees): int

The IDE (and pyright) sees cfg as int. That means:

  • cfg.bit_length() autocompletes (even though cfg is really a DictConfig).
  • Nested composition keeps its types:
    class Model:
        encoder: Encoder
    
    model_cfg = L.call(Model)(encoder=L.call(Encoder)(depth=24))
    # model_cfg : Model  (static)
    # model_cfg.encoder : Encoder  (static — autocomplete works!)
    
  • laco.instantiate(model_cfg) produces a real Model at runtime.

The full table of lie-typing constructs:

ConstructStatic type (IDE sees)Runtime value
L.call(T)(**kw)TDictConfig (with _target_)
L.partial(T)(**kw)functools.partial[T]DictConfig (with _partial_: true)
L.just(obj)type(obj)identity-instantiated node
L.required[T]()TOmegaConf.MISSING

Comparing the full tool landscape

Now that we understand what Laco does, here is how the major configuration tools compare across seven properties.

SerializableType-safeNo magic stringsLazy constructionIDE autocompleteBuilt-in CLILow learning curve
argparseNoNoYesNoPartialYesYes
Raw JSON / YAMLYesNoNoNoNoNoYes
OmegaConf + YAMLYesPartialNoNoPartialNoPartial
LightningCLIYesPartialPartialYesPartialYesPartial
HydraYesNoNoYesNoYesNo
hydra-zenYesPartialYesYesPartialYesPartial
LacoYesYesYesYesYesYesPartial

Observations from the table

  • argparse is the easiest to learn but fails on every property that matters at scale.
  • Raw YAML gains serializability but loses everything else.
  • OmegaConf and LightningCLI are partial improvements: they add types or a CLI but require framework buy-in or still leave object construction manual.
  • Hydra introduces lazy construction via _target_ but sacrifices type safety and IDE support. Magic string group references make refactoring fragile.
  • hydra-zen patches Hydra's Python-ergonomics gap but still types everything as Any.
  • Laco reaches all green except learning curve, which is what this tutorial series is here to flatten.

Wrapping Up

The configuration problem is not just about storing hyperparameters. It is about describing an entire object graph (model, optimizer, scheduler, data pipeline) in a way that is:

  1. Serializable: experiments are reproducible.
  2. Type-safe: errors surface at edit-time, not at crash-time.
  3. Lazily constructed: the config file is a recipe, not a running program.
  4. IDE-friendly: composing nested objects doesn't require guessing types by hand.

Laco achieves all four by embracing the lie-typing contract: config primitives like L.call(T) tell the IDE you have a T while quietly storing a DictConfig recipe. laco.instantiate() bakes the recipe into a real Python object whenever you need it.


The next notebook, 02.first-steps.ipynb, writes your first working Laco config, walking through L.call, laco.load, laco.instantiate, and laco.dump, and loading the linear_regression example that ships with the library.