Hyperparameters and Interpolation
Series: laco tutorial notebooks
Prerequisites: 01.why-laco.ipynb (installation), 02.first-steps.ipynb (config basics), 03.lazy-call-and-partial.ipynb (lazy call and partial)
Dependencies: torch.nn (for concrete examples)
The previous notebook showed how L.call turns a constructor call into a config node. But configs with hard-coded numbers are still hard to sweep: every in_features=256 is a copy you'd have to hunt down and change.
This notebook covers laco's interpolation system: the mechanism that lets a single change propagate through an entire config tree.
| Construct | What it produces at runtime | What it types as (IDE) |
|---|---|---|
@L.params class hps | ParamsWrapper(hps) | type[hps] (dataclass-like) |
hps.attr | "${hps.attr}" (interpolation string) | declared type of attr |
L.ref("${...}") | the string itself | caller-chosen type R |
L.r.sum(a, b) | "${sum:a,b}" (interpolation string) | float |
By the end you will be able to write a config where every dimension flows from a single
hps class, and sweeping them all at once is a single URL query parameter.
import laco
import laco.language as L
import torch.nn as nn
Section 1: The hard-coded number problem
Suppose you are building a two-layer MLP. With L.call alone, your config looks like this:
# Hard-coded dimensions — fragile!
layer1 = L.call(nn.Linear)(in_features=128, out_features=256)
layer2 = L.call(nn.Linear)(in_features=256, out_features=256)
layer3 = L.call(nn.Linear)(in_features=256, out_features=64)
# To change the hidden size from 256 → 512, you must find every "256"
# and decide whether it is a hidden dimension or something else.
# Mistakes are silent — nothing checks that layer2.in == layer1.out.
print(laco.dump(layer2))
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, in_features: 256, out_features: 256}
Three problems:
- Duplication.
256appears three times. One missed update → shape mismatch at runtime. - No provenance. The number
256carries no label. Is it the hidden size? The batch size? A coincidence? - No sweep support. To run a hyperparameter search over hidden sizes, you'd need an outer loop that rebuilds the entire config from scratch.
@L.params solves all three.
Section 2: @L.params names your hyperparameters
@L.params decorates a plain class whose annotated fields are the hyperparameters. Accessing an attribute returns an OmegaConf interpolation string: a "${hps.attr}" placeholder that OmegaConf resolves to the actual value when the config tree is instantiated.
The class body looks like a dataclass. To pyright and your IDE, it is a dataclass (thanks to @dataclass_transform). At runtime it's a ParamsWrapper that emits interpolation strings.
@L.params
class hps:
dim_in: int = 128
dim_out: int = 64
dim_hidden: int = 256
num_layers: int = 3
# Attribute access returns interpolation strings at runtime:
print(hps.dim_in) # "${hps.dim_in}"
print(hps.dim_out) # "${hps.dim_out}"
print(hps.dim_hidden) # "${hps.dim_hidden}"
print(type(hps.dim_in)) # <class 'str'>
${hps.dim_in}
${hps.dim_out}
${hps.dim_hidden}
<class 'str'>
Those strings become the values in the config node. OmegaConf resolves them later, when the full config tree (including the hps node with the actual integers) is present.
Now rewrite the MLP layers using hps:
layer1_cfg = L.call(nn.Linear)(in_features=hps.dim_in, out_features=hps.dim_hidden)
layer2_cfg = L.call(nn.Linear)(in_features=hps.dim_hidden, out_features=hps.dim_hidden)
layer3_cfg = L.call(nn.Linear)(in_features=hps.dim_hidden, out_features=hps.dim_out)
# Each layer node holds the interpolation strings, e.g. in_features: ${hps.dim_in}.
# An interpolation can only resolve when the *full* config tree also contains the
# hps node with the actual integers. On its own a fragment has no hps sibling, so
# dumping it would raise InterpolationKeyError. We assemble that tree by hand here;
# laco.load does exactly this for a config file (see Section 4). Calling hps() on
# the @L.params block returns its plain {field: value} dict.
tree = {"hps": hps(), "layer1": layer1_cfg, "layer2": layer2_cfg, "layer3": layer3_cfg}
print(laco.dump(tree))
_laco_: 1
hps: {dim_hidden: 256, dim_in: 128, dim_out: 64, num_layers: 3}
layer1: {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_in}',
out_features: '${hps.dim_hidden}'}
layer2: {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_hidden}',
out_features: '${hps.dim_hidden}'}
layer3: {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.dim_hidden}',
out_features: '${hps.dim_out}'}
The dimensions are now labeled references. Changing dim_hidden in one place propagates everywhere automatically: no hunting, no mismatches.
Section 3: @L.params, what pyright sees vs. what Python holds
This is the core lie-typing construct for hyperparameters. The static view and the runtime view diverge deliberately, and that divergence is load-bearing.
@L.params
class dims:
width: int = 512
depth: int = 6
drop: float = 0.1
# What Python holds at runtime:
print("dims.width (runtime) =", repr(dims.width)) # '${dims.width}'
print("dims.depth (runtime) =", repr(dims.depth)) # '${dims.depth}'
print("dims.drop (runtime) =", repr(dims.drop)) # '${dims.drop}'
# The IDE / pyright reports:
# dims.width : int
# dims.depth : int
# dims.drop : float
# ... which is a lie, but a useful one.
print()
print("Runtime type of dims.width:", type(dims.width)) # str
dims.width (runtime) = '${dims.width}'
dims.depth (runtime) = '${dims.depth}'
dims.drop (runtime) = '${dims.drop}'
Runtime type of dims.width: <class 'str'>
The full resolution flow
Here is the complete journey from class definition to resolved integer:
@L.params class hps
→ at import time: ParamsWrapper(hps)
hps.dim_hidden (attribute access)
→ "${hps.dim_hidden}" (str)
L.call(nn.Linear)(in_features=hps.dim_hidden, ...)
→ DictConfig { _target_: "...", in_features: "${hps.dim_hidden}", ... }
laco.instantiate(full_cfg) # full_cfg contains the hps node with actual values
→ OmegaConf resolves "${hps.dim_hidden}" → 256
→ nn.Linear(in_features=256, ...)
The resolution only works when the full config tree contains the hps node. laco.load assembles that tree from the config file's exported names.
Section 4: Using @L.params in a real config
Let's build a complete, loadable config following the pattern from linear_regression.py:
@L.params
class hps:
in_features: int = 8
out_features: int = 1
learning_rate: float = 1e-2
# root=True marks this node as the root of a config tree (it is a typing/intent
# marker; it does not bundle the hps node by itself). For the ${hps.*} references
# to resolve, the hps node must live alongside the model in one tree -- which is
# precisely what laco.load assembles from a file's exported names. We mirror that
# here so the config is self-contained and dumpable.
model_cfg = L.call(nn.Linear, root=True)(
in_features = hps.in_features,
out_features = hps.out_features,
)
full_cfg = {"hps": hps(), "model": model_cfg}
print("--- model config YAML ---")
print(laco.dump(full_cfg))
--- model config YAML ---
_laco_: 1
hps: {in_features: 8, learning_rate: 0.01, out_features: 1}
model: {_convert_: all, _target_: torch.nn.Linear, in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
Notice that the YAML shows in_features: ${hps.in_features}: the literal interpolation string. The hps node with the default values (in_features: 8) is automatically included when you call laco.load("configs://...") because laco exports all top-level names (including the result of hps()).
To override in_features from 8 to 32:
# Load from the real file with an override:
cfg = laco.load(
"configs://examples/linear_regression.py",
"hps.in_features=32",
key="model"
)
built = laco.instantiate(cfg)
print(built) # Linear(in_features=32, out_features=1, bias=True)
Linear(in_features=32, out_features=1, bias=True)
Section 5: L.ref, explicit interpolation references
hps.attr covers the common case where you own the @L.params class and can access it directly. But sometimes you need an interpolation reference:
- to a key in a different config file
- to a nested path like
${model.encoder.depth} - as a one-off, without declaring a full
@L.paramsclass
L.ref("${...}") is the explicit escape hatch. It returns the string unchanged at runtime, typed as the caller-chosen generic R.
# Explicitly typed interpolation reference
hidden_ref: int = L.ref("${hps.dim_hidden}")
print(hidden_ref) # "${hps.dim_hidden}"
print(type(hidden_ref)) # <class 'str'> (typed as int by the IDE)
# Cross-file reference (e.g., referencing an encoder's output dim
# from a decoder config that lives in a different file)
encoder_out_ref: int = L.ref("${encoder.out_dim}")
print(encoder_out_ref)
${hps.dim_hidden}
<class 'str'>
${encoder.out_dim}
When to use L.ref vs hps.attr
| Situation | Preferred construct |
|---|---|
@L.params class is in scope | hps.attr: shorter, type-checked against the class body |
| Cross-file or cross-module reference | L.ref("${...}"): explicit, no import needed |
One-off interpolation not worth a full @L.params class | L.ref("${...}") |
Nested path (e.g. ${a.b.c}) | L.ref("${a.b.c}") |
Section 6: L.r, typed resolver builders
OmegaConf interpolation strings are plain strings: they can't do arithmetic on their own. laco registers a set of custom OmegaConf resolvers (sum, div, pow, ...) and exposes typed builder methods on L.r that produce the resolver strings.
The key point: L.r.div(a, b) does not compute a / b at config-build time. It emits the string "${div:a,b}", which OmegaConf resolves to the actual quotient at instantiation time, using the resolved (integer) values of any interpolations embedded in a or b.
@L.params
class model_hps:
hidden: int = 512
num_heads: int = 8
# These all produce interpolation strings, not computed values:
print("sum :", L.r.sum(model_hps.hidden, 64))
print("div :", L.r.div(model_hps.hidden, 2))
print("pow :", L.r.pow(2, model_hps.num_heads))
print("head_dim:", L.r.div(model_hps.hidden, model_hps.num_heads))
sum : ${sum:${model_hps.hidden},64}
div : ${div:${model_hps.hidden},2}
pow : ${pow:2,${model_hps.num_heads}}
head_dim: ${div:${model_hps.hidden},${model_hps.num_heads}}
# Practical use: derive head_dim from hidden and num_heads
# so changing either one automatically updates the other.
head_dim_cfg = L.call(nn.Linear)(
in_features = L.r.div(model_hps.hidden, model_hps.num_heads),
out_features = model_hps.hidden,
)
# As before, the resolver string ${div:${model_hps.hidden},${model_hps.num_heads}}
# only resolves when the model_hps node is present in the same tree, so we bundle
# it in (just like laco.load does for a config file).
full_cfg = {"model_hps": model_hps(), "proj": head_dim_cfg}
print("--- head projection config ---")
print(laco.dump(full_cfg))
--- head projection config ---
_laco_: 1
model_hps: {hidden: 512, num_heads: 8}
proj: {_convert_: all, _target_: torch.nn.Linear, in_features: '${div:${model_hps.hidden},${model_hps.num_heads}}',
out_features: '${model_hps.hidden}'}
Bundled L.r resolvers
| Resolver | L.r builder | Computes | Notes |
|---|---|---|---|
sum | L.r.sum(a, b, ...) | a + b + ... | Any number of args |
min | L.r.min(a, b, ...) | min(a, b, ...) | Any number of args |
max | L.r.max(a, b, ...) | max(a, b, ...) | Any number of args |
div | L.r.div(a, b) | a / b | Float result |
pow | L.r.pow(a, b) | a ** b | |
mod | L.r.mod(a, b) | a % b | |
neg | L.r.neg(a) | -a | |
reciprocal | L.r.reciprocal(a) | 1 / a | |
abs | L.r.abs(a) | abs(a) | |
round | L.r.round(a, digits=0) | round(a, d) | digits required at wire level |
math | L.r.math('sqrt', a) | math.sqrt(a) | Any math module function |
Argument types for L.r methods. Each argument must be an int, float, str (including "${...}" interpolation strings), or another L.r.* result. Container types (list, dict, DictConfig) are not supported; use L.ref("${key}") to reference a config value instead.
Section 7: Overrides, changing values without editing the file
Now that all dimensions are interpolation strings pointing at hps.*, a single override changes every dependent value across the config tree. laco supports two override syntaxes.
# ---- Syntax 1: URL query string ----
# Append ?key=value&key2=value2 and optionally #fragment to select a sub-key.
cfg_url = laco.load(
"configs://examples/linear_regression.py?hps.in_features=32&hps.out_features=4#model"
)
print("URL override — model:")
print(laco.dump(cfg_url))
URL override — model:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
# ---- Syntax 2: Positional override strings ----
# Pass extra strings after the path; each is "key=value".
cfg_pos = laco.load(
"configs://examples/linear_regression.py",
"hps.in_features=32",
"hps.out_features=4",
key="model",
)
print("Positional override — model:")
print(laco.dump(cfg_pos))
Positional override — model:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
# Side-by-side: default vs. overridden
cfg_default = laco.load("configs://examples/linear_regression.py")
cfg_override = laco.load(
"configs://examples/linear_regression.py",
"hps.in_features=64",
"hps.learning_rate=5e-4",
)
print("=== default hps ===")
print(laco.dump(cfg_default["hps"]))
print("=== overridden hps ===")
print(laco.dump(cfg_override["hps"]))
=== default hps ===
{_laco_: 1, bias: true, in_features: 8, learning_rate: 0.01, momentum: 0.9, out_features: 1}
=== overridden hps ===
{_laco_: 1, bias: true, in_features: 64, learning_rate: 0.0005, momentum: 0.9, out_features: 1}
The overrides write into the hps node. Because model.in_features is "${hps.in_features}", it automatically picks up the new value at instantiation time, with no other changes required. This is the payoff for using @L.params everywhere.
Section 8: mlp.py full walkthrough
Let's read through mlp.py section by section with annotations. This is the smallest non-trivial laco config: it uses @L.params, L.call, L.repeat, and L.OrderedDict together.
# === Part 1: Hyperparameters ===
# Note: activation is typed as type[nn.Module] — laco can store a class object
# as a config value. The YAML representation is a !!python/name: tag.
@L.params
class mlp_hps:
dim_in: int = 128
dim_out: int = 128
dim_hidden: int = 256
num_layers: int = 3
activation: type[nn.Module] = nn.ReLU
print("hps.dim_in :", mlp_hps.dim_in) # "${mlp_hps.dim_in}"
print("hps.activation:", mlp_hps.activation) # "${mlp_hps.activation}"
hps.dim_in : ${mlp_hps.dim_in}
hps.activation: ${mlp_hps.activation}
# === Part 2: make_mlp factory function ===
# The function takes the resolved values as arguments — not hps attributes.
# This makes it usable both from the config system (where hps.* are
# interpolation strings) and from plain Python code (where you pass integers).
def make_mlp(*, dim_in, dim_out, dim_hidden, num_layers, activation):
return L.call(nn.Sequential, root=True)(
# L.OrderedDict groups named layers into a DictConfig.
# Each entry is a ("name", config_node) tuple.
L.OrderedDict(
(
"input",
# The input block: Linear → activation
L.call(nn.Sequential)(
L.call(nn.Linear)(in_features=dim_in, out_features=dim_hidden),
L.call(activation)(), # activation is a class reference
),
),
(
"hidden",
# L.repeat(n, src) produces n deep-copies of src in a list.
# expand_args=True unpacks the list as positional args to Sequential.
L.call(nn.Sequential, expand_args=True)(
L.repeat(
num_layers,
L.call(nn.Sequential)(
L.call(nn.Linear)(
in_features=dim_hidden,
out_features=dim_hidden,
),
L.call(activation)(),
),
),
),
),
(
"output",
L.call(nn.Sequential)(
L.call(nn.Linear)(in_features=dim_hidden, out_features=dim_out),
),
),
)
)
# === Part 3: module-level model symbol ===
# Called with hps.* — all values are interpolation strings here.
# laco.load exports this symbol and assembles the full tree with the hps node,
# so OmegaConf can resolve the interpolations at instantiate time.
model = make_mlp(
dim_in = mlp_hps.dim_in,
dim_out = mlp_hps.dim_out,
dim_hidden = mlp_hps.dim_hidden,
num_layers = mlp_hps.num_layers,
activation = mlp_hps.activation,
)
print("model config type:", type(model))
model config type: <class 'omegaconf.dictconfig.DictConfig'>
# Load the real mlp.py config and instantiate it:
mlp_cfg = laco.load("configs://examples/mlp.py#model")
mlp_model = laco.instantiate(mlp_cfg)
print(mlp_model)
print()
# Count parameters:
total_params = sum(p.numel() for p in mlp_model.parameters())
print(f"Total parameters: {total_params:,}")
Sequential(
(input): Sequential(
(0): Linear(in_features=128, out_features=256, bias=True)
(1): ReLU()
)
(hidden): Sequential(
(0): Sequential(
(0): Linear(in_features=256, out_features=256, bias=True)
(1): ReLU()
)
(1): Sequential(
(0): Linear(in_features=256, out_features=256, bias=True)
(1): ReLU()
)
(2): Sequential(
(0): Linear(in_features=256, out_features=256, bias=True)
(1): ReLU()
)
)
(output): Sequential(
(0): Linear(in_features=256, out_features=128, bias=True)
)
)
Total parameters: 263,296
# Sweep dim_hidden and num_layers without touching the file:
for dim_hidden, num_layers in [(128, 2), (256, 3), (512, 4)]:
cfg = laco.load(
"configs://examples/mlp.py",
f"hps.dim_hidden={dim_hidden}",
f"hps.num_layers={num_layers}",
key="model"
)
built = laco.instantiate(cfg)
params = sum(p.numel() for p in built.parameters())
print(f" dim_hidden={dim_hidden:3d}, num_layers={num_layers} → {params:>7,} params")
dim_hidden=128, num_layers=2 → 66,048 params
dim_hidden=256, num_layers=3 → 263,296 params
dim_hidden=512, num_layers=4 → 1,182,336 params
What L.repeat does
L.repeat(n, src) is a config-level deep-copy primitive. It stores a DictConfig node with _target_: laco.ops.repeat and num: n, src: <src node>. At instantiation time it produces a list of n independent deep-copies of whatever src instantiates to.
This is how the hidden block gets num_layers identical Linear → activation sub-modules without you writing the list by hand, and the copies do not share weights (each copy is independent after instantiation).
Recap
| Construct | Use it when… |
|---|---|
@L.params class hps | You have a group of related hyperparameters that should be overridable together |
hps.attr | Referencing a parameter from its own @L.params class (in-scope) |
L.ref("${...}") | Cross-file references, nested paths, one-off interpolations |
L.r.div(a, b) | Deriving one dimension from others arithmetically (head_dim = hidden // num_heads) |
| Override strings | Sweeping values without modifying the config file |
The interpolation contract in one sentence:hps.attr at config-build time is a string "${hps.attr}" that becomes an integer (or float, bool, ...) at laco.instantiate time: the type system lies so your editor stays green throughout.
Next in the series: 05.loading-saving-cli.ipynb covers the URL grammar for laco.load, saving configs with laco.dump/laco.save, the configs:// protocol, and the laco compose / laco show / laco run CLI commands.