Skip to content

Create an ML Experiment Tracker

Build a machine learning experiment configuration tracker using Param's declarative parameter system with validation, constraints, and change history tracking.

Claude Logo

Input

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.

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

Result

Claude leverages the param and panel skills to create a well-structured MLExperiment class with proper validation, conditional logic, and change tracking.

ML Experiment Tracker

Claude even created 25 successful tests:

Claude Finished Message

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, and param.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