Skip to content

Hyperparameter Optimisation

Optuna-based hyperparameter-optimisation layer (tinycta.hyper).

Installed via the optional hyper extra:

pip install "tinycta[hyper]"

tinycta.hyper

Hyperparameter optimisation support via Optuna.

Public API

  • Study: Frozen dataclass wrapping a completed Optuna study.
  • optimize: Convenience wrapper: build objective, run study, print, return Study.
  • get_config: Set up logger and config sections for a notebook experiment.
  • ExperimentConfig: NamedTuple returned by get_config.

ExperimentConfig

Bases: NamedTuple

Resources bundled for a notebook experiment run.

Example

from tinycta.hyper import ExperimentConfig cfg = ExperimentConfig(name="momentum", logger=None, params={"fast": 16, "slow": 64}) cfg.name 'momentum' cfg.params

The three config sections are optional and default to None, so a config file that omits one is not an error:

cfg.optuna is None and cfg.data is None True

Being a :class:~typing.NamedTuple, it also unpacks positionally:

name, _logger, params, _optuna, _data = cfg name, sorted(params) ('momentum', ['fast', 'slow'])

Source code in src/tinycta/hyper/_setup.py
class ExperimentConfig(NamedTuple):
    """Resources bundled for a notebook experiment run.

    Example:
        >>> from tinycta.hyper import ExperimentConfig
        >>> cfg = ExperimentConfig(name="momentum", logger=None, params={"fast": 16, "slow": 64})
        >>> cfg.name
        'momentum'
        >>> cfg.params
        {'fast': 16, 'slow': 64}

        The three config sections are optional and default to ``None``, so a
        config file that omits one is not an error:

        >>> cfg.optuna is None and cfg.data is None
        True

        Being a :class:`~typing.NamedTuple`, it also unpacks positionally:

        >>> name, _logger, params, _optuna, _data = cfg
        >>> name, sorted(params)
        ('momentum', ['fast', 'slow'])
    """

    name: str
    logger: Any
    params: dict[str, Any] | None = None
    optuna: dict[str, Any] | None = None
    data: dict[str, Any] | None = None

Study dataclass

Frozen wrapper around a completed Optuna study.

Example

import optuna from tinycta.hyper import Study optuna.logging.set_verbosity(optuna.logging.WARNING) s = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=0)) s.optimize(lambda trial: trial.suggest_float("x", 0.0, 1.0), n_trials=5) study = Study.from_optuna(s) study.n_trials, study.n_completed (5, 5) sorted(study.best_params) ['x']

str renders the best trial as a report block:

print(study) # doctest: +ELLIPSIS === Best parameters === x = 0... Sharpe = 0... Completed = 5 / 5 trials

A study in which every trial was pruned (each scored a NaN Sharpe) is not an error — it reports no best parameters and a NaN best value:

pruned = optuna.create_study(direction="maximize") pruned.optimize( ... lambda trial: (_ for _ in ()).throw(optuna.exceptions.TrialPruned()), n_trials=2 ... ) empty = Study.from_optuna(pruned) empty.n_completed, empty.best_params (0, {}) print(empty) No completed trials — all returned NaN Sharpe.

Source code in src/tinycta/hyper/_study.py
@dataclass(frozen=True)
class Study:
    """Frozen wrapper around a completed Optuna study.

    Example:
        >>> import optuna
        >>> from tinycta.hyper import Study
        >>> optuna.logging.set_verbosity(optuna.logging.WARNING)
        >>> s = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=0))
        >>> s.optimize(lambda trial: trial.suggest_float("x", 0.0, 1.0), n_trials=5)
        >>> study = Study.from_optuna(s)
        >>> study.n_trials, study.n_completed
        (5, 5)
        >>> sorted(study.best_params)
        ['x']

        ``str`` renders the best trial as a report block:

        >>> print(study)  # doctest: +ELLIPSIS
        === Best parameters ===
          x            = 0...
          Sharpe       = 0...
          Completed    = 5 / 5 trials

        A study in which every trial was pruned (each scored a NaN Sharpe) is not an
        error — it reports no best parameters and a NaN best value:

        >>> pruned = optuna.create_study(direction="maximize")
        >>> pruned.optimize(
        ...     lambda trial: (_ for _ in ()).throw(optuna.exceptions.TrialPruned()), n_trials=2
        ... )
        >>> empty = Study.from_optuna(pruned)
        >>> empty.n_completed, empty.best_params
        (0, {})
        >>> print(empty)
        No completed trials — all returned NaN Sharpe.
    """

    best_params: dict[str, Any]
    best_value: float
    n_completed: int
    n_trials: int
    optuna_study: optuna.Study = field(repr=False)

    def __str__(self) -> str:
        """Return a human-readable summary of the best trial."""
        if self.n_completed == 0:
            return "No completed trials — all returned NaN Sharpe."
        lines = ["=== Best parameters ==="]
        for k, v in self.best_params.items():
            lines.append(f"  {k:<12} = {v}")
        lines.append(f"  {'Sharpe':<12} = {self.best_value:.4f}")
        lines.append(f"  {'Completed':<12} = {self.n_completed} / {self.n_trials} trials")
        return "\n".join(lines)

    @classmethod
    def from_optuna(cls, s: optuna.Study) -> Study:
        """Wrap a completed optuna.Study in a frozen Study."""
        n_completed = sum(1 for t in s.trials if t.state == optuna.trial.TrialState.COMPLETE)
        if n_completed == 0:
            best_params, best_value = {}, float("nan")
        else:
            best_params, best_value = s.best_params, s.best_value
        return cls(
            best_params=best_params,
            best_value=best_value,
            n_completed=n_completed,
            n_trials=len(s.trials),
            optuna_study=s,
        )

    def plot(self, output_dir: Path) -> None:
        """Write Optuna visualisation plots to output_dir (HTML, PNG if kaleido available)."""
        output_dir.mkdir(parents=True, exist_ok=True)
        figures = {
            "optuna_history": optuna.visualization.plot_optimization_history(self.optuna_study),
            "optuna_importance": optuna.visualization.plot_param_importances(self.optuna_study),
            "optuna_parallel": optuna.visualization.plot_parallel_coordinate(self.optuna_study),
            "optuna_contour": optuna.visualization.plot_contour(self.optuna_study),
        }
        for name, fig in figures.items():
            fig.write_html(str(output_dir / f"{name}.html"))
            try:
                fig.write_image(str(output_dir / f"{name}.png"), scale=2)
            except (ValueError, ImportError) as exc:
                # PNG export needs the optional `kaleido` backend; skip if unavailable.
                logger.debug(f"Skipping PNG export for {name}: {exc}")

__str__()

Return a human-readable summary of the best trial.

Source code in src/tinycta/hyper/_study.py
def __str__(self) -> str:
    """Return a human-readable summary of the best trial."""
    if self.n_completed == 0:
        return "No completed trials — all returned NaN Sharpe."
    lines = ["=== Best parameters ==="]
    for k, v in self.best_params.items():
        lines.append(f"  {k:<12} = {v}")
    lines.append(f"  {'Sharpe':<12} = {self.best_value:.4f}")
    lines.append(f"  {'Completed':<12} = {self.n_completed} / {self.n_trials} trials")
    return "\n".join(lines)

from_optuna(s) classmethod

Wrap a completed optuna.Study in a frozen Study.

Source code in src/tinycta/hyper/_study.py
@classmethod
def from_optuna(cls, s: optuna.Study) -> Study:
    """Wrap a completed optuna.Study in a frozen Study."""
    n_completed = sum(1 for t in s.trials if t.state == optuna.trial.TrialState.COMPLETE)
    if n_completed == 0:
        best_params, best_value = {}, float("nan")
    else:
        best_params, best_value = s.best_params, s.best_value
    return cls(
        best_params=best_params,
        best_value=best_value,
        n_completed=n_completed,
        n_trials=len(s.trials),
        optuna_study=s,
    )

plot(output_dir)

Write Optuna visualisation plots to output_dir (HTML, PNG if kaleido available).

Source code in src/tinycta/hyper/_study.py
def plot(self, output_dir: Path) -> None:
    """Write Optuna visualisation plots to output_dir (HTML, PNG if kaleido available)."""
    output_dir.mkdir(parents=True, exist_ok=True)
    figures = {
        "optuna_history": optuna.visualization.plot_optimization_history(self.optuna_study),
        "optuna_importance": optuna.visualization.plot_param_importances(self.optuna_study),
        "optuna_parallel": optuna.visualization.plot_parallel_coordinate(self.optuna_study),
        "optuna_contour": optuna.visualization.plot_contour(self.optuna_study),
    }
    for name, fig in figures.items():
        fig.write_html(str(output_dir / f"{name}.html"))
        try:
            fig.write_image(str(output_dir / f"{name}.png"), scale=2)
        except (ValueError, ImportError) as exc:
            # PNG export needs the optional `kaleido` backend; skip if unavailable.
            logger.debug(f"Skipping PNG export for {name}: {exc}")

get_config(name, config_path=None)

Return logger and config sections for an experiment.

Accepts either a shared config.yml or an experiment-specific config/{name}.yml. Paths in the config are resolved relative to the notebooks directory (one level above any config/ subdirectory). NOTEBOOK_OUTPUT_FOLDER env var overrides the output directory used for the log file sink; otherwise the config-derived output directory is confined under the notebooks directory.

Example

import tempfile from pathlib import Path import yaml from tinycta.hyper import get_config

A shared config.yml supplies the sections directly:

with tempfile.TemporaryDirectory() as tmp: ... config_path = Path(tmp) / "config.yml" ... _ = config_path.write_text( ... yaml.safe_dump({"params": {"fast": 16, "slow": 64}, "data": {"output_path": "out"}}) ... ) ... cfg = get_config("momentum", config_path=config_path) ... output_exists = (Path(tmp) / "out" / "momentum").is_dir() cfg.name 'momentum' cfg.params

The output directory is created as a side effect, ready for the run's artefacts and its output.log sink:

output_exists True

An output_path that would escape the notebooks directory is rejected rather than followed:

with tempfile.TemporaryDirectory() as tmp: ... config_path = Path(tmp) / "config.yml" ... _ = config_path.write_text(yaml.safe_dump({"data": {"output_path": "../../etc"}})) ... try: ... get_config("momentum", config_path=config_path) ... except ValueError: ... print("escaping output_path rejected") escaping output_path rejected

Source code in src/tinycta/hyper/_setup.py
def get_config(name: str, config_path: Path | str | None = None) -> ExperimentConfig:
    """Return logger and config sections for an experiment.

    Accepts either a shared ``config.yml`` or an experiment-specific
    ``config/{name}.yml``.  Paths in the config are resolved relative to the
    notebooks directory (one level above any ``config/`` subdirectory).
    ``NOTEBOOK_OUTPUT_FOLDER`` env var overrides the output directory used for
    the log file sink; otherwise the config-derived output directory is confined
    under the notebooks directory.

    Example:
        >>> import tempfile
        >>> from pathlib import Path
        >>> import yaml
        >>> from tinycta.hyper import get_config

        A shared ``config.yml`` supplies the sections directly:

        >>> with tempfile.TemporaryDirectory() as tmp:
        ...     config_path = Path(tmp) / "config.yml"
        ...     _ = config_path.write_text(
        ...         yaml.safe_dump({"params": {"fast": 16, "slow": 64}, "data": {"output_path": "out"}})
        ...     )
        ...     cfg = get_config("momentum", config_path=config_path)
        ...     output_exists = (Path(tmp) / "out" / "momentum").is_dir()
        >>> cfg.name
        'momentum'
        >>> cfg.params
        {'fast': 16, 'slow': 64}

        The output directory is created as a side effect, ready for the run's
        artefacts and its ``output.log`` sink:

        >>> output_exists
        True

        An ``output_path`` that would escape the notebooks directory is rejected
        rather than followed:

        >>> with tempfile.TemporaryDirectory() as tmp:
        ...     config_path = Path(tmp) / "config.yml"
        ...     _ = config_path.write_text(yaml.safe_dump({"data": {"output_path": "../../etc"}}))
        ...     try:
        ...         get_config("momentum", config_path=config_path)
        ...     except ValueError:
        ...         print("escaping output_path rejected")
        escaping output_path rejected
    """
    config_path = Path(config_path) if config_path else Path.cwd() / "config.yml"
    cfg = _load_yaml(config_path)
    base = _resolve_base(config_path)
    sibling = _load_yaml(base / "config" / f"{name}.yml")

    data, params, optuna_cfg = _merge_sections(cfg, sibling)

    output_dir = _output_dir(base, data, name)
    _ensure_sink(output_dir / "output.log")
    logger.info(f"Writing output to: {output_dir}\nCurrent working directory: {os.getcwd()}")

    return ExperimentConfig(
        name=name,
        logger=logger,
        params=params,
        optuna=optuna_cfg,
        data=data,
    )

optimize(suggest_portfolio_fn, n_trials=100, seed=42)

Build objective, run study, log the summary and return a frozen Study.

suggest_portfolio_fn draws its parameters from the trial and returns a portfolio; the trial is then scored by that portfolio's Sharpe ratio, which the study maximises. A trial whose Sharpe is NaN is pruned rather than fatal.

Example

from types import SimpleNamespace from tinycta.hyper import optimize

Any object exposing .stats.sharpe() works here; in real use that is a jquantstats Portfolio built from the strategy's returns:

def portfolio(sharpe): ... return SimpleNamespace(stats=SimpleNamespace(sharpe=lambda: sharpe))

The objective is maximised, so the best value is the highest Sharpe seen — here the reward peaks where fast is largest:

study = optimize(lambda trial: portfolio(trial.suggest_int("fast", 1, 8)), n_trials=12) study.best_value 8.0 study.best_params

Runs are seeded, so the same objective and seed reproduce the same result:

repeat = optimize(lambda trial: portfolio(trial.suggest_int("fast", 1, 8)), n_trials=12) repeat.best_params == study.best_params True

Source code in src/tinycta/hyper/_study.py
def optimize(
    suggest_portfolio_fn: Callable[[optuna.Trial], Portfolio],
    n_trials: int = 100,
    seed: int = 42,
) -> Study:
    """Build objective, run study, log the summary and return a frozen Study.

    ``suggest_portfolio_fn`` draws its parameters from the trial and returns a
    portfolio; the trial is then scored by that portfolio's Sharpe ratio, which
    the study maximises. A trial whose Sharpe is NaN is pruned rather than fatal.

    Example:
        >>> from types import SimpleNamespace
        >>> from tinycta.hyper import optimize

        Any object exposing ``.stats.sharpe()`` works here; in real use that is a
        ``jquantstats`` ``Portfolio`` built from the strategy's returns:

        >>> def portfolio(sharpe):
        ...     return SimpleNamespace(stats=SimpleNamespace(sharpe=lambda: sharpe))

        The objective is maximised, so the best value is the highest Sharpe seen —
        here the reward peaks where ``fast`` is largest:

        >>> study = optimize(lambda trial: portfolio(trial.suggest_int("fast", 1, 8)), n_trials=12)
        >>> study.best_value
        8.0
        >>> study.best_params
        {'fast': 8}

        Runs are seeded, so the same objective and seed reproduce the same result:

        >>> repeat = optimize(lambda trial: portfolio(trial.suggest_int("fast", 1, 8)), n_trials=12)
        >>> repeat.best_params == study.best_params
        True
    """
    s = _run_study(_build_objective(suggest_portfolio_fn), n_trials=n_trials, seed=seed)
    study = Study.from_optuna(s)
    logger.info(str(study))
    return study