3.13. User-Defined Proximal Extensions¶
Read this page when the built-in modeling language gets you most of the way to the model you want, but one proximal term is missing. The goal is not to replace the whole solver interface. The goal is to teach ADMM one additional building block while keeping the rest of the model in the usual symbolic form.
A good rule of thumb is:
stay with the built-in atoms whenever the term you need already appears in Supported Building Blocks
stay with the built-in atoms when a clean equivalent form is already recognized through Symbolic Canonicalization
move to a UDF when the overall modeling pattern is still a strong fit for ADMM, but one key proximal term is missing
avoid UDFs when the difficulty is really unsupported global nonlinear structure rather than one extendable building block
A quick comparison helps:
if convex sparsity encouragement is enough, use the built-in form
lam * admm.norm(x, ord=1)if you specifically need the nonconvex count of nonzeros \(\|x\|_0\), a UDF is the right extension point
if the issue is an arbitrary coupled nonlinear program outside the documented support boundary, a UDF is usually not the right fix
This extension point is also one of the main places where ADMM differs from CVXPY. Many of the linked examples are intentionally nonconvex — they fall outside the disciplined convex programming (DCP) rules and cannot be passed to CVXPY. With ADMM and a suitable UDF, they can still be modeled directly. In the nonconvex setting, the solver acts as a practical local method and should be expected to converge to a locally optimal solution or stationary point, not a globally optimal one.
3.13.1. What a UDF Provides¶
A UDF is a Python class derived from UDFBase. It does not replace variables, constraints, or
the rest of the objective. Instead, it gives the solver the function value and the proximal step for one
custom term.
Method |
Return |
|---|---|
associated variables or expressions |
|
scalar function value |
|
list of minimizing argument values, or |
The examples below show the kinds of terms that are natural UDF candidates: exact sparsity, rank, manifold indicators, and other building blocks that are easy to describe through a proximal operator.
The UDF examples linked below are ordered from sparsity penalties to rank models to manifold or other structured indicators. The last column highlights whether each model is within the usual CVXPY DCP rules, so it is easy to see where ADMM plus UDFs extend the modeling range.
Example |
Common role |
Follows DCP rules? |
|---|---|---|
promote sparsity |
no |
|
enforce a sparsity budget |
no |
|
classical nonconvex sparsity promotion |
no |
|
promote exact group sparsity |
no |
|
promote low-rank structure |
no |
|
enforce a rank cap |
no |
|
enforce unit-norm solutions |
no |
|
enforce orthonormal columns |
no |
|
enforce probability or mixture weights |
yes |
|
model binary decisions |
no |
3.13.2. Walkthrough: The L0 Norm as a UDF¶
The tutorial below shows the full pattern on the L0 norm. It is a good first UDF example because the
data-fit term is ordinary built-in modeling, while the custom sparsity penalty is the only missing piece.
The full optimization problem is
where \(y\) is the observed vector and \(\lambda > 0\) controls how strongly sparsity is encouraged. The role of the UDF is to supply the custom term \(\|x\|_0\) in the form that ADMM needs.
The first custom method is UDFBase.eval(). It returns the scalar value of the function at a concrete
numeric point. For the L0 norm, the mathematical definition is
so eval just counts how many entries are numerically nonzero:
def eval(self, arglist):
x = np.asarray(arglist[0], dtype=float)
return float(np.count_nonzero(np.abs(x) > 1e-12))
The second custom method is UDFBase.argmin(). It returns the proximal minimizer that ADMM will call
repeatedly inside the algorithm. If the incoming point is \(v\), then this method must solve
This is the classical hard-thresholding proximal operator, so the implementation is short:
def argmin(self, lamb, arglist):
v = np.asarray(arglist[0], dtype=float)
threshold = np.sqrt(2.0 * lamb)
prox = np.where(np.abs(v) <= threshold, 0.0, v)
return [prox.tolist()]
The remaining method, UDFBase.arguments(), is the glue between the symbolic model and the numeric
arglist received by eval and argmin. In this example the custom function depends on exactly one
symbolic expression, namely \(x\), so arguments returns a one-element list. You can read this method as
“this UDF is a function of x”:
def arguments(self):
return [self.arg]
Because arguments() returns [self.arg], the first numeric argument passed later into eval(arglist)
and argmin(lamb, arglist) is the current value of that same symbolic quantity, accessed as arglist[0].
Complete runnable example:
import numpy as np
import admm
class L0Norm(admm.UDFBase):
def __init__(self, arg):
self.arg = arg
def arguments(self):
return [self.arg]
def eval(self, arglist):
x = np.asarray(arglist[0], dtype=float)
return float(np.count_nonzero(np.abs(x) > 1e-12))
def argmin(self, lamb, arglist):
v = np.asarray(arglist[0], dtype=float)
threshold = np.sqrt(2.0 * lamb)
prox = np.where(np.abs(v) <= threshold, 0.0, v)
return [prox.tolist()]
y = np.array([0.2, 2.0, 0.6, 2.2])
lam = 1.0
model = admm.Model()
x = admm.Var("x", len(y))
model.setObjective(0.5 * admm.sum(admm.square(x - y)) + lam * L0Norm(x))
model.optimize()
print(" * x: ", np.asarray(x.X)) # Expected: ≈ [0, 2, 0, 2.2]
print(" * model.ObjVal: ", model.ObjVal) # Expected: ≈ 2.2
print(" * model.StatusString: ", model.StatusString) # Expected: SOLVE_OPT_SUCCESS
This example is available as a standalone script in the examples/ folder of the ADMM repository:
python examples/udf_intro_l0.py
In this concrete example, \(\lambda = 1\), so the hard-threshold is
Therefore the coordinates behave as follows:
So the hard-thresholding proximal step keeps the large entries and removes the small ones, giving
Small entries are removed; large entries survive unchanged.
More UDF examples are collected in Examples with User-Defined Proximal Functions in the Examples chapter. For the scope boundary that usually makes UDFs worthwhile, see Support Boundary.