Function Parameter Best Practices¶
–and How to Solve the Parameter Referencing Problem
Earlier, we introduced params-proto using a simple function example. However, function-based parameter definitions have a fundamental limitation: they break the linkage between parameter definitions and their usage. This happens because functions create a new scope for local variables, disconnecting parameters from their original definitions.
Consider the following example:
@params_proto.proto
def a_function(lr: float, batch_size: int = 32, optimizer: str = "adam"):
"""Train a model with the given parameters."""
print(f"Learning Rate: {lr}")
# ❌ No way to link 'lr' back to a centralized parameter definition
If this were a class-based python namespace, you could easily reference the parameter set as below:
@proto
class Config:
lr: float
batch_size: int = 32
optimizer: str = "adam"
# Now you can reference and override the defaults via:
for Config.lr in [0.01, 0.001, 0.0001]:
print("current value:", Config.lr)
Although this works for the Config class, vanilla Python functions do not provide an equivalent way to access and
modify parameter defaults!
Why Can’t I Use Functions to Define Hyper Parameters?
While Python does allow you to inspect a function’s signature using inspect.signature(fn) or fn.__signature__,
modifying parameter defaults this way is cumbersome and doesn’t provide a clean API for hyperparameter sweeps:
import inspect
def fn(a: int = 0):
print(f"We can refer to {a} inside the function, but not the default value.")
# You CAN access defaults via inspection:
sig = inspect.signature(fn)
print(sig.parameters['a'].default) # 0
# But you CANNOT modify them cleanly:
# ❌ sig.parameters['a'].default = 10 # Parameters are read-only!
# ❌ fn.a = 10 # AttributeError: 'function' object has no attribute 'a'
# And you CANNOT iterate over them for sweeps:
# ❌ for fn.a in [0, 10, 20]: # SyntaxError!
The function signature is read-only, and there’s no natural way to write for fn.a in [...] like you can with class
attributes.
There are a few ways to work around this:
Solution |
Description |
|---|---|
Argument class |
Pass a dataclass or class instance containing all parameters. Provides type safety and IDE support. |
|
(Python 3.11+) Use |
Config singleton in closure |
Define parameters as class attributes and reference them directly. Enables iteration like |
Under the Hood: How @proto Handles Functions¶
Before diving into the workarounds, it’s worth understanding how params-proto solves this problem when you decorate a function with @proto.
When you write:
@proto
def train(lr: float = 0.01, batch_size: int = 32):
pass
params-proto doesn’t just inspect the function—it wraps it in a special ProtoWrapper object. This wrapper serves a critical purpose: it provides the attribute access interface that vanilla Python functions lack.
What ProtoWrapper Does¶
The ProtoWrapper intercepts attribute access and function calls to enable the same ergonomic API that classes provide:
@proto
def train(lr: float = 0.01, batch_size: int = 32):
print(f"Training with lr={lr}, batch_size={batch_size}")
# ProtoWrapper allows this:
train.lr = 0.001 # Store override
print(train.lr) # Read current value → 0.001
# And enables sweeps like this:
for train.lr in [0.001, 0.01, 0.1]:
train() # Each call uses the updated lr value
Behind the scenes:
Parameter defaults are extracted from the function signature and stored in
_defaultsOverrides are tracked in a separate
_overridesdictionaryAttribute access (
train.lr) checks overrides first, then falls back to defaultsFunction calls merge defaults, overrides, and any kwargs before passing them to the original function
Why Functions Need Wrapping (But Classes Don’t)¶
Classes can use Python’s metaclass protocol to intercept attribute access at the class level. The @proto decorator on classes creates them with a custom ptype metaclass that handles this transparently.
Functions, however, don’t support metaclasses. To provide the same interface, we need an explicit wrapper object. The wrapper is completely transparent when you call the function—it behaves exactly like calling the original—but enables the attribute-based configuration API.
Note
When you use @proto on a function, train is not a function anymore—it’s a ProtoWrapper instance. This is intentional and allows the ergonomic train.lr = value syntax to work.
ProtoWrapper and Sweep Integration¶
The ProtoWrapper also provides special sweep mode support:
Normal mode: Attribute assignments update
_overridesSweep mode: Attribute assignments are recorded for the sweep and trigger callbacks
Validation: During sweeps, setting non-existent parameters raises an
AttributeError
This integration allows seamless use of functions in hyperparameter sweeps alongside @proto classes:
@proto.prefix
class Config:
lr: float = 0.001
@proto.cli
def train(seed: int = 42):
pass
# Both work seamlessly in sweeps:
with Sweep(Config, train).zip as sweep:
Config.lr = [0.001, 0.01, 0.1]
train.seed = [1, 2, 3]
# Generates 3 configs with both parameters
Let’s look at each of the traditional workarounds in turn.
Option 1: Argument Data Class¶
Dataclasses provide structured data with type hints that IDEs can understand and link to definitions.
class TrainParams:
"""Parameters for the training function."""
lr: float = 0.01
batch_size: int = 32
optimizer: str = 'adam'
def train(params: TrainParams) -> None:
# ✓ Your IDE will link 'params.lr' back to the TrainParams definition
print(f"Learning Rate: {params.lr}")
print(f"Batch Size: {params.batch_size}")
# Usage:
config = TrainParams(lr=0.001)
train(params=config)
Benefits:
Attribute access (
params.lr) with IDE linkageDefault values built-in
Excellent IDE and static analyzer support
Clear, centralized parameter definition
Option 2: fn(**kwargs: Unpack) (Python 3.11+)¶
A more natural way is to use Unpack[TypedDict] to type hint **kwargs. This allows IDEs to understand the
structure of the dictionary and link back to the parameter definitions.
You cannot use a dataclass directly with (**kwargs)
This does NOT work:
def train_with_dict(**config: TrainConfig) -> None:
# Wrong: This means each VALUE must be of type TrainConfig
pass
Instead, you must use Unpack[TypedDict] as shown below.
✅ Using Unpack[TypedDict]:
from typing import TypedDict, Unpack
class TrainConfig(TypedDict):
lr: float
batch_size: int
def train_with_dict(**kwargs: Unpack[TrainConfig]) -> None:
# ✓ The type checker knows config has 'lr' and 'batch_size' keys
print(f"Learning Rate: {kwargs['lr']}")
print(f"Batch Size: {kwargs['batch_size']}")
# Usage - You can unpack a dictionary:
my_config: TrainConfig = {'lr': 0.001, 'batch_size': 64}
train_with_dict(**my_config)
# Or call with explicit keyword arguments:
train_with_dict(lr=0.001, batch_size=64)
Key Points:
Runtime behavior: The function receives a regular dictionary in the
configvariableStatic Analysis:
Unpack[TrainConfig]allows IDEs to validate that arguments match the keys and types defined inTrainConfigThis restores the “link-back” functionality for parameter references
Option 3: Config Singleton in Closure
This is similar to Options 1 and 2 in that this also exposes a global configuration object. The main difference, however, is that you no longer need to explicitly override the config values when calling the function. You can directly apply overrides to the Config object, and the function will be referencing the updated value through the config namespace during execution.
from params_proto import proto
@proto
class Config:
lr: float = 0.01
batch_size: int = 32
optimizer: str = "adam"
def train() -> None:
# ✓ Direct access to global configuration
print(f"Learning Rate: {Config.lr}")
print(f"Batch Size: {Config.batch_size}")
print(f"Optimizer: {Config.optimizer}")
# Usage - Direct attribute access and modification:
Config.lr = 0.001
train()
# Hyperparameter sweep:
for Config.lr in [0.01, 0.001, 0.0001]:
print(f"Training with lr={Config.lr}")
train()
Benefits:
Global accessibility: Parameters can be accessed from anywhere in your code
Sweeps made easy: Natural iteration over parameter values (
for Config.lr in [...])Centralized configuration: All parameters defined in one place
CLI generation: Works seamlessly with params-proto’s CLI generation features
Trade-offs:
Global state: Parameters are mutable globals, which can make testing harder
Implicit dependencies: Functions depend on external state rather than explicit arguments
Best for: Scripts, experiments, and research code where sweep functionality is important
Which Approach Should You Use?¶
Choose based on your use case:
Option 1 (Argument class): Best for libraries, APIs, and production code where explicit dependencies are important
Option 2 (
kwargs: Unpack): Best when you need dictionary-style flexibility with type safetyOption 3 (Config singleton in closure): Best for research code, experiments, and hyperparameter sweeps
params-proto supports all three patterns, allowing you to choose the right tool for your specific needs.