Describe experiments as composable config views. Get reproducibility for free.
Ato is a scope-based configuration layer for ML experiments. It treats configuration not as static files, but as a sequence of transformations (reasoning) with explicit priorities and dependencies.
pip install ato
from ato.scope import Scope from ato.adict import ADict scope = Scope() @scope.observe(default=True) def defaults(config): config.lr = 1e-3 config.epochs = 50 config.model = 'resnet50' @scope.observe(priority=1) def high_lr(config): config.lr = 3e-3 @scope.observe(priority=2, chain_with='high_lr') def long_run(config): config.epochs = 200 @scope.manual def docs(manual): manual.lr = 'Learning rate for optimizer' manual.epochs = 'Number of training epochs' @scope def train(config): print(f'Training {config.model} for {config.epochs} epochs with lr={config.lr}') if __name__ == '__main__': train()
Run it:
python train.py # lr=1e-3, epochs=50 python train.py high_lr # lr=3e-3, epochs=50 python train.py long_run # lr=3e-3, epochs=200 (chain_with auto-applies high_lr) python train.py lr=0.01 # CLI override python train.py manual # Show view application order + docs
- Core Concepts
- CLI Syntax
- Fingerprinting (Reproducibility)
- SQL Tracker (Local Experiment Tracking)
- Hyperparameter Optimization
- FAQ
Views are functions that modify configuration. Lower priority is applied first.
@scope.observe(priority=-1) # Applied first def base(config): config.lr = 0.001 @scope.observe(priority=0) # Applied second def mid(config): config.lr = 0.01 @scope.observe(priority=1) # Applied last def high(config): config.lr = 0.1
Application order:
Default Views (priority=0) → Named Views (by priority) → CLI Arguments → Lazy Views
CLI arguments always have the highest priority.
ADict is not just a dict—it's a structure-aware config object.
from ato.adict import ADict # Nested access config = ADict() config.model.backbone.layers = [64, 128, 256] # Auto-creates nested structure # Structural hashing (tracks structure, not values) config1 = ADict(lr=0.1, epochs=100) config2 = ADict(lr=0.01, epochs=200) config1.get_structural_hash() == config2.get_structural_hash() # True (same structure) config3 = ADict(lr=0.1, epochs='100') # epochs is str! config1.get_structural_hash() == config3.get_structural_hash() # False (different structure) # Access tracking config = ADict(lr=0.1, epochs=100, unused=999) _ = config.lr config.get_minimal_config() # {'lr': 0.1} - only accessed keys # Freeze/Defrost config.freeze() # Read-only config.defrost() # Editable # File I/O config = ADict.from_file('config.yaml') config.dump('config.json')
Declare dependencies with chain_with to auto-apply prerequisite views.
@scope.observe() def base_setup(config): config.project_name = 'my_project' config.data_dir = '/data' @scope.observe(chain_with='base_setup') # base_setup applied first def advanced_training(config): config.distributed = True @scope.observe(chain_with=['base_setup', 'gpu_setup']) # Multiple dependencies def multi_node_training(config): config.nodes = 4
python train.py advanced_training
# Result: base_setup → advanced_trainingViews with lazy=True are executed after CLI arguments are applied.
@scope.observe() def base_config(config): config.dataset = 'imagenet' @scope.observe(lazy=True) # Executed after CLI args def computed_config(config): if config.dataset == 'imagenet': config.num_classes = 1000 elif config.dataset == 'cifar10': config.num_classes = 10
python train.py dataset:=cifar10 computed_config
# Result: num_classes=10Python 3.11+ Context Manager:
@scope.observe() def my_config(config): config.model = 'resnet50' config.num_layers = 50 with Scope.lazy(): # Executed after CLI if config.model == 'resnet101': config.num_layers = 101
Manage completely separate configuration namespaces.
from ato.scope import Scope, MultiScope model_scope = Scope(name='model') data_scope = Scope(name='data') scope = MultiScope(model_scope, data_scope) @model_scope.observe(default=True) def model_config(model): model.backbone = 'resnet50' model.lr = 0.1 # Model learning rate @data_scope.observe(default=True) def data_config(data): data.dataset = 'cifar10' data.lr = 0.001 # Data augmentation LR (no conflict!) @scope def train(model, data): # Parameter names match scope names print(f'Model LR: {model.lr}, Data LR: {data.lr}')
python train.py model.backbone:=resnet101 data.dataset:=imagenet
| Type | Syntax | Example |
|---|---|---|
| Apply view | view_name |
python train.py high_lr long_run |
| Python expression | key=value |
lr=0.01, layers=[1,2,3], enable=True |
| String literal | key:=value |
model:=resnet50, name:="Hello World" |
| Nested key | a.b.c=value |
model.backbone:=resnet101 |
Unlike Python expressions (=), := assigns values directly as strings.
# Simple string without quotes python train.py model:=resnet50 # String with spaces requires quotes python train.py prompt:="Hello World" python train.py prompt:='Hello World' # Mixed usage python train.py lr=0.01 layers=[1,2,3] name:=experiment_1
python train.py model.backbone:=resnet101 data.batch_size=64
Tracks logic changes, ignoring comments, whitespace, and variable names.
@scope.trace(trace_id='train_step') @scope def train_v1(config): loss = model(data) return loss @scope.trace(trace_id='train_step') @scope def train_v2(config): # Added comment loss = model(data) # Same logic return loss # train_v1 and train_v2 have identical fingerprints (same logic)
When fingerprint changes:
@scope.trace(trace_id='train_step') @scope def train_v3(config): loss = model(data) * 2 # Logic changed! return loss
Tracks actual function outputs.
import numpy as np # Basic: Track full output @scope.runtime_trace(trace_id='predictions') @scope def evaluate(model, data): return model.predict(data) # init_fn: Fix randomness @scope.runtime_trace( trace_id='predictions', init_fn=lambda: np.random.seed(42) ) @scope def evaluate_with_dropout(model, data): return model.predict(data) # inspect_fn: Track specific parts @scope.runtime_trace( trace_id='predictions', inspect_fn=lambda preds: preds[:100] # Only first 100 ) @scope def evaluate_large_output(model, data): return model.predict(data)
When to use:
- Code fingerprinting: Track code changes, verify refactoring
- Runtime fingerprinting: Detect non-determinism, debug silent failures
Lightweight experiment tracking with SQLite. No server required.
from ato.db_routers.sql.manager import SQLLogger from ato.adict import ADict config = ADict( experiment=ADict( project_name='image_classification', sql=ADict(db_path='sqlite:///experiments.db') ), lr=0.001, batch_size=32 ) logger = SQLLogger(config) run_id = logger.run(tags=['baseline', 'resnet50']) for epoch in range(100): loss = train_one_epoch() acc = validate() logger.log_metric('train_loss', loss, step=epoch) logger.log_metric('val_accuracy', acc, step=epoch) logger.log_artifact(run_id, 'checkpoints/model_best.pt', data_type='model') logger.finish(status='completed')
from ato.db_routers.sql.manager import SQLFinder finder = SQLFinder(config) # Get all runs runs = finder.get_runs_in_project('image_classification') # Find best run best_run = finder.find_best_run( project_name='image_classification', metric_key='val_accuracy', mode='max' ) # Find similar experiments (same config structure) similar = finder.find_similar_runs(run_id=123)
Built-in Hyperband algorithm with successive halving.
from ato.adict import ADict from ato.hyperopt.hyperband import HyperBand from ato.scope import Scope scope = Scope() search_spaces = ADict( lr=ADict( param_type='FLOAT', param_range=(1e-5, 1e-1), num_samples=20, space_type='LOG' ), batch_size=ADict( param_type='INTEGER', param_range=(16, 128), num_samples=5, space_type='LOG' ), model=ADict( param_type='CATEGORY', categories=['resnet50', 'resnet101', 'efficientnet_b0'] ) ) hyperband = HyperBand( scope, search_spaces, halving_rate=0.3, num_min_samples=3, mode='max' ) @hyperband.main def train(config): model = create_model(config.model) optimizer = Adam(lr=config.lr) val_acc = train_and_evaluate(model, optimizer) return val_acc if __name__ == '__main__': best_result = train() print(f'Best config: {best_result.config}') print(f'Best metric: {best_result.metric}')
No. Hydra focuses on hierarchical composition and overrides; Ato focuses on priority-based reasoning and causality tracking. Use them together or separately.
No. MLflow/W&B provide dashboards and cloud tracking; Ato provides local causality tracking (config reasoning + code fingerprinting). Use them together: MLflow/W&B for metrics/dashboards, Ato for "why did this change?"
No. Ato uses local SQLite. Zero setup, zero network calls.
Yes. Ato is format-agnostic:
- Load YAML/JSON/TOML → Ato fingerprints the result
- Use argparse → Ato integrates seamlessly
- Import OpenMMLab configs →
_base_inheritance handled
Minimal:
- Config fingerprinting: microseconds
- Code fingerprinting: once at decoration time
- Runtime fingerprinting: depends on
inspect_fncomplexity - SQLite logging: milliseconds per metric
- Python >= 3.7 (Lazy evaluation requires Python >= 3.8)
- SQLAlchemy (for SQL Tracker)
- PyYAML, toml (for config serialization)
See pyproject.toml for full dependencies.
Contributions are welcome! Submit issues or pull requests.
git clone https://github.com/Dirac-Robot/ato.git cd ato pip install -e .
python -m pytest unit_tests/
MIT License