Parameter Overrides

params-proto v3 provides multiple ways to override parameter values at runtime, from CLI arguments to programmatic assignment.

The Three Decorators

Config decorators define parameter schemas:

Decorator

Scope

Use Case

@proto

Multiple instances

Library code, reusable components

@proto.prefix

Singleton (global)

Namespaced config groups (Model.lr, Training.epochs)

App decorator creates CLI entry point:

Decorator

Use Case

@proto.cli

Wraps a function or class to parse CLI args

Typical pattern: Define configs with @proto/@proto.prefix, then create an entry point with @proto.cli.

Override Precedence

Parameters can be overridden in multiple ways, with clear precedence (highest to lowest):

  1. Function kwargs - train(lr=0.1)

  2. proto.bind() context - proto.bind(lr=0.01)

  3. Direct assignment - train.lr = 0.01

  4. CLI arguments - --lr 0.01

  5. Environment variables - LR=0.01

  6. Default values - lr: float = 0.001

Override Methods

1. Command Line Arguments

The most common way to override parameters is through CLI arguments:

from params_proto import proto

@proto.cli
def train(
    lr: float = 0.001,  # Learning rate
    batch_size: int = 32,  # Batch size
    epochs: int = 100,  # Number of epochs
):
    """Train a model."""
    print(f"Training with lr={lr}, batch_size={batch_size}")

if __name__ == "__main__":
    train()

CLI usage:

# Override specific parameters
python train.py --lr 0.01 --batch-size 64

# Override all parameters
python train.py --lr 0.001 --batch-size 128 --epochs 200

# Boolean flags
python train.py --debug  # Set debug=True
python train.py --no-debug  # Set debug=False

2. Direct Assignment

Modify parameter values by directly assigning to the function or class:

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
):
    """Train a model."""
    print(f"lr={lr}, batch_size={batch_size}")

# Direct assignment before calling
train.lr = 0.01
train.batch_size = 64

train()  # Uses lr=0.01, batch_size=64

With classes:

@proto
class Params:
    lr: float = 0.001
    batch_size: int = 32

# Modify class defaults
Params.lr = 0.01
Params.batch_size = 64

# New instances use updated defaults
config = Params()
print(config.lr)  # 0.01

3. Function kwargs

Pass parameters directly when calling the function (highest priority):

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
    epochs: int = 100,
):
    """Train a model."""
    print(f"lr={lr}, batch_size={batch_size}, epochs={epochs}")

# Override via kwargs
train(lr=0.01, batch_size=64)

Kwargs override all other methods:

# Set via direct assignment
train.lr = 0.001
train.batch_size = 32

# Kwargs take precedence
train(lr=0.01, batch_size=64)  # Uses 0.01 and 64, not 0.001 and 32

4. proto.bind() Context

Use proto.bind() to set multiple overrides at once, especially useful for testing and parameter sweeps:

from params_proto import proto

@proto.prefix
class Model:
    hidden_size: int = 256
    num_layers: int = 4

@proto.prefix
class Training:
    lr: float = 0.001
    batch_size: int = 32

@proto.cli
def main(seed: int = 42):
    """Main entry point."""
    print(f"Model: hidden_size={Model.hidden_size}, num_layers={Model.num_layers}")
    print(f"Training: lr={Training.lr}, batch_size={Training.batch_size}")
    print(f"seed={seed}")

# Option 1: Function-style binding
proto.bind(
    seed=100,
    **{
        "Model.hidden_size": 512,
        "Model.num_layers": 8,
        "Training.lr": 0.01,
        "Training.batch_size": 64,
    }
)

main()  # Uses all bound values

# Option 2: Context manager (scoped)
with proto.bind(seed=200, **{"Model.hidden_size": 1024}):
    main()  # Uses seed=200, hidden_size=1024

# Values reset after context exits
main()  # Back to defaults or previous bindings

Binding precedence:

# Set default
train.lr = 0.001

# Bind new value (use direct keys for @proto.cli)
proto.bind(lr=0.01)

# Kwargs still take highest priority
train(lr=0.1)  # Uses 0.1, not 0.01

Override Precedence

When the same parameter is set multiple ways, params-proto uses this precedence order:

@proto.cli
def train(lr: float = 0.001, batch_size: int = 32):
    """Train a model."""
    print(f"lr={lr}, batch_size={batch_size}")

# 1. Default value
# lr=0.001, batch_size=32

# 2. Environment variable (if using EnvVar)
# LR=0.01 python train.py

# 3. CLI arguments
# python train.py --lr 0.01

# 4. Direct assignment
train.lr = 0.01

# 5. proto.bind() context (direct keys for @proto.cli)
proto.bind(lr=0.01)

# 6. Function kwargs (highest priority)
train(lr=0.1)  # This wins

Priority order (highest to lowest):

  1. Function kwargs

  2. proto.bind() context

  3. Direct assignment

  4. CLI arguments

  5. Environment variables

  6. Default values

Prefixed Configurations

Override prefixed configs using dotted notation:

@proto.prefix
class Model:
    name: str = "resnet50"
    hidden_size: int = 256

@proto.prefix
class Training:
    lr: float = 0.001
    batch_size: int = 32

@proto.cli
def main():
    """Train model."""
    print(f"Training {Model.name} with lr={Training.lr}")

# Method 1: Direct assignment
Model.name = "vit"
Training.lr = 0.01

main()

# Method 2: CLI with prefixes (kebab-case)
# python main.py --model.name vit --training.lr 0.01

# Method 3: proto.bind() with dotted names
proto.bind(**{
    "Model.name": "vit",
    "Training.lr": 0.01,
})

main()

Nested function parameters:

@proto.prefix
def train(
    lr: float = 0.001,
    batch_size: int = 32,
):
    """Training configuration."""
    return {"lr": lr, "batch_size": batch_size}

@proto.cli
def main(seed: int = 42):
    """Main function."""
    result = train()
    print(f"seed={seed}, training={result}")

# Override nested function with dict
result = main(seed=100, train={"lr": 0.01, "batch_size": 64})

# Override using proto.bind()
proto.bind(**{
    "seed": 200,
    "train.lr": 0.01,
    "train.batch_size": 64,
})
main()

Common Patterns

Testing

Override parameters in tests without modifying code:

def test_training():
    """Test training with different configurations."""
    from params_proto import proto

    @proto.cli
    def train(lr: float = 0.001, epochs: int = 100):
        """Train model."""
        return {"lr": lr, "epochs": epochs}

    # Test with default values
    result = train()
    assert result["lr"] == 0.001

    # Test with overrides
    result = train(lr=0.01, epochs=10)
    assert result["lr"] == 0.01
    assert result["epochs"] == 10

    # Test with proto.bind() (direct keys for @proto.cli)
    with proto.bind(lr=0.1):
        result = train()
        assert result["lr"] == 0.1

Parameter Sweeps

Use proto.bind() to run parameter sweeps:

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
    epochs: int = 100,
):
    """Train model."""
    print(f"Training: lr={lr}, batch_size={batch_size}")
    # ... training code ...
    return accuracy

# Parameter sweep (direct keys for @proto.cli)
results = []
for lr in [0.001, 0.01, 0.1]:
    for batch_size in [32, 64, 128]:
        with proto.bind(lr=lr, batch_size=batch_size):
            accuracy = train()
            results.append({
                "lr": lr,
                "batch_size": batch_size,
                "accuracy": accuracy,
            })

# Find best hyperparameters
best = max(results, key=lambda x: x["accuracy"])
print(f"Best: lr={best['lr']}, batch_size={best['batch_size']}")

Configuration Profiles

Create configuration profiles using proto.bind():

@proto.prefix
class Model:
    hidden_size: int = 256
    num_layers: int = 4
    dropout: float = 0.1

@proto.cli
def train():
    """Train model."""
    print(f"Model config: {Model.hidden_size}, {Model.num_layers}, {Model.dropout}")

# Configuration profiles
PROFILES = {
    "small": {
        "Model.hidden_size": 128,
        "Model.num_layers": 2,
        "Model.dropout": 0.1,
    },
    "medium": {
        "Model.hidden_size": 256,
        "Model.num_layers": 4,
        "Model.dropout": 0.2,
    },
    "large": {
        "Model.hidden_size": 512,
        "Model.num_layers": 8,
        "Model.dropout": 0.3,
    },
}

# Use a profile
def train_with_profile(profile: str):
    """Train with a specific profile."""
    proto.bind(**PROFILES[profile])
    train()

train_with_profile("large")

Dynamic Configuration

Build configurations dynamically:

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
    epochs: int = 100,
):
    """Train model."""
    print(f"Training with lr={lr}, batch_size={batch_size}, epochs={epochs}")

def load_config(config_file: str):
    """Load configuration from file."""
    import json
    with open(config_file) as f:
        config = json.load(f)

    # Apply configuration using proto.bind() (direct keys for @proto.cli)
    proto.bind(**config)

# Load from JSON file
# config.json: {"lr": 0.01, "batch_size": 64, "epochs": 200}
load_config("config.json")
train()  # Uses values from config.json

Partial Configuration

Override only some parameters:

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
    epochs: int = 100,
    warmup_steps: int = 1000,
):
    """Train model."""
    print(f"lr={lr}, batch_size={batch_size}, epochs={epochs}, warmup={warmup_steps}")

# Override only lr and batch_size, keep others as default
train(lr=0.01, batch_size=64)
# lr=0.01, batch_size=64, epochs=100 (default), warmup_steps=1000 (default)

# Using proto.bind() (direct keys for @proto.cli)
with proto.bind(lr=0.01):
    train()  # Only lr overridden

Context Manager Usage

The proto.bind() context manager is useful for scoped overrides:

@proto.cli
def train(lr: float = 0.001):
    """Train model."""
    print(f"Training with lr={lr}")

# Default behavior
train()  # lr=0.001

# Scoped override (direct keys for @proto.cli)
with proto.bind(lr=0.01):
    train()  # lr=0.01

    # Nested contexts
    with proto.bind(lr=0.1):
        train()  # lr=0.1 (inner context wins)

    train()  # lr=0.01 (back to outer context)

# Back to default after context exits
train()  # lr=0.001

Multiple contexts:

@proto.prefix
class Model:
    name: str = "resnet50"

@proto.prefix
class Training:
    lr: float = 0.001

@proto.cli
def main():
    """Train model."""
    print(f"Training {Model.name} with lr={Training.lr}")

# Apply multiple overrides
with proto.bind(**{
    "Model.name": "vit",
    "Training.lr": 0.01,
}):
    main()  # Both overridden

Best Practices

1. Use CLI for User-Facing Scripts

# ✓ Good: CLI-driven configuration
@proto.cli
def train(lr: float = 0.001):
    """Train model."""
    pass

if __name__ == "__main__":
    train()  # Users override via CLI

2. Use proto.bind() for Testing

# ✓ Good: Clean testing with context managers
def test_train():
    with proto.bind(lr=0.01):  # direct keys for @proto.cli
        result = train()
        assert result["lr"] == 0.01

3. Use Direct Assignment Sparingly

# ✗ Avoid: Global side effects
train.lr = 0.01  # Affects all future calls

# ✓ Better: Explicit function calls
train(lr=0.01)  # Clear and local

4. Document Override Methods

@proto.cli
def train(lr: float = 0.001):
    """Train a model.

    Args:
        lr: Learning rate

    CLI Usage:
        python train.py --lr 0.01

    Programmatic:
        train(lr=0.01)
        # or
        proto.bind(lr=0.01)  # direct keys for @proto.cli
        train()
    """
    pass

5. Validate Overridden Values

@proto.cli
def train(
    lr: float = 0.001,
    batch_size: int = 32,
):
    """Train model."""
    # Validate overridden values
    if lr <= 0 or lr >= 1:
        raise ValueError(f"lr must be in (0, 1), got {lr}")
    if batch_size < 1:
        raise ValueError(f"batch_size must be >= 1, got {batch_size}")

    print(f"Training with lr={lr}, batch_size={batch_size}")

Troubleshooting

Override Not Taking Effect

Problem: Parameter override doesn’t work

@proto.cli
def train(lr: float = 0.001):
    """Train model."""
    print(f"lr={lr}")

train.lr = 0.01  # Set override
train()  # Still shows lr=0.001 ???

Solution: Check if CLI parsing is overriding your value

# When called as a script with CLI args, CLI wins
# python train.py  # Uses CLI defaults, not train.lr

# To use programmatic overrides, call without CLI parsing
if __name__ == "__main__":
    # Option 1: Set before CLI parsing
    train.lr = 0.01
    train()  # Parses CLI, your override may be lost

    # Option 2: Use kwargs (highest priority)
    train(lr=0.01)  # Always works

    # Option 3: Use proto.bind() (direct keys for @proto.cli)
    proto.bind(lr=0.01)
    train()

Prefix Override Not Working

Problem: Dotted prefix not recognized

@proto.prefix
class Model:
    name: str = "resnet50"

# ✗ Wrong: No effect
proto.bind(Model_name="vit")

# ✓ Correct: Use dotted notation
proto.bind(**{"Model.name": "vit"})

Context Manager State

Problem: Values persist after context exit

# This should not happen, but check for:
# 1. Are you calling train() inside the context?
with proto.bind(**{"train.lr": 0.01}):
    pass  # Context exits

train()  # Should use default lr=0.001

# 2. If values persist, there may be a bug
# Report at https://github.com/geyang/params-proto/issues