Skip to content

Commit f13336b

Browse files
committed
feat: add BenchmarkLogger (NO TESTS)
1 parent 06a746f commit f13336b

7 files changed

Lines changed: 241 additions & 8 deletions

File tree

examples/noreset_shewhart.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def main() -> None:
141141
WINDOW_SIZE = 50
142142

143143
# Thresholds to evaluate
144-
THRESHOLDS = np.linspace(0, 7, 30)
144+
THRESHOLDS = np.linspace(0, 7, 3000)
145145

146146
# Error margin for TP/FP/FN matching & Delays
147147
ERROR_MARGIN = (0, 100)
@@ -168,7 +168,8 @@ def main() -> None:
168168

169169
print(f"Algorithm: ShewhartControlChart(learning_period={LEARNING_PERIOD}, window={WINDOW_SIZE})")
170170
print(
171-
f"Dataset (NoReset): {N_SERIES} series, length={SERIES_LENGTH}, change_point={CHANGE_POINT}, shift={MU_AFTER - MU_BEFORE:.1f}σ"
171+
f"Dataset (NoReset): {N_SERIES} series, length={SERIES_LENGTH}, change_point={CHANGE_POINT},"
172+
"shift={MU_AFTER - MU_BEFORE:.1f}*sigma"
172173
)
173174
print(f"Dataset (ARL): {N_SERIES} series, length={SERIES_LENGTH}, no change points")
174175
print(f"Error margin: {ERROR_MARGIN}")
@@ -197,6 +198,7 @@ def main() -> None:
197198
solver=solver,
198199
policy=policy,
199200
dump_dir="benchmark_cache/noreset",
201+
verbose=True,
200202
)
201203
noreset_results = runner.run()
202204

@@ -209,6 +211,7 @@ def main() -> None:
209211
solver=solver,
210212
mode="noreset", # uses rapid point-based extraction behind the scenes
211213
dump_dir="benchmark_cache/arl",
214+
verbose=True,
212215
)
213216
arl_results = arl_runner.run()
214217

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ scikit-learn = ">=1.5.2"
2828
plotly = "^6.6.0"
2929
jupyter = "^1.1.1"
3030
pandas = "^3.0.2"
31+
tqdm = "^4.67.3"
3132

3233
[tool.poetry.group.dev.dependencies]
3334
pytest = ">=8.2.2"

pysatl_cpd/benchmark/arl_benchmark_runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def __init__(
7171
solver: OnlineCpdSolver,
7272
mode: Literal["reset", "noreset"],
7373
dump_dir: Path | str | None = None,
74+
verbose: bool = False,
7475
) -> None:
7576
for provider in providers:
7677
if provider.change_points:
@@ -87,6 +88,7 @@ def __init__(
8788
metrics=metrics, # type: ignore[arg-type]
8889
solver=solver,
8990
dump_dir=dump_dir,
91+
verbose=verbose,
9092
)
9193

9294
self._mode = mode
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# -*- coding: ascii -*-
2+
"""
3+
Logging utilities for benchmark execution.
4+
"""
5+
6+
import logging
7+
from typing import Any
8+
9+
__author__ = "PySATL contributors"
10+
__copyright__ = "Copyright (c) 2026 PySATL project"
11+
__license__ = "SPDX-License-Identifier: MIT"
12+
13+
14+
class BenchmarkLogger:
15+
"""Dedicated logger for benchmark execution with structured logging."""
16+
17+
def __init__(self, name: str = "pysatl.benchmark"):
18+
self.logger = logging.getLogger(name)
19+
self._setup_logger()
20+
21+
def _setup_logger(self) -> None:
22+
"""Setup logger if not already configured."""
23+
if not self.logger.handlers:
24+
handler = logging.StreamHandler()
25+
formatter = logging.Formatter(
26+
"[%(asctime)s] %(levelname)-8s | %(message)s",
27+
datefmt="%H:%M:%S",
28+
)
29+
handler.setFormatter(formatter)
30+
self.logger.addHandler(handler)
31+
self.logger.setLevel(logging.INFO)
32+
33+
def info(self, msg: str, **kwargs: Any) -> None:
34+
"""Log info message with optional context."""
35+
if kwargs:
36+
msg = f"{msg} | {' | '.join(f'{k}={v}' for k, v in kwargs.items())}"
37+
self.logger.info(msg)
38+
39+
def debug(self, msg: str, **kwargs: Any) -> None:
40+
"""Log debug message with optional context."""
41+
if kwargs:
42+
msg = f"{msg} | {' | '.join(f'{k}={v}' for k, v in kwargs.items())}"
43+
self.logger.debug(msg)
44+
45+
def warning(self, msg: str, **kwargs: Any) -> None:
46+
"""Log warning message."""
47+
if kwargs:
48+
msg = f"{msg} | {' | '.join(f'{k}={v}' for k, v in kwargs.items())}"
49+
self.logger.warning(msg)
50+
51+
def error(self, msg: str, **kwargs: Any) -> None:
52+
"""Log error message."""
53+
if kwargs:
54+
msg = f"{msg} | {' | '.join(f'{k}={v}' for k, v in kwargs.items())}"
55+
self.logger.error(msg)
56+
57+
def start_benchmark(
58+
self,
59+
n_algorithms: int,
60+
n_providers: int,
61+
n_total_runs: int,
62+
) -> None:
63+
"""Log benchmark start."""
64+
self.info(
65+
"Starting benchmark execution",
66+
algorithms=n_algorithms,
67+
providers=n_providers,
68+
total_runs=n_total_runs,
69+
)
70+
71+
def algorithm_start(self, algo_name: str, n_thresholds: int) -> None:
72+
"""Log algorithm processing start."""
73+
self.info(
74+
f"Processing algorithm: {algo_name}",
75+
thresholds=n_thresholds,
76+
)
77+
78+
def threshold_processed(
79+
self,
80+
algo_name: str,
81+
threshold: float,
82+
n_providers: int,
83+
) -> None:
84+
"""Log threshold processing."""
85+
self.debug(
86+
"Threshold processed",
87+
algo=algo_name,
88+
threshold=f"{threshold:.4f}",
89+
providers=n_providers,
90+
)
91+
92+
def cache_hit(self, algo_name: str, threshold: float, provider: str) -> None:
93+
"""Log cache hit."""
94+
self.debug(
95+
"Cache hit",
96+
algo=algo_name,
97+
threshold=f"{threshold:.4f}",
98+
provider=provider,
99+
)
100+
101+
def solver_start(self, algo_name: str, provider: str, threshold: float) -> None:
102+
"""Log solver execution start."""
103+
self.debug(
104+
"Executing solver",
105+
algo=algo_name,
106+
provider=provider,
107+
threshold=f"{threshold:.4f}",
108+
)
109+
110+
def metrics_computed(
111+
self,
112+
algo_name: str,
113+
threshold: float,
114+
metric_names: list[str],
115+
) -> None:
116+
"""Log metrics computation."""
117+
self.debug(
118+
"Metrics computed",
119+
algo=algo_name,
120+
threshold=f"{threshold:.4f}",
121+
metrics=", ".join(metric_names),
122+
)
123+
124+
def benchmark_complete(self, total_runs: int, elapsed_sec: float) -> None:
125+
"""Log benchmark completion."""
126+
avg_time = elapsed_sec / total_runs if total_runs > 0 else 0
127+
self.info(
128+
"Benchmark completed",
129+
total_runs=total_runs,
130+
elapsed_time=f"{elapsed_sec:.2f}s",
131+
avg_time_per_run=f"{avg_time:.3f}s",
132+
)
133+
134+
def warning_no_metrics(self) -> None:
135+
"""Log warning about missing metrics."""
136+
self.warning("No metrics registered for evaluation")
137+
138+
def error_exception(self, algo_name: str, threshold: float, error: str) -> None:
139+
"""Log exception during benchmark."""
140+
self.error(
141+
"Error during execution",
142+
algo=algo_name,
143+
threshold=f"{threshold:.4f}",
144+
error=error,
145+
)

pysatl_cpd/benchmark/noreset/noreset_benchmark_runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ def __init__(
6464
solver: OnlineCpdSolver,
6565
policy: ThresholdPolicy,
6666
dump_dir: Path | str | None = None,
67+
verbose: bool = False,
6768
) -> None:
6869
super().__init__(
6970
algorithms=algorithms,
7071
providers=providers,
7172
metrics=metrics,
7273
solver=solver,
7374
dump_dir=dump_dir,
75+
verbose=verbose,
7476
)
7577
self._policy = policy
7678

pysatl_cpd/benchmark/online_benchmark_runner.py

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@
88
__copyright__ = "Copyright (c) 2026 PySATL project"
99
__license__ = "SPDX-License-Identifier: MIT"
1010

11+
import time
1112
from abc import ABC, abstractmethod
1213
from collections.abc import Sequence
1314
from pathlib import Path
1415
from typing import Any
1516

17+
from tqdm.auto import tqdm
18+
1619
from pysatl_cpd.analysis.labeled_data import LabeledData
20+
from pysatl_cpd.benchmark.core.benchmark_logger import BenchmarkLogger
1721
from pysatl_cpd.benchmark.metrics.multiple_run_metric import MultipleRunMetric
1822
from pysatl_cpd.core.online.ionline_algorithm import OnlineAlgorithm, OnlineAlgorithmConfiguration
1923
from pysatl_cpd.core.online.online_cpd_solver import OnlineCpdSolver
@@ -50,12 +54,15 @@ def __init__(
5054
metrics: dict[str, MultipleRunMetric[TraceT, ProviderT, Any]],
5155
solver: OnlineCpdSolver,
5256
dump_dir: Path | str | None = None,
57+
verbose: bool = False,
5358
) -> None:
5459
self._algorithms = algorithms
5560
self._providers = providers
5661
self._metrics = metrics
5762
self._solver = solver
5863
self._dump_dir = Path(dump_dir) if isinstance(dump_dir, str) else dump_dir
64+
self._verbose = verbose
65+
self._logger = BenchmarkLogger()
5966

6067
@abstractmethod
6168
def _collect_runs(
@@ -100,23 +107,94 @@ def run(
100107
(threshold, {metric_name: metric_value}) entries, one per threshold.
101108
"""
102109

110+
benchmark_start = time.time()
111+
112+
total_runs = sum(len(thresholds) for _, thresholds in self._algorithms)
113+
n_algorithms = len(self._algorithms)
114+
n_providers = len(self._providers)
115+
116+
if not self._metrics:
117+
self._logger.warning_no_metrics()
118+
119+
self._logger.start_benchmark(
120+
n_algorithms=n_algorithms,
121+
n_providers=n_providers,
122+
n_total_runs=total_runs,
123+
)
124+
103125
results: dict[
104126
tuple[str, OnlineAlgorithmConfiguration],
105127
list[tuple[float, dict[str, Any]]],
106128
] = {}
107129

108-
for algorithm, thresholds in self._algorithms:
130+
algo_iterator = tqdm(
131+
self._algorithms,
132+
disable=not self._verbose,
133+
desc="Processing algorithms",
134+
unit="algo",
135+
)
136+
137+
for algorithm, thresholds in algo_iterator:
138+
algo_name = str(algorithm)
139+
140+
self._logger.algorithm_start(algo_name, len(thresholds))
141+
109142
key: tuple[str, OnlineAlgorithmConfiguration] = (
110143
str(algorithm),
111144
algorithm.configuration,
112145
)
113146
results[key] = []
114147

115-
for threshold in thresholds:
116-
runs = self._collect_runs(algorithm, threshold, self._providers)
117-
118-
metric_values: dict[str, Any] = {name: metric.evaluate(runs) for name, metric in self._metrics.items()}
148+
threshold_iterator = tqdm(
149+
thresholds,
150+
desc=f" Thresholds ({algo_name})",
151+
disable=not self._verbose,
152+
leave=False,
153+
unit="threshold",
154+
)
119155

120-
results[key].append((threshold, metric_values))
156+
for threshold in threshold_iterator:
157+
try:
158+
self._logger.debug(
159+
"Collecting runs",
160+
algo=algo_name,
161+
threshold=f"{threshold:.4f}",
162+
)
163+
164+
runs = self._collect_runs(algorithm, threshold, self._providers)
165+
166+
self._logger.metrics_computed(
167+
algo_name=algo_name,
168+
threshold=threshold,
169+
metric_names=list(self._metrics.keys()),
170+
)
171+
172+
metric_values: dict[str, Any] = {
173+
name: metric.evaluate(runs) for name, metric in self._metrics.items()
174+
}
175+
176+
results[key].append((threshold, metric_values))
177+
178+
self._logger.threshold_processed(
179+
algo_name=algo_name,
180+
threshold=threshold,
181+
n_providers=n_providers,
182+
)
183+
184+
except Exception as e:
185+
self._logger.error_exception(
186+
algo_name=algo_name,
187+
threshold=threshold,
188+
error=str(e),
189+
)
190+
raise
191+
192+
benchmark_end = time.time()
193+
elapsed = benchmark_end - benchmark_start
194+
195+
self._logger.benchmark_complete(
196+
total_runs=total_runs,
197+
elapsed_sec=elapsed,
198+
)
121199

122200
return results

pysatl_cpd/benchmark/reset_benchmark_runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ def __init__(
5858
metrics: dict[str, MultipleRunMetric[TraceT, ProviderT, Any]],
5959
solver: OnlineCpdSolver,
6060
dump_dir: Path | str | None = None,
61+
verbose: bool = False,
6162
) -> None:
6263
super().__init__(
6364
algorithms=algorithms,
6465
providers=providers,
6566
metrics=metrics,
6667
solver=solver,
6768
dump_dir=dump_dir,
69+
verbose=verbose,
6870
)
6971

7072
def _collect_runs(

0 commit comments

Comments
 (0)