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.

Minimal UDF interface

Method

Return

UDFBase.arguments()

associated variables or expressions

UDFBase.eval()

scalar function value

UDFBase.argmin()

list of minimizing argument values, or None

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.

UDF examples

Example

Common role

Follows DCP rules?

L0 Norm

promote sparsity

no

L0 Ball Indicator

enforce a sparsity budget

no

L1/2 Quasi-Norm

classical nonconvex sparsity promotion

no

Group Sparsity

promote exact group sparsity

no

Matrix Rank Function

promote low-rank structure

no

Rank-r Indicator

enforce a rank cap

no

The Unit-Sphere Indicator

enforce unit-norm solutions

no

The Stiefel-Manifold Indicator

enforce orthonormal columns

no

The Simplex Indicator

enforce probability or mixture weights

yes

The Binary Indicator

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

\[\min_x \; \frac{1}{2}\|x - y\|_2^2 + \lambda \|x\|_0,\]

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

\[f(x) = \|x\|_0 = \#\{i : x_i \neq 0\},\]

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

\[\begin{split}\operatorname{prox}_{\lambda \|\cdot\|_0}(v) = \operatorname*{argmin}_x \; \lambda \|x\|_0 + \frac{1}{2}\|x - v\|_2^2, \qquad x_i^\star = \begin{cases} 0, & |v_i| \le \sqrt{2\lambda}, \\ v_i, & |v_i| > \sqrt{2\lambda}. \end{cases}\end{split}\]

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

\[\sqrt{2\lambda} = \sqrt{2} \approx 1.414.\]

Therefore the coordinates behave as follows:

\[0.2 \mapsto 0, \qquad 2.0 \mapsto 2.0, \qquad 0.6 \mapsto 0, \qquad 2.2 \mapsto 2.2.\]

So the hard-thresholding proximal step keeps the large entries and removes the small ones, giving

\[x^\star \approx [0,\; 2,\; 0,\; 2.2].\]

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.