"""Hyperparameter tuning for tsam aggregation.
This module provides functions for finding optimal aggregation parameters.
"""
from __future__ import annotations
import logging
import os
import shutil
import tempfile
from concurrent.futures import ProcessPoolExecutor
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, TypedDict
import numpy as np
import pandas as pd
import tqdm
from tsam.api import _parse_duration_hours, aggregate
from tsam.config import (
ClusterConfig,
ExtremeConfig,
RepresentationMethod,
SegmentConfig,
)
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from tsam.result import AggregationResult
class _AggregateOpts(TypedDict):
"""Internal TypedDict for aggregate options passed through tuning functions."""
period_duration: float
temporal_resolution: float
cluster: ClusterConfig
segment_representation: RepresentationMethod
extremes: ExtremeConfig | None
preserve_column_means: bool
round_decimals: int | None
numerical_tolerance: float
logger = logging.getLogger(__name__)
def _test_single_config_file(
args: dict,
) -> tuple[int, int, float, AggregationResult | None]:
"""Test a single configuration for parallel execution.
Loads data from file - no DataFrame pickling.
Args contains n_clusters, n_segments, data_path, and serialized aggregate options.
Returns (n_clusters, n_segments, rmse, result).
"""
n_clusters = args["n_clusters"]
n_segments = args["n_segments"]
data_path = args["data_path"]
opts = args["opts"]
try:
# Load data fresh from file - no pickling
data = pd.read_csv(
data_path, index_col=0, parse_dates=True, sep=",", decimal="."
)
# Reconstruct configs from serialized dicts
cluster = ClusterConfig(**opts["cluster_dict"])
extremes = (
ExtremeConfig(**opts["extremes_dict"])
if opts["extremes_dict"] is not None
else None
)
segments = SegmentConfig(
n_segments=n_segments,
representation=opts["segment_representation"],
)
result = aggregate(
data,
n_clusters=n_clusters,
period_duration=opts["period_duration"],
temporal_resolution=opts["temporal_resolution"],
cluster=cluster,
segments=segments,
extremes=extremes,
preserve_column_means=opts["preserve_column_means"],
round_decimals=opts["round_decimals"],
numerical_tolerance=opts["numerical_tolerance"],
)
rmse = float(np.sqrt((result.accuracy.rmse**2).mean()))
return (n_clusters, n_segments, rmse, result)
except Exception as e:
logger.warning(
"Config (n_clusters=%d, n_segments=%d) failed: %s: %s",
n_clusters,
n_segments,
type(e).__name__,
e,
)
return (n_clusters, n_segments, float("inf"), None)
def _infer_temporal_resolution(data: pd.DataFrame) -> float:
"""Infer time temporal_resolution in hours from DataFrame datetime index."""
if len(data) < 2:
return 1.0 # Default to hourly
try:
timedelta = data.index[1] - data.index[0]
return float(timedelta.total_seconds()) / 3600
except (AttributeError, TypeError):
# Try converting to datetime
try:
index = pd.to_datetime(data.index)
timedelta = index[1] - index[0]
return float(timedelta.total_seconds()) / 3600
except (ValueError, TypeError, AttributeError):
# Default to hourly if can't infer
return 1.0
@contextmanager
def _parallel_context(
data: pd.DataFrame,
aggregate_opts: _AggregateOpts,
prefix: str = "tsam_",
) -> Iterator[tuple[str, dict]]:
"""Context manager for parallel execution setup.
Saves data to temp file and yields (data_path, serialized_opts).
Cleans up temp files on exit.
"""
temp_dir = tempfile.mkdtemp(prefix=prefix)
data_path = str(Path(temp_dir) / "data.csv")
data.to_csv(data_path, sep=",", decimal=".")
# Serialize configs to dicts for pickling
serialized_opts = {
"period_duration": aggregate_opts["period_duration"],
"temporal_resolution": aggregate_opts["temporal_resolution"],
"cluster_dict": asdict(aggregate_opts["cluster"]),
"segment_representation": aggregate_opts["segment_representation"],
"extremes_dict": (
asdict(aggregate_opts["extremes"])
if aggregate_opts["extremes"] is not None
else None
),
"preserve_column_means": aggregate_opts["preserve_column_means"],
"round_decimals": aggregate_opts["round_decimals"],
"numerical_tolerance": aggregate_opts["numerical_tolerance"],
}
try:
yield data_path, serialized_opts
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def _test_configs(
configs: list[tuple[int, int]],
data: pd.DataFrame,
aggregate_opts: _AggregateOpts,
n_workers: int,
show_progress: bool = False,
progress_desc: str = "Testing configurations",
) -> list[tuple[int, int, float, AggregationResult | None]]:
"""Test a batch of configurations, either sequentially or in parallel.
Args:
configs: List of (n_clusters, n_segments) tuples to test.
data: Input time series data.
aggregate_opts: Dict with fixed aggregate parameters (period_duration,
temporal_resolution, cluster, segment_representation, extremes,
preserve_column_means, round_decimals, numerical_tolerance).
n_workers: Number of parallel workers (1 for sequential).
show_progress: Whether to show progress bar.
progress_desc: Description for progress bar.
Returns:
List of (n_clusters, n_segments, rmse, result) tuples.
"""
if not configs:
return []
results: list[tuple[int, int, float, AggregationResult | None]] = []
if n_workers > 1:
with _parallel_context(data, aggregate_opts) as (data_path, serialized_opts):
full_configs = [
{
"n_clusters": n_per,
"n_segments": n_seg,
"data_path": data_path,
"opts": serialized_opts,
}
for n_per, n_seg in configs
]
with ProcessPoolExecutor(max_workers=n_workers) as executor:
if show_progress:
results_iter = tqdm.tqdm(
executor.map(_test_single_config_file, full_configs),
total=len(full_configs),
desc=f"{progress_desc} ({n_workers} workers)",
)
else:
results_iter = executor.map(_test_single_config_file, full_configs)
results = list(results_iter)
else:
iterator: list[tuple[int, int]] | tqdm.tqdm[tuple[int, int]] = configs
if show_progress:
iterator = tqdm.tqdm(configs, desc=progress_desc)
for n_per, n_seg in iterator:
try:
segments = SegmentConfig(
n_segments=n_seg,
representation=aggregate_opts["segment_representation"],
)
result = aggregate(
data,
n_clusters=n_per,
period_duration=aggregate_opts["period_duration"],
temporal_resolution=aggregate_opts["temporal_resolution"],
cluster=aggregate_opts["cluster"],
segments=segments,
extremes=aggregate_opts["extremes"],
preserve_column_means=aggregate_opts["preserve_column_means"],
round_decimals=aggregate_opts["round_decimals"],
numerical_tolerance=aggregate_opts["numerical_tolerance"],
)
rmse = float(np.sqrt((result.accuracy.rmse**2).mean()))
results.append((n_per, n_seg, rmse, result))
except Exception as e:
logger.debug("Config (%d, %d) failed: %s", n_per, n_seg, e)
results.append((n_per, n_seg, float("inf"), None))
return results
def _get_n_workers(n_jobs: int | None) -> int:
"""Convert n_jobs parameter to actual worker count.
Follows joblib convention for negative values:
- n_jobs=None or 1: single worker (no parallelization)
- n_jobs=-1: all CPUs
- n_jobs=-2: all CPUs minus 1
- n_jobs=-N: all CPUs minus (N-1)
- n_jobs>1: exactly that many workers
"""
if n_jobs is None or n_jobs == 1:
return 1
elif n_jobs < 0:
# Negative values: all CPUs + n_jobs + 1 (e.g., -1 = all, -2 = all-1)
cpu_count = os.cpu_count() or 1
return max(1, cpu_count + n_jobs + 1)
else:
return max(1, n_jobs)
[docs]
@dataclass
class TuningResult:
"""Result of hyperparameter tuning.
Attributes
----------
n_clusters : int
Optimal number of typical periods.
n_segments : int
Optimal number of segments per period.
rmse : float
RMSE of the optimal configuration.
history : list[dict]
History of all tested configurations with their RMSE values.
best_result : AggregationResult
The AggregationResult for the optimal configuration.
all_results : list[AggregationResult]
All AggregationResults from tuning.
Examples
--------
>>> result = find_optimal_combination(df, data_reduction=0.01)
>>> result.summary # DataFrame of all tested configs
>>> result.plot() # Visualize results
>>> pareto = find_pareto_front(df, max_timesteps=500)
>>> pareto.find_by_timesteps(100) # Find config closest to 100 timesteps
>>> for agg_result in pareto: # Iterate over AggregationResults
... print(agg_result.accuracy.rmse.mean())
"""
n_clusters: int
n_segments: int
rmse: float
history: list[dict]
best_result: AggregationResult
all_results: list[AggregationResult] = field(default_factory=list)
@property
def summary(self) -> pd.DataFrame:
"""Summary DataFrame of all tested configurations."""
df = pd.DataFrame(self.history)
if "timesteps" not in df.columns and len(df) > 0:
df["timesteps"] = df["n_clusters"] * df["n_segments"]
return df
[docs]
def find_by_timesteps(self, target: int) -> AggregationResult:
"""Find the result closest to a target timestep count."""
if not self.all_results:
raise ValueError(
"No results available. Use save_all_results=True in "
"find_optimal_combination() or use find_pareto_front() instead."
)
if len(self.all_results) != len(self.history):
raise ValueError(
f"Results/history mismatch: {len(self.all_results)} results vs "
f"{len(self.history)} history entries. This may indicate "
"save_all_results was not enabled."
)
best_idx = 0
best_diff = float("inf")
for i, h in enumerate(self.history):
diff = abs(h["n_clusters"] * h["n_segments"] - target)
if diff < best_diff:
best_diff = diff
best_idx = i
return self.all_results[best_idx]
[docs]
def find_by_rmse(self, threshold: float) -> AggregationResult:
"""Find the smallest configuration that achieves a target RMSE."""
if not self.all_results:
raise ValueError(
"No results available. Use save_all_results=True in "
"find_optimal_combination() or use find_pareto_front() instead."
)
if len(self.all_results) != len(self.history):
raise ValueError(
f"Results/history mismatch: {len(self.all_results)} results vs "
f"{len(self.history)} history entries. This may indicate "
"save_all_results was not enabled."
)
# Find all configurations meeting the threshold
candidates: list[tuple[int, int]] = [] # (timesteps, index)
for i, h in enumerate(self.history):
if h["rmse"] <= threshold:
timesteps = h.get("timesteps", h["n_clusters"] * h["n_segments"])
candidates.append((timesteps, i))
if not candidates:
raise ValueError(
f"No configuration achieves RMSE <= {threshold}. "
f"Best available: {min(h['rmse'] for h in self.history):.4f}"
)
# Return the smallest configuration (by timesteps)
candidates.sort(key=lambda x: x[0])
return self.all_results[candidates[0][1]]
[docs]
def plot(self, show_labels: bool = True, **kwargs: object) -> object:
"""Plot results (RMSE vs timesteps)."""
import plotly.graph_objects as go
summary = self.summary
hover_text = [
f"{row['n_clusters']}x{row['n_segments']}<br>"
f"Timesteps: {row['timesteps']}<br>"
f"RMSE: {row['rmse']:.4f}"
for _, row in summary.iterrows()
]
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=summary["timesteps"],
y=summary["rmse"],
mode="lines+markers" if len(summary) > 1 else "markers",
marker={"size": 10},
hovertext=hover_text if show_labels else None,
hoverinfo="text" if show_labels else "x+y",
**kwargs,
)
)
fig.update_layout(
title="Tuning Results: Complexity vs Accuracy",
xaxis_title="Timesteps (n_clusters x n_segments)",
yaxis_title="RMSE",
hovermode="closest",
)
return fig
def __len__(self) -> int:
return len(self.all_results)
def __getitem__(self, index: int) -> AggregationResult:
return self.all_results[index]
def __iter__(self):
return iter(self.all_results)
[docs]
def find_clusters_for_reduction(
n_timesteps: int,
n_segments: int,
data_reduction: float,
) -> int:
"""Calculate max clusters for a target data reduction.
Parameters
----------
n_timesteps : int
Number of original timesteps.
n_segments : int
Number of segments per period.
data_reduction : float
Target reduction factor (e.g., 0.1 for 10% of original size).
Returns
-------
int
Maximum number of clusters that achieves the reduction.
Examples
--------
>>> find_clusters_for_reduction(8760, 24, 0.01) # 1% of hourly year
3
"""
return int(np.floor(data_reduction * float(n_timesteps) / n_segments))
[docs]
def find_segments_for_reduction(
n_timesteps: int,
n_clusters: int,
data_reduction: float,
) -> int:
"""Calculate max segments for a target data reduction.
Parameters
----------
n_timesteps : int
Number of original timesteps.
n_clusters : int
Number of typical periods.
data_reduction : float
Target reduction factor (e.g., 0.1 for 10% of original size).
Returns
-------
int
Maximum number of segments that achieves the reduction.
Examples
--------
>>> find_segments_for_reduction(8760, 8, 0.01) # 1% with 8 periods
10
"""
return int(np.floor(data_reduction * float(n_timesteps) / n_clusters))
[docs]
def find_optimal_combination(
data: pd.DataFrame,
data_reduction: float,
*,
period_duration: int | float | str = 24,
temporal_resolution: float | str | None = None,
cluster: ClusterConfig | None = None,
segment_representation: RepresentationMethod = "mean",
extremes: ExtremeConfig | None = None,
preserve_column_means: bool = True,
round_decimals: int | None = None,
numerical_tolerance: float = 1e-13,
show_progress: bool = True,
save_all_results: bool = False,
n_jobs: int | None = None,
) -> TuningResult:
"""Find optimal period/segment combination for a target data reduction.
Searches the Pareto-optimal frontier of period/segment combinations
that achieve the specified data reduction, returning the one with
minimum RMSE.
Parameters
----------
data : pd.DataFrame
Input time series data.
data_reduction : float
Target reduction factor (e.g., 0.01 for 1% of original size).
period_duration : int, float, or str, default 24
Length of each period. Accepts:
- int/float: hours (e.g., 24 for daily, 168 for weekly)
- str: pandas Timedelta string (e.g., '24h', '1d', '1w')
temporal_resolution : float or str, optional
Time resolution of input data. Accepts:
- float: hours (e.g., 1.0 for hourly, 0.25 for 15-minute)
- str: pandas Timedelta string (e.g., '1h', '15min', '30min')
If not provided, inferred from the datetime index.
cluster : ClusterConfig, optional
Clustering configuration.
segment_representation : str, default "mean"
How to represent each segment: "mean" or "medoid".
extremes : ExtremeConfig, optional
Configuration for preserving extreme periods.
preserve_column_means : bool, default True
Whether to rescale results to preserve original column means.
round_decimals : int, optional
Round results to this many decimal places.
numerical_tolerance : float, default 1e-13
Numerical tolerance for floating-point comparisons.
show_progress : bool, default True
Show progress bar during search.
save_all_results : bool, default False
If True, save all AggregationResults in all_results attribute.
Useful for detailed analysis but increases memory usage.
n_jobs : int, optional
Number of parallel jobs. If None or 1, runs sequentially.
Use -1 for all available CPUs, or a positive integer for
a specific number of workers. Parallel execution uses a file-based
approach where data is saved to a temp file and workers load from
disk - no DataFrame pickling, safe for sensitive data.
Returns
-------
TuningResult
Result containing optimal parameters and history.
Examples
--------
>>> result = find_optimal_combination(df, data_reduction=0.01)
>>> print(f"Optimal: {result.n_clusters} periods, "
... f"{result.n_segments} segments")
>>> # Use all CPUs for faster search (file-based, no DataFrame pickling)
>>> result = find_optimal_combination(df, data_reduction=0.01, n_jobs=-1)
"""
if cluster is None:
cluster = ClusterConfig()
# Parse duration parameters to hours
period_duration_hours = _parse_duration_hours(period_duration, "period_duration")
temporal_resolution_hours = (
_parse_duration_hours(temporal_resolution, "temporal_resolution")
if temporal_resolution is not None
else _infer_temporal_resolution(data)
)
if temporal_resolution_hours <= 0:
raise ValueError(
f"temporal_resolution must be positive, got {temporal_resolution_hours}"
)
n_timesteps = len(data)
timesteps_per_period = int(period_duration_hours / temporal_resolution_hours)
max_periods = n_timesteps // timesteps_per_period
max_segments = timesteps_per_period
# Find valid combinations on the Pareto frontier
possible_segments = np.arange(1, max_segments + 1)
possible_periods = np.arange(1, max_periods + 1)
combined_timesteps = np.outer(possible_segments, possible_periods)
valid_mask = combined_timesteps <= n_timesteps * data_reduction
valid_timesteps = combined_timesteps * valid_mask
optimal_periods_idx = np.zeros_like(valid_timesteps, dtype=bool)
optimal_periods_idx[
np.arange(valid_timesteps.shape[0]),
valid_timesteps.argmax(axis=1),
] = True
optimal_segments_idx = np.zeros_like(valid_timesteps, dtype=bool)
optimal_segments_idx[
valid_timesteps.argmax(axis=0),
np.arange(valid_timesteps.shape[1]),
] = True
pareto_mask = optimal_periods_idx & optimal_segments_idx
pareto_points = np.nonzero(pareto_mask)
configs_to_test = [
(int(possible_periods[per_idx]), int(possible_segments[seg_idx]))
for seg_idx, per_idx in zip(pareto_points[0], pareto_points[1])
]
# Bundle fixed aggregate parameters
aggregate_opts: _AggregateOpts = {
"period_duration": period_duration_hours,
"temporal_resolution": temporal_resolution_hours,
"cluster": cluster,
"segment_representation": segment_representation,
"extremes": extremes,
"preserve_column_means": preserve_column_means,
"round_decimals": round_decimals,
"numerical_tolerance": numerical_tolerance,
}
n_workers = _get_n_workers(n_jobs)
results = _test_configs(
configs_to_test,
data,
aggregate_opts,
n_workers,
show_progress=show_progress,
progress_desc="Searching configurations",
)
history: list[dict] = []
all_results: list[AggregationResult] = []
best_rmse = float("inf")
best_result = None
best_periods = 1
best_segments = 1
for n_clusters, n_segments, rmse, result in results:
if result is not None:
history.append(
{"n_clusters": n_clusters, "n_segments": n_segments, "rmse": rmse}
)
if save_all_results:
all_results.append(result)
if rmse < best_rmse:
best_rmse = rmse
best_result = result
best_periods = n_clusters
best_segments = n_segments
if best_result is None:
raise ValueError("No valid configuration found")
return TuningResult(
n_clusters=best_periods,
n_segments=best_segments,
rmse=best_rmse,
history=history,
best_result=best_result,
all_results=all_results,
)
[docs]
def find_pareto_front(
data: pd.DataFrame,
*,
period_duration: int | float | str = 24,
temporal_resolution: float | str | None = None,
max_timesteps: int | None = None,
timesteps: Sequence[int] | None = None,
cluster: ClusterConfig | None = None,
segment_representation: RepresentationMethod = "mean",
extremes: ExtremeConfig | None = None,
preserve_column_means: bool = True,
round_decimals: int | None = None,
numerical_tolerance: float = 1e-13,
show_progress: bool = True,
n_jobs: int | None = None,
) -> TuningResult:
"""Find all Pareto-optimal aggregations from 1 period to full resolution.
Uses a steepest-descent approach to efficiently explore the
period/segment space, finding configurations that are optimal
for their complexity level.
Parameters
----------
data : pd.DataFrame
Input time series data.
period_duration : int, float, or str, default 24
Length of each period. Accepts:
- int/float: hours (e.g., 24 for daily, 168 for weekly)
- str: pandas Timedelta string (e.g., '24h', '1d', '1w')
temporal_resolution : float or str, optional
Time resolution of input data. Accepts:
- float: hours (e.g., 1.0 for hourly, 0.25 for 15-minute)
- str: pandas Timedelta string (e.g., '1h', '15min', '30min')
If not provided, inferred from the datetime index.
max_timesteps : int, optional
Stop when reaching this many timesteps. If None, explores
up to full resolution. Ignored if `timesteps` is provided.
timesteps : Sequence[int], optional
Specific timestep counts to explore. If provided, only evaluates
configurations that produce approximately these timestep counts.
Useful for faster exploration with large steps or specific ranges.
Examples: range(10, 500, 10), [10, 50, 100, 200, 500]
cluster : ClusterConfig, optional
Clustering configuration.
segment_representation : str, default "mean"
How to represent each segment: "mean" or "medoid".
extremes : ExtremeConfig, optional
Configuration for preserving extreme periods.
preserve_column_means : bool, default True
Whether to rescale results to preserve original column means.
round_decimals : int, optional
Round results to this many decimal places.
numerical_tolerance : float, default 1e-13
Numerical tolerance for floating-point comparisons.
show_progress : bool, default True
Show progress bar.
n_jobs : int, optional
Number of parallel jobs for testing configurations.
If None or 1, runs sequentially. Use -1 for all available CPUs.
During steepest-descent phase, tests both directions in parallel.
Returns
-------
TuningResult
Result object containing Pareto-optimal configurations with
convenience methods for analysis and visualization.
Examples
--------
>>> pareto = find_pareto_front(df, max_timesteps=500)
>>> pareto.summary # DataFrame of all Pareto-optimal points
>>> pareto.plot() # Visualize the Pareto front
>>> pareto.find_by_timesteps(100) # Find config closest to 100 timesteps
>>> pareto.find_by_rmse(0.05) # Find smallest config with RMSE <= 0.05
>>> # Iterate over AggregationResults
>>> for agg_result in pareto:
... print(f"RMSE: {agg_result.accuracy.rmse.mean():.4f}")
>>> # Use parallel execution for faster search
>>> pareto = find_pareto_front(df, max_timesteps=500, n_jobs=-1)
>>> # Explore only specific timestep counts (faster)
>>> pareto = find_pareto_front(df, timesteps=range(10, 500, 50))
>>> # Explore a specific list of timestep targets
>>> pareto = find_pareto_front(df, timesteps=[10, 50, 100, 200, 500])
"""
if cluster is None:
cluster = ClusterConfig()
# Parse duration parameters to hours
period_duration_hours = _parse_duration_hours(period_duration, "period_duration")
temporal_resolution_hours = (
_parse_duration_hours(temporal_resolution, "temporal_resolution")
if temporal_resolution is not None
else _infer_temporal_resolution(data)
)
if temporal_resolution_hours <= 0:
raise ValueError(
f"temporal_resolution must be positive, got {temporal_resolution_hours}"
)
n_timesteps = len(data)
timesteps_per_period = int(period_duration_hours / temporal_resolution_hours)
max_periods = n_timesteps // timesteps_per_period
max_segments = timesteps_per_period
if max_timesteps is None:
max_timesteps = n_timesteps
# Bundle fixed aggregate parameters
aggregate_opts: _AggregateOpts = {
"period_duration": period_duration_hours,
"temporal_resolution": temporal_resolution_hours,
"cluster": cluster,
"segment_representation": segment_representation,
"extremes": extremes,
"preserve_column_means": preserve_column_means,
"round_decimals": round_decimals,
"numerical_tolerance": numerical_tolerance,
}
n_workers = _get_n_workers(n_jobs)
# If specific timesteps are provided, use targeted exploration
if timesteps is not None:
return _find_pareto_front_targeted(
data=data,
timesteps=timesteps,
max_periods=max_periods,
max_segments=max_segments,
aggregate_opts=aggregate_opts,
show_progress=show_progress,
n_workers=n_workers,
)
# Steepest descent exploration
return _find_pareto_front_steepest(
data=data,
max_periods=max_periods,
max_segments=max_segments,
max_timesteps=max_timesteps,
aggregate_opts=aggregate_opts,
show_progress=show_progress,
n_workers=n_workers,
)
def _find_pareto_front_targeted(
data: pd.DataFrame,
timesteps: Sequence[int],
max_periods: int,
max_segments: int,
aggregate_opts: _AggregateOpts,
show_progress: bool,
n_workers: int,
) -> TuningResult:
"""Find Pareto front for specific target timestep counts."""
# Build all configurations to test
configs_with_target: list[tuple[int, int, int]] = [] # (target, n_per, n_seg)
for target in sorted(set(timesteps)):
if target < 1:
continue
for n_seg in range(1, min(target, max_segments) + 1):
if target % n_seg == 0:
n_per = target // n_seg
if 1 <= n_per <= max_periods:
configs_with_target.append((target, n_per, n_seg))
if not configs_with_target:
raise ValueError("No valid configurations found for given timesteps")
# Test all configurations
configs = [(n_per, n_seg) for _, n_per, n_seg in configs_with_target]
results = _test_configs(
configs,
data,
aggregate_opts,
n_workers,
show_progress=show_progress,
progress_desc="Testing configurations",
)
# Group results by target timestep
results_by_target: dict[
int, list[tuple[int, int, float, AggregationResult | None]]
] = {}
for (target, _, _), result in zip(configs_with_target, results):
if target not in results_by_target:
results_by_target[target] = []
results_by_target[target].append(result)
# For each target, pick the best configuration (lowest RMSE)
history: list[dict] = []
all_results: list[AggregationResult] = []
best_rmse = float("inf")
best_result: AggregationResult | None = None
best_n_clusters = 0
best_n_segments = 0
for target in sorted(results_by_target.keys()):
target_best_rmse = float("inf")
target_best_result: AggregationResult | None = None
target_best_n_per = 0
target_best_n_seg = 0
for n_per, n_seg, rmse, agg_result in results_by_target[target]:
if agg_result is not None and rmse < target_best_rmse:
target_best_rmse = rmse
target_best_result = agg_result
target_best_n_per = n_per
target_best_n_seg = n_seg
if target_best_result is not None:
history.append(
{
"n_clusters": target_best_n_per,
"n_segments": target_best_n_seg,
"rmse": target_best_rmse,
}
)
all_results.append(target_best_result)
if target_best_rmse < best_rmse:
best_rmse = target_best_rmse
best_result = target_best_result
best_n_clusters = target_best_n_per
best_n_segments = target_best_n_seg
if best_result is None:
raise ValueError("No valid configuration found")
return TuningResult(
n_clusters=best_n_clusters,
n_segments=best_n_segments,
rmse=best_rmse,
history=history,
best_result=best_result,
all_results=all_results,
)
def _find_pareto_front_steepest(
data: pd.DataFrame,
max_periods: int,
max_segments: int,
max_timesteps: int,
aggregate_opts: _AggregateOpts,
show_progress: bool,
n_workers: int,
) -> TuningResult:
"""Find Pareto front using steepest descent exploration."""
history: list[dict] = []
all_results: list[AggregationResult] = []
best_rmse = float("inf")
best_result: AggregationResult | None = None
best_n_clusters = 1
best_n_segments = 1
current_rmse = float("inf")
n_clusters = 1
n_segments = 1
pbar = None
if show_progress:
pbar = tqdm.tqdm(total=max_timesteps, desc="Building Pareto front")
def add_result(n_c: int, n_s: int, rmse: float, result: AggregationResult) -> None:
nonlocal best_rmse, best_result, best_n_clusters, best_n_segments, current_rmse
history.append({"n_clusters": n_c, "n_segments": n_s, "rmse": rmse})
all_results.append(result)
current_rmse = rmse
if rmse < best_rmse:
best_rmse = rmse
best_result = result
best_n_clusters = n_c
best_n_segments = n_s
def update_progress() -> None:
if pbar is not None:
pbar.update(n_segments * n_clusters - pbar.n)
# Start with (1, 1)
results = _test_configs(
[(n_clusters, n_segments)],
data,
aggregate_opts,
n_workers=1,
)
if results:
_, _, rmse, agg_result = results[0]
if agg_result is not None:
add_result(n_clusters, n_segments, rmse, agg_result)
# Steepest descent phase
while (
n_clusters < max_periods
and n_segments < max_segments
and (n_segments + 1) * n_clusters <= max_timesteps
and n_segments * (n_clusters + 1) <= max_timesteps
):
candidates = [
(n_clusters, n_segments + 1),
(n_clusters + 1, n_segments),
]
results = _test_configs(
candidates,
data,
aggregate_opts,
n_workers=min(n_workers, 2),
)
_, _, rmse_seg, result_seg = results[0]
_, _, rmse_per, result_per = results[1]
gradient_seg = (
(current_rmse - rmse_seg) / n_clusters if rmse_seg < float("inf") else 0
)
gradient_per = (
(current_rmse - rmse_per) / n_segments if rmse_per < float("inf") else 0
)
if gradient_per > gradient_seg and result_per:
n_clusters += 1
add_result(n_clusters, n_segments, rmse_per, result_per)
elif result_seg:
n_segments += 1
add_result(n_clusters, n_segments, rmse_seg, result_seg)
else:
break
update_progress()
# Continue with periods only
remaining_periods = []
while n_clusters < max_periods and n_segments * (n_clusters + 1) <= max_timesteps:
n_clusters += 1
remaining_periods.append((n_clusters, n_segments))
if remaining_periods:
results = _test_configs(
remaining_periods,
data,
aggregate_opts,
n_workers,
)
for n_c, n_s, rmse, result in results:
if result is not None:
add_result(n_c, n_s, rmse, result)
if pbar is not None:
pbar.update(n_s * n_c - pbar.n)
# Continue with segments only
remaining_segments = []
while n_segments < max_segments and (n_segments + 1) * n_clusters <= max_timesteps:
n_segments += 1
remaining_segments.append((n_clusters, n_segments))
if remaining_segments:
results = _test_configs(
remaining_segments,
data,
aggregate_opts,
n_workers,
)
for n_c, n_s, rmse, result in results:
if result is not None:
add_result(n_c, n_s, rmse, result)
if pbar is not None:
pbar.update(n_s * n_c - pbar.n)
if pbar is not None:
pbar.close()
if best_result is None:
raise ValueError("No valid configuration found")
return TuningResult(
n_clusters=best_n_clusters,
n_segments=best_n_segments,
rmse=best_rmse,
history=history,
best_result=best_result,
all_results=all_results,
)