albcd
Distribution Statement A. Approved for public release: distribution is unlimited. Approved AFRL-2026-1671 28-09-2026.
Documentation: LSDOlab.github.io/ALBCD2
Augmented Lagrangian block coordinate descent (ALBCD) is a coordination scheme for distributed multidisciplinary design optimization (MDO) problems. To use ALBCD, MDO problems are first decomposed into subproblems. These subproblems are formulated with relaxed constraints and a new augmemted Lagrangian merit function replaces the original objective function. The ALBCD coordination scheme iteratively solves each subproblem using the block coordinate descent algorithm in an inner loop, while an outer loop enforces the relaxed constraints using the augmented Lagrangian method. In some scenarios, ALBCD can be used to reduce computational cost and/or enable geographically distributed optimization. With the addition of a proximal term, every limit point generated by ALBCD is a stationary point of the original nonconvex MDO problem.
Installation
pip install git+https://github.com/LSDOlab/ALBCD2.git
The core package only depends on numpy. The examples require modopt for optimization and either JAX or PyTorch for automatic differentiation. Some examples also require CVXOPT and/or matplotlib. To install all of the dependencies required to run the examples, install from a clone:
git clone https://github.com/LSDOlab/ALBCD2.git
cd ALBCD2
pip install -e ".[examples]"
ModOpt can be installed from GitHub
(pip install git+https://github.com/lsdolab/modopt.git@main)
Quick start
Minimize x1^2 + x2^2 - 1.5 x1 x2 subject to x1^2 + x2^2 >= 0.25. The
constraint couples the two blocks and is written as the equality
phi = 0.25 - x1^2 - x2^2 + s = 0 with a slack variable s >= 0. Block 1 owns the variables
(x1, s) and block 2 owns the variables x2. Each block's subproblem is solved with modopt's
SLSQP using gradients from PyTorch, so this needs the [examples] dependency install above.
It is a condensed version of
examples/pytorch/quadratic_global_circle.py.
import numpy as np
import modopt as mo
import torch
from albcd import ALBCD, Subproblem
torch.set_default_dtype(torch.float64) # modopt works in float64
def f(x1, x2):
return x1**2 + x2**2 - 1.5 * x1 * x2
def phi(x1, s, x2):
return 0.5**2 - (x1**2 + x2**2) + s
class Subproblem1(Subproblem):
"""Owns x1 and the slack s >= 0."""
xl = np.array([-np.inf, 0.0])
xu = np.array([np.inf, np.inf])
def objective(self, v, other, y, mu):
"""Augmented Lagrangian as a function of this block's variables v."""
x1, s, x2 = v[0], v[1], other[0]
c = phi(x1, s, x2)
return f(x1, x2) + torch.sum(y * c) + 0.5 * torch.sum(mu * c**2)
def solve(self, x, y, mu, data, outputs) -> None:
other, y, mu = (torch.as_tensor(a) for a in (self.other(x), y, mu))
obj = lambda v: self.objective(torch.as_tensor(v), other, y, mu)
prob = mo.ProblemLite(x0=self.decompose(x), obj=lambda v: float(obj(v)),
grad=lambda v: torch.func.grad(obj)(torch.as_tensor(v)).numpy(),
xl=self.xl, xu=self.xu, name=type(self).__name__)
optimizer = mo.SLSQP(prob, solver_options={'maxiter': 100, 'ftol': 1e-8}, turn_off_outputs=True)
optimizer.solve()
outputs["x"] = self.recompose(x, optimizer.results['x'])
outputs["phi"] = np.array([phi(*outputs["x"])])
def residual(self, x, y, mu, data) -> float:
"""Projected-gradient stationarity residual: zero iff v is a KKT point for xl <= v <= xu."""
v = self.decompose(x)
args = (torch.as_tensor(a) for a in (v, self.other(x), y, mu))
grad = torch.func.grad(self.objective)(*args).numpy()
return float(np.max(np.abs(v - np.clip(v - grad, self.xl, self.xu))))
class Subproblem2(Subproblem):
"""Owns x2, which is unbounded."""
xl = np.array([-np.inf])
xu = np.array([np.inf])
def objective(self, v, other, y, mu):
"""Augmented Lagrangian as a function of this block's variables v."""
x1, s, x2 = other[0], other[1], v[0]
c = phi(x1, s, x2)
return f(x1, x2) + torch.sum(y * c) + 0.5 * torch.sum(mu * c**2)
def solve(self, x, y, mu, data, outputs) -> None:
other, y, mu = (torch.as_tensor(a) for a in (self.other(x), y, mu))
obj = lambda v: self.objective(torch.as_tensor(v), other, y, mu)
prob = mo.ProblemLite(x0=self.decompose(x), obj=lambda v: float(obj(v)),
grad=lambda v: torch.func.grad(obj)(torch.as_tensor(v)).numpy(),
xl=self.xl, xu=self.xu, name=type(self).__name__)
optimizer = mo.SLSQP(prob, solver_options={'maxiter': 100, 'ftol': 1e-8}, turn_off_outputs=True)
optimizer.solve()
outputs["x"] = self.recompose(x, optimizer.results['x'])
outputs["phi"] = np.array([phi(*outputs["x"])])
def residual(self, x, y, mu, data) -> float:
"""Projected-gradient stationarity residual: zero iff v is a KKT point for xl <= v <= xu."""
v = self.decompose(x)
args = (torch.as_tensor(a) for a in (v, self.other(x), y, mu))
grad = torch.func.grad(self.objective)(*args).numpy()
return float(np.max(np.abs(v - np.clip(v - grad, self.xl, self.xu))))
opt = ALBCD(
subproblems=[Subproblem1(index=slice(0, 2)), Subproblem2(index=slice(2, 3))],
x0=np.array([-0.5, 0.0, 1.0]),
mu0=np.array([1.0]),
opt_tol=[1e-2, 1e-4],
max_inner_iter=100,
)
opt.solve()
print(opt.success, opt.x) # True, approximately [0.354 0. 0.354]: x1 = x2 = sqrt(2)/4 on the circle, s = 0
Unconstrained problems
For a problem without coupling constraints, pass unconstrained=True and omit mu0.
The subproblems then set only outputs["x"] (no "phi"), and ALBCD reduces to
block coordinate descent: each opt_tol phase runs up to max_inner_iter sweeps until the
residual reaches that phase's tolerance, with no multiplier or penalty updates.
Constraints local to a single block are still allowed. See
examples/powell.py and
2d_rosenbrock.py (JAX and PyTorch).
opt = ALBCD(subproblems, x0, unconstrained=True, opt_tol=1e-6, max_inner_iter=100)
Examples
See examples/. Every problem below is included twice, once with gradients from JAX and once from PyTorch.
| Example | Notable features |
|---|---|
quadratic_global_circle.py |
Quadratic objective with a nonlinear inequality coupling two blocks via a slack variable; the quick start problem above. |
quadratic_global_linear.py |
Quadratic objective with a linear inequality coupling two blocks. |
quadratic_global_linear_cvxopt.py |
Same problem as quadratic_global_linear.py, but each block is solved by CVXOPT using exact Hessians instead of SLSQP. |
quadratic_consensus_circle.py |
Consensus form: each block owns a full copy of the variables and handles the circle constraint locally, with coupling constraints forcing the copies to agree. |
rosenbrock_consensus.py |
Consensus form of the nonconvex Rosenbrock function. |
proximal_rosenbrock_consensus.py |
Same problem as rosenbrock_consensus.py, with a proximal term added to each block's subproblem for stabilization. |
2d_rosenbrock.py |
Unconstrained (unconstrained=True) two-dimensional Rosenbrock function with one variable per block: plain block coordinate descent zigzags along the valley to the minimum. |
powell.py is Powell's unconstrained example (unconstrained=True), on which block coordinate descent cycles around six vertices of a cube instead of converging. Each block's minimizer and residual are explicit, so it needs only NumPy and matplotlib.
Three larger examples solve engineering design optimization problems and compare against a monolithic reference:
| Example | Notable features |
|---|---|
| aerostruct/ (JAX and PyTorch) | Aerostructural wing design: minimizes drag subject to lift = weight while sizing the spar wall thickness for a tip-deflection constraint. Aerodynamics (vortex-lattice) and structure (EB-beam) are the two coupled blocks. |
| uCRM/ (JAX and PyTorch) | Multipoint wing design: minimizes average fuel burn over N missions with different payloads/ranges, each mission a block with its own copy of the wing twist coupled by consensus. |
| cart_pole/ (JAX and PyTorch) | Cart-pole co-design under uncertainty: designs the pole's length and mass together with a collocated swing-up trajectory to minimize the mean control effort over N sampled gravity/friction scenarios, each scenario a block with its own copy of the pole design coupled by consensus. |
Tests
pip install -e ".[test]"
pytest
The example tests are skipped if modopt, matplotlib or the autodiff library (JAX or PyTorch) isn't installed.
References
The following papers describe early variants of the ALBCD coordination scheme:
@inproceedings{orndorff2026distributed,
author = {Orndorff, Nicholas C. and Lupp, Christopher and Hwang, John T.},
title = {{A Distributed Algorithm for Large-Scale Multidisciplinary Design Optimization With Global Constraints}},
booktitle = {AIAA AVIATION 2026 Forum},
year = {2026},
doi = {10.2514/6.2026-4500},
}
@inproceedings{orndorff2025distributed,
author = {Orndorff, Nicholas C. and Hwang, John T.},
title = {{A Distributed Method for Solving Large-Scale Multidisciplinary Optimization Problems}},
booktitle = {AIAA AVIATION Forum and ASCEND 2025},
year = {2025},
doi = {10.2514/6.2025-3736},
}
Augmented Lagrangian block coordinate descent (ALBCD).
Solves min f(x) s.t. phi(x) = 0, where x is partitioned into
blocks, each owned by a ~albcd.Subproblem, and phi are the
coupling constraints between blocks. Constraints local to one block are
handled inside that block's subproblem; coupling inequalities can be
written as equalities with slack variables.
Each outer iteration approximately minimizes the augmented Lagrangian
L(x; y, mu) = f(x) + y^T phi(x) + 1/2 sum_i mu_i phi_i(x)^2
by sweeping over the subproblems (block coordinate descent) until every
subproblem residual is at most opt_tol. It then updates the multipliers,
y <- y + mu * phi, and multiplies mu_i by rho wherever
|phi_i| did not drop below tau times its previous value. The solve
stops once max|phi| <= feas_tol in the final phase.
With unconstrained=True there are no coupling constraints: y,
mu and phi are empty, the augmented Lagrangian is just f, and
the solve reduces to block coordinate descent. Each opt_tol phase is
one outer iteration of at most max_inner_iter sweeps, until every
subproblem residual is at most that phase's tolerance. Constraints local
to one block are still allowed.
Parameters
- subproblems (list of Subproblem): One subproblem per block, solved in list order during each sweep.
- x0 (array_like): Initial design vector.
- mu0 (array_like, optional):
Initial penalty parameters, one per coupling constraint; sets the
length of
yandphi. Required unlessunconstrained=True, in which case it must be omitted. - data0 (dict, optional): Initial values of any quantities the subproblems share, keyed by name.
- max_mu (float): Upper bound on each penalty parameter.
- rho (float): Penalty growth factor.
- tau (float): Required reduction factor of each constraint violation per outer iteration.
- feas_tol (float):
Feasibility tolerance on
max|phi|. - opt_tol (float or sequence of float): Inner loop optimality tolerance for each phase; its length sets the number of phases (e.g. a loose tolerance first, then a tight one).
- max_outer_iter (int): Maximum number of outer iterations, counted across all phases.
- max_inner_iter (int): Maximum number of sweeps per outer iteration.
- max_y (float): Bound on the magnitude of each Lagrange multiplier.
- verbose (bool): Print per-iteration diagnostics.
- unconstrained (bool):
The problem has no coupling constraints, so
mu0is omitted and no subproblem sets the"phi"output. Only block coordinate descent runs, once peropt_tolphase (max_outer_iterstill bounds the number of phases);max_mu,rho,tau,feas_tolandmax_yare unused.
Attributes
- x, y, mu, phi (ndarray):
Design vector, multipliers, penalty parameters and coupling
constraints; the solution once
solve()returns.y,muandphiare empty when unconstrained. - success (bool):
True if the solve ended feasible (
max|phi| <= feas_tol) and optimal (the last inner loop reached the finalopt_tol); when unconstrained, only optimal. - data (dict): Quantities shared between subproblems, passed to each solve() and residual().
- x_history (list of ndarray):
x0followed byxafter every subproblem solve. - feas_history (list of float):
max|phi|after every sweep (0 when unconstrained). - opt_history (list of float):
Max-norm KKT stationarity residual over all blocks after every sweep,
the value the inner loop compares with
opt_tol. Aligned withfeas_history: entryiof both describes thei-th sweep. - tf (float):
Wall-clock time of
solve()in seconds.
Base class for one block of an ALBCD problem.
Subclasses implement solve() and residual(), and can override
setup() for one-time work such as compiling functions. The solver
passes both the current design vector x, multipliers y, penalty
parameters mu and its data dictionary, and writes any output of
solve() other than x and phi into data, so blocks can
exchange additional quantities.
Parameters
- index (slice or array_like of int):
Entries of the global design vector
xowned by this block.
Minimize the augmented Lagrangian over this block with the other blocks fixed.
Must set outputs["x"], the global design vector with this block's
entries updated (see recompose()), and outputs["phi"], the
coupling constraints evaluated at that same x. For an unconstrained
problem the augmented Lagrangian is just the objective, and there is
no phi.
Return the max-norm KKT stationarity residual of this block's subproblem at x.