Notebook

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:

ToolWhat 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
Output
Value  : {'_target_': 'builtins.dict', '_convert_': 'all', 'a': 1, 'b': 2, 'c': 3}
Type   : <class 'omegaconf.dictconfig.DictConfig'>
_target_: builtins.dict
Output
<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 of dict"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 cfg as type dict. At runtime it is a DictConfig. 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)
Output
target : builtins.dict
name   : laco
version: 1
Output
<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))
Output
result      : {'a': 1, 'b': 2, 'c': 3}
type(result): <class 'dict'>
Output
<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:

  1. Reading cfg._target_: "builtins.dict"
  2. Importing the target: import builtins; cls = builtins.dict
  3. 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
Output
{'label': 'point', 'coords': {'x': 10, 'y': 20}}
<class 'dict'>
Output
<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}")
Output
All keys in the recipe:
  '_target_'           -> 'builtins.dict'
  '_convert_'          -> 'all'
  'a'                  -> 1
  'b'                  -> 2
  'c'                  -> 3

Reserved keys and their meanings:

KeyMeaning
_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)
Output
{_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()
Output
Reloaded type : <class 'omegaconf.dictconfig.DictConfig'>
_target_      : builtins.dict
a, b, c       : 1 2 3

The round-trip L.call(T)(**kw)laco.dumplaco.loadlaco.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}")
Output
type(cfg)  : <class 'omegaconf.dictconfig.DictConfig'>
Top-level keys:
  hps
  model
  optimizer

The config file exports three names (from its __all__):

  • model: an L.call(nn.Linear) recipe (eager construction)
  • optimizer: an L.partial(optim.SGD) recipe (deferred construction, needed because model.parameters() only exists after the model is built)
  • hps: an @L.params dataclass with all hyperparameters

Let's look at the model recipe:

# torch required
print("model recipe:")
print(laco.dump(cfg.model))
Output
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))
Output
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)
Output
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))
Output
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))
Output
{_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]}")
Output
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 caseURI
Load and instantiate just one objectconfigs://...#model
Share a hyperparameter set across scriptsconfigs://...#hps
Swap out the optimizer independentlyconfigs://...#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.

FromToVia
.py config file (Python source)DictConfig (in-memory recipe)laco.load()
.yaml / .json file (serialized recipe)DictConfiglaco.load()
DictConfigLive Python object (real model / optimizer)laco.instantiate()
DictConfig.yaml filelaco.dump()

You can also build the DictConfig directly in memory, skipping the file step entirely: cfg = L.call(T)(**kw).

Reading the pipeline

  • DictConfig is the central hub: the in-memory representation of a recipe, produced by L.call, by laco.load, or by round-tripping through laco.dumplaco.load.
  • .py config file is where you author configs in Python. laco.load executes the file in a restricted sandbox and returns its exported names.
  • .yaml file 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.instantiate is called; everything before that is metadata.

The round trip (DictConfig.yamlDictConfig) 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)
Output
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}
Output
<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).