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).")
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-4or--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_dimnow needs to be a list of layer widths. Argparse can donargs='+', but then your CLI becomespython 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
{'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)
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:
| Problem | Detail |
|---|---|
| No lazy construction | Must manually map strings to classes (getattr) |
| No IDE autocomplete | cfg is dict[str, Any], so there are no hints |
| Runtime-only errors | Typos in keys are silent until the experiment crashes |
| No nested objects | Representing 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)
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)
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 aLightningDataModule. 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)
# 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.modelisDictConfig, notnn.Linear. The IDE cannot autocompletecfg.model.in_features. - The
@hydra.maindecorator 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)
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
type at runtime : <class 'omegaconf.dictconfig.DictConfig'>
_target_ key : builtins.int
<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 thoughcfgis really aDictConfig).- 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 realModelat runtime.
The full table of lie-typing constructs:
| Construct | Static type (IDE sees) | Runtime value |
|---|---|---|
L.call(T)(**kw) | T | DictConfig (with _target_) |
L.partial(T)(**kw) | functools.partial[T] | DictConfig (with _partial_: true) |
L.just(obj) | type(obj) | identity-instantiated node |
L.required[T]() | T | OmegaConf.MISSING |
Comparing the full tool landscape
Now that we understand what Laco does, here is how the major configuration tools compare across seven properties.
| Serializable | Type-safe | No magic strings | Lazy construction | IDE autocomplete | Built-in CLI | Low learning curve | |
|---|---|---|---|---|---|---|---|
| argparse | No | No | Yes | No | Partial | Yes | Yes |
| Raw JSON / YAML | Yes | No | No | No | No | No | Yes |
| OmegaConf + YAML | Yes | Partial | No | No | Partial | No | Partial |
| LightningCLI | Yes | Partial | Partial | Yes | Partial | Yes | Partial |
| Hydra | Yes | No | No | Yes | No | Yes | No |
| hydra-zen | Yes | Partial | Yes | Yes | Partial | Yes | Partial |
| Laco | Yes | Yes | Yes | Yes | Yes | Yes | Partial |
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:
- Serializable: experiments are reproducible.
- Type-safe: errors surface at edit-time, not at crash-time.
- Lazily constructed: the config file is a recipe, not a running program.
- 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.