Notebook

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.

ConstructWhat it produces at runtimeWhat it types as (IDE)
@L.params class hpsParamsWrapper(hps)type[hps] (dataclass-like)
hps.attr"${hps.attr}" (interpolation string)declared type of attr
L.ref("${...}")the string itselfcaller-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))
Output
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, in_features: 256, out_features: 256}

Three problems:

  1. Duplication. 256 appears three times. One missed update → shape mismatch at runtime.
  2. No provenance. The number 256 carries no label. Is it the hidden size? The batch size? A coincidence?
  3. 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'>
Output
${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))
Output
_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
Output
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))
Output
--- 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)
Output
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.params class

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)
Output
${hps.dim_hidden}
<class 'str'>
${encoder.out_dim}

When to use L.ref vs hps.attr

SituationPreferred construct
@L.params class is in scopehps.attr: shorter, type-checked against the class body
Cross-file or cross-module referenceL.ref("${...}"): explicit, no import needed
One-off interpolation not worth a full @L.params classL.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))
Output
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))
Output
--- 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

ResolverL.r builderComputesNotes
sumL.r.sum(a, b, ...)a + b + ...Any number of args
minL.r.min(a, b, ...)min(a, b, ...)Any number of args
maxL.r.max(a, b, ...)max(a, b, ...)Any number of args
divL.r.div(a, b)a / bFloat result
powL.r.pow(a, b)a ** b
modL.r.mod(a, b)a % b
negL.r.neg(a)-a
reciprocalL.r.reciprocal(a)1 / a
absL.r.abs(a)abs(a)
roundL.r.round(a, digits=0)round(a, d)digits required at wire level
mathL.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))
Output
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))
Output
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"]))
Output
=== 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}"
Output
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))
Output
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:,}")
Output
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")
Output
  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

ConstructUse it when…
@L.params class hpsYou have a group of related hyperparameters that should be overridable together
hps.attrReferencing 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 stringsSweeping 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.