Source code for decent_bench.distributed_algorithms

from abc import ABC, abstractmethod
from dataclasses import dataclass

import numpy as np
from numpy import float64
from numpy.typing import NDArray

from decent_bench.network import Network


[docs] class DstAlgorithm(ABC): """Distributed algorithm - agents collaborate to solve an optimization problem using peer-to-peer communication.""" @property @abstractmethod def name(self) -> str: """Name of the algorithm."""
[docs] @abstractmethod def run(self, network: Network) -> None: """ Run the algorithm. Args: network: provides agents, neighbors etc. """
[docs] @dataclass(eq=False) class DGD(DstAlgorithm): r""" Distributed gradient descent characterized by the update step below. .. math:: \mathbf{x}_{i, k+1} = (\sum_{j} \mathbf{W}_{ij} \mathbf{x}_{j,k}) - \rho \nabla f_i(\mathbf{x}_{i,k}) where :math:`\mathbf{x}_{i, k}` is agent i's local optimization variable at iteration k, j is a neighbor of i or i itself, :math:`\mathbf{W}_{ij}` is the metropolis weight between agent i and j, :math:`\rho` is the step size, and :math:`f_i` is agent i's local cost function. """ iterations: int step_size: float name: str = "DGD"
[docs] def run(self, network: Network) -> None: r""" Run the algorithm with all :math:`\mathbf{x}` initialized using :func:`numpy.zeros`. Args: network: provides agents, neighbors etc. """ for agent in network.get_all_agents(): x0 = np.zeros(agent.cost_function.domain_shape) agent.initialize(x=x0, received_msgs=dict.fromkeys(network.get_neighbors(agent), x0)) W = network.metropolis_weights # noqa: N806 for k in range(self.iterations): for i in network.get_active_agents(k): neighborhood_avg = np.sum([W[i, j] * x_j for j, x_j in i.received_messages.items()], axis=0) neighborhood_avg += W[i, i] * i.x i.x = neighborhood_avg - self.step_size * i.cost_function.gradient(i.x) for i in network.get_active_agents(k): network.broadcast(i, i.x) for i in network.get_active_agents(k): network.receive_all(i)
[docs] @dataclass(eq=False) class GT1(DstAlgorithm): r""" Gradient tracking algorithm characterized by the update step below. .. math:: \mathbf{y}_{i, k+1} = \mathbf{x}_{i, k} - \rho \nabla f_i(\mathbf{x}_{i,k}) .. math:: \mathbf{x}_{i, k+1} = \mathbf{y}_{i, k+1} - \mathbf{y}_{i, k} + \sum_j \mathbf{W}_{ij} \mathbf{x}_{j,k} where :math:`\mathbf{x}_{i, k}` is agent i's local optimization variable at iteration k, :math:`\rho` is the step size, :math:`f_i` is agent i's local cost function, j is a neighbor of i or i itself, and :math:`\mathbf{W}_{ij}` is the metropolis weight between agent i and j. """ iterations: int step_size: float name: str = "GT1"
[docs] def run(self, network: Network) -> None: r""" Run the algorithm with all :math:`\mathbf{x}` and :math:`\mathbf{y}` initialized using :func:`numpy.zeros`. Args: network: provides agents, neighbors etc. """ for agent in network.get_all_agents(): x0 = np.zeros(agent.cost_function.domain_shape) y0 = np.zeros(agent.cost_function.domain_shape) neighbors = network.get_neighbors(agent) agent.initialize(x=x0, received_msgs=dict.fromkeys(neighbors, x0), aux_vars={"y": y0}) W = network.metropolis_weights # noqa: N806 for k in range(self.iterations): for i in network.get_active_agents(k): i.aux_vars["y_new"] = i.x - self.step_size * i.cost_function.gradient(i.x) neighborhood_avg = np.sum([W[i, j] * x_j for j, x_j in i.received_messages.items()], axis=0) neighborhood_avg += W[i, i] * i.x i.x = i.aux_vars["y_new"] - i.aux_vars["y"] + neighborhood_avg i.aux_vars["y"] = i.aux_vars["y_new"] for i in network.get_active_agents(k): network.broadcast(i, i.x) for i in network.get_active_agents(k): network.receive_all(i)
[docs] @dataclass(eq=False) class GT2(DstAlgorithm): r""" Gradient tracking algorithm characterized by the update step below. .. math:: \mathbf{y}_{i, k+1} = \mathbf{x}_{i, k} - \rho \nabla f_i(\mathbf{x}_{i,k}) .. math:: \mathbf{x}_{i, k+1} = \sum_j \frac{1}{2} (\mathbf{I} + \mathbf{W})_{ij} (\mathbf{x}_{j,k} + \mathbf{y}_{j, k+1} - \mathbf{y}_{j, k}) where :math:`\mathbf{x}_{i, k}` is agent i's local optimization variable at iteration k, :math:`\rho` is the step size, :math:`f_i` is agent i's local cost function, j is a neighbor of i or i itself, and :math:`\mathbf{W}_{ij}` is the metropolis weight between agent i and j. """ iterations: int step_size: float name: str = "GT2"
[docs] def run(self, network: Network) -> None: r""" Run the algorithm with all :math:`\mathbf{x}` and :math:`\mathbf{y}` initialized using :func:`numpy.zeros`. Args: network: provides agents, neighbors etc. """ for i in network.get_all_agents(): x0 = np.zeros(i.cost_function.domain_shape) y0 = np.zeros(i.cost_function.domain_shape) y1 = x0 - self.step_size * i.cost_function.gradient(x0) # note: msg0's y1 is an approximation of the neighbors' y1 (x0 and y0 are exact: all agents start with same) msg0 = x0 + y1 - y0 i.initialize( x=x0, aux_vars={"y": y0, "y_new": y1}, received_msgs=dict.fromkeys(network.get_neighbors(i), msg0), ) W = 0.5 * (np.eye(*(network.metropolis_weights.shape)) + network.metropolis_weights) # noqa: N806 for k in range(self.iterations): for i in network.get_active_agents(k): i.x = np.sum([W[i, j] * msg for j, msg in i.received_messages.items()], axis=0) + W[i, i] * ( i.x + i.aux_vars["y_new"] - i.aux_vars["y"] ) i.aux_vars["y"] = i.aux_vars["y_new"] i.aux_vars["y_new"] = i.x - self.step_size * i.cost_function.gradient(i.x) for i in network.get_active_agents(k): network.broadcast(i, i.x + i.aux_vars["y_new"] - i.aux_vars["y"]) for i in network.get_active_agents(k): network.receive_all(i)
[docs] @dataclass(eq=False) class ADMM(DstAlgorithm): r""" Distributed Alternating Direction Method of Multipliers characterized by the update step below. .. math:: \mathbf{x}_{i, k+1} = \operatorname{prox}_{\frac{1}{\rho N_i} f_i} \left(\sum_j \mathbf{Z}_{ij, k} \frac{1}{\rho N_i} \right) .. math:: \mathbf{Z}_{ij, k+1} = (1-\alpha) \mathbf{Z}_{ij, k} - \alpha (\mathbf{Z}_{ji, k} - 2 \rho \mathbf{x}_{j, k+1}) where :math:`\mathbf{x}_{i, k}` is agent i's local optimization variable at iteration k, :math:`\operatorname{prox}` is the proximal operator described in :meth:`CostFunction.proximal() <decent_bench.cost_functions.CostFunction.proximal>`, :math:`\rho > 0` is the Lagrangian penalty parameter, :math:`N_i` is the number of neighbors of i, :math:`f_i` is i's local cost function, j is a neighbor of i, and :math:`\alpha \in (0, 1)` is the relaxation parameter. """ iterations: int rho: float alpha: float name: str = "ADMM"
[docs] def run(self, network: Network) -> None: r""" Run the algorithm with :math:`\mathbf{Z}` initialized using :func:`numpy.zeros`. Args: network: provides agents, neighbors etc. """ pN = {i: self.rho * len(network.get_neighbors(i)) for i in network.get_all_agents()} # noqa: N806 all_agents = network.get_all_agents() for agent in all_agents: z0 = np.zeros((len(all_agents), *(agent.cost_function.domain_shape))) x1 = agent.cost_function.proximal(y=np.sum(z0, axis=0) / pN[agent], rho=1 / pN[agent]) # note: msg0's x1 is an approximation of the neighbors' x1 (z0 is exact: all agents start with same) msg0: NDArray[float64] = z0[agent] - 2 * self.rho * x1 agent.initialize( x=x1, aux_vars={"z": z0}, received_msgs=dict.fromkeys(network.get_neighbors(agent), msg0), ) for k in range(self.iterations): for i in network.get_active_agents(k): i.x = i.cost_function.proximal(y=np.sum(i.aux_vars["z"], axis=0) / pN[i], rho=1 / pN[i]) for i in network.get_active_agents(k): for j in network.get_neighbors(i): network.send(i, j, i.aux_vars["z"][j] - 2 * self.rho * i.x) for i in network.get_active_agents(k): network.receive_all(i) for i in network.get_active_agents(k): for j in network.get_neighbors(i): i.aux_vars["z"][j] = (1 - self.alpha) * i.aux_vars["z"][j] - self.alpha * (i.received_messages[j])