Build a machine learning experiment configuration tracker using Param's declarative parameter system with validation, constraints, and change history tracking.
Ask Claude Code to create a Param-based ML experiment tracker:
Create an MLExperiment param class with:
- model_name: string parameter
- learning_rate: number constrained to range 0.0001-1.0
- batch_size: integer that must be a power of 2 only (8, 16, 32, 64, etc.)
- epochs: positive integer
- early_stopping: boolean flag
- patience: integer that should only be validated when early_stopping is True
Requirements:
- Track all parameter changes in a history list with timestamps
- Add a method to get the current configuration as a dictionary
- Add a method to reset to default values
- Include proper docstrings and type hints
- Create a simple Panel UI to edit the experiment parameters and view history
Output should be a single Python file app.py. Add tests in test.py and make sure all tests pass.
!!! tip "Using the Param Skill" Claude Code has access to the HoloViz MCP server which includes a param skill with best practices for creating Parameterized classes. The skill guides Claude on:
- Using appropriate parameter types (`param.String`, `param.Number`, `param.Integer`, etc.)
- Implementing custom validation with bounds and constraints
- Setting up parameter dependencies with `@param.depends`
- Tracking parameter changes with watchers
Claude leverages the param and panel skills to create a well-structured MLExperiment class with proper validation, conditional logic, and change tracking.
Claude even created 25 successful tests:
Code
# pyright: reportAssignmentType=false
"""
ML Experiment Configuration with Parameter Tracking.
A Param-based class for managing machine learning experiment configurations
with validation, history tracking, and a Panel UI for interactive editing.
"""
from datetime import datetime
from typing import Any
import panel as pn
import param
pn.extension(throttled=True)
# Custom Parameter Types
class PowerOfTwoInteger(param.Integer):
"""Integer parameter that must be a power of 2 (8, 16, 32, 64, etc.)."""
def __init__(self, default: int = 32, **params):
super().__init__(default=default, **params)
def _validate_value(self, val: int | None, allow_None: bool) -> None:
super()._validate_value(val, allow_None)
if val is not None and val > 0:
# Check if val is a power of 2: val & (val - 1) == 0
if not (val & (val - 1) == 0):
raise ValueError(f"Parameter {self.name!r} must be a power of 2 (e.g., 8, 16, 32, 64), not {val!r}.")
class MLExperiment(param.Parameterized):
"""
Machine Learning Experiment Configuration.
A parameterized class for managing ML experiment settings with automatic
validation, change tracking, and serialization support.
Attributes
----------
model_name : str
Name of the model architecture (e.g., 'ResNet50', 'BERT').
learning_rate : float
Learning rate for optimization, constrained to [0.0001, 1.0].
batch_size : int
Training batch size, must be a power of 2 (8, 16, 32, 64, etc.).
epochs : int
Number of training epochs, must be positive.
early_stopping : bool
Whether to enable early stopping during training.
patience : int
Number of epochs to wait before early stopping (only validated when early_stopping=True).
history : list
List of parameter change records with timestamps.
Examples
--------
>>> exp = MLExperiment(model_name="ResNet50", learning_rate=0.001)
>>> exp.batch_size = 64
>>> print(exp.get_config())
{'model_name': 'ResNet50', 'learning_rate': 0.001, 'batch_size': 64, ...}
"""
model_name: str = param.String(
default="ResNet50",
doc="Name of the model architecture (e.g., 'ResNet50', 'BERT').",
)
learning_rate: float = param.Number(
default=0.001,
bounds=(0.0001, 1.0),
step=0.0001,
doc="Learning rate for optimization, constrained to [0.0001, 1.0].",
)
batch_size: int = PowerOfTwoInteger(
default=32,
bounds=(1, 1024),
doc="Training batch size, must be a power of 2 (8, 16, 32, 64, etc.).",
)
epochs: int = param.Integer(
default=10,
bounds=(1, None),
doc="Number of training epochs, must be positive.",
)
early_stopping: bool = param.Boolean(
default=False,
doc="Whether to enable early stopping during training.",
)
patience: int = param.Integer(
default=5,
bounds=(1, None),
doc="Number of epochs to wait before early stopping (validated only when early_stopping=True).",
)
history: list = param.List(
default=[],
item_type=dict,
doc="List of parameter change records with timestamps.",
)
# Store default values for reset functionality
_defaults: dict = param.Dict(default={}, precedence=-1)
def __init__(self, **params):
# Capture defaults before initialization
defaults = {
"model_name": params.get("model_name", "ResNet50"),
"learning_rate": params.get("learning_rate", 0.001),
"batch_size": params.get("batch_size", 32),
"epochs": params.get("epochs", 10),
"early_stopping": params.get("early_stopping", False),
"patience": params.get("patience", 5),
}
super().__init__(**params)
self._defaults = defaults
@param.depends("early_stopping", "patience", watch=True, on_init=True)
def _validate_patience(self) -> None:
"""Validate patience only when early_stopping is enabled."""
if self.early_stopping and self.patience < 1:
raise ValueError(f"Parameter 'patience' must be at least 1 when early_stopping is enabled, not {self.patience!r}.")
@param.depends(
"model_name",
"learning_rate",
"batch_size",
"epochs",
"early_stopping",
"patience",
watch=True,
)
def _track_changes(self) -> None:
"""Track all parameter changes with timestamps."""
# Get current values
current_config = self.get_config()
# Create history entry
entry = {
"timestamp": datetime.now().isoformat(),
"config": current_config.copy(),
}
# Append to history (create new list to trigger reactivity)
self.history = self.history + [entry]
def get_config(self) -> dict[str, Any]:
"""
Get the current experiment configuration as a dictionary.
Returns
-------
dict[str, Any]
Dictionary containing all experiment parameters.
Examples
--------
>>> exp = MLExperiment(model_name="BERT", epochs=20)
>>> config = exp.get_config()
>>> print(config['model_name'])
'BERT'
"""
return {
"model_name": self.model_name,
"learning_rate": self.learning_rate,
"batch_size": self.batch_size,
"epochs": self.epochs,
"early_stopping": self.early_stopping,
"patience": self.patience,
}
def reset(self) -> None:
"""
Reset all parameters to their default values.
This resets the experiment configuration to the values it had
when the instance was created. History is preserved.
Examples
--------
>>> exp = MLExperiment(model_name="ResNet50")
>>> exp.learning_rate = 0.1
>>> exp.reset()
>>> print(exp.learning_rate)
0.001
"""
self.param.update(**self._defaults)
class MLExperimentUI(pn.viewable.Viewer):
"""
Panel UI for editing ML Experiment parameters and viewing history.
Provides an interactive interface for modifying experiment settings
and reviewing the change history.
Parameters
----------
experiment : MLExperiment, optional
The experiment instance to edit. If not provided, creates a new one.
"""
experiment: MLExperiment = param.ClassSelector(
class_=MLExperiment,
default=None,
doc="The MLExperiment instance to edit.",
)
def __init__(self, experiment: MLExperiment | None = None, **params):
if experiment is None:
experiment = MLExperiment()
super().__init__(experiment=experiment, **params)
with pn.config.set(sizing_mode="stretch_width"):
# Create widgets from parameters
self._model_name_input = pn.widgets.TextInput.from_param(
self.experiment.param.model_name,
name="Model Name",
)
self._learning_rate_input = pn.widgets.FloatSlider.from_param(
self.experiment.param.learning_rate,
name="Learning Rate",
format="0.0000",
)
# Batch size as select for power of 2 values
self._batch_size_input = pn.widgets.Select.from_param(
self.experiment.param.batch_size,
name="Batch Size",
options=[8, 16, 32, 64, 128, 256, 512, 1024],
)
self._epochs_input = pn.widgets.IntSlider.from_param(
self.experiment.param.epochs,
name="Epochs",
start=1,
end=100,
)
self._early_stopping_input = pn.widgets.Checkbox.from_param(
self.experiment.param.early_stopping,
name="Early Stopping",
)
self._patience_input = pn.widgets.IntSlider.from_param(
self.experiment.param.patience,
name="Patience",
start=1,
end=50,
)
# Reset button
self._reset_button = pn.widgets.Button(
name="Reset to Defaults",
button_type="warning",
)
self._reset_button.on_click(self._on_reset_click)
# Collect inputs
self._inputs = pn.Column(
"## Configuration",
self._model_name_input,
self._learning_rate_input,
self._batch_size_input,
self._epochs_input,
self._early_stopping_input,
self._patience_input,
pn.layout.Divider(),
self._reset_button,
max_width=350,
)
# Output panes - created once with reactive content
self._config_pane = pn.pane.JSON(
self._current_config,
name="Current Config",
depth=2,
sizing_mode="stretch_width",
)
self._history_pane = pn.pane.Markdown(
self._history_display,
sizing_mode="stretch_width",
)
# Collect outputs
self._outputs = pn.Column(
"## Current Configuration",
self._config_pane,
pn.layout.Divider(),
"## Change History",
self._history_pane,
)
# Combined layout
self._panel = pn.Row(
self._inputs,
self._outputs,
sizing_mode="stretch_width",
)
def _on_reset_click(self, _event: Any) -> None:
"""Handle reset button click."""
self.experiment.reset()
@param.depends(
"experiment.model_name",
"experiment.learning_rate",
"experiment.batch_size",
"experiment.epochs",
"experiment.early_stopping",
"experiment.patience",
)
def _current_config(self) -> dict[str, Any]:
"""Return current configuration for JSON pane."""
return self.experiment.get_config()
@param.depends("experiment.history")
def _history_display(self) -> str:
"""Return formatted history for markdown pane."""
if not self.experiment.history:
return "*No changes recorded yet.*"
lines = []
for i, entry in enumerate(reversed(self.experiment.history[-10:]), 1):
timestamp = entry["timestamp"]
config = entry["config"]
lines.append(f"### Change {len(self.experiment.history) - i + 1}")
lines.append(f"**Time:** {timestamp}")
lines.append(f"- Model: `{config['model_name']}`")
lines.append(f"- LR: `{config['learning_rate']}`")
lines.append(f"- Batch: `{config['batch_size']}`")
lines.append(f"- Epochs: `{config['epochs']}`")
lines.append(f"- Early Stop: `{config['early_stopping']}` (patience: `{config['patience']}`)")
lines.append("")
if len(self.experiment.history) > 10:
lines.insert(0, f"*Showing last 10 of {len(self.experiment.history)} changes.*\n")
return "\n".join(lines)
def __panel__(self) -> pn.Row:
"""Return the panel layout for notebook display."""
return self._panel
@classmethod
def create_app(cls, experiment: MLExperiment | None = None) -> pn.template.FastListTemplate:
"""
Create a servable Panel application.
Parameters
----------
experiment : MLExperiment, optional
The experiment instance to edit.
Returns
-------
pn.template.FastListTemplate
A Panel template ready to be served.
"""
instance = cls(experiment=experiment)
template = pn.template.FastListTemplate(
title="ML Experiment Configuration",
sidebar=[
pn.pane.Markdown(
"### ML Experiment Tracker\n\nConfigure your machine learning experiment parameters. All changes are automatically tracked with timestamps."
),
instance._inputs,
],
main=[instance._outputs],
main_layout=None,
)
return template
# Serve the app
if pn.state.served:
MLExperimentUI.create_app().servable()Key features demonstrated:
- Parameter Types: Uses
param.String,param.Number,param.Integer, andparam.Boolean - Validation: Learning rate bounded to 0.0001-1.0, batch size restricted to powers of 2
- Conditional Logic: Patience parameter disabled when early_stopping is False
- Change Tracking: Automatic history with timestamps via param watchers
- Panel Integration: Direct widget binding with
from_param()for reactive UI