First Steps with Laco
Prerequisites: 01.why-laco.ipynb (the lie-typing contract).
Dependencies: Only laco is required to run the first four sections. Cells that
require PyTorch are labeled with a # torch required comment.
By the end of this notebook you will be comfortable with the four core tools:
| Tool | What it does |
|---|---|
L.call(T)(**kw) | Build a recipe that will call T(**kw) |
laco.instantiate(cfg) | Execute the recipe: returns a real T |
laco.dump(cfg) | Serialize a recipe to a YAML string |
laco.load(uri) | Load a recipe from a .py or .yaml config file |
Section 1: Your First L.call
Start with a plain Python dict, no torch needed, so you can run this cell
immediately.
import laco
import laco.language as L
# Build a recipe that says: "call dict(a=1, b=2, c=3)"
cfg = L.call(dict)(a=1, b=2, c=3)
print("Value :", cfg)
print("Type :", type(cfg))
print("_target_:", cfg._target_) # noqa: LACO001
Value : {'_target_': 'builtins.dict', '_convert_': 'all', 'a': 1, 'b': 2, 'c': 3}
Type : <class 'omegaconf.dictconfig.DictConfig'>
_target_: builtins.dict
<cell-12>:5: LazyCallIntrospectionWarning: L.call(dict, 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(dict)(a=1, b=2, c=3)
What just happened?
L.call(dict) returns a callable factory: think of it like a curried constructor.
When you call it with (a=1, b=2, c=3) you get back a DictConfig (an OmegaConf
structured dict) that stores:
_target_: the fully-qualified import path ofdict→"builtins.dict"a,b,c:1,2,3_convert_:"all"(how OmegaConf types are converted on instantiation; more on this in Section 3)
No dict has been constructed yet. cfg is a recipe, not a result.
The lie-typing contract: your type checker (pyright, mypy, VS Code) sees
cfgas typedict. At runtime it is aDictConfig. This lets you nest configs while keeping static types.
# A slightly more interesting example: build a recipe for dict
cfg2 = L.call(dict)(name="laco", version=1)
print("target :", cfg2._target_) # noqa: LACO001
print("name :", cfg2.name)
print("version:", cfg2.version)
target : builtins.dict
name : laco
version: 1
<cell-13>:2: LazyCallIntrospectionWarning: L.call(dict, strict=True): cannot introspect target signature; strict-mode kwarg validation is disabled for this call. Pass `strict=False` explicitly to silence this warning.
cfg2 = L.call(dict)(name="laco", version=1)
Notice that you can read back the stored kwargs as attributes on the DictConfig.
This is because DictConfig behaves like both a dict and an object: cfg2.name is
the same as cfg2["name"].
Section 2: Materialising with laco.instantiate
A recipe is useless until you bake it. laco.instantiate is the oven.
cfg = L.call(dict)(a=1, b=2, c=3)
result = laco.instantiate(cfg)
print("result :", result)
print("type(result):", type(result))
result : {'a': 1, 'b': 2, 'c': 3}
type(result): <class 'dict'>
<cell-14>:1: LazyCallIntrospectionWarning: L.call(dict, 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(dict)(a=1, b=2, c=3)
laco.instantiate works by:
- Reading
cfg._target_:"builtins.dict" - Importing the target:
import builtins; cls = builtins.dict - Calling
cls(**remaining_kwargs):dict(a=1, b=2, c=3)
If the recipe contains nested recipes (e.g. a model config that contains an
optimizer config), instantiate recurses depth-first by default.
Let's see a nested example with pure stdlib types:
from collections import OrderedDict
# Outer recipe: a dict whose 'mapping' value is itself a recipe
inner_cfg = L.call(dict)(x=10, y=20)
outer_cfg = L.call(dict)(label="point", coords=inner_cfg)
# Instantiate the outer recipe — inner is instantiated first (recursive=True by default)
result = laco.instantiate(outer_cfg)
print(result)
print(type(result["coords"])) # dict, not DictConfig
{'label': 'point', 'coords': {'x': 10, 'y': 20}}
<class 'dict'>
<cell-15>:4: LazyCallIntrospectionWarning: L.call(dict, strict=True): cannot introspect target signature; strict-mode kwarg validation is disabled for this call. Pass `strict=False` explicitly to silence this warning.
inner_cfg = L.call(dict)(x=10, y=20)
Section 3: Inspecting the Wire Format
Every Laco recipe stores a small set of reserved keys alongside your kwargs.
cfg = L.call(dict)(a=1, b=2, c=3)
# Iterate all keys in the DictConfig
from omegaconf import OmegaConf
print("All keys in the recipe:")
for key, value in OmegaConf.to_container(cfg, resolve=False).items():
print(f" {key!r:20} -> {value!r}")
All keys in the recipe:
'_target_' -> 'builtins.dict'
'_convert_' -> 'all'
'a' -> 1
'b' -> 2
'c' -> 3
Reserved keys and their meanings:
| Key | Meaning |
|---|---|
_target_ | Fully-qualified import path of the class/function to call |
_convert_ | How OmegaConf containers are converted. "all" means ListConfig/DictConfig are converted to plain Python list/dict before being passed to the target |
_recursive_ | Whether nested recipes are instantiated recursively (default True) |
_partial_ | Set to True by L.partial. instantiate returns functools.partial instead of the live object |
The _convert_="all" default is what makes result["coords"] a plain dict rather
than a DictConfig in the nested example above.
Now let's see how the recipe looks when serialized to YAML:
yaml_str = laco.dump(cfg)
print(yaml_str)
{_convert_: all, _laco_: 1, _target_: builtins.dict, a: 1, b: 2, c: 3}
Notice the _laco_: 1 line at the top: this is a schema-version marker that
Laco uses to detect version mismatches when loading files. You will always see it in
files produced by laco.dump.
The YAML output is a valid Hydra-compatible config: you could load it with plain Hydra
(hydra.utils.instantiate) and get the same result.
Let's round-trip it:
import tempfile, pathlib
# Write to a temp YAML file, then load it back
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_str)
tmp_path = f.name
reloaded = laco.load(tmp_path)
print("Reloaded type :", type(reloaded))
print("_target_ :", reloaded._target_) # noqa: LACO001
print("a, b, c :", reloaded.a, reloaded.b, reloaded.c)
# Clean up
pathlib.Path(tmp_path).unlink()
Reloaded type : <class 'omegaconf.dictconfig.DictConfig'>
_target_ : builtins.dict
a, b, c : 1 2 3
The round-trip L.call(T)(**kw) → laco.dump → laco.load → laco.instantiate
is the heart of Laco's reproducibility story: configs are portable YAML files, but
you never have to write them by hand.
Section 4: Loading from a File
Requires PyTorch from this section onwards. If you don't have torch installed, you can read along: the concepts transfer directly to any other class.
Laco ships with example configs under the configs:// URI scheme, which resolves to
the sources/laco/examples/ directory inside the package.
The linear_regression.py example defines a model, an optimizer factory, and a
hyperparameter namespace:
# torch required
import laco
# Load the entire config module — returns a DictConfig with all exported names
cfg = laco.load("configs://examples/linear_regression.py")
print("type(cfg) :", type(cfg))
print("Top-level keys:")
for key in cfg:
print(f" {key}")
type(cfg) : <class 'omegaconf.dictconfig.DictConfig'>
Top-level keys:
hps
model
optimizer
The config file exports three names (from its __all__):
model: anL.call(nn.Linear)recipe (eager construction)optimizer: anL.partial(optim.SGD)recipe (deferred construction, needed becausemodel.parameters()only exists after the model is built)hps: an@L.paramsdataclass with all hyperparameters
Let's look at the model recipe:
# torch required
print("model recipe:")
print(laco.dump(cfg.model))
model recipe:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
# torch required
print("optimizer recipe:")
print(laco.dump(cfg.optimizer))
optimizer recipe:
{_convert_: all, _laco_: 1, _partial_: true, _target_: torch.optim.SGD, lr: '${hps.learning_rate}',
momentum: '${hps.momentum}'}
Notice that the optimizer dump shows _partial_: true: this tells laco.instantiate
to return a functools.partial instead of a live optimizer. You then call the partial
with model.parameters() to get the real SGD instance.
Now let's instantiate the model:
# torch required
model = laco.instantiate(cfg.model)
print("model type :", type(model))
print("model :", model)
# The optimizer is a partial — call it with model.parameters()
opt_factory = laco.instantiate(cfg.optimizer)
optimizer = opt_factory(model.parameters())
print("optimizer type:", type(optimizer))
print("optimizer :", optimizer)
model type : <class 'torch.nn.modules.linear.Linear'>
model : Linear(in_features=8, out_features=1, bias=True)
optimizer type: <class 'torch.optim.sgd.SGD'>
optimizer : SGD (
Parameter Group 0
dampening: 0
differentiable: False
foreach: None
fused: None
lr: 0.01
maximize: False
momentum: 0.9
nesterov: False
weight_decay: 0
)
Section 5: The Fragment Selector (#name)
Loading the entire config module and then accessing .model is fine, but Laco lets you
be more precise: the fragment selector (#) picks a single exported name directly.
# torch required
# Load only the model recipe
model_cfg = laco.load("configs://examples/linear_regression.py#model")
print("type :", type(model_cfg))
print(laco.dump(model_cfg))
type : <class 'omegaconf.dictconfig.DictConfig'>
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
# torch required
# Load only the optimizer recipe
optimizer_cfg = laco.load("configs://examples/linear_regression.py#optimizer")
print(laco.dump(optimizer_cfg))
{_convert_: all, _laco_: 1, _partial_: true, _target_: torch.optim.SGD, lr: '${hps.learning_rate}',
momentum: '${hps.momentum}'}
# torch required
# Load only the hyperparameter namespace
hps_cfg = laco.load("configs://examples/linear_regression.py#hps")
print("hps_cfg type:", type(hps_cfg))
print("Fields:")
for key in hps_cfg:
print(f" {key} = {hps_cfg[key]}")
hps_cfg type: <class 'omegaconf.dictconfig.DictConfig'>
Fields:
in_features = 8
out_features = 1
bias = True
learning_rate = 0.01
momentum = 0.9
When to use the fragment selector:
| Use case | URI |
|---|---|
| Load and instantiate just one object | configs://...#model |
| Share a hyperparameter set across scripts | configs://...#hps |
| Swap out the optimizer independently | configs://...#optimizer |
| Load everything (whole config module) | configs://... (no fragment) |
The fragment syntax works for .py config files and for nested keys in .yaml files
(e.g. myconfig.yaml#training.scheduler).
Section 6: The load → instantiate → dump Pipeline
The three operations form a triangle of transformations. Understanding how they relate is the mental model you need for everything else in Laco.
| From | To | Via |
|---|---|---|
.py config file (Python source) | DictConfig (in-memory recipe) | laco.load() |
.yaml / .json file (serialized recipe) | DictConfig | laco.load() |
DictConfig | Live Python object (real model / optimizer) | laco.instantiate() |
DictConfig | .yaml file | laco.dump() |
You can also build the DictConfig directly in memory, skipping the file step entirely: cfg = L.call(T)(**kw).
Reading the pipeline
DictConfigis the central hub: the in-memory representation of a recipe, produced byL.call, bylaco.load, or by round-tripping throughlaco.dump→laco.load..pyconfig file is where you author configs in Python.laco.loadexecutes the file in a restricted sandbox and returns its exported names..yamlfile is the serialized form. It is what you commit to git for reproducibility, or what a training framework writes to its output directory.- Live Python object is the real model, optimizer, etc. It only exists
after
laco.instantiateis called; everything before that is metadata.
The round trip (DictConfig → .yaml → DictConfig) shows that laco.dump and
laco.load are inverses: the recipe is fully preserved through serialization.
Putting it all together: a mini pipeline
Let's run the full pipeline using only stdlib types (no torch needed).
import laco
import laco.language as L
import tempfile, pathlib
# ── Step 1: Author a recipe in Python ──────────────────────────────────────
recipe = L.call(dict)(name="experiment", lr=1e-3, batch_size=32)
print("Step 1 — recipe (DictConfig):")
print(" type :", type(recipe))
print(" target:", recipe._target_) # noqa: LACO001
# ── Step 2: Serialise to YAML ───────────────────────────────────────────────
yaml_str = laco.dump(recipe)
print("\nStep 2 — YAML:\n", yaml_str)
# ── Step 3: (Pretend to) save and reload ────────────────────────────────────
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_str)
tmp = pathlib.Path(f.name)
reloaded = laco.load(str(tmp))
tmp.unlink()
print("Step 3 — reloaded type:", type(reloaded))
# ── Step 4: Instantiate ─────────────────────────────────────────────────────
live_obj = laco.instantiate(reloaded)
print("\nStep 4 — live object:")
print(" type :", type(live_obj))
print(" value :", live_obj)
Step 1 — recipe (DictConfig):
type : <class 'omegaconf.dictconfig.DictConfig'>
target: builtins.dict
Step 2 — YAML:
{_convert_: all, _laco_: 1, _target_: builtins.dict, batch_size: 32, lr: 0.001}
Step 3 — reloaded type: <class 'omegaconf.dictconfig.DictConfig'>
Step 4 — live object:
type : <class 'dict'>
value : {'batch_size': 32, 'lr': 0.001}
<cell-27>:6: LazyCallIntrospectionWarning: L.call(dict, strict=True): cannot introspect target signature; strict-mode kwarg validation is disabled for this call. Pass `strict=False` explicitly to silence this warning.
recipe = L.call(dict)(name="experiment", lr=1e-3, batch_size=32)
Summary
In this notebook you learned the four core building blocks of Laco:
L.call(T)(**kwargs) → DictConfig
Builds a recipe for T(**kwargs). The recipe stores the target class path and all
keyword arguments. No object is constructed until laco.instantiate is called.
laco.instantiate(cfg) → live object
Executes a recipe: reads cfg._target_, imports the class, and calls it with the
stored kwargs. Recurses into nested recipes by default.
laco.dump(cfg) → YAML string
Serializes a recipe to YAML. The output includes a _laco_: 1 schema-version marker
and is fully round-trip safe.
laco.load(uri) → DictConfig
Loads a recipe from a .py config file (via the configs:// URI scheme), a plain
.yaml file, or a .json file. Use the #fragment suffix to select a single
exported name.
Next: 03.lazy-call-and-partial.ipynb covers exactly when L.call and L.partial
construct objects, and why that timing matters when one config depends on another
(the classic case: an optimizer that needs a model's parameters).