Skip to content

plot_nested_splits

yohou.plotting.plot_nested_splits(y, outer_splitter, inner_splitter, *, outer_fold=-1, X_actual=None, train_color=None, test_color=None, outer_train_color=None, outer_test_color=None, train_name='Inner train', test_name='Inner validation', outer_train_name='Outer train (refit, best params)', outer_test_name='Outer test (scored)', show_legend=True, title=None, x_label=None, y_label=None, width=None, height=None, resampler=None, line_width=10.0, facet_n_cols=2)

Plot a nested cross-validation as a timeline visualization.

Shows both levels of a nested cross-validation. The inner rows are the cross-validation carved from one outer fold's training window, which selects the hyperparameters. The outer row above them shows the full outer training window the model is then refit on with those hyperparameters, plus the outer test window it is scored on (held out from the inner tuning). The figure is generated from the splitters themselves, so it is faithful to actual splitting behavior rather than hand-drawn.

Parameters

Name Type Description Default
y DataFrame

Target time series with mandatory "time" column.

required
outer_splitter BaseSplitter

Splitter for the outer (evaluation) loop.

required
inner_splitter BaseSplitter

Splitter for the inner (tuning) loop, applied to an outer fold's training slice.

required
outer_fold int or all

Which outer fold to render. An integer selects a single outer fold (default -1, the last fold, whose training window is largest). "all" renders one subplot per outer fold.

-1
X_actual DataFrame or None

Actual features passed to split(). Sliced to the outer training window for the inner split.

None
train_color str or None

Colors for the inner-train, inner-validation, outer-refit-train, and outer-test segments. If None, the first four colors from the yohou palette are used.

None
test_color str or None

Colors for the inner-train, inner-validation, outer-refit-train, and outer-test segments. If None, the first four colors from the yohou palette are used.

None
outer_train_color str or None

Colors for the inner-train, inner-validation, outer-refit-train, and outer-test segments. If None, the first four colors from the yohou palette are used.

None
outer_test_color str or None

Colors for the inner-train, inner-validation, outer-refit-train, and outer-test segments. If None, the first four colors from the yohou palette are used.

None
train_name str

Legend label for the inner training segments.

"Inner train"
test_name str

Legend label for the inner validation segments.

"Inner validation"
outer_train_name str

Legend label for the outer training window the model is refit on.

"Outer train (refit, best params)"
outer_test_name str

Legend label for the outer test window the refit model is scored on.

"Outer test (scored)"
show_legend bool

Whether to show the legend.

True
title str or None

Plot title. Defaults to "Nested Cross-Validation Splits".

None
x_label str or None

X-axis label. Defaults to "Time".

None
y_label str or None

Y-axis label. Defaults to "Fold".

None
width int or None

Plot width in pixels.

None
height int or None

Plot height in pixels.

None
resampler bool or widget or None

Enable plotly-resampler for large datasets.

None
line_width float

Width of the segment bars.

10.0
facet_n_cols int

Number of subplot columns when outer_fold="all".

2

Returns

Type Description
Figure

Plotly figure object.

Raises

Type Description
TypeError

If a splitter is not a BaseSplitter.

ValueError

If y is empty or missing 'time', outer_fold is out of range, or an outer fold's training slice is too small for the inner splitter.

Examples

>>> import polars as pl
>>> from yohou.plotting import plot_nested_splits
>>> from yohou.model_selection import ExpandingWindowSplitter
>>> y = pl.DataFrame({
...     "time": pl.date_range(pl.date(2020, 1, 1), pl.date(2020, 12, 31), "1d", eager=True),
...     "value": list(range(366)),
... })
>>> fig = plot_nested_splits(
...     y,
...     ExpandingWindowSplitter(n_splits=3, test_size=30),
...     ExpandingWindowSplitter(n_splits=3, test_size=20),
... )
>>> len(fig.data) > 0
True

See Also

Source Code

Source code in src/yohou/plotting/model_selection.py
def plot_nested_splits(
    y: pl.DataFrame,
    outer_splitter: BaseSplitter,
    inner_splitter: BaseSplitter,
    *,
    outer_fold: int | Literal["all"] = -1,
    X_actual: pl.DataFrame | None = None,
    train_color: str | None = None,
    test_color: str | None = None,
    outer_train_color: str | None = None,
    outer_test_color: str | None = None,
    train_name: str = "Inner train",
    test_name: str = "Inner validation",
    outer_train_name: str = "Outer train (refit, best params)",
    outer_test_name: str = "Outer test (scored)",
    show_legend: bool = True,
    title: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    width: int | None = None,
    height: int | None = None,
    resampler: bool | Literal["widget"] | None = None,
    line_width: float = 10.0,
    facet_n_cols: int = 2,
) -> go.Figure:
    """
    Plot a nested cross-validation as a timeline visualization.

    Shows both levels of a nested cross-validation. The **inner** rows are the
    cross-validation carved from one outer fold's training window, which selects the
    hyperparameters. The **outer** row above them shows the full outer training
    window the model is then refit on with those hyperparameters, plus the outer test
    window it is scored on (held out from the inner tuning). The figure is generated
    from the splitters themselves, so it is faithful to actual splitting behavior
    rather than hand-drawn.

    Parameters
    ----------
    y : pl.DataFrame
        Target time series with mandatory "time" column.
    outer_splitter : BaseSplitter
        Splitter for the outer (evaluation) loop.
    inner_splitter : BaseSplitter
        Splitter for the inner (tuning) loop, applied to an outer fold's training slice.
    outer_fold : int or "all", default=-1
        Which outer fold to render. An integer selects a single outer fold (default
        ``-1``, the last fold, whose training window is largest). ``"all"`` renders one
        subplot per outer fold.
    X_actual : pl.DataFrame or None, default=None
        Actual features passed to ``split()``. Sliced to the outer training window for
        the inner split.
    train_color, test_color, outer_train_color, outer_test_color : str or None, default=None
        Colors for the inner-train, inner-validation, outer-refit-train, and
        outer-test segments. If None, the first four colors from the yohou palette
        are used.
    train_name : str, default="Inner train"
        Legend label for the inner training segments.
    test_name : str, default="Inner validation"
        Legend label for the inner validation segments.
    outer_train_name : str, default="Outer train (refit, best params)"
        Legend label for the outer training window the model is refit on.
    outer_test_name : str, default="Outer test (scored)"
        Legend label for the outer test window the refit model is scored on.
    show_legend : bool, default=True
        Whether to show the legend.
    title : str or None, default=None
        Plot title. Defaults to "Nested Cross-Validation Splits".
    x_label : str or None, default=None
        X-axis label. Defaults to "Time".
    y_label : str or None, default=None
        Y-axis label. Defaults to "Fold".
    width : int or None, default=None
        Plot width in pixels.
    height : int or None, default=None
        Plot height in pixels.
    resampler : bool or "widget" or None, default=None
        Enable plotly-resampler for large datasets.
    line_width : float, default=10.0
        Width of the segment bars.
    facet_n_cols : int, default=2
        Number of subplot columns when ``outer_fold="all"``.

    Returns
    -------
    go.Figure
        Plotly figure object.

    Raises
    ------
    TypeError
        If a splitter is not a ``BaseSplitter``.
    ValueError
        If ``y`` is empty or missing 'time', ``outer_fold`` is out of range, or an
        outer fold's training slice is too small for the inner splitter.

    Examples
    --------
    >>> import polars as pl
    >>> from yohou.plotting import plot_nested_splits
    >>> from yohou.model_selection import ExpandingWindowSplitter

    >>> y = pl.DataFrame({
    ...     "time": pl.date_range(pl.date(2020, 1, 1), pl.date(2020, 12, 31), "1d", eager=True),
    ...     "value": list(range(366)),
    ... })

    >>> fig = plot_nested_splits(
    ...     y,
    ...     ExpandingWindowSplitter(n_splits=3, test_size=30),
    ...     ExpandingWindowSplitter(n_splits=3, test_size=20),
    ... )
    >>> len(fig.data) > 0
    True

    See Also
    --------
    [`plot_splits`][yohou.plotting.plot_splits] : Plot a flat (single-level) CV.
    [`ExpandingWindowSplitter`][yohou.model_selection.ExpandingWindowSplitter] : Expanding window cross-validation.
    """
    validate_plotting_data(y)
    validate_plotting_params(width=width, height=height)
    for label, splitter in (("outer_splitter", outer_splitter), ("inner_splitter", inner_splitter)):
        if not isinstance(splitter, BaseSplitter):
            msg = f"Expected BaseSplitter for {label}, got {type(splitter).__name__}"
            raise TypeError(msg)

    colors = resolve_color_palette(None, 4)
    train_color = train_color or colors[0]
    test_color = test_color or colors[1]
    outer_train_color = outer_train_color or colors[2]
    outer_test_color = outer_test_color or colors[3]

    times = y["time"]
    outer_splits = list(outer_splitter.split(y, X_actual))
    n_outer = len(outer_splits)

    title_default = title or "Nested Cross-Validation Splits"
    x_label_default = x_label or "Time"
    y_label_default = y_label or "Fold"

    if outer_fold == "all":
        return _plot_nested_all(
            y,
            times,
            outer_splits,
            inner_splitter,
            X_actual,
            train_color=train_color,
            test_color=test_color,
            outer_train_color=outer_train_color,
            outer_test_color=outer_test_color,
            train_name=train_name,
            test_name=test_name,
            outer_train_name=outer_train_name,
            outer_test_name=outer_test_name,
            show_legend=show_legend,
            title=title_default,
            x_label=x_label_default,
            y_label=y_label_default,
            width=width,
            height=height,
            resampler=resampler,
            line_width=line_width,
            facet_n_cols=facet_n_cols,
        )

    if not isinstance(outer_fold, int):
        msg = f"outer_fold must be an int or 'all', got {outer_fold!r}"
        raise ValueError(msg)
    if not -n_outer <= outer_fold < n_outer:
        msg = f"outer_fold={outer_fold} is out of range for {n_outer} outer folds"
        raise ValueError(msg)

    outer_train_idx, outer_test_idx = outer_splits[outer_fold]
    y_inner = y[outer_train_idx]
    x_inner = X_actual[outer_train_idx] if X_actual is not None else None
    inner_times = y_inner["time"]
    inner_splits = list(inner_splitter.split(y_inner, x_inner))

    fig = _create_figure(resampler)
    for j, (inner_train_idx, inner_test_idx) in enumerate(inner_splits):
        _add_split_row(
            fig,
            inner_times,
            inner_train_idx,
            inner_test_idx,
            f"Inner fold {j + 1}",
            train_color=train_color,
            test_color=test_color,
            train_name=train_name,
            test_name=test_name,
            line_width=line_width,
            show_train_legend=(j == 0),
            show_test_legend=(j == 0),
            train_group="inner_train",
            test_group="inner_val",
        )
    # Outer evaluation row: the full outer training window the model is refit on with
    # the best hyperparameters, plus the held-out outer test window it is scored on.
    _add_split_row(
        fig,
        times,
        outer_train_idx,
        outer_test_idx,
        "Outer fold",
        train_color=outer_train_color,
        test_color=outer_test_color,
        train_name=outer_train_name,
        test_name=outer_test_name,
        line_width=line_width,
        show_train_legend=show_legend,
        show_test_legend=show_legend,
        train_group="outer_train",
        test_group="outer_test",
    )

    n_plot_rows = len(inner_splits) + 1
    height_default = height or (300 + n_plot_rows * 30)
    fig = apply_default_layout(
        fig,
        title=title_default,
        x_label=x_label_default,
        y_label=y_label_default,
        width=width,
        height=height_default,
    )
    fig.update_layout(showlegend=show_legend)
    return fig