Parameter Iterators (piter)¶
The piter operator provides a lightweight, composable way to create parameter sweeps using simple dictionaries and operators. Unlike the Sweep class which requires @proto decorated classes, piter works with plain dictionaries and supports lazy evaluation for memory efficiency.
Syntax: piter @ {...}¶
params-proto uses the @ operator for clean, readable parameter iteration:
# Preferred syntax (v3.0.0+)
configs = piter @ {"lr": [0.001, 0.01], "batch_size": [32, 64]}
# Legacy syntax (still supported for backward compatibility)
configs = piter({"lr": [0.001, 0.01], "batch_size": [32, 64]})
Both syntaxes are fully functional, but @ is preferred for cleaner, more readable code.
Operators Quick Reference¶
Operator |
Name |
Description |
Example |
Result |
|---|---|---|---|---|
|
Create |
Create iterator from dict |
|
2 configs |
|
Product |
Cartesian product |
|
4 configs (2×2) |
|
Override |
Add fixed params to all |
|
Same count, +seed |
|
Repeat |
Repeat each config n times |
|
3× count |
Operator Precedence¶
@ and * have the same precedence (both are multiplicative), evaluated left-to-right:
# This works - evaluated as ((piter @ {...}) * {...}) * {...}
piter @ {"lr": [0.001, 0.01]} * {"bs": [32, 64]} * {"seed": [1, 2]}
# Parentheses needed for % and ** in complex expressions
(piter @ {"lr": [0.001, 0.01]} * {"bs": [32, 64]}) % {"device": "cuda"}
(piter @ {"lr": [0.001, 0.01]}) ** 3
Precedence order (high to low): ** (power) > @ * (same level) > % (modulo)
# These are equivalent:
piter @ {"lr": [0.001, 0.01]} * {"bs": [32, 64]} % {"seed": 42}
((piter @ {"lr": [0.001, 0.01]}) * {"bs": [32, 64]}) % {"seed": 42}
# Power binds tighter than product:
piter @ {"lr": [0.001, 0.01]} ** 3 * {"bs": [32, 64]} # Error! ** binds first
(piter @ {"lr": [0.001, 0.01]}) ** 3 * {"bs": [32, 64]} # Correct: 6 * 2 = 12 configs
Quick Start¶
from params_proto.hyper import piter
# Create a parameter sweep from a dictionary (zips by default)
configs = piter @ {"lr": [0.001, 0.01], "batch_size": [32, 64]}
# Iterate over zipped configurations (2 configs)
for config in configs:
print(config)
# {'lr': 0.001, 'batch_size': 32}
# {'lr': 0.01, 'batch_size': 64}
# For Cartesian product, use the * operator (only first needs piter @)
configs = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
# This creates 4 configs: all combinations
Key Features¶
Lazy evaluation: Configurations are generated on-the-fly, not stored in memory
Composable: Combine iterators using operators (
*,%,**)Reusable: Results are cached, so you can iterate multiple times
Memory efficient: Only materializes when needed via
.to_list()orlen()
Basic Usage¶
Creating a piter¶
# Lists of values are zipped element-wise (default behavior)
configs = piter @ {
"lr": [0.001, 0.01, 0.1],
"batch_size": [32, 64, 128]
}
# Produces 3 configs (zipped): (0.001, 32), (0.01, 64), (0.1, 128)
# Single values
fixed = piter @ {"seed": 42, "epochs": 100}
# Produces 1 config
# For Cartesian product, use * operator (only first needs piter @)
configs = piter @ {"lr": [0.001, 0.01, 0.1]} * {"batch_size": [32, 64]}
# Produces 6 configs (3 × 2)
# With prefixes for multiple parameter groups (zipped)
configs = piter @ {
"model.depth": [18, 50],
"training.lr": [0.001, 0.01]
}
# Produces 2 configs (zipped)
Materializing Configs¶
# Lazy iteration (recommended)
for config in configs:
train(config)
# Convert to list (materializes all configs)
config_list = configs.to_list()
# or
config_list = configs.list
# Get length (materializes internally)
num_configs = len(configs)
Operators¶
Cartesian Product (*)¶
Combine parameter iterators to create all possible combinations. When chaining multiple dicts, only the first needs piter @:
# Preferred: chain with * operator (only first needs piter @)
combined = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
list(combined)
# [
# {'lr': 0.001, 'batch_size': 32},
# {'lr': 0.001, 'batch_size': 64},
# {'lr': 0.01, 'batch_size': 32},
# {'lr': 0.01, 'batch_size': 64}
# ]
# Also works: separate piter @ for each (legacy style)
piter1 = piter @ {"lr": [0.001, 0.01]}
piter2 = piter @ {"batch_size": [32, 64]}
combined = piter1 * piter2 # Same result
Use case: Exploring all combinations of independent hyperparameters.
Override (%)¶
Apply fixed parameters to all configurations:
# Create configs with Cartesian product
configs = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
# Override with a dict
with_seed = configs % {"seed": 42, "device": "cuda"}
list(with_seed)
# [
# {'lr': 0.001, 'batch_size': 32, 'seed': 42, 'device': 'cuda'},
# {'lr': 0.001, 'batch_size': 64, 'seed': 42, 'device': 'cuda'},
# {'lr': 0.01, 'batch_size': 32, 'seed': 42, 'device': 'cuda'},
# {'lr': 0.01, 'batch_size': 64, 'seed': 42, 'device': 'cuda'}
# ]
# Override with another piter (uses first config)
with_defaults = configs % (piter @ {"seed": 42, "device": "cuda"})
Use case: Adding fixed parameters (seed, device, logging config) to all experiments.
Repeat (**)¶
Repeat each configuration n times:
configs = piter @ {"lr": [0.001, 0.01]}
repeated = configs ** 3
list(repeated)
# [
# {'lr': 0.001},
# {'lr': 0.001},
# {'lr': 0.001},
# {'lr': 0.01},
# {'lr': 0.01},
# {'lr': 0.01}
# ]
Use case: Running multiple trials/seeds for each configuration.
Composition Patterns¶
Pattern 1: Grid Search with Fixed Seed¶
# Grid search over hyperparameters (chain with * for Cartesian product)
grid = (
piter @ {"lr": [0.001, 0.01, 0.1]}
* {"batch_size": [32, 64, 128]}
* {"weight_decay": [0.0, 0.0001, 0.001]}
)
# Add fixed seed to all configs
experiments = grid % {"seed": 42}
# 27 configs (3 × 3 × 3), all with seed=42
Pattern 2: Multiple Trials per Config¶
# Define hyperparameter search space (Cartesian product)
configs = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
# Run 5 trials per config with different seeds
trials = configs ** 5
# 20 total runs (4 configs × 5 trials)
Pattern 3: Combining Multiple Parameter Groups¶
# Model architecture variations
models = piter @ {"model.type": ["resnet18", "resnet50", "vit"]}
# Training hyperparameters (chain with * for Cartesian product)
training = piter @ {"training.lr": [0.001, 0.01]} * {"training.batch_size": [32, 64]}
# All combinations
experiments = models * training
# 12 configs (3 models × 2 lr × 2 batch_size)
Pattern 4: Chained Composition¶
# Build complex sweep by chaining operators (only first needs piter @)
experiments = ((
piter @ {"model": ["resnet", "vit"]}
* {"lr": [0.001, 0.01]}
* {"batch_size": [32, 64]}
) % {"seed": 42, "device": "cuda"}) ** 3
# 24 total runs (2 × 2 × 2 = 8 configs, 3 trials each)
Integration with Sweep¶
The Sweep class also supports piter operators:
from params_proto import proto, Sweep
from params_proto.hyper import piter
@proto
class Config:
lr: float = 0.001
batch_size: int = 32
# Create sweep the traditional way
sweep = Sweep(Config)
with sweep.product:
Config.lr = [0.001, 0.01]
Config.batch_size = [32, 64]
# Use operators on Sweep objects
with_seed = sweep % {"seed": 42}
repeated = sweep ** 3
# Can also mix Sweep with piter
combined = sweep * (piter @ {"optimizer": ["adam", "sgd"]})
Comparison: piter vs Sweep¶
Feature |
|
|
|---|---|---|
Input |
Plain dictionaries |
|
Syntax |
|
|
Default behavior |
Zip (element-wise) |
Context-dependent ( |
Operators |
|
Context managers ( |
Lazy |
Yes |
No (materializes in context) |
Type checking |
No |
Yes (via |
Proto integration |
No |
Yes (updates class attributes) |
Use case |
Quick sweeps, scripting |
Production configs, type safety |
Best Practices¶
1. Use descriptive keys¶
# Good: Clear parameter names with prefixes
piter @ {
"model.depth": [18, 50],
"training.lr": [0.001, 0.01],
"training.optimizer": ["adam", "sgd"]
}
# Avoid: Ambiguous names
piter @ {"d": [18, 50], "l": [0.001, 0.01]}
2. Materialize only when necessary¶
# Good: Iterate lazily
for config in experiments:
train(config)
# Avoid: Unnecessary materialization
all_configs = experiments.to_list() # Uses memory
for config in all_configs:
train(config)
3. Use operators for clarity¶
# Good: Use * for Cartesian product (only first needs piter @)
grid = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
# 4 configs: all combinations
# Good: Use zip (default) for related parameters
paired = piter @ {"lr": [0.001, 0.01], "weight_decay": [0.0001, 0.001]}
# 2 configs: (0.001, 0.0001) and (0.01, 0.001)
# Good: Use % for fixed values
with_defaults = (piter @ {"lr": [0.001, 0.01]}) % {"seed": 42, "device": "cuda"}
# Avoid: Mixing independent parameters in single dict (implicit zip)
mixed = piter @ {"lr": [0.001, 0.01], "batch_size": [32, 64]}
# Only 2 configs (zipped), might not be what you want for grid search
4. Combine with type-safe configs in production¶
from params_proto import proto
from params_proto.hyper import piter
@proto
class Config:
lr: float = 0.001
batch_size: int = 32
seed: int = 42
# Use piter for sweep definition (Cartesian product for grid search)
sweep_configs = (
piter @ {"lr": [0.001, 0.01, 0.1]}
* {"batch_size": [32, 64]}
) % {"seed": 42}
# Apply to typed config
for overrides in sweep_configs:
Config._update(overrides)
train() # Config.lr, Config.batch_size are type-checked
Advanced Examples¶
Conditional Parameter Sweeps¶
# Different learning rates for different optimizers
adam_configs = piter @ {"optimizer": "adam"} * {"lr": [0.0001, 0.001, 0.01]}
sgd_configs = (
piter @ {"optimizer": "sgd"}
* {"lr": [0.01, 0.1, 1.0]}
* {"momentum": [0.9, 0.95]}
)
# Combine into single sweep (use list concatenation)
all_configs = adam_configs.to_list() + sgd_configs.to_list()
# 3 adam configs + 6 sgd configs = 9 total
Nested Grids with Fixed Outer Parameters¶
# Coarse grid
coarse = piter @ {"lr": [0.001, 0.01, 0.1]}
# For each coarse lr, fine-tune batch size
fine_tuned = []
for coarse_config in coarse:
fine = (piter @ {"batch_size": [16, 32, 64, 128]}) % coarse_config
fine_tuned.extend(fine.to_list())
# 12 total configs (3 lr × 4 batch_size)
Hierarchical Parameter Groups¶
# Dataset variations
datasets = piter @ {"data.name": ["cifar10", "cifar100", "imagenet"]}
# Model architectures per dataset
cifar_models = piter @ {"model.type": ["resnet18", "resnet34"]}
imagenet_models = piter @ {"model.type": ["resnet50", "resnet101"]}
# Training configs
training = piter @ {"training.lr": [0.001, 0.01]}
# Compose based on dataset
cifar10_exps = piter @ {"data.name": "cifar10"} * cifar_models * training
cifar100_exps = piter @ {"data.name": "cifar100"} * cifar_models * training
imagenet_exps = piter @ {"data.name": "imagenet"} * imagenet_models * training
# Combine all
all_experiments = (
cifar10_exps.to_list() +
cifar100_exps.to_list() +
imagenet_exps.to_list()
)
API Reference¶
piter @ spec or piter(spec: dict) -> ParameterIterator¶
Create a parameter iterator from a specification dictionary.
Syntax:
# Preferred (v3.0.0+)
configs = piter @ {"lr": [0.001, 0.01]}
# Legacy (still supported)
configs = piter({"lr": [0.001, 0.01]})
Args:
spec: Dict mapping parameter names (strings) to values or lists of values
Returns:
ParameterIteratorthat zips parameter lists element-wise
Note: For Cartesian product, use the * operator to chain multiple dicts. Only the first needs piter @:
configs = piter @ {"lr": [0.001, 0.01]} * {"batch_size": [32, 64]}
ParameterIterator Methods¶
Method |
Description |
|---|---|
|
Iterate over configurations |
|
Materialize all configs to a list |
|
Property alias for |
|
Return number of configs (materializes) |
|
Cartesian product with another iterator ( |
|
Apply overrides to all configs ( |
|
Repeat each config n times ( |