From b1c6d4cac42548d1dec04a4d283806dd521cdb92 Mon Sep 17 00:00:00 2001 From: simpag Date: Wed, 17 Dec 2025 01:50:37 +0100 Subject: [PATCH 01/15] enh(Agent): Periodic historical x tracking --- decent_bench/agents.py | 37 +++++---- decent_bench/benchmark.py | 9 ++- decent_bench/benchmark_problem.py | 5 ++ decent_bench/metrics/metric_utils.py | 27 ++++++- decent_bench/metrics/plot_metrics.py | 107 ++++++++++++++++++++------ decent_bench/metrics/table_metrics.py | 62 +++++++++------ decent_bench/networks.py | 4 +- 7 files changed, 182 insertions(+), 69 deletions(-) diff --git a/decent_bench/agents.py b/decent_bench/agents.py index 597f3de..8b9aaed 100644 --- a/decent_bench/agents.py +++ b/decent_bench/agents.py @@ -11,13 +11,16 @@ class Agent: - """Agent with unique id, local cost function, and activation scheme.""" + """Agent with unique id, local cost function, activation scheme and history period.""" - def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme): + def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme, history_period: int): self._id = agent_id self._cost = cost self._activation = activation - self._x_history: list[Array] = [] + self._history_period = history_period + self._x_step = 0 + self._current_x: Array | None = None + self._x_history: dict[int, Array] = {} self._auxiliary_variables: dict[str, Array] = {} self._received_messages: dict[Agent, Array] = {} self._n_sent_messages = 0 @@ -55,24 +58,20 @@ def x(self) -> Array: """ Local optimization variable x. - Warning: - Do not use in-place operations (``+=``, ``-=``, ``*=``, etc.) on this property. - In-place operations will corrupt the optimization history by modifying all - historical values. Always use ``agent.x = agent.x + value`` instead of - ``agent.x += value``. Does not affect the outcome of the optimization, but - will affect logging and metrics that depend on the optimization history. - Raises: RuntimeError: if x is retrieved before being set or initialized """ - if not self._x_history: + if self._current_x is None: raise RuntimeError("x must be initialized before being accessed") - return self._x_history[-1] + return self._current_x @x.setter def x(self, x: Array) -> None: - self._x_history.append(x) + self._x_step += 1 + self._current_x = x + if self._x_step % self._history_period == 0: + self._x_history[self._x_step] = iop.copy(x) @property def messages(self) -> Mapping[Agent, Array]: @@ -106,7 +105,9 @@ def initialize( if x is not None: if iop.shape(x) != self.cost.shape: raise ValueError(f"Initialized x has shape {iop.shape(x)}, expected {self.cost.shape}") - self._x_history = [iop.copy(x)] + self._x_history = {0: iop.copy(x)} + self._current_x = iop.copy(x) + self._x_step = 0 if aux_vars: self._auxiliary_variables = {k: iop.copy(v) for k, v in aux_vars.items()} if received_msgs: @@ -138,7 +139,8 @@ class AgentMetricsView: """Immutable view of agent that exposes useful properties for calculating metrics.""" cost: Cost - x_history: list[Array] + x_history: dict[int, Array] + x_updates: int n_function_calls: int n_gradient_calls: int n_hessian_calls: int @@ -150,9 +152,14 @@ class AgentMetricsView: @staticmethod def from_agent(agent: Agent) -> AgentMetricsView: """Create from agent.""" + # Apppend the last x if not already recorded + if agent._current_x is not None and agent._x_step not in agent._x_history: # noqa: SLF001 + agent._x_history[agent._x_step] = agent._current_x # noqa: SLF001 + return AgentMetricsView( cost=agent.cost, x_history=agent._x_history, # noqa: SLF001 + x_updates=agent._x_step, # noqa: SLF001 n_function_calls=agent._n_function_calls, # noqa: SLF001 n_gradient_calls=agent._n_gradient_calls, # noqa: SLF001 n_hessian_calls=agent._n_hessian_calls, # noqa: SLF001 diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index 1ce7d1b..31d78b1 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -31,6 +31,7 @@ def benchmark( table_metrics: list[TableMetric] = DEFAULT_TABLE_METRICS, table_fmt: Literal["grid", "latex"] = "grid", *, + computational_cost: pm.ComputationalCost | None = None, n_trials: int = 30, confidence_level: float = 0.95, log_level: int = logging.INFO, @@ -51,6 +52,8 @@ def benchmark( table_metrics: metrics to tabulate as confidence intervals after the execution, defaults to :const:`~decent_bench.metrics.table_metrics.DEFAULT_TABLE_METRICS` table_fmt: table format, grid is suitable for the terminal while latex can be copy-pasted into a latex document + computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead + of computational cost n_trials: number of times to run each algorithm on the benchmark problem, running more trials improves the statistical results, at least 30 trials are recommended for the central limit theorem to apply confidence_level: confidence level of the confidence intervals @@ -82,10 +85,8 @@ def benchmark( resulting_agent_states: dict[Algorithm, list[list[AgentMetricsView]]] = {} for alg, networks in resulting_nw_states.items(): resulting_agent_states[alg] = [[AgentMetricsView.from_agent(a) for a in nw.agents()] for nw in networks] - with Status("Creating table"): - tm.tabulate(resulting_agent_states, benchmark_problem, table_metrics, confidence_level, table_fmt) - with Status("Creating plot"): - pm.plot(resulting_agent_states, benchmark_problem, plot_metrics) + tm.tabulate(resulting_agent_states, benchmark_problem, table_metrics, confidence_level, table_fmt) + pm.plot(resulting_agent_states, benchmark_problem, plot_metrics, computational_cost) LOGGER.info("Benchmark execution complete, thanks for using decent-bench") log_listener.stop() diff --git a/decent_bench/benchmark_problem.py b/decent_bench/benchmark_problem.py index ad7377a..6fc0170 100644 --- a/decent_bench/benchmark_problem.py +++ b/decent_bench/benchmark_problem.py @@ -41,6 +41,7 @@ class BenchmarkProblem: network_structure: graph defining how agents are connected x_optimal: solution that minimizes the sum of the cost functions, used for calculating metrics costs: local cost functions, each one is given to one agent + history_period: period for recording agent history agent_activations: setting for agent activation/participation, each scheme is applied to one agent message_compression: message compression setting message_noise: message noise setting @@ -51,6 +52,7 @@ class BenchmarkProblem: network_structure: AnyGraph x_optimal: Array costs: Sequence[Cost] + agent_history_period: int agent_activations: Sequence[AgentActivationScheme] message_compression: CompressionScheme message_noise: NoiseScheme @@ -61,6 +63,7 @@ def create_regression_problem( cost_cls: type[LinearRegressionCost | LogisticRegressionCost], *, n_agents: int = 100, + agent_history_period: int = 1, n_neighbors_per_agent: int = 3, asynchrony: bool = False, compression: bool = False, @@ -73,6 +76,7 @@ def create_regression_problem( Args: cost_cls: type of cost function n_agents: number of agents + agent_history_period: period for recording agent history n_neighbors_per_agent: number of neighbors per agent asynchrony: if true, agents only have a 50% probability of being active/participating at any given time compression: if true, messages are rounded to 4 significant digits @@ -100,6 +104,7 @@ def create_regression_problem( return BenchmarkProblem( network_structure=network_structure, costs=costs, + agent_history_period=agent_history_period, x_optimal=x_optimal, agent_activations=agent_activations, message_compression=message_compression, diff --git a/decent_bench/metrics/metric_utils.py b/decent_bench/metrics/metric_utils.py index dfac782..dfe8971 100644 --- a/decent_bench/metrics/metric_utils.py +++ b/decent_bench/metrics/metric_utils.py @@ -6,6 +6,7 @@ from numpy import linalg as la from numpy.linalg import LinAlgError from numpy.typing import NDArray +from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn import decent_bench.utils.interoperability as iop from decent_bench.agents import AgentMetricsView @@ -13,6 +14,24 @@ from decent_bench.utils.array import Array +class MetricProgressBar(Progress): + """ + Progress bar for metric calculations. + + Make sure to set the field *status* in the task to show custom status messages. + + """ + + def __init__(self) -> None: + super().__init__( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + TimeRemainingColumn(elapsed_when_finished=True), + TextColumn("{task.fields[status]}"), + ) + + def single(values: Sequence[float]) -> float: """ Assert that *values* contain exactly one element and return it. @@ -37,7 +56,11 @@ def x_mean(agents: tuple[AgentMetricsView, ...], iteration: int = -1) -> Array: ValueError: if no agent reached *iteration* """ - all_x_at_iter = [a.x_history[iteration] for a in agents if len(a.x_history) > iteration] + if iteration == -1: + all_x_at_iter = [a.x_history[max(a.x_history)] for a in agents if len(a.x_history) > 0] + else: + all_x_at_iter = [a.x_history[iteration] for a in agents if iteration in a.x_history] + if len(all_x_at_iter) == 0: raise ValueError(f"No agent reached iteration {iteration}") @@ -83,7 +106,7 @@ def x_error(agent: AgentMetricsView, problem: BenchmarkProblem) -> NDArray[float where :math:`\mathbf{x}_k` is the agent's local x at iteration k, and :math:`\mathbf{x}^\star` is the optimal x defined in the *problem*. """ - x_per_iteration = np.asarray([iop.to_numpy(x) for x in agent.x_history]) + x_per_iteration = np.asarray([iop.to_numpy(x) for _, x in sorted(agent.x_history.items())]) opt_x = problem.x_optimal errors: NDArray[float64] = la.norm(x_per_iteration - opt_x, axis=tuple(range(1, x_per_iteration.ndim))) return errors diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 0a0e30c..61279e8 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Sequence +from dataclasses import dataclass import matplotlib.pyplot as plt import numpy as np @@ -19,6 +20,17 @@ Y = float +@dataclass(eq=False) +class ComputationalCost: + """Computational costs associated with an algorithm for plot metrics.""" + + function: float = 1.0 + gradient: float = 1.0 + hessian: float = 1.0 + proximal: float = 1.0 + communication: float = 1.0 + + class PlotMetric(ABC): """ Metric to plot at the end of the benchmarking execution. @@ -65,8 +77,10 @@ class RegretPerIteration(PlotMetric): y_label: str = "regret" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 - iter_reached_by_all = min(len(a.x_history) for a in agents) - return [(i, utils.regret(agents, problem, i)) for i in range(iter_reached_by_all)] + # Determine the set of recorded iterations common to all agents and use those + common_iters = set.intersection(*(set(a.x_history.keys()) for a in agents)) if agents else set() + sorted_iters = sorted(common_iters) + return [(i, utils.regret(agents, problem, i)) for i in sorted_iters] class GradientNormPerIteration(PlotMetric): @@ -86,8 +100,10 @@ class GradientNormPerIteration(PlotMetric): y_label: str = "gradient norm" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 - iter_reached_by_all = min(len(a.x_history) for a in agents) - return [(i, utils.gradient_norm(agents, i)) for i in range(iter_reached_by_all)] + # Determine the set of recorded iterations common to all agents and use those + common_iters = set.intersection(*(set(a.x_history.keys()) for a in agents)) if agents else set() + sorted_iters = sorted(common_iters) + return [(i, utils.gradient_norm(agents, i)) for i in sorted_iters] DEFAULT_PLOT_METRICS = [ @@ -106,10 +122,11 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble MARKERS = ["o", "s", "v", "^", "*", "D", "H", "<", ">", "p"] -def plot( +def plot( # noqa: PLR0914 resulting_agent_states: dict[Algorithm, list[list[AgentMetricsView]]], problem: BenchmarkProblem, metrics: list[PlotMetric], + computational_cost: ComputationalCost | None, ) -> None: """ Plot the execution results with one subplot per metric. @@ -121,6 +138,8 @@ def plot( problem: benchmark problem whose properties, e.g. :attr:`~decent_bench.benchmark_problem.BenchmarkProblem.x_optimal`, are used for metric calculations metrics: metrics to calculate and plot + computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead + of computational cost Raises: RuntimeError: if the figure manager can't be retrieved @@ -129,31 +148,49 @@ def plot( if not metrics: return LOGGER.info(f"Plot metric definitions can be found here: {PLOT_METRICS_DOC_LINK}") - metric_subplots: list[tuple[PlotMetric, SubPlot]] = _create_metric_subplots(metrics) - for metric, subplot in metric_subplots: - for i, (alg, agent_states) in enumerate(resulting_agent_states.items()): - color = COLORS[i] if i < len(COLORS) else [random.random() for _ in range(3)] - marker = MARKERS[i] if i < len(MARKERS) else random.choice(MARKERS) - data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial(agent_states, problem, metric) - flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] - if not np.isfinite(flattened_data).all(): - msg = f"Skipping plot {metric.y_label}/{metric.x_label} for {alg.name}: found nan or inf in datapoints." - LOGGER.warning(msg) - continue - mean_curve: Sequence[tuple[X, Y]] = _calculate_mean_curve(data_per_trial) - x, y_mean = zip(*mean_curve, strict=True) - subplot.plot(x, y_mean, label=alg.name, color=color, marker=marker, markevery=max(1, int(len(x) / 20))) - y_min, y_max = _calculate_envelope(data_per_trial) - subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) - subplot.legend() + max_iterations = max(alg.iterations for alg in resulting_agent_states) + use_cost = computational_cost is not None + metric_subplots: list[tuple[PlotMetric, SubPlot]] = _create_metric_subplots(metrics, use_cost) + with utils.MetricProgressBar() as progress: + plot_task = progress.add_task( + "Generating plots", total=len(metric_subplots) * len(resulting_agent_states), status="" + ) + for metric, subplot in metric_subplots: + progress.update( + plot_task, + status=f"Task: {metric.y_label} vs {_get_formatted_x_label(metric.x_label, use_cost)}", + ) + for i, (alg, agent_states) in enumerate(resulting_agent_states.items()): + color = COLORS[i] if i < len(COLORS) else [random.random() for _ in range(3)] + marker = MARKERS[i] if i < len(MARKERS) else random.choice(MARKERS) + data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial(agent_states, problem, metric) + flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] + if not np.isfinite(flattened_data).all(): + msg = ( + f"Skipping plot {metric.y_label}/{metric.x_label} for {alg.name}: " + "found nan or inf in datapoints." + ) + LOGGER.warning(msg) + continue + mean_curve: Sequence[tuple[X, Y]] = _calculate_mean_curve(data_per_trial) + x, y_mean = zip(*mean_curve, strict=True) + if computational_cost is not None: + total_computational_cost = _calc_total_cost(agent_states, computational_cost) + x = tuple(val * total_computational_cost / max_iterations for val in x) + subplot.plot(x, y_mean, label=alg.name, color=color, marker=marker, markevery=max(1, int(len(x) / 20))) + y_min, y_max = _calculate_envelope(data_per_trial) + subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) + progress.advance(plot_task) + subplot.legend() + progress.update(plot_task, status="Finalizing plots") manager = plt.get_current_fig_manager() if not manager: raise RuntimeError("Something went wrong, did not receive a FigureManager...") - plt.tight_layout() + plt.tight_layout(pad=1.2) plt.show() -def _create_metric_subplots(metrics: list[PlotMetric]) -> list[tuple[PlotMetric, SubPlot]]: +def _create_metric_subplots(metrics: list[PlotMetric], use_cost: bool) -> list[tuple[PlotMetric, SubPlot]]: subplots_per_row = 2 n_metrics = len(metrics) n_rows = math.ceil(n_metrics / subplots_per_row) @@ -163,7 +200,7 @@ def _create_metric_subplots(metrics: list[PlotMetric]) -> list[tuple[PlotMetric, fig.delaxes(sp) metric_subplots = list(zip(metrics, subplots[:n_metrics], strict=True)) for metric, sp in metric_subplots: - sp.set_xlabel(metric.x_label) + sp.set_xlabel(_get_formatted_x_label(metric.x_label, use_cost)) sp.set_ylabel(metric.y_label) if metric.x_log: sp.set_xscale("log") @@ -172,6 +209,26 @@ def _create_metric_subplots(metrics: list[PlotMetric]) -> list[tuple[PlotMetric, return metric_subplots +def _get_formatted_x_label(x_label: str, use_cost: bool) -> str: + return f"{x_label} (computational cost units)" if use_cost else x_label + + +def _calc_total_cost(agent_states: list[list[AgentMetricsView]], computational_cost: ComputationalCost) -> float: + mean_function_calls = np.mean([a.n_function_calls for agents in agent_states for a in agents]) + mean_gradient_calls = np.mean([a.n_gradient_calls for agents in agent_states for a in agents]) + mean_hessian_calls = np.mean([a.n_hessian_calls for agents in agent_states for a in agents]) + mean_proximal_calls = np.mean([a.n_proximal_calls for agents in agent_states for a in agents]) + mean_communication_calls = np.mean([a.n_sent_messages for agents in agent_states for a in agents]) + + return float( + computational_cost.function * mean_function_calls + + computational_cost.gradient * mean_gradient_calls + + computational_cost.hessian * mean_hessian_calls + + computational_cost.proximal * mean_proximal_calls + + computational_cost.communication * mean_communication_calls + ) + + def _get_data_per_trial( agents_per_trial: list[list[AgentMetricsView]], problem: BenchmarkProblem, metric: PlotMetric ) -> list[Sequence[tuple[X, Y]]]: diff --git a/decent_bench/metrics/table_metrics.py b/decent_bench/metrics/table_metrics.py index 2bc1130..47718c9 100644 --- a/decent_bench/metrics/table_metrics.py +++ b/decent_bench/metrics/table_metrics.py @@ -26,11 +26,14 @@ class TableMetric(ABC): statistics: sequence of statistics such as :func:`min`, :func:`sum`, and :func:`~numpy.average` used for aggregating the data retrieved with :func:`get_data_from_trial` into a single value, each statistic gets its own row in the table + fmt: format string used to format the values in the table, defaults to ".2e". See :meth:`str.format` + documentation for details on the format string options. """ - def __init__(self, statistics: list[Statistic]): + def __init__(self, statistics: list[Statistic], fmt: str = ".2e"): self.statistics = statistics + self.fmt = fmt @property @abstractmethod @@ -88,7 +91,10 @@ class XError(TableMetric): description: str = "x error" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 - return [float(la.norm(iop.to_numpy(problem.x_optimal) - iop.to_numpy(a.x_history[-1]))) for a in agents] + return [ + float(la.norm(iop.to_numpy(problem.x_optimal) - iop.to_numpy(a.x_history[max(a.x_history)]))) + for a in agents + ] class AsymptoticConvergenceOrder(TableMetric): @@ -149,7 +155,7 @@ class XUpdates(TableMetric): description: str = "nr x updates" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 - return [len(a.x_history) - 1 for a in agents] + return [a.x_updates for a in agents] class FunctionCalls(TableMetric): @@ -283,30 +289,35 @@ def tabulate( headers = ["Metric (statistic)"] + [alg.name for alg in algs] rows: list[list[str]] = [] statistics_abbr = {"average": "avg", "median": "mdn"} - for metric in metrics: - for statistic in metric.statistics: - row = [f"{metric.description} ({statistics_abbr.get(statistic.__name__) or statistic.__name__})"] - for alg in algs: - agent_states_per_trial = resulting_agent_states[alg] - with warnings.catch_warnings(action="ignore"): - agg_data_per_trial = _aggregate_data_per_trial(agent_states_per_trial, problem, metric, statistic) + with warnings.catch_warnings(action="ignore"), utils.MetricProgressBar() as progress: + n_statistics = sum(len(metric.statistics) for metric in metrics) + table_task = progress.add_task("Generating table", total=n_statistics, status="") + for metric in metrics: + progress.update(table_task, status=f"Task: {metric.description}") + data_per_trial = [_data_per_trial(resulting_agent_states[a], problem, metric) for a in algs] + for statistic in metric.statistics: + row = [f"{metric.description} ({statistics_abbr.get(statistic.__name__) or statistic.__name__})"] + for i in range(len(algs)): + agg_data_per_trial = [statistic(trial) for trial in data_per_trial[i]] mean, margin_of_error = _calculate_mean_and_margin_of_error(agg_data_per_trial, confidence_level) - formatted_confidence_interval = _format_confidence_interval(mean, margin_of_error) - row.append(formatted_confidence_interval) - rows.append(row) + formatted_confidence_interval = _format_confidence_interval(mean, margin_of_error, metric.fmt) + row.append(formatted_confidence_interval) + rows.append(row) + progress.advance(table_task) + progress.update(table_task, status="Finalizing table") formatted_table = tb.tabulate(rows, headers, tablefmt=table_fmt) LOGGER.info("\n" + formatted_table) -def _aggregate_data_per_trial( - agents_per_trial: list[list[AgentMetricsView]], problem: BenchmarkProblem, metric: TableMetric, statistic: Statistic -) -> list[float]: - aggregated_data_per_trial: list[float] = [] +def _data_per_trial( + agents_per_trial: list[list[AgentMetricsView]], problem: BenchmarkProblem, metric: TableMetric +) -> list[Sequence[float]]: + data_per_trial: list[Sequence[float]] = [] for agents in agents_per_trial: trial_data = metric.get_data_from_trial(agents, problem) - aggregated_trial_data = statistic(trial_data) - aggregated_data_per_trial.append(aggregated_trial_data) - return aggregated_data_per_trial + data_per_trial.append(trial_data) + + return data_per_trial def _calculate_mean_and_margin_of_error(data: list[float], confidence_level: float) -> tuple[float, float]: @@ -317,11 +328,18 @@ def _calculate_mean_and_margin_of_error(data: list[float], confidence_level: flo ) if np.isfinite(mean) and np.isfinite(raw_interval).all(): return (float(mean), float(mean - raw_interval[0])) + return np.nan, np.nan -def _format_confidence_interval(mean: float, margin_of_error: float) -> str: - formatted_confidence_interval = f"{mean:.2e} \u00b1 {margin_of_error:.2e}" +def _format_confidence_interval(mean: float, margin_of_error: float, fmt: str) -> str: + try: + formatted_confidence_interval = f"{mean:{fmt}} \u00b1 {margin_of_error:{fmt}}" + except ValueError: + LOGGER.warning(f"Invalid format string '{fmt}', defaulting to scientific notation") + formatted_confidence_interval = f"{mean:.2e} \u00b1 {margin_of_error:.2e}" + if any(np.isnan([mean, margin_of_error])): formatted_confidence_interval += " (diverged?)" + return formatted_confidence_interval diff --git a/decent_bench/networks.py b/decent_bench/networks.py index 1a972c2..b9b1ee6 100644 --- a/decent_bench/networks.py +++ b/decent_bench/networks.py @@ -195,7 +195,9 @@ def create_distributed_network(problem: BenchmarkProblem) -> P2PNetwork: raise NotImplementedError("Support for multi-graphs has not been implemented yet") if not nx.is_connected(problem.network_structure): raise NotImplementedError("Support for disconnected graphs has not been implemented yet") - agents = [Agent(i, problem.costs[i], problem.agent_activations[i]) for i in range(n_agents)] + agents = [ + Agent(i, problem.costs[i], problem.agent_activations[i], problem.agent_history_period) for i in range(n_agents) + ] agent_node_map = {node: agents[i] for i, node in enumerate(problem.network_structure.nodes())} graph = nx.relabel_nodes(problem.network_structure, agent_node_map) return P2PNetwork( From be73521a39a28a4e65517d20ae7546f3a8358870 Mon Sep 17 00:00:00 2001 From: simpag Date: Wed, 17 Dec 2025 01:51:06 +0100 Subject: [PATCH 02/15] docs(Advanded): Add advanced developer guide --- docs/source/advanced.rst | 919 ++++++++++++++++++ .../api/decent_bench.metrics.metric_utils.rst | 4 +- docs/source/developer.rst | 5 + docs/source/index.rst | 1 + 4 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 docs/source/advanced.rst diff --git a/docs/source/advanced.rst b/docs/source/advanced.rst new file mode 100644 index 0000000..2b38e12 --- /dev/null +++ b/docs/source/advanced.rst @@ -0,0 +1,919 @@ +Advanced Developer Guide +======================== + +This guide covers the more advanced and unique architectural approaches taken in :doc:`Decent Bench `, +particularly focusing on the interoperability system that enables seamless framework-agnostic operations. + +.. contents:: Contents + :local: + :depth: 2 + +Runtime Array Unwrapping +------------------------ + +Overview +~~~~~~~~ + +One of the most unique features of :doc:`Decent Bench ` is its **runtime array unwrapping** mechanism. +The :class:`~decent_bench.utils.array.Array` class serves as a wrapper around framework-specific +array types (NumPy, PyTorch, TensorFlow, JAX, etc) that provides operator overloading and seamless +interoperability. To avoid the performance overhead of creating wrapper objects, the interoperability +system internally unwraps arrays at runtime using Python's :class:`~typing.TYPE_CHECKING` constant, while still +presenting the :class:`~decent_bench.utils.array.Array` type to users and type checkers. + +How It Works +~~~~~~~~~~~~ + +The key to this approach is the ``decent_bench.utils.interoperability._helpers._return_array`` +helper function: + +.. code-block:: python + + from typing import TYPE_CHECKING + + def _return_array(array: SupportedArrayTypes) -> Array: + """ + Wrap a framework-native array in an Array wrapper. + + This helper standardizes return types across interoperability functions, + returning the same framework-native object at runtime, while providing a + typed Array during static type checking. + """ + if not TYPE_CHECKING: + return array # Return native array at runtime + + return Array(array) # Only for type checkers + +**Static Type Checking (Development Time):** + +When type checkers like ``mypy`` or ``pyright`` analyze your code, ``TYPE_CHECKING`` is ``True``, +so they see the function returning :class:`~decent_bench.utils.array.Array` objects. This provides proper type hints and IDE support. + +**Runtime Execution:** + +When Python actually executes the code, ``TYPE_CHECKING`` is ``False``, so the function directly +returns the native framework array (:class:`numpy.ndarray`, :class:`torch.Tensor`, etc.) without creating an +additional wrapper object. This means: + +- **Zero overhead**: No wrapper objects are created at runtime +- **Native performance**: Operations execute at full framework speed +- **Transparent to users**: Users work with :class:`~decent_bench.utils.array.Array` objects via operator overloading and see consistent behavior + +Example +~~~~~~~ + +Consider this interoperability function: + +.. code-block:: python + + def stack(arrays: Sequence[Array], dim: int = 0) -> Array: + """Stack arrays along a new dimension.""" + # Extract native arrays from wrappers + # Will only be Array objects during type checking or + # if it is the first time any operation is performed on them + values = [arr.value if isinstance(arr, Array) else arr + for arr in arrays] + + if isinstance(values[0], np.ndarray): + result = np.stack(values, axis=dim) + return _return_array(result) # Returns np.ndarray at runtime! + # ... other frameworks + +Then when you write: + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + from decent_bench.utils.types import SupportedFrameworks, SupportedDevices + + # Users create arrays using interoperability functions + x = iop.randn((3,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + y = iop.zeros((3,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + + # Stack them together + result = iop.stack([x, y, x + y], dim=0) + +* **Type checker sees:** result is :class:`~decent_bench.utils.array.Array` +* **Runtime:** result is actually :class:`numpy.ndarray` (unwrapped for performance) +* **Users:** interact with it as an :class:`~decent_bench.utils.array.Array` **through operators** + +**Why This Matters:** + +1. **Type Safety**: Developers get full IDE support and type checking for :class:`~decent_bench.utils.array.Array` operations +2. **Performance**: Zero runtime overhead - internally uses native arrays without wrapper object creation +3. **Seamless Interoperability**: Users write framework-agnostic code using :class:`~decent_bench.utils.array.Array` objects and operators +4. **Consistency**: The same :class:`~decent_bench.utils.array.Array` interface works across NumPy, PyTorch, TensorFlow, and other supported frameworks + +Design Goals and Implementation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Design Goals:** + +- **Performance**: Eliminate overhead from wrapper objects while maintaining clean abstraction +- **Interoperability**: Provide a unified interface across NumPy, PyTorch, TensorFlow, etc. +- **Type Safety**: Enable full static type checking and IDE support +- **User Experience**: Users work with :class:`~decent_bench.utils.array.Array` objects; unwrapping is an internal optimization + +**How Users Interact:** + +Users should work with :class:`~decent_bench.utils.array.Array` objects and rely on: + +- **Array creation**: Use :func:`iop.randn() `, :func:`iop.zeros() `, etc. to create arrays +- **Operator overloading**: Use ``+``, ``-``, ``*``, ``/``, ``@``, etc. on :class:`~decent_bench.utils.array.Array` objects +- **Interoperability functions**: Use :func:`iop.sum() `, :func:`iop.mean() `, :func:`iop.transpose() `, etc. +- **Framework conversion**: Use :func:`iop.to_torch() `, :func:`iop.to_numpy() `, etc. when needed + +The fact that arrays are internally unwrapped at runtime is a performance optimization that +users don't need to think about - they simply work with :class:`~decent_bench.utils.array.Array` objects throughout their code. +Never directly instantiate :class:`~decent_bench.utils.array.Array` objects; **always use the interoperability functions**. + + +Array Class and Interoperability Package +----------------------------------------- + +How They Work Together +~~~~~~~~~~~~~~~~~~~~~~ + +The :class:`~decent_bench.utils.array.Array` class and the :mod:`~decent_bench.utils.interoperability` +package work in tandem to provide seamless cross-framework operations: + +1. **Array Class**: Provides operator overloading (``+``, ``-``, ``*``, ``@``, etc.) +2. **Interoperability Package**: Implements the actual framework-specific operations +3. **Runtime Unwrapping**: Ensures zero performance overhead + +**Complete Example:** + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + from decent_bench.utils.types import SupportedFrameworks, SupportedDevices + + # Create Array objects using interoperability functions + # This ensures frameworks and devices are correctly handled + x = iop.randn((10, 5), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + y = iop.ones_like(x) # Create an array of ones with same shape/framework/device as x + weight = iop.randn((5, 3), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + + # Option 1: Use operators (calls iop functions internally) + z = x + y # Calls iop.add(x, y) internally + z = x @ weight # Calls iop.matmul(x, weight) internally + z = z ** 2 # Calls iop.power(z, 2) internally + + # Option 2: Use interoperability functions directly + z = iop.add(x, y) + z = iop.matmul(x, weight) + mean = iop.mean(z) + norm = iop.norm(z) + +* Both approaches work identically +* Both are framework-agnostic +* Both benefit from runtime unwrapping + +**Operator Overloading Implementation:** + +The :class:`~decent_bench.utils.array.Array` class delegates all operators to interoperability functions: + +.. code-block:: python + + class Array: + def __add__(self, other): + return iop.add(self, other) # Delegates to interop + + def __matmul__(self, other): + return iop.matmul(self, other) # Delegates to interop + +This means users get clean syntax (``x + y``) while the interoperability package handles +framework detection and native operations in one unified system. + + +Interoperability System +----------------------- + +Architecture +~~~~~~~~~~~~ + +The interoperability system is designed with several layers: + +1. **Type Definitions** (``_imports_types.py``): Conditional imports and type aliases +2. **Helper Functions** (``_helpers.py``): Framework detection and conversion utilities +3. **Core Functions** (``_functions.py``): Array creation, conversion, and manipulation +4. **Operators** (``_operators.py``): Arithmetic and mathematical operations +5. **Extended Operations** (``_ext.py``): In-place operations, extension package meant for advanced use +6. **Decorators** (``_decorators.py``): Automatic type conversion for class methods + +Framework Detection +~~~~~~~~~~~~~~~~~~~ + +The system automatically detects which framework an array belongs to: + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + + framework, device = iop.framework_device_of_array(my_array) + # Returns: (SupportedFrameworks.NUMPY, SupportedDevices.CPU) + +This is used internally to route operations to the correct framework-specific implementation. + + +Implementing New Interoperability Functions +-------------------------------------------- + +If you need to add a new operation to the interoperability layer, follow this pattern: + +Step 1: Define the Function Signature +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create your function in ``decent_bench/utils/interoperability/_functions.py`` (for general operations) +or ``_operators.py`` (for arithmetic operations): + +.. code-block:: python + + def my_operation( + array: Array, # or Array | SupportedArrayTypes for arithmetic operators + parameter: int, + ) -> Array: + """ + Description of your operation. + + Args: + array (Array): Input array. + parameter (int): Description of parameter. + + Returns: + Result in the same framework type as the input. + + Raises: + TypeError: if the framework type is unsupported. + """ + +Step 2: Extract the Native Value +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Always extract the underlying native array first in case that the input array is not already unwrapped (happens on first use): + +.. code-block:: python + + def my_operation(array: Array, parameter: int) -> Array: + # Extract native array if wrapped + value = array.value if isinstance(array, Array) else array + +Step 3: Implement Framework-Specific Logic +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use ``isinstance`` checks to handle each framework: + +.. code-block:: python + + def my_operation(array: Array | SupportedArrayTypes, parameter: int) -> Array: + value = array.value if isinstance(array, Array) else array + + # NumPy implementation + if isinstance(value, np.ndarray | np.generic): + result = np.my_numpy_function(value, parameter) + return _return_array(result) + + # PyTorch implementation + if torch and isinstance(value, torch.Tensor): + result = torch.my_torch_function(value, parameter) + return _return_array(result) + + # TensorFlow implementation + if tf and isinstance(value, tf.Tensor): + result = tf.my_tf_function(value, parameter) + return _return_array(result) + + # JAX implementation + if jnp and isinstance(value, jnp.ndarray | jnp.generic): + result = jnp.my_jax_function(value, parameter) + return _return_array(result) + + raise TypeError(f"Unsupported framework type: {type(value)}") + +**Important Notes:** + +- Always check if the framework is imported before using it (``if torch and ...``) +- Use the type tuples from ``_imports_types.py``: ``_np_types``, ``_torch_types``, etc if multiple types are acceptable, see their definition for allowed types. +- Always return using ``_return_array()`` for the unwrapping mechanism to work +- Raise ``TypeError`` with a descriptive message for unsupported types + +Step 4: Handle Device Management +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For operations that create new arrays, use the framework and device parameter: + +.. code-block:: python + + def my_creation_function( + shape: tuple[int, ...], + framework: SupportedFrameworks, + device: SupportedDevices, + ) -> Array: + # Convert device literal to framework-specific representation + framework_device = _device_literal_to_framework_device(device, framework) + + if framework == SupportedFrameworks.TORCH: + result = torch.my_function(shape, device=framework_device) + return _return_array(result) + # ... other frameworks + +Step 5: Export Your Function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add your function to ``decent_bench/utils/interoperability/__init__.py``: + +.. code-block:: python + + from ._functions import ( + # ... existing imports + my_operation, + ) + + __all__ = [ + # ... existing exports + "my_operation", + ] + +Step 6: Add Tests +~~~~~~~~~~~~~~~~~ + +Create comprehensive tests in ``test/utils/test_interoperability.py``: + +.. code-block:: python + + @pytest.mark.parametrize( + "framework,device", + [ + ("numpy", "cpu"), + pytest.param("torch", "cpu", marks=pytest.mark.skipif( + not TORCH_AVAILABLE, reason="PyTorch not available" + )), + # ... other frameworks + ], + ) + def test_my_operation(framework, device): + arr = create_array([1.0, 2.0, 3.0], framework, device) + result = iop.my_operation(arr, parameter=2) + + expected = create_array([...], framework, device) + assert_arrays_equal(result, expected, framework) + + +Adding Support for New Frameworks +---------------------------------- + +If you want to extend :doc:`Decent Bench ` to support additional array/tensor frameworks beyond +the already supported ones, follow this guide. + +Overview +~~~~~~~~ + +Adding a new framework requires changes across multiple files in the interoperability system: + +1. Update type definitions and imports +2. Add framework literal to supported frameworks +3. Implement device handling +4. Update framework detection logic +5. Add conversion functions +6. Update all existing interoperability operations +7. Add comprehensive tests + +This is a significant undertaking but follows a consistent pattern throughout. + +Step 1: Update Type Definitions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Edit ``decent_bench/utils/interoperability/_imports_types.py``: + +.. code-block:: python + + # Add conditional import for your framework + myframework = None + with contextlib.suppress(ImportError, ModuleNotFoundError): + import myframework as _myframework + myframework = _myframework + + _myframework_types = ( + myframework.Tensor, ... if myframework else (float,), + ) + +Step 2: Add Framework Literal +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Edit ``decent_bench/utils/types.py`` to add your framework to the ``SupportedFrameworks`` enum: + +.. code-block:: python + + class SupportedFrameworks(Enum): + """Supported deep learning frameworks.""" + + NUMPY = "numpy" + TORCH = "torch" + TENSORFLOW = "tensorflow" + JAX = "jax" + MYFRAMEWORK = "myframework" # Add your framework + +Step 3: Implement Device Handling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Update ``decent_bench/utils/interoperability/_helpers.py`` to handle device conversion: + +.. code-block:: python + + def _device_literal_to_framework_device( + device: SupportedDevices, + framework: SupportedFrameworks + ) -> Any: + """Convert SupportedDevices literal to framework-specific device.""" + # ... existing frameworks ... + + if myframework and framework == SupportedFrameworks.MYFRAMEWORK: + # Implement framework-specific device handling + # Return the appropriate device representation + if device == SupportedDevices.CPU: + return myframework.device("cpu") + return myframework.device("gpu") + + raise ValueError(f"Unsupported framework: {framework}") + +Update the ``framework_device_of_array`` function: + +.. code-block:: python + + def framework_device_of_array(array: Array) -> tuple[SupportedFrameworks, SupportedDevices]: + """Determine the framework and device of the given Array.""" + value = array.value if isinstance(array, Array) else array + + # ... existing framework checks ... + + if myframework and isinstance(value, _myframework_types): + device_str = value.device # Adjust based on framework API + device_type = ( + SupportedDevices.GPU if "gpu" in device_str + else SupportedDevices.CPU + ) + return SupportedFrameworks.MYFRAMEWORK, device_type + + raise TypeError(f"Unsupported framework type: {type(value)}") + +Step 4: Add Conversion Functions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add a conversion function in ``decent_bench/utils/interoperability/_functions.py``: + +.. code-block:: python + + # Define type tuple for isinstance checks + if TYPE_CHECKING: + ... # existing imports + from myframework import Tensor as MyFrameworkTensor + + def to_myframework( + array: Array | SupportedArrayTypes, + device: SupportedDevices + ) -> MyFrameworkTensor: + """ + Convert input array to a MyFramework tensor. + + Args: + array (Array | SupportedArrayTypes): Input Array + device (SupportedDevices): Device of the input array. + + Returns: + MyFrameworkTensor: Converted tensor. + + Raises: + ImportError: if MyFramework is not installed. + """ + if not myframework: + raise ImportError("MyFramework is not installed.") + + value = array.value if isinstance(array, Array) else array + framework_device = _device_literal_to_framework_device( + device, SupportedFrameworks.MYFRAMEWORK + ) + + # Handle conversion from each supported framework + if isinstance(value, myframework.Tensor): + return cast("MyFrameworkTensor", value.to(framework_device)) + if isinstance(value, np.ndarray | np.generic): + return cast("MyFrameworkTensor", + myframework.from_numpy(value).to(framework_device)) + if torch and isinstance(value, torch.Tensor): + return cast("MyFrameworkTensor", + myframework.from_numpy(value.cpu().numpy()).to(framework_device)) + # ... handle other frameworks ... + + # Try a direct conversion to check if possible + return cast("MyFrameworkTensor", + myframework.tensor(value, device=framework_device)) + +Update the ``to_array`` and all other ``to_"framework"`` functions to include your framework: + +.. code-block:: python + + def to_array( + array: Array | SupportedArrayTypes, + framework: SupportedFrameworks, + device: SupportedDevices, + ) -> Array: + """Convert an array to the specified framework type.""" + # ... existing frameworks ... + + if myframework and framework == SupportedFrameworks.MYFRAMEWORK: + return _return_array(to_myframework(array, device)) + + raise TypeError(f"Unsupported framework type: {framework}") + +Step 5: Update All Interoperability Operations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Every function in ``_operators.py`` and ``_functions.py`` needs to handle your framework. + +Example for an operator in ``_operators.py``: + +.. code-block:: python + + def add(array1: Array | SupportedArrayTypes, + array2: Array | SupportedArrayTypes) -> Array: + """Element-wise addition of two arrays.""" + value1 = array1.value if isinstance(array1, Array) else array1 + value2 = array2.value if isinstance(array2, Array) else array2 + + # ... existing frameworks ... + + if myframework and isinstance(value1, _myframework_types): + return _return_array(myframework.add(value1, value2)) + + raise TypeError(f"Unsupported framework type: {type(value1)}") + +Example for a function in ``_functions.py``: + +.. code-block:: python + + def sum( + array: Array, + dim: int | tuple[int, ...] | None = None, + keepdims: bool = False, + ) -> Array: + """Sum elements of an array.""" + value = array.value if isinstance(array, Array) else array + + # ... existing frameworks ... + + if myframework and isinstance(value, _myframework_types): + return _return_array(myframework.sum(value, axis=dim, keepdims=keepdims)) + + raise TypeError(f"Unsupported framework type: {type(value)}") + +You'll need to update every operation: ``add``, ``sub``, ``mul``, ``div``, ``matmul``, ``power``, +``sqrt``, ``mean``, ``max``, ``min``, ``transpose``, ``reshape``, ``zeros``, ``ones``, ``randn``, etc. + +Step 6: Update Decorators +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Update ``_decorators.py`` to handle conversion in the ``autodecorate_cost_method``: + +.. code-block:: python + + def _get_converter(framework: SupportedFrameworks) -> Callable: + if framework == SupportedFrameworks.NUMPY: + return to_numpy + if framework == SupportedFrameworks.TORCH: + return to_torch + if framework == SupportedFrameworks.TENSORFLOW: + return to_tensorflow + if framework == SupportedFrameworks.JAX: + return to_jax + if framework == SupportedFrameworks.MYFRAMEWORK: + return to_myframework + + raise ValueError(f"Unsupported framework: {framework}") + +Step 7: Export New Functions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add exports to ``decent_bench/utils/interoperability/__init__.py``: + +.. code-block:: python + + from ._functions import ( + # ... existing imports ... + to_myframework, + ) + + __all__ = [ + # ... existing exports ... + "to_myframework", + ] + +Step 8: Add Comprehensive Tests +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Update tests and add test for `to_myframework` in ``test/utils/test_interoperability.py``: + +.. code-block:: python + + # Add availability check + try: + import myframework + MYFRAMEWORK_AVAILABLE = True + MYFRAMEWORK_GPU_AVAILABLE = myframework.cuda.is_available() + except (ImportError, ModuleNotFoundError): + MYFRAMEWORK_AVAILABLE = False + MYFRAMEWORK_GPU_AVAILABLE = False + + # Add to parameterized tests + @pytest.mark.parametrize( + "framework,device", + [ + ("numpy", "cpu"), + # ... existing frameworks ... + pytest.param("myframework", "cpu", marks=pytest.mark.skipif( + not MYFRAMEWORK_AVAILABLE, reason="MyFramework not available" + )), + pytest.param("myframework", "gpu", marks=pytest.mark.skipif( + not MYFRAMEWORK_GPU_AVAILABLE, reason="MyFramework GPU not available" + )), + ], + ) + def test_to_my_framework(framework, device): + pass + +Step 9: Update Documentation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Update the documentation to mention the new framework: + +- Add to the list of supported frameworks in ``docs/source/user.rst`` +- Update any framework-specific examples +- Add installation instructions if needed + +Checklist +~~~~~~~~~ + +Use this checklist when adding a new framework: + +.. code-block:: text + + ☐ Add conditional import to _imports_types.py + ☐ Define type tuple (_myframework_types) + ☐ Add to SupportedFrameworks enum in types.py + ☐ Implement _device_literal_to_framework_device + ☐ Implement framework_device_of_array detection + ☐ Implement to_myframework conversion + ☐ Update to_array function + ☐ Update to_"framework" for every "framework" to handle your framework + ☐ Update all operators: add, sub, mul, div, matmul, power, etc. + ☐ Update all in-place operators: iadd, isub, imul, idiv, ipow + ☐ Update all functions: sum, mean, min, max, argmax, argmin, etc. + ☐ Update creation functions: zeros, ones, eye, randn, etc. + ☐ Update utility functions: shape, reshape, transpose, stack, etc. + ☐ Update _get_converter in _decorators.py + ☐ Export to_myframework in __init__.py + ☐ Add framework availability checks in tests + ☐ Add to parameterized test fixtures + ☐ Test all operations with new framework + ☐ Test CPU and GPU devices (if applicable) + ☐ Update documentation + ☐ Add installation instructions + +Common Considerations +~~~~~~~~~~~~~~~~~~~~~ + +**API Differences:** + +Different frameworks have different APIs. Pay attention to: + +- Parameter names (``axis`` vs ``dim`` vs ``dimension``) +- Return types (some frameworks return scalars, others return 0-d arrays) +- Indexing behavior +- Broadcasting rules +- Gradient computation (some frameworks track gradients by default) + +**Performance:** + +- Some frameworks may not support certain operations efficiently +- Consider framework-specific optimizations +- Be aware of memory layout differences (row-major vs column-major) + +**Device Management:** + +- Not all frameworks support GPU computation +- Device transfer may have different APIs +- Some frameworks use different GPU backends (CUDA, ROCm, Metal, etc.) + +**Type System:** + +- Be careful with dtype conversions +- Some frameworks have more restrictive type systems +- Handle scalar vs array returns consistently + +**Main Goals:** + +- Mimic numpy behavior as closely as possible +- Maintain consistent behavior across frameworks +- Ensure performance is acceptable +- Provide clear error messages for unsupported operations + + +Advanced Decorator: autodecorate_cost_method +--------------------------------------------- + +Purpose +~~~~~~~ + +The :func:`~decent_bench.utils.interoperability.autodecorate_cost_method` decorator is a specialized +decorator that automatically handles type conversion for :class:`~decent_bench.costs.Cost` subclass methods. +It enables users to implement cost functions in their preferred framework while the decorator handles +conversion automatically. + +How It Works +~~~~~~~~~~~~ + +The decorator performs three key operations: + +1. **Unwraps Input Arrays**: Converts :class:`~decent_bench.utils.array.Array` arguments to the cost's native framework type +2. **Calls the Method**: Executes the user's framework-specific implementation +3. **Wraps Output**: Converts the return value back to :class:`~decent_bench.utils.array.Array` if the superclass expects it (still using runtime unwrapping) + +Usage Pattern +~~~~~~~~~~~~~ + +When implementing a custom cost function: + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + import numpy as np + from numpy.typing import NDArray + from decent_bench.costs import Cost + + class MyCustomCost(Cost): + + @iop.autodecorate_cost_method(Cost.function) + def function(self, x: NDArray[float]) -> float: + # Implement using NumPy + # Decorator handles Array -> NDArray conversion + return float(np.sum(x ** 2)) + + @iop.autodecorate_cost_method(Cost.gradient) + def gradient(self, x: NDArray[float]) -> NDArray[float]: + # Implement using NumPy + # Decorator handles Array -> NDArray and NDArray -> Array conversion + return 2 * x + +**Key Points:** + +- The first argument **must** be named ``x`` (used to determine target framework) +- Use the framework-specific type hints (``NDArray``, ``torch.Tensor``, etc.) +- The decorator matches the superclass method's return type annotation (make sure to specify the correct superclass method you are decorating) +- Warnings are emitted if input arrays have mismatched frameworks + +Framework Mismatch Warnings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If an input array's framework differs from the cost's framework, a warning is logged: + +.. code-block:: python + + # Cost is configured for PyTorch + my_cost = MyCustomCost(framework=SupportedFrameworks.TORCH, ...) + + # But we pass a NumPy array + result = my_cost.function(numpy_array) + # WARNING: Converting array from framework numpy to torch in method function. + # This may lead to unexpected behavior or performance issues. + + +Best Practices +-------------- + +Performance Considerations +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Use Array Objects**: Work with :class:`~decent_bench.utils.array.Array` objects and leverage operator overloading +2. **Avoid Unnecessary Conversions**: Keep arrays in their framework; only convert when needed +3. **Leverage Runtime Unwrapping**: Trust that the system handles performance internally + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + from decent_bench.utils.types import SupportedFrameworks, SupportedDevices + + # Good: Create arrays with iop and use operators + x = iop.randn((100,), SupportedFrameworks.TORCH, SupportedDevices.GPU) + weight = iop.ones_like(x) + matrix = iop.eye((100,), SupportedFrameworks.TORCH, SupportedDevices.GPU) + + for i in range(1000): + x = x + weight # Efficient: uses runtime unwrapping + x = x @ matrix # No wrapper overhead + + # Also good: Use interoperability functions + for i in range(1000): + x = iop.add(x, weight) + x = iop.matmul(x, matrix) + +* **Avoid:** Manually extracting .value defeats the abstraction +* **Users:** Should not access ``array.value`` directly + +Working with Array Objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Recommended Usage:** + +.. code-block:: python + + import decent_bench.utils.interoperability as iop + from decent_bench.utils.types import SupportedFrameworks, SupportedDevices + + # Create Array objects using interoperability functions + x = iop.randn((100, 50), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + weight = iop.randn((50, 10), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + bias = iop.zeros((10,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) + + # or use ones_like, zeros_like etc + zero_weight = iop.zeros_like(weight) + one_bias = iop.ones_like(bias) + + # Use operators for arithmetic + result = (x + 1) * 2 / 3 + result = x @ weight + bias + + # Use interoperability functions for operations + mean_val = iop.mean(x) + std_val = iop.sqrt(iop.mean((x - mean_val) ** 2)) + normalized = (x - mean_val) / std_val + + # Convert frameworks when needed + torch_version = iop.to_torch(x, SupportedDevices.GPU) + +**What to Avoid:** + +.. code-block:: python + + import numpy as np + from decent_bench.utils.array import Array + + # Don't create Array objects directly + x = Array(np.array([1, 2, 3])) + + # Don't manually extract .value in user code + native_array = x.value # Defeats the abstraction + + # Don't bypass the Array interface + result = np.add(x.value, y.value) # Use x + y instead + + # The Array class is meant to be your interface + # The runtime unwrapping is an internal optimization + # Always create arrays through iop functions + +Testing Framework-Agnostic Code +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use parameterized tests to verify all frameworks: + +.. code-block:: python + + import pytest + + @pytest.mark.parametrize("framework", [SupportedFrameworks.NUMPY, ...]) + def test_my_algorithm(framework): + if framework == SupportedFrameworks.TORCH and not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + # Create arrays in target framework + x = create_test_array(framework) + + # Test your algorithm + result = my_algorithm(x) + + # Verify results + assert_correct_framework(result, framework) + + +Common Pitfalls +~~~~~~~~~~~~~~~ + +**For Users:** + +1. **Don't create Array objects directly**: Use :meth:`iop.randn() `, :meth:`iop.zeros() `, etc., not ``Array(...)`` +2. **Don't access .value directly**: Use the :class:`~decent_bench.utils.array.Array` interface and operators instead +3. **Don't bypass interoperability**: Use ``x + y`` or :meth:`iop.add() `, not ``np.add()`` +4. **Trust the abstraction**: Runtime unwrapping is automatic; you don't need to manage it + +**For Developers Extending the System:** + +1. **Incorrect isinstance checks**: Use the type tuples from ``_imports_types.py`` +2. **Missing framework availability checks**: Always check ``if torch and ...`` +3. **Not extracting .value in interop functions**: Interop functions must extract the native array +4. **Forgetting _return_array()**: Always use ``_return_array()`` for consistent unwrapping and type checking + + +Further Reading +--------------- + +- :doc:`API Reference ` +- :doc:`User Guide ` for basic usage +- `Type Checking Documentation `_ diff --git a/docs/source/api/decent_bench.metrics.metric_utils.rst b/docs/source/api/decent_bench.metrics.metric_utils.rst index 6bd20f8..80acd8b 100644 --- a/docs/source/api/decent_bench.metrics.metric_utils.rst +++ b/docs/source/api/decent_bench.metrics.metric_utils.rst @@ -4,4 +4,6 @@ decent\_bench.metrics.metric\_utils .. automodule:: decent_bench.metrics.metric_utils :members: :show-inheritance: - :undoc-members: \ No newline at end of file + :undoc-members: + :exclude-members: + MetricProgressBar, \ No newline at end of file diff --git a/docs/source/developer.rst b/docs/source/developer.rst index dd86af4..21609ce 100644 --- a/docs/source/developer.rst +++ b/docs/source/developer.rst @@ -158,3 +158,8 @@ Releases 2. Merge the change into main with commit message :code:`meta: Bump version to .. (#)`. 3. Create a new release on GitHub. 4. Publish to PyPI using :code:`hatch clean && hatch build && hatch publish`. + +Next Steps +---------- +Continue to the :doc:`Advanced Developer Guide ` for more in-depth information on the architecture and design +decisions behind decent-bench. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 67056eb..819785d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,4 +16,5 @@ providing realistic algorithm comparisons in a user-friendly and highly configur user API Reference developer + advanced author From 2261f247282ed7902d23bf1d6052d180f898cb19 Mon Sep 17 00:00:00 2001 From: simpag Date: Wed, 17 Dec 2025 01:54:37 +0100 Subject: [PATCH 03/15] test(Agent): Test inplace operators and some user docs update --- docs/source/user.rst | 1 + test/test_agents.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 test/test_agents.py diff --git a/docs/source/user.rst b/docs/source/user.rst index d0e04b9..1af691a 100644 --- a/docs/source/user.rst +++ b/docs/source/user.rst @@ -89,6 +89,7 @@ Configure communication constraints and other settings for out-of-the-box regres problem = benchmark_problem.create_regression_problem( LinearRegressionCost, n_agents=100, + agent_history_period=10, # Record metrics every 10 iterations n_neighbors_per_agent=3, asynchrony=True, compression=True, diff --git a/test/test_agents.py b/test/test_agents.py new file mode 100644 index 0000000..4e3b1d3 --- /dev/null +++ b/test/test_agents.py @@ -0,0 +1,172 @@ +import numpy as np +import pytest + +import decent_bench.utils.interoperability as iop +from decent_bench.agents import Agent +from decent_bench.costs import LinearRegressionCost +from decent_bench.utils.types import SupportedDevices, SupportedFrameworks + +try: + import torch + + TORCH_AVAILABLE = True + TORCH_CUDA_AVAILABLE = torch.cuda.is_available() +except ModuleNotFoundError: + TORCH_AVAILABLE = False + TORCH_CUDA_AVAILABLE = False + +try: + import tensorflow as tf + + TF_AVAILABLE = True + TF_GPU_AVAILABLE = len(tf.config.list_physical_devices("GPU")) > 0 +except (ImportError, ModuleNotFoundError): + TF_AVAILABLE = False + TF_GPU_AVAILABLE = False + +try: + import jax + import jax.numpy as jnp + + JAX_AVAILABLE = True + JAX_GPU_AVAILABLE = len(jax.devices("gpu")) > 0 +except (ImportError, ModuleNotFoundError): + JAX_AVAILABLE = False + JAX_GPU_AVAILABLE = False +except RuntimeError: + # JAX raises RuntimeError if no GPU is available when querying devices + JAX_GPU_AVAILABLE = False + + +@pytest.mark.parametrize( + "framework,device", + [ + pytest.param( + SupportedFrameworks.NUMPY, + SupportedDevices.CPU, + ), + pytest.param( + SupportedFrameworks.TORCH, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not TORCH_AVAILABLE, reason="PyTorch not available"), + ), + pytest.param( + SupportedFrameworks.TORCH, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not TORCH_CUDA_AVAILABLE, reason="PyTorch CUDA not available"), + ), + pytest.param( + SupportedFrameworks.TENSORFLOW, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not TF_AVAILABLE, reason="TensorFlow not available"), + ), + pytest.param( + SupportedFrameworks.TENSORFLOW, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not TF_GPU_AVAILABLE, reason="TensorFlow GPU not available"), + ), + pytest.param( + SupportedFrameworks.JAX, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not JAX_AVAILABLE, reason="JAX not available"), + ), + pytest.param( + SupportedFrameworks.JAX, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not JAX_GPU_AVAILABLE, reason="JAX GPU not available"), + ), + ], +) +def test_in_place_operations_history(framework: SupportedFrameworks, device: SupportedDevices): + """Test that in-place operations on agent.x properly update the history.""" + agent = Agent(0, LinearRegressionCost(np.array([[1.0, 1.0, 1.0]]), np.array([1.0])), None, history_period=1) # type: ignore # noqa: PGH003 + + initial = iop.zeros((3,), framework=framework, device=device) + agent.initialize(x=initial) + + def assert_state(expected_x, expected_history): + """Helper to verify agent state and history.""" + np.testing.assert_array_almost_equal( + iop.to_numpy(agent.x), + expected_x, + decimal=5, + err_msg=f"Expected x: {expected_x}, but got: {iop.to_numpy(agent.x)}", + ) + assert len(agent._x_history) == len(expected_history), ( + f"Expected history length: {len(expected_history)}, but got: {len(agent._x_history)}" + ) + for i, expected in enumerate(expected_history): + np.testing.assert_array_almost_equal( + iop.to_numpy(agent._x_history[i]), + expected, + decimal=5, + err_msg=f"At history index {i}, expected: {expected}, but got: {iop.to_numpy(agent._x_history)}", + ) + + # Initial state + assert_state( + np.array([0.0, 0.0, 0.0]), + [ + np.array([0.0, 0.0, 0.0]), + ], + ) + + # Test += operator + agent.x += 1.0 + assert_state( + np.array([1.0, 1.0, 1.0]), + [ + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + ], + ) + + # Test *= operator + agent.x *= 2.0 + assert_state( + np.array([2.0, 2.0, 2.0]), + [ + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + np.array([2.0, 2.0, 2.0]), + ], + ) + + # Test **= operator + agent.x **= 2.0 + assert_state( + np.array([4.0, 4.0, 4.0]), + [ + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + np.array([2.0, 2.0, 2.0]), + np.array([4.0, 4.0, 4.0]), + ], + ) + + # Test /= operator + agent.x /= 2.0 + assert_state( + np.array([2.0, 2.0, 2.0]), + [ + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + np.array([2.0, 2.0, 2.0]), + np.array([4.0, 4.0, 4.0]), + np.array([2.0, 2.0, 2.0]), + ], + ) + + # Test -= operator + agent.x -= 1.0 + assert_state( + np.array([1.0, 1.0, 1.0]), + [ + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + np.array([2.0, 2.0, 2.0]), + np.array([4.0, 4.0, 4.0]), + np.array([2.0, 2.0, 2.0]), + np.array([1.0, 1.0, 1.0]), + ], + ) From bb25c0c38165a6d200642b2fd8e2d4b01615c61b Mon Sep 17 00:00:00 2001 From: simpag Date: Wed, 17 Dec 2025 02:04:38 +0100 Subject: [PATCH 04/15] ref(Docs): Move advanced dev guide to new PR --- docs/source/advanced.rst | 919 -------------------------------------- docs/source/developer.rst | 7 +- docs/source/index.rst | 1 - 3 files changed, 1 insertion(+), 926 deletions(-) delete mode 100644 docs/source/advanced.rst diff --git a/docs/source/advanced.rst b/docs/source/advanced.rst deleted file mode 100644 index 2b38e12..0000000 --- a/docs/source/advanced.rst +++ /dev/null @@ -1,919 +0,0 @@ -Advanced Developer Guide -======================== - -This guide covers the more advanced and unique architectural approaches taken in :doc:`Decent Bench `, -particularly focusing on the interoperability system that enables seamless framework-agnostic operations. - -.. contents:: Contents - :local: - :depth: 2 - -Runtime Array Unwrapping ------------------------- - -Overview -~~~~~~~~ - -One of the most unique features of :doc:`Decent Bench ` is its **runtime array unwrapping** mechanism. -The :class:`~decent_bench.utils.array.Array` class serves as a wrapper around framework-specific -array types (NumPy, PyTorch, TensorFlow, JAX, etc) that provides operator overloading and seamless -interoperability. To avoid the performance overhead of creating wrapper objects, the interoperability -system internally unwraps arrays at runtime using Python's :class:`~typing.TYPE_CHECKING` constant, while still -presenting the :class:`~decent_bench.utils.array.Array` type to users and type checkers. - -How It Works -~~~~~~~~~~~~ - -The key to this approach is the ``decent_bench.utils.interoperability._helpers._return_array`` -helper function: - -.. code-block:: python - - from typing import TYPE_CHECKING - - def _return_array(array: SupportedArrayTypes) -> Array: - """ - Wrap a framework-native array in an Array wrapper. - - This helper standardizes return types across interoperability functions, - returning the same framework-native object at runtime, while providing a - typed Array during static type checking. - """ - if not TYPE_CHECKING: - return array # Return native array at runtime - - return Array(array) # Only for type checkers - -**Static Type Checking (Development Time):** - -When type checkers like ``mypy`` or ``pyright`` analyze your code, ``TYPE_CHECKING`` is ``True``, -so they see the function returning :class:`~decent_bench.utils.array.Array` objects. This provides proper type hints and IDE support. - -**Runtime Execution:** - -When Python actually executes the code, ``TYPE_CHECKING`` is ``False``, so the function directly -returns the native framework array (:class:`numpy.ndarray`, :class:`torch.Tensor`, etc.) without creating an -additional wrapper object. This means: - -- **Zero overhead**: No wrapper objects are created at runtime -- **Native performance**: Operations execute at full framework speed -- **Transparent to users**: Users work with :class:`~decent_bench.utils.array.Array` objects via operator overloading and see consistent behavior - -Example -~~~~~~~ - -Consider this interoperability function: - -.. code-block:: python - - def stack(arrays: Sequence[Array], dim: int = 0) -> Array: - """Stack arrays along a new dimension.""" - # Extract native arrays from wrappers - # Will only be Array objects during type checking or - # if it is the first time any operation is performed on them - values = [arr.value if isinstance(arr, Array) else arr - for arr in arrays] - - if isinstance(values[0], np.ndarray): - result = np.stack(values, axis=dim) - return _return_array(result) # Returns np.ndarray at runtime! - # ... other frameworks - -Then when you write: - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - from decent_bench.utils.types import SupportedFrameworks, SupportedDevices - - # Users create arrays using interoperability functions - x = iop.randn((3,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - y = iop.zeros((3,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - - # Stack them together - result = iop.stack([x, y, x + y], dim=0) - -* **Type checker sees:** result is :class:`~decent_bench.utils.array.Array` -* **Runtime:** result is actually :class:`numpy.ndarray` (unwrapped for performance) -* **Users:** interact with it as an :class:`~decent_bench.utils.array.Array` **through operators** - -**Why This Matters:** - -1. **Type Safety**: Developers get full IDE support and type checking for :class:`~decent_bench.utils.array.Array` operations -2. **Performance**: Zero runtime overhead - internally uses native arrays without wrapper object creation -3. **Seamless Interoperability**: Users write framework-agnostic code using :class:`~decent_bench.utils.array.Array` objects and operators -4. **Consistency**: The same :class:`~decent_bench.utils.array.Array` interface works across NumPy, PyTorch, TensorFlow, and other supported frameworks - -Design Goals and Implementation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -**Design Goals:** - -- **Performance**: Eliminate overhead from wrapper objects while maintaining clean abstraction -- **Interoperability**: Provide a unified interface across NumPy, PyTorch, TensorFlow, etc. -- **Type Safety**: Enable full static type checking and IDE support -- **User Experience**: Users work with :class:`~decent_bench.utils.array.Array` objects; unwrapping is an internal optimization - -**How Users Interact:** - -Users should work with :class:`~decent_bench.utils.array.Array` objects and rely on: - -- **Array creation**: Use :func:`iop.randn() `, :func:`iop.zeros() `, etc. to create arrays -- **Operator overloading**: Use ``+``, ``-``, ``*``, ``/``, ``@``, etc. on :class:`~decent_bench.utils.array.Array` objects -- **Interoperability functions**: Use :func:`iop.sum() `, :func:`iop.mean() `, :func:`iop.transpose() `, etc. -- **Framework conversion**: Use :func:`iop.to_torch() `, :func:`iop.to_numpy() `, etc. when needed - -The fact that arrays are internally unwrapped at runtime is a performance optimization that -users don't need to think about - they simply work with :class:`~decent_bench.utils.array.Array` objects throughout their code. -Never directly instantiate :class:`~decent_bench.utils.array.Array` objects; **always use the interoperability functions**. - - -Array Class and Interoperability Package ------------------------------------------ - -How They Work Together -~~~~~~~~~~~~~~~~~~~~~~ - -The :class:`~decent_bench.utils.array.Array` class and the :mod:`~decent_bench.utils.interoperability` -package work in tandem to provide seamless cross-framework operations: - -1. **Array Class**: Provides operator overloading (``+``, ``-``, ``*``, ``@``, etc.) -2. **Interoperability Package**: Implements the actual framework-specific operations -3. **Runtime Unwrapping**: Ensures zero performance overhead - -**Complete Example:** - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - from decent_bench.utils.types import SupportedFrameworks, SupportedDevices - - # Create Array objects using interoperability functions - # This ensures frameworks and devices are correctly handled - x = iop.randn((10, 5), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - y = iop.ones_like(x) # Create an array of ones with same shape/framework/device as x - weight = iop.randn((5, 3), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - - # Option 1: Use operators (calls iop functions internally) - z = x + y # Calls iop.add(x, y) internally - z = x @ weight # Calls iop.matmul(x, weight) internally - z = z ** 2 # Calls iop.power(z, 2) internally - - # Option 2: Use interoperability functions directly - z = iop.add(x, y) - z = iop.matmul(x, weight) - mean = iop.mean(z) - norm = iop.norm(z) - -* Both approaches work identically -* Both are framework-agnostic -* Both benefit from runtime unwrapping - -**Operator Overloading Implementation:** - -The :class:`~decent_bench.utils.array.Array` class delegates all operators to interoperability functions: - -.. code-block:: python - - class Array: - def __add__(self, other): - return iop.add(self, other) # Delegates to interop - - def __matmul__(self, other): - return iop.matmul(self, other) # Delegates to interop - -This means users get clean syntax (``x + y``) while the interoperability package handles -framework detection and native operations in one unified system. - - -Interoperability System ------------------------ - -Architecture -~~~~~~~~~~~~ - -The interoperability system is designed with several layers: - -1. **Type Definitions** (``_imports_types.py``): Conditional imports and type aliases -2. **Helper Functions** (``_helpers.py``): Framework detection and conversion utilities -3. **Core Functions** (``_functions.py``): Array creation, conversion, and manipulation -4. **Operators** (``_operators.py``): Arithmetic and mathematical operations -5. **Extended Operations** (``_ext.py``): In-place operations, extension package meant for advanced use -6. **Decorators** (``_decorators.py``): Automatic type conversion for class methods - -Framework Detection -~~~~~~~~~~~~~~~~~~~ - -The system automatically detects which framework an array belongs to: - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - - framework, device = iop.framework_device_of_array(my_array) - # Returns: (SupportedFrameworks.NUMPY, SupportedDevices.CPU) - -This is used internally to route operations to the correct framework-specific implementation. - - -Implementing New Interoperability Functions --------------------------------------------- - -If you need to add a new operation to the interoperability layer, follow this pattern: - -Step 1: Define the Function Signature -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Create your function in ``decent_bench/utils/interoperability/_functions.py`` (for general operations) -or ``_operators.py`` (for arithmetic operations): - -.. code-block:: python - - def my_operation( - array: Array, # or Array | SupportedArrayTypes for arithmetic operators - parameter: int, - ) -> Array: - """ - Description of your operation. - - Args: - array (Array): Input array. - parameter (int): Description of parameter. - - Returns: - Result in the same framework type as the input. - - Raises: - TypeError: if the framework type is unsupported. - """ - -Step 2: Extract the Native Value -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Always extract the underlying native array first in case that the input array is not already unwrapped (happens on first use): - -.. code-block:: python - - def my_operation(array: Array, parameter: int) -> Array: - # Extract native array if wrapped - value = array.value if isinstance(array, Array) else array - -Step 3: Implement Framework-Specific Logic -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Use ``isinstance`` checks to handle each framework: - -.. code-block:: python - - def my_operation(array: Array | SupportedArrayTypes, parameter: int) -> Array: - value = array.value if isinstance(array, Array) else array - - # NumPy implementation - if isinstance(value, np.ndarray | np.generic): - result = np.my_numpy_function(value, parameter) - return _return_array(result) - - # PyTorch implementation - if torch and isinstance(value, torch.Tensor): - result = torch.my_torch_function(value, parameter) - return _return_array(result) - - # TensorFlow implementation - if tf and isinstance(value, tf.Tensor): - result = tf.my_tf_function(value, parameter) - return _return_array(result) - - # JAX implementation - if jnp and isinstance(value, jnp.ndarray | jnp.generic): - result = jnp.my_jax_function(value, parameter) - return _return_array(result) - - raise TypeError(f"Unsupported framework type: {type(value)}") - -**Important Notes:** - -- Always check if the framework is imported before using it (``if torch and ...``) -- Use the type tuples from ``_imports_types.py``: ``_np_types``, ``_torch_types``, etc if multiple types are acceptable, see their definition for allowed types. -- Always return using ``_return_array()`` for the unwrapping mechanism to work -- Raise ``TypeError`` with a descriptive message for unsupported types - -Step 4: Handle Device Management -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For operations that create new arrays, use the framework and device parameter: - -.. code-block:: python - - def my_creation_function( - shape: tuple[int, ...], - framework: SupportedFrameworks, - device: SupportedDevices, - ) -> Array: - # Convert device literal to framework-specific representation - framework_device = _device_literal_to_framework_device(device, framework) - - if framework == SupportedFrameworks.TORCH: - result = torch.my_function(shape, device=framework_device) - return _return_array(result) - # ... other frameworks - -Step 5: Export Your Function -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Add your function to ``decent_bench/utils/interoperability/__init__.py``: - -.. code-block:: python - - from ._functions import ( - # ... existing imports - my_operation, - ) - - __all__ = [ - # ... existing exports - "my_operation", - ] - -Step 6: Add Tests -~~~~~~~~~~~~~~~~~ - -Create comprehensive tests in ``test/utils/test_interoperability.py``: - -.. code-block:: python - - @pytest.mark.parametrize( - "framework,device", - [ - ("numpy", "cpu"), - pytest.param("torch", "cpu", marks=pytest.mark.skipif( - not TORCH_AVAILABLE, reason="PyTorch not available" - )), - # ... other frameworks - ], - ) - def test_my_operation(framework, device): - arr = create_array([1.0, 2.0, 3.0], framework, device) - result = iop.my_operation(arr, parameter=2) - - expected = create_array([...], framework, device) - assert_arrays_equal(result, expected, framework) - - -Adding Support for New Frameworks ----------------------------------- - -If you want to extend :doc:`Decent Bench ` to support additional array/tensor frameworks beyond -the already supported ones, follow this guide. - -Overview -~~~~~~~~ - -Adding a new framework requires changes across multiple files in the interoperability system: - -1. Update type definitions and imports -2. Add framework literal to supported frameworks -3. Implement device handling -4. Update framework detection logic -5. Add conversion functions -6. Update all existing interoperability operations -7. Add comprehensive tests - -This is a significant undertaking but follows a consistent pattern throughout. - -Step 1: Update Type Definitions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Edit ``decent_bench/utils/interoperability/_imports_types.py``: - -.. code-block:: python - - # Add conditional import for your framework - myframework = None - with contextlib.suppress(ImportError, ModuleNotFoundError): - import myframework as _myframework - myframework = _myframework - - _myframework_types = ( - myframework.Tensor, ... if myframework else (float,), - ) - -Step 2: Add Framework Literal -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Edit ``decent_bench/utils/types.py`` to add your framework to the ``SupportedFrameworks`` enum: - -.. code-block:: python - - class SupportedFrameworks(Enum): - """Supported deep learning frameworks.""" - - NUMPY = "numpy" - TORCH = "torch" - TENSORFLOW = "tensorflow" - JAX = "jax" - MYFRAMEWORK = "myframework" # Add your framework - -Step 3: Implement Device Handling -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Update ``decent_bench/utils/interoperability/_helpers.py`` to handle device conversion: - -.. code-block:: python - - def _device_literal_to_framework_device( - device: SupportedDevices, - framework: SupportedFrameworks - ) -> Any: - """Convert SupportedDevices literal to framework-specific device.""" - # ... existing frameworks ... - - if myframework and framework == SupportedFrameworks.MYFRAMEWORK: - # Implement framework-specific device handling - # Return the appropriate device representation - if device == SupportedDevices.CPU: - return myframework.device("cpu") - return myframework.device("gpu") - - raise ValueError(f"Unsupported framework: {framework}") - -Update the ``framework_device_of_array`` function: - -.. code-block:: python - - def framework_device_of_array(array: Array) -> tuple[SupportedFrameworks, SupportedDevices]: - """Determine the framework and device of the given Array.""" - value = array.value if isinstance(array, Array) else array - - # ... existing framework checks ... - - if myframework and isinstance(value, _myframework_types): - device_str = value.device # Adjust based on framework API - device_type = ( - SupportedDevices.GPU if "gpu" in device_str - else SupportedDevices.CPU - ) - return SupportedFrameworks.MYFRAMEWORK, device_type - - raise TypeError(f"Unsupported framework type: {type(value)}") - -Step 4: Add Conversion Functions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Add a conversion function in ``decent_bench/utils/interoperability/_functions.py``: - -.. code-block:: python - - # Define type tuple for isinstance checks - if TYPE_CHECKING: - ... # existing imports - from myframework import Tensor as MyFrameworkTensor - - def to_myframework( - array: Array | SupportedArrayTypes, - device: SupportedDevices - ) -> MyFrameworkTensor: - """ - Convert input array to a MyFramework tensor. - - Args: - array (Array | SupportedArrayTypes): Input Array - device (SupportedDevices): Device of the input array. - - Returns: - MyFrameworkTensor: Converted tensor. - - Raises: - ImportError: if MyFramework is not installed. - """ - if not myframework: - raise ImportError("MyFramework is not installed.") - - value = array.value if isinstance(array, Array) else array - framework_device = _device_literal_to_framework_device( - device, SupportedFrameworks.MYFRAMEWORK - ) - - # Handle conversion from each supported framework - if isinstance(value, myframework.Tensor): - return cast("MyFrameworkTensor", value.to(framework_device)) - if isinstance(value, np.ndarray | np.generic): - return cast("MyFrameworkTensor", - myframework.from_numpy(value).to(framework_device)) - if torch and isinstance(value, torch.Tensor): - return cast("MyFrameworkTensor", - myframework.from_numpy(value.cpu().numpy()).to(framework_device)) - # ... handle other frameworks ... - - # Try a direct conversion to check if possible - return cast("MyFrameworkTensor", - myframework.tensor(value, device=framework_device)) - -Update the ``to_array`` and all other ``to_"framework"`` functions to include your framework: - -.. code-block:: python - - def to_array( - array: Array | SupportedArrayTypes, - framework: SupportedFrameworks, - device: SupportedDevices, - ) -> Array: - """Convert an array to the specified framework type.""" - # ... existing frameworks ... - - if myframework and framework == SupportedFrameworks.MYFRAMEWORK: - return _return_array(to_myframework(array, device)) - - raise TypeError(f"Unsupported framework type: {framework}") - -Step 5: Update All Interoperability Operations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Every function in ``_operators.py`` and ``_functions.py`` needs to handle your framework. - -Example for an operator in ``_operators.py``: - -.. code-block:: python - - def add(array1: Array | SupportedArrayTypes, - array2: Array | SupportedArrayTypes) -> Array: - """Element-wise addition of two arrays.""" - value1 = array1.value if isinstance(array1, Array) else array1 - value2 = array2.value if isinstance(array2, Array) else array2 - - # ... existing frameworks ... - - if myframework and isinstance(value1, _myframework_types): - return _return_array(myframework.add(value1, value2)) - - raise TypeError(f"Unsupported framework type: {type(value1)}") - -Example for a function in ``_functions.py``: - -.. code-block:: python - - def sum( - array: Array, - dim: int | tuple[int, ...] | None = None, - keepdims: bool = False, - ) -> Array: - """Sum elements of an array.""" - value = array.value if isinstance(array, Array) else array - - # ... existing frameworks ... - - if myframework and isinstance(value, _myframework_types): - return _return_array(myframework.sum(value, axis=dim, keepdims=keepdims)) - - raise TypeError(f"Unsupported framework type: {type(value)}") - -You'll need to update every operation: ``add``, ``sub``, ``mul``, ``div``, ``matmul``, ``power``, -``sqrt``, ``mean``, ``max``, ``min``, ``transpose``, ``reshape``, ``zeros``, ``ones``, ``randn``, etc. - -Step 6: Update Decorators -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Update ``_decorators.py`` to handle conversion in the ``autodecorate_cost_method``: - -.. code-block:: python - - def _get_converter(framework: SupportedFrameworks) -> Callable: - if framework == SupportedFrameworks.NUMPY: - return to_numpy - if framework == SupportedFrameworks.TORCH: - return to_torch - if framework == SupportedFrameworks.TENSORFLOW: - return to_tensorflow - if framework == SupportedFrameworks.JAX: - return to_jax - if framework == SupportedFrameworks.MYFRAMEWORK: - return to_myframework - - raise ValueError(f"Unsupported framework: {framework}") - -Step 7: Export New Functions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Add exports to ``decent_bench/utils/interoperability/__init__.py``: - -.. code-block:: python - - from ._functions import ( - # ... existing imports ... - to_myframework, - ) - - __all__ = [ - # ... existing exports ... - "to_myframework", - ] - -Step 8: Add Comprehensive Tests -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Update tests and add test for `to_myframework` in ``test/utils/test_interoperability.py``: - -.. code-block:: python - - # Add availability check - try: - import myframework - MYFRAMEWORK_AVAILABLE = True - MYFRAMEWORK_GPU_AVAILABLE = myframework.cuda.is_available() - except (ImportError, ModuleNotFoundError): - MYFRAMEWORK_AVAILABLE = False - MYFRAMEWORK_GPU_AVAILABLE = False - - # Add to parameterized tests - @pytest.mark.parametrize( - "framework,device", - [ - ("numpy", "cpu"), - # ... existing frameworks ... - pytest.param("myframework", "cpu", marks=pytest.mark.skipif( - not MYFRAMEWORK_AVAILABLE, reason="MyFramework not available" - )), - pytest.param("myframework", "gpu", marks=pytest.mark.skipif( - not MYFRAMEWORK_GPU_AVAILABLE, reason="MyFramework GPU not available" - )), - ], - ) - def test_to_my_framework(framework, device): - pass - -Step 9: Update Documentation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Update the documentation to mention the new framework: - -- Add to the list of supported frameworks in ``docs/source/user.rst`` -- Update any framework-specific examples -- Add installation instructions if needed - -Checklist -~~~~~~~~~ - -Use this checklist when adding a new framework: - -.. code-block:: text - - ☐ Add conditional import to _imports_types.py - ☐ Define type tuple (_myframework_types) - ☐ Add to SupportedFrameworks enum in types.py - ☐ Implement _device_literal_to_framework_device - ☐ Implement framework_device_of_array detection - ☐ Implement to_myframework conversion - ☐ Update to_array function - ☐ Update to_"framework" for every "framework" to handle your framework - ☐ Update all operators: add, sub, mul, div, matmul, power, etc. - ☐ Update all in-place operators: iadd, isub, imul, idiv, ipow - ☐ Update all functions: sum, mean, min, max, argmax, argmin, etc. - ☐ Update creation functions: zeros, ones, eye, randn, etc. - ☐ Update utility functions: shape, reshape, transpose, stack, etc. - ☐ Update _get_converter in _decorators.py - ☐ Export to_myframework in __init__.py - ☐ Add framework availability checks in tests - ☐ Add to parameterized test fixtures - ☐ Test all operations with new framework - ☐ Test CPU and GPU devices (if applicable) - ☐ Update documentation - ☐ Add installation instructions - -Common Considerations -~~~~~~~~~~~~~~~~~~~~~ - -**API Differences:** - -Different frameworks have different APIs. Pay attention to: - -- Parameter names (``axis`` vs ``dim`` vs ``dimension``) -- Return types (some frameworks return scalars, others return 0-d arrays) -- Indexing behavior -- Broadcasting rules -- Gradient computation (some frameworks track gradients by default) - -**Performance:** - -- Some frameworks may not support certain operations efficiently -- Consider framework-specific optimizations -- Be aware of memory layout differences (row-major vs column-major) - -**Device Management:** - -- Not all frameworks support GPU computation -- Device transfer may have different APIs -- Some frameworks use different GPU backends (CUDA, ROCm, Metal, etc.) - -**Type System:** - -- Be careful with dtype conversions -- Some frameworks have more restrictive type systems -- Handle scalar vs array returns consistently - -**Main Goals:** - -- Mimic numpy behavior as closely as possible -- Maintain consistent behavior across frameworks -- Ensure performance is acceptable -- Provide clear error messages for unsupported operations - - -Advanced Decorator: autodecorate_cost_method ---------------------------------------------- - -Purpose -~~~~~~~ - -The :func:`~decent_bench.utils.interoperability.autodecorate_cost_method` decorator is a specialized -decorator that automatically handles type conversion for :class:`~decent_bench.costs.Cost` subclass methods. -It enables users to implement cost functions in their preferred framework while the decorator handles -conversion automatically. - -How It Works -~~~~~~~~~~~~ - -The decorator performs three key operations: - -1. **Unwraps Input Arrays**: Converts :class:`~decent_bench.utils.array.Array` arguments to the cost's native framework type -2. **Calls the Method**: Executes the user's framework-specific implementation -3. **Wraps Output**: Converts the return value back to :class:`~decent_bench.utils.array.Array` if the superclass expects it (still using runtime unwrapping) - -Usage Pattern -~~~~~~~~~~~~~ - -When implementing a custom cost function: - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - import numpy as np - from numpy.typing import NDArray - from decent_bench.costs import Cost - - class MyCustomCost(Cost): - - @iop.autodecorate_cost_method(Cost.function) - def function(self, x: NDArray[float]) -> float: - # Implement using NumPy - # Decorator handles Array -> NDArray conversion - return float(np.sum(x ** 2)) - - @iop.autodecorate_cost_method(Cost.gradient) - def gradient(self, x: NDArray[float]) -> NDArray[float]: - # Implement using NumPy - # Decorator handles Array -> NDArray and NDArray -> Array conversion - return 2 * x - -**Key Points:** - -- The first argument **must** be named ``x`` (used to determine target framework) -- Use the framework-specific type hints (``NDArray``, ``torch.Tensor``, etc.) -- The decorator matches the superclass method's return type annotation (make sure to specify the correct superclass method you are decorating) -- Warnings are emitted if input arrays have mismatched frameworks - -Framework Mismatch Warnings -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If an input array's framework differs from the cost's framework, a warning is logged: - -.. code-block:: python - - # Cost is configured for PyTorch - my_cost = MyCustomCost(framework=SupportedFrameworks.TORCH, ...) - - # But we pass a NumPy array - result = my_cost.function(numpy_array) - # WARNING: Converting array from framework numpy to torch in method function. - # This may lead to unexpected behavior or performance issues. - - -Best Practices --------------- - -Performance Considerations -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -1. **Use Array Objects**: Work with :class:`~decent_bench.utils.array.Array` objects and leverage operator overloading -2. **Avoid Unnecessary Conversions**: Keep arrays in their framework; only convert when needed -3. **Leverage Runtime Unwrapping**: Trust that the system handles performance internally - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - from decent_bench.utils.types import SupportedFrameworks, SupportedDevices - - # Good: Create arrays with iop and use operators - x = iop.randn((100,), SupportedFrameworks.TORCH, SupportedDevices.GPU) - weight = iop.ones_like(x) - matrix = iop.eye((100,), SupportedFrameworks.TORCH, SupportedDevices.GPU) - - for i in range(1000): - x = x + weight # Efficient: uses runtime unwrapping - x = x @ matrix # No wrapper overhead - - # Also good: Use interoperability functions - for i in range(1000): - x = iop.add(x, weight) - x = iop.matmul(x, matrix) - -* **Avoid:** Manually extracting .value defeats the abstraction -* **Users:** Should not access ``array.value`` directly - -Working with Array Objects -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -**Recommended Usage:** - -.. code-block:: python - - import decent_bench.utils.interoperability as iop - from decent_bench.utils.types import SupportedFrameworks, SupportedDevices - - # Create Array objects using interoperability functions - x = iop.randn((100, 50), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - weight = iop.randn((50, 10), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - bias = iop.zeros((10,), SupportedFrameworks.NUMPY, SupportedDevices.CPU) - - # or use ones_like, zeros_like etc - zero_weight = iop.zeros_like(weight) - one_bias = iop.ones_like(bias) - - # Use operators for arithmetic - result = (x + 1) * 2 / 3 - result = x @ weight + bias - - # Use interoperability functions for operations - mean_val = iop.mean(x) - std_val = iop.sqrt(iop.mean((x - mean_val) ** 2)) - normalized = (x - mean_val) / std_val - - # Convert frameworks when needed - torch_version = iop.to_torch(x, SupportedDevices.GPU) - -**What to Avoid:** - -.. code-block:: python - - import numpy as np - from decent_bench.utils.array import Array - - # Don't create Array objects directly - x = Array(np.array([1, 2, 3])) - - # Don't manually extract .value in user code - native_array = x.value # Defeats the abstraction - - # Don't bypass the Array interface - result = np.add(x.value, y.value) # Use x + y instead - - # The Array class is meant to be your interface - # The runtime unwrapping is an internal optimization - # Always create arrays through iop functions - -Testing Framework-Agnostic Code -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Use parameterized tests to verify all frameworks: - -.. code-block:: python - - import pytest - - @pytest.mark.parametrize("framework", [SupportedFrameworks.NUMPY, ...]) - def test_my_algorithm(framework): - if framework == SupportedFrameworks.TORCH and not TORCH_AVAILABLE: - pytest.skip("PyTorch not available") - - # Create arrays in target framework - x = create_test_array(framework) - - # Test your algorithm - result = my_algorithm(x) - - # Verify results - assert_correct_framework(result, framework) - - -Common Pitfalls -~~~~~~~~~~~~~~~ - -**For Users:** - -1. **Don't create Array objects directly**: Use :meth:`iop.randn() `, :meth:`iop.zeros() `, etc., not ``Array(...)`` -2. **Don't access .value directly**: Use the :class:`~decent_bench.utils.array.Array` interface and operators instead -3. **Don't bypass interoperability**: Use ``x + y`` or :meth:`iop.add() `, not ``np.add()`` -4. **Trust the abstraction**: Runtime unwrapping is automatic; you don't need to manage it - -**For Developers Extending the System:** - -1. **Incorrect isinstance checks**: Use the type tuples from ``_imports_types.py`` -2. **Missing framework availability checks**: Always check ``if torch and ...`` -3. **Not extracting .value in interop functions**: Interop functions must extract the native array -4. **Forgetting _return_array()**: Always use ``_return_array()`` for consistent unwrapping and type checking - - -Further Reading ---------------- - -- :doc:`API Reference ` -- :doc:`User Guide ` for basic usage -- `Type Checking Documentation `_ diff --git a/docs/source/developer.rst b/docs/source/developer.rst index 21609ce..b580cfb 100644 --- a/docs/source/developer.rst +++ b/docs/source/developer.rst @@ -157,9 +157,4 @@ Releases 1. Update the version in pyproject.toml using `Semantic Versioning `_. 2. Merge the change into main with commit message :code:`meta: Bump version to .. (#)`. 3. Create a new release on GitHub. -4. Publish to PyPI using :code:`hatch clean && hatch build && hatch publish`. - -Next Steps ----------- -Continue to the :doc:`Advanced Developer Guide ` for more in-depth information on the architecture and design -decisions behind decent-bench. \ No newline at end of file +4. Publish to PyPI using :code:`hatch clean && hatch build && hatch publish`. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 819785d..67056eb 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -16,5 +16,4 @@ providing realistic algorithm comparisons in a user-friendly and highly configur user API Reference developer - advanced author From e758a7721ac974e13f1e7c8610665cd423023e86 Mon Sep 17 00:00:00 2001 From: simpag Date: Thu, 18 Dec 2025 18:13:53 +0100 Subject: [PATCH 05/15] fix(PR): Fix PR comments --- decent_bench/agents.py | 7 ++- decent_bench/benchmark.py | 2 +- decent_bench/benchmark_problem.py | 2 +- decent_bench/metrics/plot_metrics.py | 9 +-- decent_bench/metrics/table_metrics.py | 22 +++++-- docs/source/developer.rst | 8 +++ docs/source/index.rst | 2 + test/test_agents.py | 90 ++++++++++++++++++++++++++- 8 files changed, 129 insertions(+), 13 deletions(-) diff --git a/decent_bench/agents.py b/decent_bench/agents.py index 8b9aaed..7f14118 100644 --- a/decent_bench/agents.py +++ b/decent_bench/agents.py @@ -14,6 +14,9 @@ class Agent: """Agent with unique id, local cost function, activation scheme and history period.""" def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme, history_period: int): + if history_period <= 0: + raise ValueError("history_period must be a positive integer") + self._id = agent_id self._cost = cost self._activation = activation @@ -152,9 +155,9 @@ class AgentMetricsView: @staticmethod def from_agent(agent: Agent) -> AgentMetricsView: """Create from agent.""" - # Apppend the last x if not already recorded + # Append the last x if not already recorded if agent._current_x is not None and agent._x_step not in agent._x_history: # noqa: SLF001 - agent._x_history[agent._x_step] = agent._current_x # noqa: SLF001 + agent._x_history[agent._x_step] = iop.copy(agent._current_x) # noqa: SLF001 return AgentMetricsView( cost=agent.cost, diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index 31d78b1..5f9aacb 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -75,7 +75,7 @@ def benchmark( """ manager = Manager() log_listener = logger.start_log_listener(manager, log_level) - LOGGER.info("Starting benchmark execution, progress bar increments with each completed trial ") + LOGGER.info("Starting benchmark execution ") with Status("Generating initial network state"): nw_init_state = create_distributed_network(benchmark_problem) LOGGER.debug(f"Nr of agents: {len(nw_init_state.agents())}") diff --git a/decent_bench/benchmark_problem.py b/decent_bench/benchmark_problem.py index 6fc0170..b264aa2 100644 --- a/decent_bench/benchmark_problem.py +++ b/decent_bench/benchmark_problem.py @@ -41,7 +41,7 @@ class BenchmarkProblem: network_structure: graph defining how agents are connected x_optimal: solution that minimizes the sum of the cost functions, used for calculating metrics costs: local cost functions, each one is given to one agent - history_period: period for recording agent history + agent_history_period: period for recording agent history agent_activations: setting for agent activation/participation, each scheme is applied to one agent message_compression: message compression setting message_noise: message noise setting diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 61279e8..1d4ed6f 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -20,7 +20,7 @@ Y = float -@dataclass(eq=False) +@dataclass class ComputationalCost: """Computational costs associated with an algorithm for plot metrics.""" @@ -122,11 +122,12 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble MARKERS = ["o", "s", "v", "^", "*", "D", "H", "<", ">", "p"] -def plot( # noqa: PLR0914 +def plot( resulting_agent_states: dict[Algorithm, list[list[AgentMetricsView]]], problem: BenchmarkProblem, metrics: list[PlotMetric], computational_cost: ComputationalCost | None, + computational_cost_scalar: float = 1e-4, ) -> None: """ Plot the execution results with one subplot per metric. @@ -140,6 +141,7 @@ def plot( # noqa: PLR0914 metrics: metrics to calculate and plot computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost + computational_cost_scalar: scalar to convert computational cost to more manageable units for plotting Raises: RuntimeError: if the figure manager can't be retrieved @@ -148,7 +150,6 @@ def plot( # noqa: PLR0914 if not metrics: return LOGGER.info(f"Plot metric definitions can be found here: {PLOT_METRICS_DOC_LINK}") - max_iterations = max(alg.iterations for alg in resulting_agent_states) use_cost = computational_cost is not None metric_subplots: list[tuple[PlotMetric, SubPlot]] = _create_metric_subplots(metrics, use_cost) with utils.MetricProgressBar() as progress: @@ -176,7 +177,7 @@ def plot( # noqa: PLR0914 x, y_mean = zip(*mean_curve, strict=True) if computational_cost is not None: total_computational_cost = _calc_total_cost(agent_states, computational_cost) - x = tuple(val * total_computational_cost / max_iterations for val in x) + x = tuple(val * total_computational_cost * computational_cost_scalar for val in x) subplot.plot(x, y_mean, label=alg.name, color=color, marker=marker, markevery=max(1, int(len(x) / 20))) y_min, y_max = _calculate_envelope(data_per_trial) subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) diff --git a/decent_bench/metrics/table_metrics.py b/decent_bench/metrics/table_metrics.py index 47718c9..4cee9e3 100644 --- a/decent_bench/metrics/table_metrics.py +++ b/decent_bench/metrics/table_metrics.py @@ -333,13 +333,27 @@ def _calculate_mean_and_margin_of_error(data: list[float], confidence_level: flo def _format_confidence_interval(mean: float, margin_of_error: float, fmt: str) -> str: - try: - formatted_confidence_interval = f"{mean:{fmt}} \u00b1 {margin_of_error:{fmt}}" - except ValueError: + if not _is_valid_float_format_spec(fmt): LOGGER.warning(f"Invalid format string '{fmt}', defaulting to scientific notation") - formatted_confidence_interval = f"{mean:.2e} \u00b1 {margin_of_error:.2e}" + fmt = ".2e" + + formatted_confidence_interval = f"{mean:{fmt}} \u00b1 {margin_of_error:{fmt}}" if any(np.isnan([mean, margin_of_error])): formatted_confidence_interval += " (diverged?)" return formatted_confidence_interval + + +def _is_valid_float_format_spec(fmt: str) -> bool: + """ + Validate that the given format spec can be used to format a float. + + This avoids attempting to format real values with an invalid format string. + + """ + try: + f"{0.01:{fmt}}" + except (ValueError, TypeError): + return False + return True diff --git a/docs/source/developer.rst b/docs/source/developer.rst index b580cfb..c6deaa3 100644 --- a/docs/source/developer.rst +++ b/docs/source/developer.rst @@ -23,7 +23,15 @@ Installation for Development source .tox/dev/bin/activate # activate dev env on Mac/Linux .\.tox\dev\Scripts\activate # activate dev env on Windows +Optionally install development dependencies with proper gpu support, e.g. for PyTorch and TensorFlow: +.. code-block:: + + tox -e dev-gpu + +It is not recommended to use the development environments for regular usage of decent-bench, as they +contain additional packages that are not needed for that purpose. This may cause performance degradation +due to multiple packages competing for resources (e.g. GPU resources). Tooling ------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 67056eb..68a98ef 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,8 @@ Welcome to decent-bench! decent-bench allows you to benchmark decentralized optimization algorithms under various communication constraints, providing realistic algorithm comparisons in a user-friendly and highly configurable setting. +Report any bugs you *may find* to `GitHub `_. + .. toctree:: :maxdepth: 1 diff --git a/test/test_agents.py b/test/test_agents.py index 4e3b1d3..dd24e2f 100644 --- a/test/test_agents.py +++ b/test/test_agents.py @@ -26,7 +26,6 @@ try: import jax - import jax.numpy as jnp JAX_AVAILABLE = True JAX_GPU_AVAILABLE = len(jax.devices("gpu")) > 0 @@ -170,3 +169,92 @@ def assert_state(expected_x, expected_history): np.array([1.0, 1.0, 1.0]), ], ) + + +@pytest.mark.parametrize( + "framework,device", + [ + pytest.param( + SupportedFrameworks.NUMPY, + SupportedDevices.CPU, + ), + pytest.param( + SupportedFrameworks.TORCH, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not TORCH_AVAILABLE, reason="PyTorch not available"), + ), + pytest.param( + SupportedFrameworks.TORCH, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not TORCH_CUDA_AVAILABLE, reason="PyTorch CUDA not available"), + ), + pytest.param( + SupportedFrameworks.TENSORFLOW, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not TF_AVAILABLE, reason="TensorFlow not available"), + ), + pytest.param( + SupportedFrameworks.TENSORFLOW, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not TF_GPU_AVAILABLE, reason="TensorFlow GPU not available"), + ), + pytest.param( + SupportedFrameworks.JAX, + SupportedDevices.CPU, + marks=pytest.mark.skipif(not JAX_AVAILABLE, reason="JAX not available"), + ), + pytest.param( + SupportedFrameworks.JAX, + SupportedDevices.GPU, + marks=pytest.mark.skipif(not JAX_GPU_AVAILABLE, reason="JAX GPU not available"), + ), + ], +) +@pytest.mark.parametrize("history_period", [1, 5, 10]) +def test_agent_history_period(framework: SupportedFrameworks, device: SupportedDevices, history_period: int): + """Test that agent history is recorded according to the specified history period.""" + agent = Agent( + 0, + LinearRegressionCost(np.array([[1.0, 1.0, 1.0]]), np.array([1.0])), + None, + history_period=history_period, + ) + + initial = iop.zeros((3,), framework=framework, device=device) + agent.initialize(x=initial) + + def assert_state(expected_x, expected_history): + """Helper to verify agent state and history.""" + np.testing.assert_array_almost_equal( + iop.to_numpy(agent.x), + expected_x, + decimal=5, + err_msg=f"Expected x: {expected_x}, but got: {iop.to_numpy(agent.x)}", + ) + assert len(agent._x_history) == len(expected_history), ( + f"Expected history length: {len(expected_history)}, but got: {len(agent._x_history)}" + ) + steps = sorted(agent._x_history.keys()) + for i, expected in zip(steps, expected_history, strict=True): + np.testing.assert_array_almost_equal( + iop.to_numpy(agent._x_history[i]), + expected, + decimal=5, + err_msg=f"At history index {i}, expected: {expected}, but got: {iop.to_numpy(agent._x_history)}", + ) + + expected_history_length = 5 # Excluding the initial state, so +1 later + n_updates = expected_history_length * history_period + for _ in range(n_updates): + agent.x += 1.0 + + assert_state( + np.array([n_updates, n_updates, n_updates]), + [ + np.array([0.0, 0.0, 0.0]), + ] + + [ + np.array([i * history_period, i * history_period, i * history_period]) + for i in range(1, expected_history_length + 1) + ], + ) From 0e49f32111175b7e7d15a062256c19dc84539120 Mon Sep 17 00:00:00 2001 From: simpag Date: Fri, 19 Dec 2025 18:17:42 +0100 Subject: [PATCH 06/15] fix(Agent): PR issues --- decent_bench/agents.py | 18 ++--- decent_bench/benchmark.py | 11 ++- decent_bench/benchmark_problem.py | 10 +-- decent_bench/metrics/metric_utils.py | 17 +++- decent_bench/metrics/plot_metrics.py | 80 +++++++++++++++---- decent_bench/metrics/table_metrics.py | 12 ++- decent_bench/networks.py | 3 +- .../api/snippets/computational_cost.rst | 8 ++ docs/source/index.rst | 2 + docs/source/user.rst | 2 +- test/test_agents.py | 6 +- 11 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 docs/source/api/snippets/computational_cost.rst diff --git a/decent_bench/agents.py b/decent_bench/agents.py index 7f14118..04ad881 100644 --- a/decent_bench/agents.py +++ b/decent_bench/agents.py @@ -21,11 +21,11 @@ def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme, self._cost = cost self._activation = activation self._history_period = history_period - self._x_step = 0 self._current_x: Array | None = None self._x_history: dict[int, Array] = {} self._auxiliary_variables: dict[str, Array] = {} self._received_messages: dict[Agent, Array] = {} + self._n_x_updates = 0 self._n_sent_messages = 0 self._n_received_messages = 0 self._n_sent_messages_dropped = 0 @@ -71,10 +71,10 @@ def x(self) -> Array: @x.setter def x(self, x: Array) -> None: - self._x_step += 1 + self._n_x_updates += 1 self._current_x = x - if self._x_step % self._history_period == 0: - self._x_history[self._x_step] = iop.copy(x) + if self._n_x_updates % self._history_period == 0: + self._x_history[self._n_x_updates] = iop.copy(x) @property def messages(self) -> Mapping[Agent, Array]: @@ -110,7 +110,7 @@ def initialize( raise ValueError(f"Initialized x has shape {iop.shape(x)}, expected {self.cost.shape}") self._x_history = {0: iop.copy(x)} self._current_x = iop.copy(x) - self._x_step = 0 + self._n_x_updates = 0 if aux_vars: self._auxiliary_variables = {k: iop.copy(v) for k, v in aux_vars.items()} if received_msgs: @@ -143,7 +143,7 @@ class AgentMetricsView: cost: Cost x_history: dict[int, Array] - x_updates: int + n_x_updates: int n_function_calls: int n_gradient_calls: int n_hessian_calls: int @@ -156,13 +156,13 @@ class AgentMetricsView: def from_agent(agent: Agent) -> AgentMetricsView: """Create from agent.""" # Append the last x if not already recorded - if agent._current_x is not None and agent._x_step not in agent._x_history: # noqa: SLF001 - agent._x_history[agent._x_step] = iop.copy(agent._current_x) # noqa: SLF001 + if agent._current_x is not None and agent._n_x_updates not in agent._x_history: # noqa: SLF001 + agent._x_history[agent._n_x_updates] = iop.copy(agent._current_x) # noqa: SLF001 return AgentMetricsView( cost=agent.cost, x_history=agent._x_history, # noqa: SLF001 - x_updates=agent._x_step, # noqa: SLF001 + n_x_updates=agent._n_x_updates, # noqa: SLF001 n_function_calls=agent._n_function_calls, # noqa: SLF001 n_gradient_calls=agent._n_gradient_calls, # noqa: SLF001 n_hessian_calls=agent._n_hessian_calls, # noqa: SLF001 diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index 5f9aacb..db49403 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -32,6 +32,7 @@ def benchmark( table_fmt: Literal["grid", "latex"] = "grid", *, computational_cost: pm.ComputationalCost | None = None, + x_axis_scaling: float = 1e-4, n_trials: int = 30, confidence_level: float = 0.95, log_level: int = logging.INFO, @@ -54,6 +55,8 @@ def benchmark( table_fmt: table format, grid is suitable for the terminal while latex can be copy-pasted into a latex document computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost + x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more + manageable units for plotting. Only used if ``computational_cost`` is provided. n_trials: number of times to run each algorithm on the benchmark problem, running more trials improves the statistical results, at least 30 trials are recommended for the central limit theorem to apply confidence_level: confidence level of the confidence intervals @@ -72,6 +75,12 @@ def benchmark( If ``progress_step`` is too small performance may degrade due to the overhead of updating the progress bar too often. + Computational cost can be interpreted as the cost of running the algorithm on a specific hardware setup. + Therefore the computational cost could be seen as the number of operations performed (similar to FLOPS) but + weighted by the time or energy it takes to perform them on the specific hardware. + + .. include:: snippets/computational_cost.rst + """ manager = Manager() log_listener = logger.start_log_listener(manager, log_level) @@ -86,7 +95,7 @@ def benchmark( for alg, networks in resulting_nw_states.items(): resulting_agent_states[alg] = [[AgentMetricsView.from_agent(a) for a in nw.agents()] for nw in networks] tm.tabulate(resulting_agent_states, benchmark_problem, table_metrics, confidence_level, table_fmt) - pm.plot(resulting_agent_states, benchmark_problem, plot_metrics, computational_cost) + pm.plot(resulting_agent_states, benchmark_problem, plot_metrics, computational_cost, x_axis_scaling) LOGGER.info("Benchmark execution complete, thanks for using decent-bench") log_listener.stop() diff --git a/decent_bench/benchmark_problem.py b/decent_bench/benchmark_problem.py index b264aa2..2037ef1 100644 --- a/decent_bench/benchmark_problem.py +++ b/decent_bench/benchmark_problem.py @@ -41,7 +41,7 @@ class BenchmarkProblem: network_structure: graph defining how agents are connected x_optimal: solution that minimizes the sum of the cost functions, used for calculating metrics costs: local cost functions, each one is given to one agent - agent_history_period: period for recording agent history + agent_state_snapshot_period: period for recording agent state snapshots agent_activations: setting for agent activation/participation, each scheme is applied to one agent message_compression: message compression setting message_noise: message noise setting @@ -52,7 +52,7 @@ class BenchmarkProblem: network_structure: AnyGraph x_optimal: Array costs: Sequence[Cost] - agent_history_period: int + agent_state_snapshot_period: int agent_activations: Sequence[AgentActivationScheme] message_compression: CompressionScheme message_noise: NoiseScheme @@ -63,7 +63,7 @@ def create_regression_problem( cost_cls: type[LinearRegressionCost | LogisticRegressionCost], *, n_agents: int = 100, - agent_history_period: int = 1, + agent_state_snapshot_period: int = 1, n_neighbors_per_agent: int = 3, asynchrony: bool = False, compression: bool = False, @@ -76,7 +76,7 @@ def create_regression_problem( Args: cost_cls: type of cost function n_agents: number of agents - agent_history_period: period for recording agent history + agent_state_snapshot_period: period for recording agent state snapshots n_neighbors_per_agent: number of neighbors per agent asynchrony: if true, agents only have a 50% probability of being active/participating at any given time compression: if true, messages are rounded to 4 significant digits @@ -104,7 +104,7 @@ def create_regression_problem( return BenchmarkProblem( network_structure=network_structure, costs=costs, - agent_history_period=agent_history_period, + agent_state_snapshot_period=agent_state_snapshot_period, x_optimal=x_optimal, agent_activations=agent_activations, message_compression=message_compression, diff --git a/decent_bench/metrics/metric_utils.py b/decent_bench/metrics/metric_utils.py index dfe8971..cb8fda6 100644 --- a/decent_bench/metrics/metric_utils.py +++ b/decent_bench/metrics/metric_utils.py @@ -79,7 +79,7 @@ def regret(agents: list[AgentMetricsView], problem: BenchmarkProblem, iteration: mean_x = x_mean(tuple(agents), iteration) optimal_cost = sum(a.cost.function(x_opt) for a in agents) actual_cost = sum(a.cost.function(mean_x) for a in agents) - return abs(optimal_cost - actual_cost) + return actual_cost - optimal_cost def gradient_norm(agents: list[AgentMetricsView], iteration: int = -1) -> float: @@ -154,3 +154,18 @@ def iterative_convergence_rate_and_order(agent: AgentMetricsView, problem: Bench except LinAlgError: rate, order = np.nan, np.nan return rate, order + + +def common_sorted_iterations(agents: Sequence[AgentMetricsView]) -> list[int]: + """ + Get a sorted list of all common iterations reached by agents in *agents*. + + Args: + agents: sequence of agents to get the common iterations from + + Returns: + sorted list of iterations reached by all agents + + """ + common_iters = set.intersection(*(set(a.x_history.keys()) for a in agents)) if agents else set() + return sorted(common_iters) diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 1d4ed6f..b264e9f 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -1,5 +1,4 @@ import math -import random import warnings from abc import ABC, abstractmethod from collections import defaultdict @@ -78,9 +77,7 @@ class RegretPerIteration(PlotMetric): def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 # Determine the set of recorded iterations common to all agents and use those - common_iters = set.intersection(*(set(a.x_history.keys()) for a in agents)) if agents else set() - sorted_iters = sorted(common_iters) - return [(i, utils.regret(agents, problem, i)) for i in sorted_iters] + return [(i, utils.regret(agents, problem, i)) for i in utils.common_sorted_iterations(agents)] class GradientNormPerIteration(PlotMetric): @@ -101,9 +98,7 @@ class GradientNormPerIteration(PlotMetric): def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 # Determine the set of recorded iterations common to all agents and use those - common_iters = set.intersection(*(set(a.x_history.keys()) for a in agents)) if agents else set() - sorted_iters = sorted(common_iters) - return [(i, utils.gradient_norm(agents, i)) for i in sorted_iters] + return [(i, utils.gradient_norm(agents, i)) for i in utils.common_sorted_iterations(agents)] DEFAULT_PLOT_METRICS = [ @@ -118,16 +113,31 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble """ PLOT_METRICS_DOC_LINK = "https://decent-bench.readthedocs.io/en/latest/api/decent_bench.metrics.plot_metrics.html" -COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"] -MARKERS = ["o", "s", "v", "^", "*", "D", "H", "<", ">", "p"] +COLORS = [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + "#34495e", + "#16a085", + "#686901", +] +MARKERS = ["o", "s", "v", "^", "*", "D", "H", "<", ">", "p", "P", "X"] +STYLES = ["-", ":", "--", "-.", (5, (10, 3)), (0, (5, 10)), (0, (3, 1, 1, 1))] -def plot( +def plot( # noqa: PLR0914 resulting_agent_states: dict[Algorithm, list[list[AgentMetricsView]]], problem: BenchmarkProblem, metrics: list[PlotMetric], computational_cost: ComputationalCost | None, - computational_cost_scalar: float = 1e-4, + x_axis_scaling: float = 1e-4, ) -> None: """ Plot the execution results with one subplot per metric. @@ -141,7 +151,15 @@ def plot( metrics: metrics to calculate and plot computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost - computational_cost_scalar: scalar to convert computational cost to more manageable units for plotting + x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more + manageable units for plotting. Only used if ``computational_cost`` is provided. + + Note: + Computational cost can be interpreted as the cost of running the algorithm on a specific hardware setup. + Therefore the computational cost could be seen as the number of operations performed (similar to FLOPS) but + weighted by the time or energy it takes to perform them on the specific hardware. + + .. include:: snippets/computational_cost.rst Raises: RuntimeError: if the figure manager can't be retrieved @@ -162,8 +180,7 @@ def plot( status=f"Task: {metric.y_label} vs {_get_formatted_x_label(metric.x_label, use_cost)}", ) for i, (alg, agent_states) in enumerate(resulting_agent_states.items()): - color = COLORS[i] if i < len(COLORS) else [random.random() for _ in range(3)] - marker = MARKERS[i] if i < len(MARKERS) else random.choice(MARKERS) + marker, linestyle, color = _get_marker_style_color(i) data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial(agent_states, problem, metric) flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] if not np.isfinite(flattened_data).all(): @@ -177,8 +194,16 @@ def plot( x, y_mean = zip(*mean_curve, strict=True) if computational_cost is not None: total_computational_cost = _calc_total_cost(agent_states, computational_cost) - x = tuple(val * total_computational_cost * computational_cost_scalar for val in x) - subplot.plot(x, y_mean, label=alg.name, color=color, marker=marker, markevery=max(1, int(len(x) / 20))) + x = tuple(val * total_computational_cost * x_axis_scaling for val in x) + subplot.plot( + x, + y_mean, + label=alg.name, + color=color, + marker=marker, + linestyle=linestyle, + markevery=max(1, int(len(x) / 20)), + ) y_min, y_max = _calculate_envelope(data_per_trial) subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) progress.advance(plot_task) @@ -210,6 +235,29 @@ def _create_metric_subplots(metrics: list[PlotMetric], use_cost: bool) -> list[t return metric_subplots +def _get_marker_style_color( + index: int, +) -> tuple[str, Sequence[int | tuple[int, int, int, int] | str | tuple[int, int]], str]: + """ + Get deterministic unique marker, line style, and color for a given index. + + Cycles through all combinations to ensure the first n indices (where n = + len(MARKERS) * len(STYLES)) are unique. Colors cycle based on index, + markers cycle first, then styles to maximize marker distinctiveness for B&W printing. + """ + # Calculate total unique combinations + n_combinations = len(MARKERS) * len(STYLES) + + # Reduce index to valid range + idx = index % n_combinations + + color_idx = index % len(COLORS) + marker_idx = idx % len(MARKERS) + style_idx = (idx // len(MARKERS)) % len(STYLES) + + return MARKERS[marker_idx], STYLES[style_idx], COLORS[color_idx] + + def _get_formatted_x_label(x_label: str, use_cost: bool) -> str: return f"{x_label} (computational cost units)" if use_cost else x_label diff --git a/decent_bench/metrics/table_metrics.py b/decent_bench/metrics/table_metrics.py index 4cee9e3..e9bcda2 100644 --- a/decent_bench/metrics/table_metrics.py +++ b/decent_bench/metrics/table_metrics.py @@ -26,8 +26,14 @@ class TableMetric(ABC): statistics: sequence of statistics such as :func:`min`, :func:`sum`, and :func:`~numpy.average` used for aggregating the data retrieved with :func:`get_data_from_trial` into a single value, each statistic gets its own row in the table - fmt: format string used to format the values in the table, defaults to ".2e". See :meth:`str.format` - documentation for details on the format string options. + fmt: format string used to format the values in the table, defaults to ".2e". Common formats include: + - ".2e": scientific notation with 2 decimal places + - ".3f": fixed-point notation with 3 decimal places + - ".4g": general format with 4 significant digits + - ".1%": percentage format with 1 decimal place + + Where the integer specifies the precision. + See :meth:`str.format` documentation for details on the format string options. """ @@ -155,7 +161,7 @@ class XUpdates(TableMetric): description: str = "nr x updates" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 - return [a.x_updates for a in agents] + return [a.n_x_updates for a in agents] class FunctionCalls(TableMetric): diff --git a/decent_bench/networks.py b/decent_bench/networks.py index b9b1ee6..a982fa3 100644 --- a/decent_bench/networks.py +++ b/decent_bench/networks.py @@ -196,7 +196,8 @@ def create_distributed_network(problem: BenchmarkProblem) -> P2PNetwork: if not nx.is_connected(problem.network_structure): raise NotImplementedError("Support for disconnected graphs has not been implemented yet") agents = [ - Agent(i, problem.costs[i], problem.agent_activations[i], problem.agent_history_period) for i in range(n_agents) + Agent(i, problem.costs[i], problem.agent_activations[i], problem.agent_state_snapshot_period) + for i in range(n_agents) ] agent_node_map = {node: agents[i] for i, node in enumerate(problem.network_structure.nodes())} graph = nx.relabel_nodes(problem.network_structure, agent_node_map) diff --git a/docs/source/api/snippets/computational_cost.rst b/docs/source/api/snippets/computational_cost.rst new file mode 100644 index 0000000..9d9c95b --- /dev/null +++ b/docs/source/api/snippets/computational_cost.rst @@ -0,0 +1,8 @@ +Computational cost is calculated as: +.. math:: + + \text{Total Cost} = c_f N_f + c_g N_g + c_h N_h + c_p N_p + c_c N_c + +where :math:`c_f, c_g, c_h, c_p, c_c` are the costs per function, gradient, Hessian, proximal, and communication +call respectively, and :math:`N_f, N_g, N_h, N_p, N_c` are the mean number of function, gradient, Hessian, +proximal, and communication calls across all agents and trials. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 68a98ef..3172132 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,8 @@ Welcome to decent-bench! decent-bench allows you to benchmark decentralized optimization algorithms under various communication constraints, providing realistic algorithm comparisons in a user-friendly and highly configurable setting. +Contributions are very welcome, see developer guide on how to get started. Please contact `Dr. Nicola Bastianello `_ +for discussions or start an open discussion at `GitHub `_. Report any bugs you *may find* to `GitHub `_. .. toctree:: diff --git a/docs/source/user.rst b/docs/source/user.rst index 1af691a..c8da1ef 100644 --- a/docs/source/user.rst +++ b/docs/source/user.rst @@ -89,7 +89,7 @@ Configure communication constraints and other settings for out-of-the-box regres problem = benchmark_problem.create_regression_problem( LinearRegressionCost, n_agents=100, - agent_history_period=10, # Record metrics every 10 iterations + agent_state_snapshot_period=10, # Record metrics every 10 iterations n_neighbors_per_agent=3, asynchrony=True, compression=True, diff --git a/test/test_agents.py b/test/test_agents.py index dd24e2f..3d7819f 100644 --- a/test/test_agents.py +++ b/test/test_agents.py @@ -211,7 +211,7 @@ def assert_state(expected_x, expected_history): ], ) @pytest.mark.parametrize("history_period", [1, 5, 10]) -def test_agent_history_period(framework: SupportedFrameworks, device: SupportedDevices, history_period: int): +def test_agent_state_snapshot_period(framework: SupportedFrameworks, device: SupportedDevices, history_period: int): """Test that agent history is recorded according to the specified history period.""" agent = Agent( 0, @@ -235,6 +235,10 @@ def assert_state(expected_x, expected_history): f"Expected history length: {len(expected_history)}, but got: {len(agent._x_history)}" ) steps = sorted(agent._x_history.keys()) + assert steps == list(range(0, history_period * (len(expected_history)), history_period)), ( + f"Expected history steps: {list(range(0, history_period * (len(expecteded_history)), history_period))}, " + f"but got: {steps}" + ) for i, expected in zip(steps, expected_history, strict=True): np.testing.assert_array_almost_equal( iop.to_numpy(agent._x_history[i]), From 8e27b5a2e708647b2198232030674ea8427faaf5 Mon Sep 17 00:00:00 2001 From: simpag Date: Sat, 20 Dec 2025 17:19:34 +0100 Subject: [PATCH 07/15] fix(Agent): Fix PR comments --- decent_bench/benchmark.py | 16 +- decent_bench/benchmark_problem.py | 4 +- decent_bench/metrics/plot_metrics.py | 193 +++++++++++++----- .../api/snippets/computational_cost.rst | 2 +- test/test_agents.py | 2 +- 5 files changed, 161 insertions(+), 56 deletions(-) diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index db49403..dc12cb4 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -40,6 +40,7 @@ def benchmark( progress_step: int | None = None, show_speed: bool = False, show_trial: bool = False, + compare_iterations_and_computational_cost: bool = False, ) -> None: """ Benchmark distributed algorithms. @@ -70,6 +71,8 @@ def benchmark( If `None`, the progress bar uses 1 unit per trial. show_speed: whether to show speed (iterations/second) in the progress bar. show_trial: whether to show which trials are currently running in the progress bar. + compare_iterations_and_computational_cost: whether to plot both metric vs computational cost and + metric vs iterations. Only used if ``computational_cost`` is provided. Note: If ``progress_step`` is too small performance may degrade due to the @@ -81,6 +84,10 @@ def benchmark( .. include:: snippets/computational_cost.rst + If ``computational_cost`` is provided and ``compare_iterations_and_computational_cost`` is ``True``, each metric + will be plotted twice: once against computational cost and once against iterations. + Computational cost plots will be shown on the left and iteration plots on the right. + """ manager = Manager() log_listener = logger.start_log_listener(manager, log_level) @@ -95,7 +102,14 @@ def benchmark( for alg, networks in resulting_nw_states.items(): resulting_agent_states[alg] = [[AgentMetricsView.from_agent(a) for a in nw.agents()] for nw in networks] tm.tabulate(resulting_agent_states, benchmark_problem, table_metrics, confidence_level, table_fmt) - pm.plot(resulting_agent_states, benchmark_problem, plot_metrics, computational_cost, x_axis_scaling) + pm.plot( + resulting_agent_states, + benchmark_problem, + plot_metrics, + computational_cost, + x_axis_scaling, + compare_iterations_and_computational_cost, + ) LOGGER.info("Benchmark execution complete, thanks for using decent-bench") log_listener.stop() diff --git a/decent_bench/benchmark_problem.py b/decent_bench/benchmark_problem.py index 2037ef1..b479916 100644 --- a/decent_bench/benchmark_problem.py +++ b/decent_bench/benchmark_problem.py @@ -41,7 +41,7 @@ class BenchmarkProblem: network_structure: graph defining how agents are connected x_optimal: solution that minimizes the sum of the cost functions, used for calculating metrics costs: local cost functions, each one is given to one agent - agent_state_snapshot_period: period for recording agent state snapshots + agent_state_snapshot_period: period for recording agent state snapshots, used for plot metrics agent_activations: setting for agent activation/participation, each scheme is applied to one agent message_compression: message compression setting message_noise: message noise setting @@ -76,7 +76,7 @@ def create_regression_problem( Args: cost_cls: type of cost function n_agents: number of agents - agent_state_snapshot_period: period for recording agent state snapshots + agent_state_snapshot_period: period for recording agent state snapshots, used for plot metrics n_neighbors_per_agent: number of neighbors per agent asynchrony: if true, agents only have a 50% probability of being active/participating at any given time compression: if true, messages are rounded to 4 significant digits diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index b264e9f..886cc02 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -8,6 +8,7 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.axes import Axes as SubPlot +from matplotlib.figure import Figure import decent_bench.metrics.metric_utils as utils from decent_bench.agents import AgentMetricsView @@ -44,11 +45,6 @@ def __init__(self, *, x_log: bool = False, y_log: bool = True): self.x_log = x_log self.y_log = y_log - @property - @abstractmethod - def x_label(self) -> str: - """Label for the x-axis.""" - @property @abstractmethod def y_label(self) -> str: @@ -72,7 +68,6 @@ class RegretPerIteration(PlotMetric): its calculation. """ - x_label: str = "iteration" y_label: str = "regret" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 @@ -93,7 +88,6 @@ class GradientNormPerIteration(PlotMetric): included in the calculation. """ - x_label: str = "iteration" y_label: str = "gradient norm" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 @@ -113,6 +107,10 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble """ PLOT_METRICS_DOC_LINK = "https://decent-bench.readthedocs.io/en/latest/api/decent_bench.metrics.plot_metrics.html" +X_LABELS = { + "iterations": "iterations", + "computational_cost": "time (computational cost units)", +} COLORS = [ "#1f77b4", "#ff7f0e", @@ -132,12 +130,13 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble STYLES = ["-", ":", "--", "-.", (5, (10, 3)), (0, (5, 10)), (0, (3, 1, 1, 1))] -def plot( # noqa: PLR0914 +def plot( # noqa: PLR0917 resulting_agent_states: dict[Algorithm, list[list[AgentMetricsView]]], problem: BenchmarkProblem, metrics: list[PlotMetric], computational_cost: ComputationalCost | None, x_axis_scaling: float = 1e-4, + compare_iterations_and_computational_cost: bool = False, ) -> None: """ Plot the execution results with one subplot per metric. @@ -153,6 +152,8 @@ def plot( # noqa: PLR0914 of computational cost x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more manageable units for plotting. Only used if ``computational_cost`` is provided. + compare_iterations_and_computational_cost: whether to plot both metric vs computational cost and + metric vs iterations. Only used if ``computational_cost`` is provided. Note: Computational cost can be interpreted as the cost of running the algorithm on a specific hardware setup. @@ -168,71 +169,165 @@ def plot( # noqa: PLR0914 if not metrics: return LOGGER.info(f"Plot metric definitions can be found here: {PLOT_METRICS_DOC_LINK}") + + if len(metrics) > 4: + LOGGER.warning( + f"Plotting {len(metrics)} (> 4) metrics may result in a cluttered figure. " + "Consider reducing the number of metrics for better readability." + ) + use_cost = computational_cost is not None - metric_subplots: list[tuple[PlotMetric, SubPlot]] = _create_metric_subplots(metrics, use_cost) + fig, metric_subplots = _create_metric_subplots(metrics, use_cost, compare_iterations_and_computational_cost) with utils.MetricProgressBar() as progress: plot_task = progress.add_task( - "Generating plots", total=len(metric_subplots) * len(resulting_agent_states), status="" + "Generating plots", + total=len(metric_subplots) + * len(resulting_agent_states) + // (2 if use_cost and compare_iterations_and_computational_cost else 1), + status="", ) - for metric, subplot in metric_subplots: + x_label = X_LABELS["computational_cost" if use_cost else "iterations"] + for metric_index in range(len(metrics)): progress.update( plot_task, - status=f"Task: {metric.y_label} vs {_get_formatted_x_label(metric.x_label, use_cost)}", + status=f"Task: {metrics[metric_index].y_label} vs {x_label}", ) for i, (alg, agent_states) in enumerate(resulting_agent_states.items()): - marker, linestyle, color = _get_marker_style_color(i) - data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial(agent_states, problem, metric) - flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] - if not np.isfinite(flattened_data).all(): + data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial( + agent_states, problem, metrics[metric_index] + ) + if not _is_finite(data_per_trial): msg = ( - f"Skipping plot {metric.y_label}/{metric.x_label} for {alg.name}: " - "found nan or inf in datapoints." + f"Skipping plot {metrics[metric_index].y_label}/{x_label} " + f"for {alg.name}: found nan or inf in datapoints." ) LOGGER.warning(msg) continue - mean_curve: Sequence[tuple[X, Y]] = _calculate_mean_curve(data_per_trial) - x, y_mean = zip(*mean_curve, strict=True) - if computational_cost is not None: - total_computational_cost = _calc_total_cost(agent_states, computational_cost) - x = tuple(val * total_computational_cost * x_axis_scaling for val in x) - subplot.plot( - x, - y_mean, - label=alg.name, - color=color, - marker=marker, - linestyle=linestyle, - markevery=max(1, int(len(x) / 20)), + _plot( + metric_subplots, + data_per_trial, + computational_cost, + compare_iterations_and_computational_cost, + x_axis_scaling, + agent_states, + alg, + metric_index, + i, ) - y_min, y_max = _calculate_envelope(data_per_trial) - subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) progress.advance(plot_task) - subplot.legend() progress.update(plot_task, status="Finalizing plots") + + # Create a single legend at the top of the figure + handles, labels = metric_subplots[0].get_legend_handles_labels() + fig.legend(handles, labels, loc="upper center", ncol=min(len(labels), 4), bbox_to_anchor=(0.5, 1.0), frameon=True) + manager = plt.get_current_fig_manager() if not manager: raise RuntimeError("Something went wrong, did not receive a FigureManager...") - plt.tight_layout(pad=1.2) + plt.tight_layout(rect=(0, 0, 1, 0.92)) # Leave space at the top for the legend plt.show() -def _create_metric_subplots(metrics: list[PlotMetric], use_cost: bool) -> list[tuple[PlotMetric, SubPlot]]: - subplots_per_row = 2 - n_metrics = len(metrics) - n_rows = math.ceil(n_metrics / subplots_per_row) - fig, subplots = plt.subplots(nrows=n_rows, ncols=subplots_per_row) - subplots = subplots.flatten() - for sp in subplots[n_metrics:]: +def _create_metric_subplots( + metrics: list[PlotMetric], use_cost: bool, compare_iterations_and_computational_cost: bool +) -> tuple[Figure, list[SubPlot]]: + subplots_per_row = 2 if use_cost and compare_iterations_and_computational_cost else 1 + n_plots = len(metrics) * (2 if use_cost and compare_iterations_and_computational_cost else 1) + n_rows = math.ceil(n_plots / subplots_per_row) + # Calculate figure size: width per column + height per row, with extra height for legend + fig_width = 6 * subplots_per_row + fig_height = 4 * n_rows + 2 # +2 for legend space + fig, subplot_axes = plt.subplots( + nrows=n_rows, + ncols=subplots_per_row, + figsize=(fig_width, fig_height), + sharex="col", + sharey="row", + ) + subplots: list[SubPlot] = subplot_axes.flatten() + for sp in subplots[n_plots:]: fig.delaxes(sp) - metric_subplots = list(zip(metrics, subplots[:n_metrics], strict=True)) - for metric, sp in metric_subplots: - sp.set_xlabel(_get_formatted_x_label(metric.x_label, use_cost)) - sp.set_ylabel(metric.y_label) + + x_label = X_LABELS["computational_cost" if use_cost else "iterations"] + for i in range(n_plots): + metric = metrics[i // (2 if use_cost and compare_iterations_and_computational_cost else 1)] + sp = subplots[i] + + # Only set x label for subplots in the last row + row = i // subplots_per_row + if row == n_rows - 1: + if use_cost and compare_iterations_and_computational_cost and i % 2 == 1: + sp.set_xlabel(X_LABELS["iterations"]) + else: + sp.set_xlabel(x_label) + + # Only set y label for left column subplots + if use_cost and compare_iterations_and_computational_cost and i % 2 == 0: + sp.set_ylabel(metric.y_label) + if metric.x_log: sp.set_xscale("log") if metric.y_log: sp.set_yscale("log") - return metric_subplots + + sp.grid(True, which="both", linestyle="--", linewidth=0.5, alpha=0.7) # noqa: FBT003 + + return fig, subplots[:n_plots] + + +def _is_finite(data_per_trial: list[Sequence[tuple[X, Y]]]) -> bool: + flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] + return np.isfinite(flattened_data).all().item() + + +def _plot( # noqa: PLR0917 + metric_subplots: list[SubPlot], + data_per_trial: list[Sequence[tuple[X, Y]]], + computational_cost: ComputationalCost | None, + compare_iterations_and_computational_cost: bool, + x_axis_scaling: float, + agent_states: list[list[AgentMetricsView]], + alg: Algorithm, + metric_index: int, + iteration: int, +) -> None: + use_cost = computational_cost is not None + subplot_idx = metric_index * (2 if use_cost and compare_iterations_and_computational_cost else 1) + + mean_curve: Sequence[tuple[X, Y]] = _calculate_mean_curve(data_per_trial) + x, y_mean = zip(*mean_curve, strict=True) + y_min, y_max = _calculate_envelope(data_per_trial) + if computational_cost is not None: + total_computational_cost = _calc_total_cost(agent_states, computational_cost) + x_computational = tuple(val * total_computational_cost * x_axis_scaling for val in x) + if compare_iterations_and_computational_cost: + # Plot value vs iterations subplot first + iter_idx = metric_index * 2 + 1 + _plot_subplot(metric_subplots[iter_idx], x, y_mean, y_min, y_max, alg.name, iteration) + x = x_computational + _plot_subplot(metric_subplots[subplot_idx], x, y_mean, y_min, y_max, alg.name, iteration) + + +def _plot_subplot( # noqa: PLR0917 + subplot: SubPlot, + x: Sequence[float], + y_mean: Sequence[float], + y_min: Sequence[float], + y_max: Sequence[float], + label: str, + iteration: int, +) -> None: + marker, linestyle, color = _get_marker_style_color(iteration) + subplot.plot( + x, + y_mean, + label=label, + color=color, + marker=marker, + linestyle=linestyle, + markevery=max(1, int(len(x) / 10)), + ) + subplot.fill_between(x, y_min, y_max, color=color, alpha=0.1) def _get_marker_style_color( @@ -258,10 +353,6 @@ def _get_marker_style_color( return MARKERS[marker_idx], STYLES[style_idx], COLORS[color_idx] -def _get_formatted_x_label(x_label: str, use_cost: bool) -> str: - return f"{x_label} (computational cost units)" if use_cost else x_label - - def _calc_total_cost(agent_states: list[list[AgentMetricsView]], computational_cost: ComputationalCost) -> float: mean_function_calls = np.mean([a.n_function_calls for agents in agent_states for a in agents]) mean_gradient_calls = np.mean([a.n_gradient_calls for agents in agent_states for a in agents]) diff --git a/docs/source/api/snippets/computational_cost.rst b/docs/source/api/snippets/computational_cost.rst index 9d9c95b..7add96a 100644 --- a/docs/source/api/snippets/computational_cost.rst +++ b/docs/source/api/snippets/computational_cost.rst @@ -1,6 +1,6 @@ Computational cost is calculated as: -.. math:: +.. math:: \text{Total Cost} = c_f N_f + c_g N_g + c_h N_h + c_p N_p + c_c N_c where :math:`c_f, c_g, c_h, c_p, c_c` are the costs per function, gradient, Hessian, proximal, and communication diff --git a/test/test_agents.py b/test/test_agents.py index 3d7819f..d1b2652 100644 --- a/test/test_agents.py +++ b/test/test_agents.py @@ -236,7 +236,7 @@ def assert_state(expected_x, expected_history): ) steps = sorted(agent._x_history.keys()) assert steps == list(range(0, history_period * (len(expected_history)), history_period)), ( - f"Expected history steps: {list(range(0, history_period * (len(expecteded_history)), history_period))}, " + f"Expected history steps: {list(range(0, history_period * (len(expected_history)), history_period))}, " f"but got: {steps}" ) for i, expected in zip(steps, expected_history, strict=True): From 3c202e2a6e02ada7ed53def94e920c950bf3ac08 Mon Sep 17 00:00:00 2001 From: simpag Date: Sat, 20 Dec 2025 17:25:41 +0100 Subject: [PATCH 08/15] fix(Agent): Change history_period to state_snapshot_period --- decent_bench/agents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/decent_bench/agents.py b/decent_bench/agents.py index 04ad881..51ebb10 100644 --- a/decent_bench/agents.py +++ b/decent_bench/agents.py @@ -11,16 +11,16 @@ class Agent: - """Agent with unique id, local cost function, activation scheme and history period.""" + """Agent with unique id, local cost function, activation scheme and state snapshot period.""" - def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme, history_period: int): - if history_period <= 0: - raise ValueError("history_period must be a positive integer") + def __init__(self, agent_id: int, cost: Cost, activation: AgentActivationScheme, state_snapshot_period: int): + if state_snapshot_period <= 0: + raise ValueError("state_snapshot_period must be a positive integer") self._id = agent_id self._cost = cost self._activation = activation - self._history_period = history_period + self._state_snapshot_period = state_snapshot_period self._current_x: Array | None = None self._x_history: dict[int, Array] = {} self._auxiliary_variables: dict[str, Array] = {} @@ -73,7 +73,7 @@ def x(self) -> Array: def x(self, x: Array) -> None: self._n_x_updates += 1 self._current_x = x - if self._n_x_updates % self._history_period == 0: + if self._n_x_updates % self._state_snapshot_period == 0: self._x_history[self._n_x_updates] = iop.copy(x) @property From d9f5372c8b8e5692ccb8cd1ab706b090e2988d62 Mon Sep 17 00:00:00 2001 From: simpag Date: Sat, 20 Dec 2025 17:28:31 +0100 Subject: [PATCH 09/15] fix(Test/Agent): Fix initial argument in test --- test/test_agents.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/test_agents.py b/test/test_agents.py index d1b2652..58f0e73 100644 --- a/test/test_agents.py +++ b/test/test_agents.py @@ -78,7 +78,7 @@ ) def test_in_place_operations_history(framework: SupportedFrameworks, device: SupportedDevices): """Test that in-place operations on agent.x properly update the history.""" - agent = Agent(0, LinearRegressionCost(np.array([[1.0, 1.0, 1.0]]), np.array([1.0])), None, history_period=1) # type: ignore # noqa: PGH003 + agent = Agent(0, LinearRegressionCost(np.array([[1.0, 1.0, 1.0]]), np.array([1.0])), None, state_snapshot_period=1) # type: ignore # noqa: PGH003 initial = iop.zeros((3,), framework=framework, device=device) agent.initialize(x=initial) @@ -210,14 +210,14 @@ def assert_state(expected_x, expected_history): ), ], ) -@pytest.mark.parametrize("history_period", [1, 5, 10]) -def test_agent_state_snapshot_period(framework: SupportedFrameworks, device: SupportedDevices, history_period: int): +@pytest.mark.parametrize("state_snapshot_period", [1, 5, 10]) +def test_agent_state_snapshot_period(framework: SupportedFrameworks, device: SupportedDevices, state_snapshot_period: int): """Test that agent history is recorded according to the specified history period.""" agent = Agent( 0, LinearRegressionCost(np.array([[1.0, 1.0, 1.0]]), np.array([1.0])), None, - history_period=history_period, + state_snapshot_period=state_snapshot_period, ) initial = iop.zeros((3,), framework=framework, device=device) @@ -235,8 +235,8 @@ def assert_state(expected_x, expected_history): f"Expected history length: {len(expected_history)}, but got: {len(agent._x_history)}" ) steps = sorted(agent._x_history.keys()) - assert steps == list(range(0, history_period * (len(expected_history)), history_period)), ( - f"Expected history steps: {list(range(0, history_period * (len(expected_history)), history_period))}, " + assert steps == list(range(0, state_snapshot_period * (len(expected_history)), state_snapshot_period)), ( + f"Expected history steps: {list(range(0, state_snapshot_period * (len(expected_history)), state_snapshot_period))}, " f"but got: {steps}" ) for i, expected in zip(steps, expected_history, strict=True): @@ -248,7 +248,7 @@ def assert_state(expected_x, expected_history): ) expected_history_length = 5 # Excluding the initial state, so +1 later - n_updates = expected_history_length * history_period + n_updates = expected_history_length * state_snapshot_period for _ in range(n_updates): agent.x += 1.0 @@ -258,7 +258,7 @@ def assert_state(expected_x, expected_history): np.array([0.0, 0.0, 0.0]), ] + [ - np.array([i * history_period, i * history_period, i * history_period]) + np.array([i * state_snapshot_period, i * state_snapshot_period, i * state_snapshot_period]) for i in range(1, expected_history_length + 1) ], ) From 92fae583c7af508db2d4e13f8dbc8d1877d1daf9 Mon Sep 17 00:00:00 2001 From: simpag Date: Mon, 22 Dec 2025 20:12:51 +0100 Subject: [PATCH 10/15] fix(Plots): Fix plotting --- decent_bench/benchmark.py | 7 ++ decent_bench/metrics/metric_utils.py | 4 + decent_bench/metrics/plot_metrics.py | 160 +++++++++++++++++++------- decent_bench/metrics/table_metrics.py | 36 +++--- docs/source/user.rst | 5 +- 5 files changed, 149 insertions(+), 63 deletions(-) diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index dc12cb4..c5fd909 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -31,6 +31,8 @@ def benchmark( table_metrics: list[TableMetric] = DEFAULT_TABLE_METRICS, table_fmt: Literal["grid", "latex"] = "grid", *, + plot_grid: bool = True, + plot_path: str | None = None, computational_cost: pm.ComputationalCost | None = None, x_axis_scaling: float = 1e-4, n_trials: int = 30, @@ -54,6 +56,9 @@ def benchmark( table_metrics: metrics to tabulate as confidence intervals after the execution, defaults to :const:`~decent_bench.metrics.table_metrics.DEFAULT_TABLE_METRICS` table_fmt: table format, grid is suitable for the terminal while latex can be copy-pasted into a latex document + plot_grid: whether to show grid lines on the plots + plot_path: optional file path to save the generated plot as an image file. If ``None``, the plot will + only be displayed computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more @@ -109,6 +114,8 @@ def benchmark( computational_cost, x_axis_scaling, compare_iterations_and_computational_cost, + plot_path, + plot_grid, ) LOGGER.info("Benchmark execution complete, thanks for using decent-bench") log_listener.stop() diff --git a/decent_bench/metrics/metric_utils.py b/decent_bench/metrics/metric_utils.py index cb8fda6..febf36a 100644 --- a/decent_bench/metrics/metric_utils.py +++ b/decent_bench/metrics/metric_utils.py @@ -160,6 +160,10 @@ def common_sorted_iterations(agents: Sequence[AgentMetricsView]) -> list[int]: """ Get a sorted list of all common iterations reached by agents in *agents*. + Since the agents can sample their states periodically, and may sample at different iterations, + this function returns only the iterations that are common to all agents. These iterations can then be used + to compute metrics that require synchronized iterations. + Args: agents: sequence of agents to get the common iterations from diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 886cc02..5f90e87 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -47,7 +47,7 @@ def __init__(self, *, x_log: bool = False, y_log: bool = True): @property @abstractmethod - def y_label(self) -> str: + def plot_description(self) -> str: """Label for the y-axis.""" @abstractmethod @@ -68,7 +68,7 @@ class RegretPerIteration(PlotMetric): its calculation. """ - y_label: str = "regret" + plot_description: str = "regret" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 # Determine the set of recorded iterations common to all agents and use those @@ -88,7 +88,7 @@ class GradientNormPerIteration(PlotMetric): included in the calculation. """ - y_label: str = "gradient norm" + plot_description: str = "gradient norm" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[tuple[X, Y]]: # noqa: D102 # Determine the set of recorded iterations common to all agents and use those @@ -137,6 +137,8 @@ def plot( # noqa: PLR0917 computational_cost: ComputationalCost | None, x_axis_scaling: float = 1e-4, compare_iterations_and_computational_cost: bool = False, + plot_path: str | None = None, + plot_grid: bool = True, ) -> None: """ Plot the execution results with one subplot per metric. @@ -151,9 +153,12 @@ def plot( # noqa: PLR0917 computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more - manageable units for plotting. Only used if ``computational_cost`` is provided. + manageable units for plotting. Only used if ``computational_cost`` is provided compare_iterations_and_computational_cost: whether to plot both metric vs computational cost and - metric vs iterations. Only used if ``computational_cost`` is provided. + metric vs iterations. Only used if ``computational_cost`` is provided + plot_path: optional file path to save the generated plot as an image file. If ``None``, the plot will + only be displayed + plot_grid: whether to show grid lines on the plots Note: Computational cost can be interpreted as the cost of running the algorithm on a specific hardware setup. @@ -162,9 +167,6 @@ def plot( # noqa: PLR0917 .. include:: snippets/computational_cost.rst - Raises: - RuntimeError: if the figure manager can't be retrieved - """ if not metrics: return @@ -176,21 +178,29 @@ def plot( # noqa: PLR0917 "Consider reducing the number of metrics for better readability." ) + did_plot = False use_cost = computational_cost is not None - fig, metric_subplots = _create_metric_subplots(metrics, use_cost, compare_iterations_and_computational_cost) + two_columns = use_cost and compare_iterations_and_computational_cost + fig, subplots, n_cols = _create_metric_subplots( + metrics, + list(resulting_agent_states.keys()), + use_cost, + compare_iterations_and_computational_cost, + plot_grid, + ) + metric_subplots = subplots[n_cols:] + legend_subplots = subplots[:n_cols] with utils.MetricProgressBar() as progress: plot_task = progress.add_task( "Generating plots", - total=len(metric_subplots) - * len(resulting_agent_states) - // (2 if use_cost and compare_iterations_and_computational_cost else 1), + total=len(metric_subplots) * len(resulting_agent_states) // (2 if two_columns else 1), status="", ) x_label = X_LABELS["computational_cost" if use_cost else "iterations"] for metric_index in range(len(metrics)): progress.update( plot_task, - status=f"Task: {metrics[metric_index].y_label} vs {x_label}", + status=f"Task: {metrics[metric_index].plot_description} vs {x_label}", ) for i, (alg, agent_states) in enumerate(resulting_agent_states.items()): data_per_trial: list[Sequence[tuple[X, Y]]] = _get_data_per_trial( @@ -198,10 +208,11 @@ def plot( # noqa: PLR0917 ) if not _is_finite(data_per_trial): msg = ( - f"Skipping plot {metrics[metric_index].y_label}/{x_label} " + f"Skipping plot {metrics[metric_index].plot_description}/{x_label} " f"for {alg.name}: found nan or inf in datapoints." ) LOGGER.warning(msg) + progress.advance(plot_task) continue _plot( metric_subplots, @@ -214,65 +225,130 @@ def plot( # noqa: PLR0917 metric_index, i, ) + did_plot = True progress.advance(plot_task) progress.update(plot_task, status="Finalizing plots") - # Create a single legend at the top of the figure - handles, labels = metric_subplots[0].get_legend_handles_labels() - fig.legend(handles, labels, loc="upper center", ncol=min(len(labels), 4), bbox_to_anchor=(0.5, 1.0), frameon=True) + if not did_plot: + LOGGER.warning("No plots were generated due to invalid data.") + return + + _show_figure(fig, metric_subplots, legend_subplots, plot_path) + +def _show_figure( + fig: Figure, + metric_subplots: list[SubPlot], + legend_subplots: list[SubPlot], + plot_path: str | None = None, +) -> None: manager = plt.get_current_fig_manager() if not manager: raise RuntimeError("Something went wrong, did not receive a FigureManager...") - plt.tight_layout(rect=(0, 0, 1, 0.92)) # Leave space at the top for the legend + + # Create a single legend at the top of the figure + handles, labels = metric_subplots[0].get_legend_handles_labels() + label_cols = min(len(labels), 4 if len(legend_subplots) > 1 else 3) + + # Draw the canvas to calculate bounding boxes and layout + fig.canvas.draw() + + # Get the bounding box of the leftmost and rightmost subplots to align legend with plot area + left_plot = legend_subplots[0].get_position() + right_plot = legend_subplots[-1].get_position() + plot_center = (left_plot.x0 + right_plot.x1) / 2 + + # Create the legend to get the height of the legend box + fig.legend( + handles, + labels, + loc="upper center", + ncol=label_cols, + bbox_to_anchor=(plot_center, 1.0), + frameon=True, + ) + + if plot_path is not None: + fig.savefig(plot_path) + LOGGER.info(f"Saved plot to: {plot_path}") + plt.show() -def _create_metric_subplots( - metrics: list[PlotMetric], use_cost: bool, compare_iterations_and_computational_cost: bool -) -> tuple[Figure, list[SubPlot]]: - subplots_per_row = 2 if use_cost and compare_iterations_and_computational_cost else 1 +def _create_metric_subplots( # noqa: PLR0912 + metrics: list[PlotMetric], + algs: list[Algorithm], + use_cost: bool, + compare_iterations_and_computational_cost: bool, + plot_grid: bool, +) -> tuple[Figure, list[SubPlot], int]: + n_cols = 2 if use_cost and compare_iterations_and_computational_cost else 1 n_plots = len(metrics) * (2 if use_cost and compare_iterations_and_computational_cost else 1) - n_rows = math.ceil(n_plots / subplots_per_row) - # Calculate figure size: width per column + height per row, with extra height for legend - fig_width = 6 * subplots_per_row - fig_height = 4 * n_rows + 2 # +2 for legend space + n_rows = math.ceil(n_plots / n_cols) + + # Calculate space needed for legend in inches. From empirical measurements: + # One row requires 0.2511 inches of space, two rows 0.4606 inches, four rows 0.8794 inches + # and five rows 1.0899 inches. This gives us the formula: legend_space_inches = 0.2094 * label_rows + 0.0417 + label_cols = min(len(algs), 4 if n_cols == 2 else 3) + label_rows = math.ceil(len(algs) / label_cols) + legend_space_inches = 0.2094 * label_rows + 0.0417 + + # Allocate fixed space per plot (4.0 inches) plus spacing + # matplotlib's constrained layout adds ~0.5 inches of padding per row and ~0.8 inches for overall margins + fig_width = 4.0 * n_cols + 1.0 + spacing_per_row = 0.5 # space between rows and margins + fig_height = legend_space_inches + (4.0 + spacing_per_row) * n_rows + 0.3 + fig, subplot_axes = plt.subplots( - nrows=n_rows, - ncols=subplots_per_row, + nrows=n_rows + 1, # +1 to leave space for legend + ncols=n_cols, figsize=(fig_width, fig_height), + height_ratios=[legend_space_inches] + [4.0] * n_rows, sharex="col", sharey="row", + layout="constrained", + gridspec_kw={"hspace": 0.01}, ) - subplots: list[SubPlot] = subplot_axes.flatten() - for sp in subplots[n_plots:]: + if isinstance(subplot_axes, SubPlot): + subplots: list[SubPlot] = [subplot_axes] + else: + subplots = subplot_axes.flatten() + + if subplots is None: + raise RuntimeError("Something went wrong, did not receive subplot axes...") + + for i in range(n_cols): + subplots[i].axis("off") # Hide the top-left subplot reserved for legend + + for sp in subplots[n_plots + n_cols :]: fig.delaxes(sp) - x_label = X_LABELS["computational_cost" if use_cost else "iterations"] for i in range(n_plots): - metric = metrics[i // (2 if use_cost and compare_iterations_and_computational_cost else 1)] - sp = subplots[i] + metric = metrics[i // (2 if n_cols == 2 else 1)] + sp = subplots[i + n_cols] # Only set x label for subplots in the last row - row = i // subplots_per_row - if row == n_rows - 1: - if use_cost and compare_iterations_and_computational_cost and i % 2 == 1: - sp.set_xlabel(X_LABELS["iterations"]) + if i // n_cols == n_rows - 1: + # For comparison mode, right column shows iterations, left shows cost + if n_cols == 2: + sp.set_xlabel(X_LABELS["iterations"] if i % 2 == 1 else X_LABELS["computational_cost"]) else: - sp.set_xlabel(x_label) + # Single column mode: show cost if enabled, otherwise iterations + sp.set_xlabel(X_LABELS["computational_cost" if use_cost else "iterations"]) # Only set y label for left column subplots - if use_cost and compare_iterations_and_computational_cost and i % 2 == 0: - sp.set_ylabel(metric.y_label) + if i % n_cols == 0: + sp.set_ylabel(metric.plot_description) if metric.x_log: sp.set_xscale("log") if metric.y_log: sp.set_yscale("log") - sp.grid(True, which="both", linestyle="--", linewidth=0.5, alpha=0.7) # noqa: FBT003 + if plot_grid: + sp.grid(True, which="major", linestyle="--", linewidth=0.5, alpha=0.7) # noqa: FBT003 - return fig, subplots[:n_plots] + return fig, subplots[: n_plots + n_cols], n_cols def _is_finite(data_per_trial: list[Sequence[tuple[X, Y]]]) -> bool: diff --git a/decent_bench/metrics/table_metrics.py b/decent_bench/metrics/table_metrics.py index e9bcda2..70e2e2f 100644 --- a/decent_bench/metrics/table_metrics.py +++ b/decent_bench/metrics/table_metrics.py @@ -43,7 +43,7 @@ def __init__(self, statistics: list[Statistic], fmt: str = ".2e"): @property @abstractmethod - def description(self) -> str: + def table_description(self) -> str: """Metric description to display in the table.""" @abstractmethod @@ -60,7 +60,7 @@ class Regret(TableMetric): .. include:: snippets/global_cost_error.rst """ - description: str = "regret \n[<1e-9 = exact conv.]" + table_description: str = "regret \n[<1e-9 = exact conv.]" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> tuple[float]: # noqa: D102 return (utils.regret(agents, problem, iteration=-1),) @@ -75,7 +75,7 @@ class GradientNorm(TableMetric): .. include:: snippets/global_gradient_optimality.rst """ - description: str = "gradient norm" + table_description: str = "gradient norm" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> tuple[float]: # noqa: D102 return (utils.gradient_norm(agents, iteration=-1),) @@ -94,7 +94,7 @@ class XError(TableMetric): """ - description: str = "x error" + table_description: str = "x error" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 return [ @@ -110,7 +110,7 @@ class AsymptoticConvergenceOrder(TableMetric): .. include:: snippets/asymptotic_convergence_rate_and_order.rst """ - description: str = "asymptotic convergence order" + table_description: str = "asymptotic convergence order" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 return [utils.asymptotic_convergence_rate_and_order(a, problem)[1] for a in agents] @@ -123,7 +123,7 @@ class AsymptoticConvergenceRate(TableMetric): .. include:: snippets/asymptotic_convergence_rate_and_order.rst """ - description: str = "asymptotic convergence rate" + table_description: str = "asymptotic convergence rate" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 return [utils.asymptotic_convergence_rate_and_order(a, problem)[0] for a in agents] @@ -136,7 +136,7 @@ class IterativeConvergenceOrder(TableMetric): .. include:: snippets/iterative_convergence_rate_and_order.rst """ - description: str = "iterative convergence order" + table_description: str = "iterative convergence order" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 return [utils.iterative_convergence_rate_and_order(a, problem)[1] for a in agents] @@ -149,7 +149,7 @@ class IterativeConvergenceRate(TableMetric): .. include:: snippets/iterative_convergence_rate_and_order.rst """ - description: str = "iterative convergence rate" + table_description: str = "iterative convergence rate" def get_data_from_trial(self, agents: list[AgentMetricsView], problem: BenchmarkProblem) -> list[float]: # noqa: D102 return [utils.iterative_convergence_rate_and_order(a, problem)[0] for a in agents] @@ -158,7 +158,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], problem: Benchmark class XUpdates(TableMetric): """Number of iterations/updates of x per agent.""" - description: str = "nr x updates" + table_description: str = "nr x updates" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_x_updates for a in agents] @@ -167,7 +167,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class FunctionCalls(TableMetric): """Number of cost function evaluate calls per agent.""" - description: str = "nr function calls" + table_description: str = "nr function calls" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_function_calls for a in agents] @@ -176,7 +176,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class GradientCalls(TableMetric): """Number of cost function gradient calls per agent.""" - description: str = "nr gradient calls" + table_description: str = "nr gradient calls" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_gradient_calls for a in agents] @@ -185,7 +185,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class HessianCalls(TableMetric): """Number of cost function hessian calls per agent.""" - description: str = "nr hessian calls" + table_description: str = "nr hessian calls" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_hessian_calls for a in agents] @@ -194,7 +194,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class ProximalCalls(TableMetric): """Number of cost function proximal calls per agent.""" - description: str = "nr proximal calls" + table_description: str = "nr proximal calls" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_proximal_calls for a in agents] @@ -203,7 +203,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class SentMessages(TableMetric): """Number of sent messages per agent.""" - description: str = "nr sent messages" + table_description: str = "nr sent messages" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_sent_messages for a in agents] @@ -212,7 +212,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class ReceivedMessages(TableMetric): """Number of received messages per agent.""" - description: str = "nr received messages" + table_description: str = "nr received messages" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_received_messages for a in agents] @@ -221,7 +221,7 @@ def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProble class SentMessagesDropped(TableMetric): """Number of sent messages that were dropped per agent.""" - description: str = "nr sent messages dropped" + table_description: str = "nr sent messages dropped" def get_data_from_trial(self, agents: list[AgentMetricsView], _: BenchmarkProblem) -> list[float]: # noqa: D102 return [a.n_sent_messages_dropped for a in agents] @@ -299,10 +299,10 @@ def tabulate( n_statistics = sum(len(metric.statistics) for metric in metrics) table_task = progress.add_task("Generating table", total=n_statistics, status="") for metric in metrics: - progress.update(table_task, status=f"Task: {metric.description}") + progress.update(table_task, status=f"Task: {metric.table_description}") data_per_trial = [_data_per_trial(resulting_agent_states[a], problem, metric) for a in algs] for statistic in metric.statistics: - row = [f"{metric.description} ({statistics_abbr.get(statistic.__name__) or statistic.__name__})"] + row = [f"{metric.table_description} ({statistics_abbr.get(statistic.__name__) or statistic.__name__})"] for i in range(len(algs)): agg_data_per_trial = [statistic(trial) for trial in data_per_trial[i]] mean, margin_of_error = _calculate_mean_and_margin_of_error(agg_data_per_trial, confidence_level) diff --git a/docs/source/user.rst b/docs/source/user.rst index c8da1ef..a15a388 100644 --- a/docs/source/user.rst +++ b/docs/source/user.rst @@ -378,7 +378,7 @@ Create your own metrics to tabulate and/or plot. return float(la.norm(iop.to_numpy(problem.optimal_x) - iop.to_numpy(agent.x_per_iteration[i]))) class XError(TableMetric): - description: str = "x error" + table_description: str = "x error" def get_data_from_trial( self, agents: list[AgentMetricsView], problem: BenchmarkProblem @@ -386,8 +386,7 @@ Create your own metrics to tabulate and/or plot. return [x_error_at_iter(a, problem) for a in agents] class MaxXErrorPerIteration(PlotMetric): - x_label: str = "iteration" - y_label: str = "max x error" + plot_description: str = "max x error" def get_data_from_trial( self, agents: list[AgentMetricsView], problem: BenchmarkProblem From 14f41e0573572688c0287d7ecd4ad89d7ee4b623 Mon Sep 17 00:00:00 2001 From: simpag Date: Mon, 22 Dec 2025 20:25:52 +0100 Subject: [PATCH 11/15] ref(Plot): Reorder functions --- decent_bench/metrics/plot_metrics.py | 78 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 5f90e87..98728ba 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -236,45 +236,6 @@ def plot( # noqa: PLR0917 _show_figure(fig, metric_subplots, legend_subplots, plot_path) -def _show_figure( - fig: Figure, - metric_subplots: list[SubPlot], - legend_subplots: list[SubPlot], - plot_path: str | None = None, -) -> None: - manager = plt.get_current_fig_manager() - if not manager: - raise RuntimeError("Something went wrong, did not receive a FigureManager...") - - # Create a single legend at the top of the figure - handles, labels = metric_subplots[0].get_legend_handles_labels() - label_cols = min(len(labels), 4 if len(legend_subplots) > 1 else 3) - - # Draw the canvas to calculate bounding boxes and layout - fig.canvas.draw() - - # Get the bounding box of the leftmost and rightmost subplots to align legend with plot area - left_plot = legend_subplots[0].get_position() - right_plot = legend_subplots[-1].get_position() - plot_center = (left_plot.x0 + right_plot.x1) / 2 - - # Create the legend to get the height of the legend box - fig.legend( - handles, - labels, - loc="upper center", - ncol=label_cols, - bbox_to_anchor=(plot_center, 1.0), - frameon=True, - ) - - if plot_path is not None: - fig.savefig(plot_path) - LOGGER.info(f"Saved plot to: {plot_path}") - - plt.show() - - def _create_metric_subplots( # noqa: PLR0912 metrics: list[PlotMetric], algs: list[Algorithm], @@ -351,6 +312,45 @@ def _create_metric_subplots( # noqa: PLR0912 return fig, subplots[: n_plots + n_cols], n_cols +def _show_figure( + fig: Figure, + metric_subplots: list[SubPlot], + legend_subplots: list[SubPlot], + plot_path: str | None = None, +) -> None: + manager = plt.get_current_fig_manager() + if not manager: + raise RuntimeError("Something went wrong, did not receive a FigureManager...") + + # Create a single legend at the top of the figure + handles, labels = metric_subplots[0].get_legend_handles_labels() + label_cols = min(len(labels), 4 if len(legend_subplots) > 1 else 3) + + # Draw the canvas to calculate bounding boxes and layout + fig.canvas.draw() + + # Get the bounding box of the leftmost and rightmost subplots to align legend with plot area + left_plot = legend_subplots[0].get_position() + right_plot = legend_subplots[-1].get_position() + plot_center = (left_plot.x0 + right_plot.x1) / 2 + + # Create the legend to get the height of the legend box + fig.legend( + handles, + labels, + loc="upper center", + ncol=label_cols, + bbox_to_anchor=(plot_center, 1.0), + frameon=True, + ) + + if plot_path is not None: + fig.savefig(plot_path) + LOGGER.info(f"Saved plot to: {plot_path}") + + plt.show() + + def _is_finite(data_per_trial: list[Sequence[tuple[X, Y]]]) -> bool: flattened_data: list[tuple[X, Y]] = [d for trial in data_per_trial for d in trial] return np.isfinite(flattened_data).all().item() From fe4b547668815b0c95daae6260ebb505b0857aac Mon Sep 17 00:00:00 2001 From: simpag Date: Mon, 22 Dec 2025 20:33:35 +0100 Subject: [PATCH 12/15] fix(Networks): Add snapshot period to FED network creation --- decent_bench/networks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/decent_bench/networks.py b/decent_bench/networks.py index 09612b1..90fc7ca 100644 --- a/decent_bench/networks.py +++ b/decent_bench/networks.py @@ -518,7 +518,10 @@ def create_federated_network(problem: BenchmarkProblem) -> FedNetwork: server, max_degree = max(degrees.items(), key=lambda item: item[1]) # noqa: FURB118 if max_degree != n_agents - 1 or any(deg != 1 for node, deg in degrees.items() if node != server): raise ValueError("Federated network requires a star topology (one server connected to all clients)") - agents = [Agent(i, problem.costs[i], problem.agent_activations[i]) for i in range(n_agents)] + agents = [ + Agent(i, problem.costs[i], problem.agent_activations[i], problem.agent_state_snapshot_period) + for i in range(n_agents) + ] agent_node_map = {node: agents[i] for i, node in enumerate(problem.network_structure.nodes())} graph = nx.relabel_nodes(problem.network_structure, agent_node_map) return FedNetwork( From 620b0381a879e921c4c02fb56a9ba8c9a53ebe16 Mon Sep 17 00:00:00 2001 From: simpag Date: Mon, 22 Dec 2025 20:50:40 +0100 Subject: [PATCH 13/15] ref(Plot): Update arg doc --- decent_bench/benchmark.py | 4 ++-- decent_bench/metrics/plot_metrics.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/decent_bench/benchmark.py b/decent_bench/benchmark.py index c5fd909..3a953cd 100644 --- a/decent_bench/benchmark.py +++ b/decent_bench/benchmark.py @@ -57,8 +57,8 @@ def benchmark( :const:`~decent_bench.metrics.table_metrics.DEFAULT_TABLE_METRICS` table_fmt: table format, grid is suitable for the terminal while latex can be copy-pasted into a latex document plot_grid: whether to show grid lines on the plots - plot_path: optional file path to save the generated plot as an image file. If ``None``, the plot will - only be displayed + plot_path: optional file path to save the generated plot as an image file (e.g., "plots.png"). If ``None``, + the plot will only be displayed computational_cost: computational cost settings for plot metrics, if ``None`` x-axis will be iterations instead of computational cost x_axis_scaling: scaling factor for computational cost x-axis, used to convert the cost units into more diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 98728ba..786479a 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -156,8 +156,8 @@ def plot( # noqa: PLR0917 manageable units for plotting. Only used if ``computational_cost`` is provided compare_iterations_and_computational_cost: whether to plot both metric vs computational cost and metric vs iterations. Only used if ``computational_cost`` is provided - plot_path: optional file path to save the generated plot as an image file. If ``None``, the plot will - only be displayed + plot_path: optional file path to save the generated plot as an image file (e.g., "plots.png"). If ``None``, + the plot will only be displayed plot_grid: whether to show grid lines on the plots Note: @@ -345,7 +345,7 @@ def _show_figure( ) if plot_path is not None: - fig.savefig(plot_path) + fig.savefig(plot_path, dpi=300) LOGGER.info(f"Saved plot to: {plot_path}") plt.show() From a28e95c966a1cbf881f7828c753c9e30c18cc9cf Mon Sep 17 00:00:00 2001 From: simpag Date: Tue, 23 Dec 2025 17:24:19 +0100 Subject: [PATCH 14/15] fix(Plot): Allow matplotlib to handle layout --- decent_bench/metrics/plot_metrics.py | 60 +++++++--------------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/decent_bench/metrics/plot_metrics.py b/decent_bench/metrics/plot_metrics.py index 786479a..8729b87 100644 --- a/decent_bench/metrics/plot_metrics.py +++ b/decent_bench/metrics/plot_metrics.py @@ -181,19 +181,16 @@ def plot( # noqa: PLR0917 did_plot = False use_cost = computational_cost is not None two_columns = use_cost and compare_iterations_and_computational_cost - fig, subplots, n_cols = _create_metric_subplots( + fig, metric_subplots = _create_metric_subplots( metrics, - list(resulting_agent_states.keys()), use_cost, compare_iterations_and_computational_cost, plot_grid, ) - metric_subplots = subplots[n_cols:] - legend_subplots = subplots[:n_cols] with utils.MetricProgressBar() as progress: plot_task = progress.add_task( "Generating plots", - total=len(metric_subplots) * len(resulting_agent_states) // (2 if two_columns else 1), + total=len(metric_subplots) * len(resulting_agent_states), status="", ) x_label = X_LABELS["computational_cost" if use_cost else "iterations"] @@ -212,7 +209,7 @@ def plot( # noqa: PLR0917 f"for {alg.name}: found nan or inf in datapoints." ) LOGGER.warning(msg) - progress.advance(plot_task) + progress.advance(plot_task, 2 if two_columns else 1) continue _plot( metric_subplots, @@ -226,49 +223,32 @@ def plot( # noqa: PLR0917 i, ) did_plot = True - progress.advance(plot_task) + progress.advance(plot_task, 2 if two_columns else 1) progress.update(plot_task, status="Finalizing plots") if not did_plot: LOGGER.warning("No plots were generated due to invalid data.") return - _show_figure(fig, metric_subplots, legend_subplots, plot_path) + _show_figure(fig, metric_subplots, two_columns, plot_path) -def _create_metric_subplots( # noqa: PLR0912 +def _create_metric_subplots( metrics: list[PlotMetric], - algs: list[Algorithm], use_cost: bool, compare_iterations_and_computational_cost: bool, plot_grid: bool, -) -> tuple[Figure, list[SubPlot], int]: +) -> tuple[Figure, list[SubPlot]]: n_cols = 2 if use_cost and compare_iterations_and_computational_cost else 1 - n_plots = len(metrics) * (2 if use_cost and compare_iterations_and_computational_cost else 1) + n_plots = len(metrics) * n_cols n_rows = math.ceil(n_plots / n_cols) - # Calculate space needed for legend in inches. From empirical measurements: - # One row requires 0.2511 inches of space, two rows 0.4606 inches, four rows 0.8794 inches - # and five rows 1.0899 inches. This gives us the formula: legend_space_inches = 0.2094 * label_rows + 0.0417 - label_cols = min(len(algs), 4 if n_cols == 2 else 3) - label_rows = math.ceil(len(algs) / label_cols) - legend_space_inches = 0.2094 * label_rows + 0.0417 - - # Allocate fixed space per plot (4.0 inches) plus spacing - # matplotlib's constrained layout adds ~0.5 inches of padding per row and ~0.8 inches for overall margins - fig_width = 4.0 * n_cols + 1.0 - spacing_per_row = 0.5 # space between rows and margins - fig_height = legend_space_inches + (4.0 + spacing_per_row) * n_rows + 0.3 - fig, subplot_axes = plt.subplots( - nrows=n_rows + 1, # +1 to leave space for legend + nrows=n_rows, ncols=n_cols, - figsize=(fig_width, fig_height), - height_ratios=[legend_space_inches] + [4.0] * n_rows, sharex="col", sharey="row", layout="constrained", - gridspec_kw={"hspace": 0.01}, ) if isinstance(subplot_axes, SubPlot): subplots: list[SubPlot] = [subplot_axes] @@ -278,15 +258,12 @@ def _create_metric_subplots( # noqa: PLR0912 if subplots is None: raise RuntimeError("Something went wrong, did not receive subplot axes...") - for i in range(n_cols): - subplots[i].axis("off") # Hide the top-left subplot reserved for legend - for sp in subplots[n_plots + n_cols :]: fig.delaxes(sp) for i in range(n_plots): metric = metrics[i // (2 if n_cols == 2 else 1)] - sp = subplots[i + n_cols] + sp = subplots[i] # Only set x label for subplots in the last row if i // n_cols == n_rows - 1: @@ -309,13 +286,13 @@ def _create_metric_subplots( # noqa: PLR0912 if plot_grid: sp.grid(True, which="major", linestyle="--", linewidth=0.5, alpha=0.7) # noqa: FBT003 - return fig, subplots[: n_plots + n_cols], n_cols + return fig, subplots[:n_plots] def _show_figure( fig: Figure, metric_subplots: list[SubPlot], - legend_subplots: list[SubPlot], + two_columns: bool, plot_path: str | None = None, ) -> None: manager = plt.get_current_fig_manager() @@ -324,23 +301,14 @@ def _show_figure( # Create a single legend at the top of the figure handles, labels = metric_subplots[0].get_legend_handles_labels() - label_cols = min(len(labels), 4 if len(legend_subplots) > 1 else 3) - - # Draw the canvas to calculate bounding boxes and layout - fig.canvas.draw() - - # Get the bounding box of the leftmost and rightmost subplots to align legend with plot area - left_plot = legend_subplots[0].get_position() - right_plot = legend_subplots[-1].get_position() - plot_center = (left_plot.x0 + right_plot.x1) / 2 + label_cols = min(len(labels), 4 if two_columns else 3) # Create the legend to get the height of the legend box fig.legend( handles, labels, - loc="upper center", + loc="outside upper center", ncol=label_cols, - bbox_to_anchor=(plot_center, 1.0), frameon=True, ) From 55d1a657212ba4e797331e1964d172bf47c6afa1 Mon Sep 17 00:00:00 2001 From: simpag Date: Tue, 23 Dec 2025 18:56:21 +0100 Subject: [PATCH 15/15] docs(User): Update example plot --- docs/source/_static/plot.png | Bin 100998 -> 146156 bytes docs/source/user.rst | 9 +++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/source/_static/plot.png b/docs/source/_static/plot.png index 0e77c24c2848d586be66f58fbbc06541f86a80d8..731ffdad478064a79c6c4b74a9e52d6882218042 100644 GIT binary patch literal 146156 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 XOXP=``N`MhZ?z8UA4uPK^4EU@Ep0~a literal 100998 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