Skip to content

Commit 37b057d

Browse files
authored
Add proximal gradient optimizers: FBS, FISTA, and FASTA (#427)
* Implement forward-backward splitting (FBS) and some proximal operators. * Minor cleanups for FrankWolfe. * Allow FunctionTest() to take instantiated functions. * Some minor fixes to some test functions. * Add tests for FBS and proximal operators. * Clean up documentation. * Add documentation for FBS to optimizers.md. * Add some documentation about proximal operators and functions. * Fix header guard name. * Remove unused member. * Some minor FBS cleanups. * Add QuadraticFunction for testing. * Try to clean up L1 constraint application a little bit. * Add FISTA and tests. * Document FISTA. * Remove inaccurate comment. * Clean up to match Beck and Teboulle paper instead of FASTA paper. * Some minor efficiency cleanups. * Add debugged and working FASTA implementation. * Add documentation for FASTA. * Make L1Constraint tests robust to the element type. * Minor fixes for compilation and tests. * Try to fix AppVeyor build. * Bump version number to fix mlpack integration build. * Add changelog entry for FBS/FISTA/FASTA. * Fixes for FP16 compilation and failing tests. * Fix compilation issue. * Add instantiated versions of tolerances for older compilers / C++14. * Adjust tolerances. * Try to make MSVC happy with where values are defined vs. declared. * Make sure that bandicoot is included if needed.
1 parent 8151354 commit 37b057d

30 files changed

Lines changed: 3374 additions & 50 deletions

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
BoundaryBoxConstraint(lowerBound, upperBound), ...);
4343
```
4444

45+
* Add proximal gradient optimizers for L1-constrained and other related
46+
problems: `FBS`, `FISTA`, and `FASTA`
47+
([#427](https://github.com/mlpack/ensmallen/pull/427)). See the
48+
documentation for more details.
49+
4550
### ensmallen 2.22.2: "E-Bike Excitement"
4651
###### 2025-04-30
4752
* Fix include statement in `tests/de_test.cpp`

doc/function_types.md

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ Each of the implemented methods is allowed to have additional cv-modifiers
130130
The following optimizers can be used with differentiable functions:
131131

132132
* [L-BFGS](#l-bfgs) (`ens::L_BFGS`)
133+
* [Forward-backward splitting (FBS)](#forward-backward-splitting-fbs) (`ens::FBS`)
134+
* [Fast Iterative Shrinkage-Thresholding Algorithm (FISTA)](#fast-iterative-shrinkage-thresholding-algorithm-fista) (`ens::FISTA`)
135+
* [Fast Adaptive Shrinkage/Thresholding Algorithm (FASTA)](#fast-adaptive-shrinkage-thresholding-algorithm-fasta) (`ens::FASTA`)
133136
* [FrankWolfe](#frank-wolfe) (`ens::FrankWolfe`)
134137
* [GradientDescent](#gradient-descent) (`ens::GradientDescent`)
135138
- Any optimizer for [arbitrary functions](#arbitrary-functions)
@@ -210,6 +213,12 @@ class LinearRegressionEWGFunction
210213
g = -2 * data * v;
211214
return arma::accu(v % v); // equivalent to \| v \|^2
212215
}
216+
217+
private:
218+
// The data.
219+
const arma::mat& data;
220+
// The responses to each data point.
221+
const arma::rowvec& responses;
213222
};
214223

215224
int main()
@@ -249,7 +258,7 @@ int main()
249258
const double time1 = clock.toc();
250259

251260
std::cout << "LinearRegressionFunction with Evaluate() and Gradient() took "
252-
<< time1 << " seconds to converge to the model: " << std::endl;
261+
<< time1 << " seconds to converge to the model: " << std::endl;
253262
std::cout << lrf1Params.t();
254263

255264
// Create the second objective function, which uses EvaluateWithGradient().
@@ -273,6 +282,150 @@ int main()
273282
274283
</details>
275284
285+
### Proximal operators and non-differentiable functions
286+
287+
Some optimization problems involve non-differentiable components, and can be
288+
expressed as the optimization below:
289+
290+
$$ \operatorname{argmin}_x h(x) = \operatorname{argmin}_x f(x) + g(x). $$
291+
292+
Here, `f(x)` is a regular differentiable function (as in the previous
293+
subsection), and `g(x)` is a non-differentiable arbitrary function. These
294+
classes of functions can still be optimized using *proximal gradient
295+
optimizers*. The following proximal gradient optimizers are implemented in
296+
ensmallen (these can also optimize differentiable functions only, taking `g(x) =
297+
0`):
298+
299+
* [Forward-backward splitting (FBS)](#forward-backward-splitting-fbs) (`ens::FBS`)
300+
* [Fast Iterative Shrinkage-Thresholding Algorithm (FISTA)](#fast-iteartive-shrinkage-thresholding-algorithm-fista) (`ens::FISTA`)
301+
* [Fast Adaptive Shrinkage/Thresholding Algorithm (FASTA)](#fast-adaptive-shrinkage-thresholding-algorithm-fasta) (`ens::FASTA`)
302+
303+
ensmallen implements a few `g(x)` options that can be used with proximal
304+
gradient optimizers:
305+
306+
* `L1Penalty(`_`lambda`_`)`: $g(x) = \lambda \| x \|_1$
307+
* `L1Constraint(`_`lambda`_`)`: $g(x)$ is the constraint $\| x \|_1 \le \lambda$
308+
309+
For example, by pairing `L1Penalty` with the `LinearRegressionFunction` from the
310+
previous section, we can implement L1-penalized (sparse) linear regression:
311+
312+
<details>
313+
<summary>Click to collapse/expand example code.
314+
</summary>
315+
316+
```c++
317+
#include <ensmallen.hpp>
318+
319+
// Define a differentiable objective function by implementing only
320+
// EvaluateWithGradient().
321+
class LinearRegressionEWGFunction
322+
{
323+
public:
324+
// Construct the object with the given data matrix and responses.
325+
LinearRegressionEWGFunction(const arma::mat& dataIn,
326+
const arma::rowvec& responsesIn) :
327+
data(dataIn), responses(responsesIn) { }
328+
329+
// Simultaneously compute both the objective function and gradient for model
330+
// parameters x. Note that this is faster than implementing Evaluate() and
331+
// Gradient() individually because it caches the computation of
332+
// (responses - x.t() * data)!
333+
double EvaluateWithGradient(const arma::mat& x, arma::mat& g)
334+
{
335+
const arma::rowvec v = (responses - x.t() * data);
336+
g = -2 * data * v;
337+
return arma::accu(v % v); // equivalent to \| v \|^2
338+
}
339+
340+
private:
341+
// The data.
342+
const arma::mat& data;
343+
// The responses to each data point.
344+
const arma::rowvec& responses;
345+
};
346+
347+
int main()
348+
{
349+
// First, generate some random data, with 1000 points and 500 dimensions.
350+
// This data has no pattern and as such will make a model that's not very
351+
// useful---but the purpose here is just demonstration. :)
352+
//
353+
// For a more "real world" situation, load a dataset from file using X.load()
354+
// and y.load() (but make sure the matrix is column-major, so that each
355+
// observation/data point corresponds to a *column*, *not* a row.
356+
arma::mat data(500, 1000, arma::fill::randn);
357+
arma::rowvec responses(1000, arma::fill::randn);
358+
359+
// Create a starting point for our optimization as the vector of all zeros.
360+
// The model has 500 parameters, so the shape is 500x1.
361+
arma::mat startingPoint(500, 1, arma::fill::zeros);
362+
363+
// Construct the objective function f(x), and the penalty function g(x) with a
364+
// lambda value of 0.1.
365+
LinearRegressionEWGFunction lrf(data, responses);
366+
ens::L1Penalty g(0.1);
367+
368+
// Create the FBS optimizer with default parameters, and optimize the function
369+
// f(x) + g(x) (i.e. L1-penalized linear regression).
370+
// The ens::FBS class can be replaced with any ensmallen proximal gradient
371+
// optimizer.
372+
ens::FBS fbs(g);
373+
arma::mat lrfParams(startingPoint);
374+
fbs.Optimize(lrf, lrfParams);
375+
376+
// Count the number of nonzeros in the final model.
377+
// To get fewer nonzeros, the penalty value (0.1) could be increased.
378+
std::cout << "Number of nonzeros in optimized parameter vector: "
379+
<< arma::accu(lrfParams != 0) << "." << std::endl;
380+
}
381+
```
382+
383+
</details>
384+
385+
It is possible to implement a custom proximal operator (`g(x)`). To do so, a
386+
class with two methods (`Evaluate()` and `BackwardStep()`) must be defined:
387+
388+
<details open>
389+
<summary>Click to collapse/expand example code.
390+
</summary>
391+
392+
```c++
393+
// Compute the value of g(x).
394+
double Evaluate(const arma::mat& x);
395+
396+
// Perform a backward step (proximal step) on `x`, given that the forward step
397+
// size was `stepSize`.
398+
void BackwardStep(arma::mat& x, const double stepSize);
399+
```
400+
401+
</details>
402+
403+
A simple implementation is below for the `L1Penalty` class:
404+
405+
<details open>
406+
<summary>Click to collapse/expand example code.
407+
</summary>
408+
409+
```c++
410+
double L1Penalty::Evaluate(const arma::mat& coordinates) const
411+
{
412+
// Compute the L1 penalty.
413+
return norm(vectorise(coordinates), 1) * lambda;
414+
}
415+
416+
void L1Penalty::ProximalStep(arma::mat& coordinates,
417+
const double stepSize) const
418+
{
419+
// Apply the backwards step coordinate-wise.
420+
// (See Goldstein, Studer, and Baraniuk 2009, eq. (12).)
421+
coordinates.transform([this, stepSize](double val) { return (val > 0.0) ?
422+
(std::max(0.0, val - lambda * stepSize)) :
423+
(std::min(0.0, val + lambda * stepSize)); });
424+
}
425+
```
426+
427+
</details>
428+
276429
### Partially differentiable functions
277430

278431
Some differentiable functions have the additional property that the gradient

0 commit comments

Comments
 (0)