Notebook

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:

ConstructWhat it produces at runtimeWhat it types as (IDE)
L.call(T)(**kw)DictConfig nodeT
L.partial(fn)(**kw)DictConfig with _partial_: truefunctools.partial[T]
L.just(value)identity-instantiated nodetype(value)
L.required[T]()omegaconf.MISSINGT

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'>
Output
<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'>
Output
<class 'omegaconf.dictconfig.DictConfig'>
<class 'torch.nn.modules.linear.Linear'>

Why would you want to delay construction?

  1. Serializable. A DictConfig can be saved to YAML and loaded back on another machine. A live nn.Module cannot.
  2. Overridable. Before calling laco.instantiate, you can swap in_features=784 for in_features=512 with a one-liner: no re-import, no find-and-replace.
  3. Composable. Config nodes nest: a model_cfg can contain an encoder_cfg which contains a layer_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))
Output
_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)
Output
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)")
Output
(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
Output
--- 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"])
Output
<class 'functools.partial'>
<class 'torch.optim.adam.Adam'>
lr = 0.001

The call chain has three steps:

  1. L.partial(Adam)(lr=1e-3)DictConfig with _partial_: true
  2. laco.instantiate(optimizer_cfg)functools.partial(Adam, lr=1e-3)
  3. optimizer_factory(model.parameters()) → actual Adam instance

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.

WhenEvent
Config-build timeL.call(nn.Linear)(in_features=784)
Config-build timeL.partial(Adam)(lr=1e-3)
Run timelaco.instantiate(model_cfg)nn.Linear
Run timelaco.instantiate(optim_cfg)functools.partial
Run timefactory(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))
Output
_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)
Output
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))
Output
--- 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])
Output
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:

ExpressionRuntime valueStatic type (IDE/pyright)
L.required[int]()omegaconf.MISSINGint
L.required[float]()omegaconf.MISSINGfloat
omegaconf.MISSINGomegaconf.MISSINGAny (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))
Output
=== 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)
Output
Linear(in_features=8, out_features=1, bias=True)
Linear(in_features=32, out_features=1, bias=True)

Recap

ConstructUse 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.