Lazy Call and Partial
Series: laco tutorial notebooks
Prerequisites: 01.why-laco.ipynb (motivation), 02.first-steps.ipynb (config basics)
Dependencies: torch (for nn.Linear, optim.Adam)
Python builds objects the moment you call their constructor. nn.Linear(784, 10) runs right now. laco lets you write the same constructor call but defer it: the result is a plain data structure (an OmegaConf DictConfig) that describes how to build something without doing it.
This notebook covers:
| Construct | What it produces at runtime | What it types as (IDE) |
|---|---|---|
L.call(T)(**kw) | DictConfig node | T |
L.partial(fn)(**kw) | DictConfig with _partial_: true | functools.partial[T] |
L.just(value) | identity-instantiated node | type(value) |
L.required[T]() | omegaconf.MISSING | T |
By the end you will understand why a PyTorch optimizer must use L.partial, and how the timing table separates config-build time from run time.
import laco
import laco.language as L
import torch
import torch.nn as nn
import torch.optim as optim
Section 1: When does construction happen?
With plain Python, construction happens on the line where you call the constructor. There is no separation between "describe what to build" and "build it".
# --- Plain Python (eager) ---
# The model exists the instant this line runs.
model_eager = nn.Linear(784, 10)
print(type(model_eager)) # <class 'torch.nn.modules.linear.Linear'>
<class 'torch.nn.modules.linear.Linear'>
# --- laco (lazy) ---
# L.call returns a DictConfig — a recipe, not a model.
cfg = L.call(nn.Linear)(in_features=784, out_features=10)
print(type(cfg)) # <class 'omegaconf.DictConfig'>
# Construction happens here:
model_lazy = laco.instantiate(cfg)
print(type(model_lazy)) # <class 'torch.nn.modules.linear.Linear'>
<class 'omegaconf.dictconfig.DictConfig'>
<class 'torch.nn.modules.linear.Linear'>
Why would you want to delay construction?
- Serializable. A
DictConfigcan be saved to YAML and loaded back on another machine. A livenn.Modulecannot. - Overridable. Before calling
laco.instantiate, you can swapin_features=784forin_features=512with a one-liner: no re-import, no find-and-replace. - Composable. Config nodes nest: a
model_cfgcan contain anencoder_cfgwhich contains alayer_cfg. The whole tree is a dict you can inspect, diff, and version-control.
Section 2: L.call in detail
When you call L.call(nn.Linear)(in_features=784, out_features=10), laco builds a DictConfig with special keys that Hydra (and laco.instantiate) understand:
_target_: the dotted import path of the class to construct_convert_: how to convert OmegaConf containers (default:"all")- everything else: the constructor keyword arguments
Let's inspect the raw node:
cfg = L.call(nn.Linear)(in_features=784, out_features=10)
print("_target_ :", cfg._target_) # noqa: LACO001
print("_convert_:", cfg._convert_) # noqa: LACO001
print("in_features :", cfg.in_features) # noqa: LACO001
print("out_features:", cfg.out_features) # noqa: LACO001
print()
print("--- laco.dump (YAML) ---")
print(laco.dump(cfg))
_target_ : torch.nn.Linear
_convert_: all
in_features : 784
out_features: 10
--- laco.dump (YAML) ---
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, in_features: 784, out_features: 10}
strict=True: catching typos at config-build time
By default L.call is strict: it validates your keyword arguments against the target's signature right now, not later at instantiation. A typo surfaces immediately with a clear error message pointing at the config file line, instead of being buried inside a traceback from laco.instantiate.
try:
bad_cfg = L.call(nn.Linear)(in_featurez=784, out_features=10) # typo!
except TypeError as e:
print("TypeError caught:")
print(e)
TypeError caught:
L.call(Linear, strict=True): unknown keyword argument(s) ['in_featurez'] — not in target signature. Known parameters: ['bias', 'device', 'dtype', 'in_features', 'out_features']. Pass `strict=False` to L.call(...) (or `L.partial(..., strict=False)`) to opt out.
The error names the offending kwarg and lists the known parameters. Compare this with the alternative: if validation only happened at instantiation, the typo would survive serialization, config merges, and overrides, only failing when the object is finally constructed, possibly far from where the mistake was made.
If you have a dynamic target whose signature can't be introspected (C-extensions, for example), pass strict=False to opt out.
Section 3: Why L.partial exists
Here is a concrete example of a construction problem that L.call alone cannot solve.
You want to configure an Adam optimizer with a specific learning rate and weight decay. But torch.optim.Adam requires model.parameters() as its first argument, and the model doesn't exist yet at config-build time.
Calling L.call(optim.Adam)(params=???, lr=1e-3) is a dead end: there is no sensible value for params until after the model has been instantiated.
# WRONG: can't do this at config time — model doesn't exist yet!
#
# optimizer = optim.Adam(model.parameters(), lr=1e-3) # model is not defined here
#
# This is a plain Python problem: Adam needs params that only exist post-construction.
print("(skipped — intentionally broken approach)")
(skipped — intentionally broken approach)
The solution: L.partial.
L.partial(fn)(**kw) produces a config node with _partial_: true. When laco.instantiate sees that flag, it returns a functools.partial object instead of calling the function. You supply the missing params argument later, after the model exists.
# Build the optimizer config — no model needed yet.
optimizer_cfg = L.partial(optim.Adam)(lr=1e-3, weight_decay=1e-5)
print("--- optimizer config (YAML) ---")
print(laco.dump(optimizer_cfg))
print("_partial_ field:", optimizer_cfg._partial_) # noqa: LACO001
--- optimizer config (YAML) ---
{_convert_: all, _laco_: 1, _partial_: true, _target_: torch.optim.Adam, lr: 0.001,
weight_decay: 1.0e-05}
_partial_ field: True
# Build the model config and instantiate it.
model_cfg = L.call(nn.Linear)(in_features=784, out_features=10)
model = laco.instantiate(model_cfg) # nn.Linear is created here
# Instantiate the optimizer config → we get a functools.partial, not an Adam yet.
optimizer_factory = laco.instantiate(optimizer_cfg)
print(type(optimizer_factory)) # <class 'functools.partial'>
# Now supply model.parameters() — Adam is created here.
optimizer = optimizer_factory(model.parameters())
print(type(optimizer)) # <class 'torch.optim.adam.Adam'>
print("lr =", optimizer.param_groups[0]["lr"])
<class 'functools.partial'>
<class 'torch.optim.adam.Adam'>
lr = 0.001
The call chain has three steps:
L.partial(Adam)(lr=1e-3)→DictConfigwith_partial_: truelaco.instantiate(optimizer_cfg)→functools.partial(Adam, lr=1e-3)optimizer_factory(model.parameters())→ actualAdaminstance
Steps 1 and 2 happen at config time (before the training loop); step 3 happens at run time (after the model is built).
Section 4: Config-build time vs. run time
The key insight from this notebook, laid out explicitly. Config-build time is usually import time of your config file. Run time is the training script.
| When | Event |
|---|---|
| Config-build time | L.call(nn.Linear)(in_features=784) |
| Config-build time | L.partial(Adam)(lr=1e-3) |
| Run time | laco.instantiate(model_cfg) → nn.Linear |
| Run time | laco.instantiate(optim_cfg) → functools.partial |
| Run time | factory(model.parameters()) → Adam |
Both lazy constructs are declared once, at config-build time (usually import time
of your config file). laco.instantiate executes them at run time (inside the
training script) — the partial only becomes a real Adam once it's called with
model.parameters().
Section 5: Wrapping an existing value with L.just
Sometimes you already have a Python object, such as a pre-computed tensor or a constant, and you want to embed it inside a config tree so it participates in composition. L.just(value) wraps it in a node whose _target_ is laco.ops.identity, the built-in pass-through function. Instantiating the node returns the original value unchanged.
pretrained_weights = torch.zeros(10, 5) # some pre-computed tensor
cfg_just = L.just(pretrained_weights)
print("_target_:", cfg_just._target_) # noqa: LACO001 → 'laco.ops.identity'
print("value :", type(cfg_just.value)) # noqa: LACO001 → torch.Tensor
print()
recovered = laco.instantiate(cfg_just)
print("recovered type :", type(recovered))
print("values identical:", torch.equal(recovered, pretrained_weights))
_target_: laco.ops.identity
value : <class 'torch.Tensor'>
recovered type : <class 'torch.Tensor'>
values identical: True
Important caveat. L.just keeps the value in memory. If you try to serialize the config with laco.dump and reload it from YAML, only OmegaConf-native types (ints, floats, strings, bools, lists, dicts) will survive the round-trip. A raw torch.Tensor stored via L.just works for in-memory composition, but it does not survive YAML serialization to disk.
Section 6: Mandatory fields with L.required[T]()
Some config fields genuinely have no sensible default. They must be provided by whoever loads the config: a vocabulary size, a number of classes, a path. Leaving them as None is misleading (that's a valid value for some fields), and hard-coding them defeats the purpose of a config.
laco's solution: L.required[T](). At runtime it returns omegaconf.MISSING, which OmegaConf treats as a sentinel meaning "this value must be supplied before the config can be used". In the IDE it types as T, so your type-checker keeps working.
import omegaconf
# Demonstrate what L.required[int]() actually returns at runtime:
missing_value = L.required[int]()
print("L.required[int]() is :", missing_value)
print("Is omegaconf.MISSING :", missing_value is omegaconf.MISSING)
L.required[int]() is : ???
Is omegaconf.MISSING : True
# A config with a required field — modelled after text_classifier.py
cfg_with_required = L.call(nn.Embedding)(
num_embeddings=L.required[int](),
embedding_dim=64,
)
# laco.dump walks the tree and resolves interpolations and mandatory values
# as it goes, so dumping a node that still holds a MISSING field would raise
# MissingMandatoryValue. To inspect the *unresolved* recipe, render it with
# OmegaConf.to_yaml(...), which leaves the ??? sentinel in place.
print("--- YAML (note: num_embeddings shows as ???) ---")
print(omegaconf.OmegaConf.to_yaml(cfg_with_required))
--- YAML (note: num_embeddings shows as ???) ---
_target_: torch.nn.Embedding
_convert_: all
num_embeddings: ???
embedding_dim: 64
# Instantiating without filling in the required field raises immediately:
try:
laco.instantiate(cfg_with_required)
except Exception as e:
print(type(e).__name__) # MissingMandatoryValue
print(str(e)[:120])
MissingMandatoryValue
Missing mandatory value: num_embeddings
full_key: num_embeddings
object_type=dict
L.required[T]() vs omegaconf.MISSING: the typing difference
Why not write omegaconf.MISSING directly? You can, but you lose static type information:
| Expression | Runtime value | Static type (IDE/pyright) |
|---|---|---|
L.required[int]() | omegaconf.MISSING | int |
L.required[float]() | omegaconf.MISSING | float |
omegaconf.MISSING | omegaconf.MISSING | Any (no type info) |
The runtime behavior is identical: both produce the MISSING sentinel and will raise MissingMandatoryValue if not overridden. The difference lives entirely in the type checker. Callers who load the config with laco.load("...", "num_embeddings=1000") supply the value before instantiation, so they never see the error.
Section 7: linear_regression.py walkthrough
Let's walk through the actual linear_regression.py example file line by line, applying everything learned in this notebook. The file defines a single nn.Linear model and a partially-applied SGD optimizer: the smallest realistic laco config.
# === linear_regression.py (annotated) ===
import laco.language as L # the config DSL
from torch import nn, optim
# --- 1. Hyperparameters ---
# @L.params wraps the class so that attribute access returns OmegaConf
# interpolation strings like "${hps.in_features}" instead of the bare int.
# The IDE still sees `int` thanks to @dataclass_transform.
# (Full coverage of @L.params in NB03.)
@L.params
class hps:
in_features: int = 8
out_features: int = 1
bias: bool = True
learning_rate: float = 1e-2
momentum: float = 0.9
# --- 2. Model config ---
# L.call(nn.Linear) returns a factory. Calling it with kwargs produces a
# DictConfig. root=True is a hint for the type checker (makes the IDE see
# the return as DictConfig rather than nn.Linear; useful for the top-level
# exported symbol).
#
# hps.in_features is "${hps.in_features}" at runtime — an interpolation string
# that OmegaConf resolves to 8 only when the hps node is present *as a sibling*
# in the same config tree.
model = L.call(nn.Linear, root=True)(
in_features = hps.in_features,
out_features = hps.out_features,
bias = hps.bias,
)
# --- 3. Optimizer config ---
# L.partial because SGD needs model.parameters() — unavailable at config time.
# laco.instantiate(optimizer) gives back functools.partial(SGD, lr=..., momentum=...).
# The training script then calls optimizer_factory(model.parameters()) to get the
# real SGD instance.
optimizer = L.partial(optim.SGD)(
lr = hps.learning_rate,
momentum = hps.momentum,
)
# --- 4. Bundle the tree ---
# In the real file, `__all__ = ["model", "optimizer", "hps"]` exports all three;
# laco.load assembles them into one DictConfig, which is what lets the
# "${hps.*}" interpolations in `model`/`optimizer` resolve against the `hps`
# node. Here we reproduce that bundling explicitly. Dumping `model` on its own
# would raise InterpolationKeyError, because its "${hps.*}" references have no
# `hps` sibling to point at.
import omegaconf
cfg = omegaconf.OmegaConf.create(
{"hps": hps(), "model": model, "optimizer": optimizer}
)
print("=== full config (model + optimizer + hps) ===")
print(laco.dump(cfg))
=== full config (model + optimizer + hps) ===
_laco_: 1
hps: {bias: true, in_features: 8, learning_rate: 0.01, momentum: 0.9, out_features: 1}
model: {_convert_: all, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
optimizer: {_convert_: all, _partial_: true, _target_: torch.optim.SGD, lr: '${hps.learning_rate}',
momentum: '${hps.momentum}'}
# Load the real file and instantiate the model:
cfg = laco.load("configs://examples/linear_regression.py#model")
built_model = laco.instantiate(cfg)
print(built_model) # Linear(in_features=8, out_features=1, bias=True)
# Load with an override — change in_features without editing the file:
cfg_wide = laco.load(
"configs://examples/linear_regression.py",
"hps.in_features=32",
key="model"
)
built_wide = laco.instantiate(cfg_wide)
print(built_wide) # Linear(in_features=32, out_features=1, bias=True)
Linear(in_features=8, out_features=1, bias=True)
Linear(in_features=32, out_features=1, bias=True)
Recap
| Construct | Use it when… |
|---|---|
L.call(T)(**kw) | You want a full object, no deferred arguments |
L.partial(fn)(**kw) | The object needs an argument that only exists after another object is built (classic: optimizer needing model parameters) |
L.just(value) | You have an in-memory object and want to embed it in a config tree |
L.required[T]() | A field is genuinely mandatory, no sensible default exists |
Next: 04.hyperparameters-and-interpolation.ipynb covers @L.params and the interpolation system that makes sweeping hyperparameters across a whole config tree a one-liner.