From a282b5532f867edaf7b016b023925e502d4883da Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 16:19:56 +0500 Subject: [PATCH 1/8] Port the Stiefel manifold solvers to Fortran 77 Move the pystmopt algorithm into this template and reimplement every numerical step in Fortran 77, reached through f2py. The Fortran side owns the whole computation, not just the kernels: the manifold geometry, the proximal operators, the Barzilai-Borwein step sizes and the solver loops themselves all live there. Python only marshals arguments, calls back into the user objective, and prints progress. The dense linear algebra the solvers need -- matrix products, Cholesky, a Jacobi eigensolver for the polar factor, modified Gram-Schmidt -- is hand written alongside, so the extension links against nothing but the Fortran runtime. src/smblas.f dense kernels src/smman.f Stiefel geometry src/smprox.f proximal operators src/smslpg.f SLPG drivers and the Arrow-Hurwicz inner iteration src/smpencf.f PenCF driver src/_smopt.pyf f2py signatures src/smopt/ the Python layer The public interface is unchanged: SLPG_smooth, SLPG, SLPG_l21, PenCF, Stiefel, prox_l1 and prox_l21 keep their signatures and their output dictionary, now under the smopt namespace. Two defects in the original are fixed along the way. Init_point compared an array against None with ==, which raises rather than testing for a missing argument. Post_process divided by a vanishing singular value, returning a point off the manifold whenever the l_{2,1} penalty had zeroed enough rows to make the iterate singular; the rank deficient directions now get an orthonormal completion, which is what a singular value decomposition hands back anyway. tests/reference.py keeps a NumPy transcription of the original as a test oracle, and the suite compares whole trajectories against it rather than just the final answer. Three build fixes were needed to make the template work here: - f2py splits its own command line on whitespace, so the absolute --build-dir broke on any path containing a space. bin/run_f2py.py stages the signature file into the build directory and runs f2py on a bare file name instead. - Meson linked this mixed C/Fortran target with gcc, which rejects the -static-* flags; the link language is now pinned to Fortran, and the flags are probed rather than assumed, since -static-libquadmath only exists from GCC 13 on. - An SPDX license expression cannot be combined with a License :: classifier, which meson-python refuses outright. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 105 +++++++-- bin/build.py | 4 +- bin/run_f2py.py | 57 +++++ docs/api.md | 160 +++++++++++++ docs/index.md | 53 ++++- docs/installation.md | 32 +-- docs/quickstart.md | 161 +++++++++++++ docs/references.md | 35 +++ docs/theory.md | 133 +++++++++++ meson.build | 6 +- mkdocs.yml | 18 +- pyproject.toml | 64 ++++-- src/_smopt.pyf | 280 +++++++++++++++++++++++ src/meson.build | 90 ++++++-- src/smblas.f | 330 +++++++++++++++++++++++++++ src/smman.f | 281 +++++++++++++++++++++++ src/smopt/__init__.py | 55 +++++ src/smopt/_bridge.py | 175 ++++++++++++++ src/smopt/manifold/__init__.py | 6 + src/smopt/manifold/stiefel.py | 164 +++++++++++++ src/smopt/solver/__init__.py | 7 + src/smopt/solver/pencf.py | 100 ++++++++ src/smopt/solver/slpg.py | 224 ++++++++++++++++++ src/smopt/utility/__init__.py | 6 + src/smopt/utility/utility.py | 70 ++++++ src/smpencf.f | 116 ++++++++++ src/smprox.f | 84 +++++++ src/smslpg.f | 389 +++++++++++++++++++++++++++++++ tests/.gitkeep | 0 tests/conftest.py | 35 +++ tests/reference.py | 406 +++++++++++++++++++++++++++++++++ tests/test_manifold.py | 172 ++++++++++++++ tests/test_solvers.py | 328 ++++++++++++++++++++++++++ tests/test_utility.py | 77 +++++++ 34 files changed, 4139 insertions(+), 84 deletions(-) create mode 100644 bin/run_f2py.py create mode 100644 src/_smopt.pyf create mode 100644 src/smblas.f create mode 100644 src/smman.f create mode 100644 src/smopt/__init__.py create mode 100644 src/smopt/_bridge.py create mode 100644 src/smopt/manifold/__init__.py create mode 100644 src/smopt/manifold/stiefel.py create mode 100644 src/smopt/solver/__init__.py create mode 100644 src/smopt/solver/pencf.py create mode 100644 src/smopt/solver/slpg.py create mode 100644 src/smopt/utility/__init__.py create mode 100644 src/smopt/utility/utility.py create mode 100644 src/smpencf.f create mode 100644 src/smprox.f create mode 100644 src/smslpg.f delete mode 100644 tests/.gitkeep create mode 100644 tests/conftest.py create mode 100644 tests/reference.py create mode 100644 tests/test_manifold.py create mode 100644 tests/test_solvers.py create mode 100644 tests/test_utility.py diff --git a/README.md b/README.md index 98704b2..f3f7209 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,113 @@ -# +# smopt -**** +**Stiefel manifold optimization, with all numerics in Fortran 77** -[![Tests](https://github.com/eggzec//actions/workflows/test.yml/badge.svg)](https://github.com/eggzec//actions/workflows/test.yml) -[![Documentation](https://github.com/eggzec//actions/workflows/docs.yml/badge.svg)](https://github.com/eggzec//actions/workflows/docs.yml) +[![Tests](https://github.com/eggzec/smopt/actions/workflows/test.yml/badge.svg)](https://github.com/eggzec/smopt/actions/workflows/test.yml) +[![Documentation](https://github.com/eggzec/smopt/actions/workflows/docs.yml/badge.svg)](https://github.com/eggzec/smopt/actions/workflows/docs.yml) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![codecov](https://codecov.io/github/eggzec//graph/badge.svg)](https://codecov.io/github/eggzec/) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=eggzec_&metric=alert_status)](https://sonarcloud.io/project/overview?id=eggzec_) +[![codecov](https://codecov.io/github/eggzec/smopt/graph/badge.svg)](https://codecov.io/github/eggzec/smopt) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=eggzec_smopt&metric=alert_status)](https://sonarcloud.io/project/overview?id=eggzec_smopt) [![License](https://img.shields.io/badge/license-GPL%203.0-blue.svg)](./LICENSE) -[![PyPI Downloads](https://img.shields.io/pypi/dm/.svg?label=PyPI%20downloads)](https://pypi.org/project//) -[![Python versions](https://img.shields.io/pypi/pyversions/.svg)](https://pypi.org/project//) +[![PyPI Downloads](https://img.shields.io/pypi/dm/smopt.svg?label=PyPI%20downloads)](https://pypi.org/project/smopt/) +[![Python versions](https://img.shields.io/pypi/pyversions/smopt.svg)](https://pypi.org/project/smopt/) -`` +`smopt` minimizes a smooth function, optionally plus a nonsmooth +regularizer, over the matrices with orthonormal columns: + +$$ +\min_{X \in \mathbb{R}^{n\times p}} f(X) + r(X) +\quad \text{subject to} \quad X^\top X = I_p. +$$ + +The solvers are penalty-free first-order methods. Rather than retracting +along geodesics, they work in the ambient space and dissolve the +orthogonality constraint with a cheap feasibility restoring map, so an +iteration costs little more than a gradient evaluation and a couple of +small matrix products. + +Everything numerical — the manifold geometry, the proximal operators, +the Barzilai-Borwein step sizes and the solver loops themselves — is +written in Fortran 77 and reached through f2py. Python supplies the +objective through a callback and handles reporting. NumPy is the only +runtime dependency; the extension links against nothing but the Fortran +runtime. ## Quick example ```python -import +import numpy as np +from smopt import SLPG_smooth, Stiefel + +n, p = 1000, 10 +M = Stiefel(n, p) +A = np.diag(np.arange(n, dtype=float)) + + +def obj_fun(X): + """Return the objective and its Euclidean gradient together.""" + AX = A @ X + return float(np.sum(X * AX)), 2.0 * AX + + +X, out = SLPG_smooth(obj_fun, M) +print(out["fval"], out["fea"]) ``` +## Solvers + +| Name | Comment | Call | +| --- | --- | --- | +| `SLPG_smooth` | penalty-free first-order method for smooth problems | `SLPG_smooth(obj_fun, M)` | +| `SLPG` | penalty-free first-order method for nonsmooth problems | `SLPG(obj_fun, M, prox=...)` | +| `SLPG_l21` | penalty-free first-order method for $\ell_{2,1}$ regularized problems | `SLPG_l21(obj_fun, M, gamma=...)` | +| `PenCF` | constraint dissolving penalty method | `PenCF(Xinit, obj_fun, M)` | + +Every solver returns `(X, out)`, where `out` carries the `fvals`, `kkts` +and `feas` histories together with the final `fval`, `kkt` and `fea`. + ## Installation ```bash -pip install +pip install smopt ``` -Requires Python 3.10+ and NumPy. No external runtime dependencies. See the -[full installation guide](https://eggzec.github.io//installation/) for +Requires Python 3.10+ and NumPy. See the +[full installation guide](https://eggzec.github.io/smopt/installation/) for uv, poetry, and source builds. +Building from source additionally needs a Fortran compiler; the wheels +carry the Fortran runtime, so installing one does not. + +## Layout + +``` +src/ + smblas.f dense kernels: matmul, Cholesky, Jacobi eigensolver, Gram-Schmidt + smman.f Stiefel geometry: C, JA, JC, the A map, the polar retraction + smprox.f proximal operators and the l_{2,1} multiplier + smslpg.f the SLPG solver drivers and the Arrow-Hurwicz inner iteration + smpencf.f the PenCF driver + _smopt.pyf f2py signatures binding the above to Python + smopt/ the thin Python layer: argument marshalling and reporting +tests/ + reference.py a NumPy transcription of the algorithm, used as a test oracle +``` + ## Documentation -- [Theory](https://eggzec.github.io//theory/) — mathematical background, hierarchical basis, algorithms -- [Quickstart](https://eggzec.github.io//quickstart/) — runnable examples -- [API Reference](https://eggzec.github.io//api/) — class and function signature and arguments -- [References](https://eggzec.github.io//references/) — literature citations +- [Theory](https://eggzec.github.io/smopt/theory/) — the manifold, the constraint dissolving map, the algorithms +- [Quickstart](https://eggzec.github.io/smopt/quickstart/) — runnable examples +- [API Reference](https://eggzec.github.io/smopt/api/) — class and function signatures and arguments +- [References](https://eggzec.github.io/smopt/references/) — literature citations + +## Acknowledgement + +The algorithm ported here originates in the STOP toolbox by Nachuan +Xiao, Lei Wang, Bin Gao, Xin Liu and Ya-xiang Yuan +(). `smopt` re-implements its numerics in +Fortran 77 behind the same solver interface. ## License diff --git a/bin/build.py b/bin/build.py index 83be46e..7437a14 100755 --- a/bin/build.py +++ b/bin/build.py @@ -78,7 +78,7 @@ def wheel(): def clean(): logger.debug("Starting cleanup ...") - run_command("uv pip uninstall ") + run_command("uv pip uninstall smopt") for entry in Path("").iterdir(): if entry.name in ["dist", "build", "lib", ".pytest_cache", ".ruff_cache"]: @@ -103,7 +103,7 @@ def clean(): def main(): - parser = argparse.ArgumentParser(description=" Build Script") + parser = argparse.ArgumentParser(description="smopt Build Script") parser.add_argument( "mode", help="""Build mode: diff --git a/bin/run_f2py.py b/bin/run_f2py.py new file mode 100644 index 0000000..81d9582 --- /dev/null +++ b/bin/run_f2py.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Generate the f2py wrappers for a signature file. + +f2py re-splits its own command line on whitespace, so a build directory +containing a space -- ``C:\\Users\\First Last\\...``, which is the norm on +Windows -- is torn into fragments and the run fails with a confusing +mixture of "Skipping file" and "Access is denied" errors. + +The workaround is to make sure no argument f2py sees ever contains a +separator or a space: the signature file is copied into the build +directory and f2py is invoked from there on the bare file name. + +Usage: + run_f2py.py [extra f2py arguments...] +""" + +import shutil +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + """Copy the signature file into the build directory and run f2py. + + Returns: + The exit status of the f2py invocation. + """ + if len(sys.argv) < 3: + print(__doc__, file=sys.stderr) + return 2 + + signature = Path(sys.argv[1]).resolve() + build_dir = Path(sys.argv[2]).resolve() + extra_args = sys.argv[3:] + + build_dir.mkdir(parents=True, exist_ok=True) + staged = build_dir / signature.name + if staged != signature: + shutil.copyfile(signature, staged) + + return subprocess.call( # noqa: S603 + [ + sys.executable, + "-m", + "numpy.f2py", + signature.name, + "--build-dir", + ".", + *extra_args, + ], + cwd=str(build_dir), + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/api.md b/docs/api.md index b0d5c88..3c1e077 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1 +1,161 @@ # API Reference + +Everything below is re-exported at the top level, so +`from smopt import SLPG_smooth` and +`from smopt.solver import SLPG_smooth` are equivalent. + +## Manifold + +### `smopt.Stiefel` + +```python +Stiefel(n: int, p: int) +``` + +The manifold $\{X \in \mathbb{R}^{n\times p} : X^\top X = I_p\}$. Carries +the dimensions and exposes the geometric maps; each one is evaluated by +the Fortran 77 core. Raises `ValueError` if the dimensions are not +positive or if `p > n`. + +| Attribute | Meaning | +| --- | --- | +| `dim` | `n * p`, the dimension of the ambient space | + +| Method | Returns | +| --- | --- | +| `Phi(M)` | $(M + M^\top)/2$ for a `(p, p)` matrix | +| `C(X)` | $X^\top X - I_p$ | +| `Feas_eval(X)` | $\|X^\top X - I_p\|_F$, as a `float` | +| `JA(X, G)` | $G - X\,\Phi(X^\top G)$ | +| `JC(X, Lambda)` | $X\,\Phi(\Lambda)$ | +| `JC_transpose(X, D)` | $\Phi(X^\top D)$ | +| `A(X)` | the feasibility restoring map | +| `Post_process(X)` | the orthogonal polar factor $UV^\top$ | +| `Init_point(Xinit=None)` | a feasible starting point | + +`Init_point` draws a standard normal matrix when `Xinit` is omitted, and +orthonormalizes whatever it ends up with unless it is already feasible. + +## Solvers + +Every solver returns `(X, out)`, where `X` is the solution and `out` is a +dictionary: + +| Key | Meaning | +| --- | --- | +| `fvals` | objective value per iteration | +| `kkts` | stationarity measure per iteration | +| `feas` | feasibility measure per iteration | +| `fval`, `kkt`, `fea` | the final values | +| `beta` | the penalty used, `PenCF` only | + +The objective is a single callable returning the value and the Euclidean +gradient together: + +```python +def obj_fun(X): # X has shape (n, p) + return fval, grad # grad has shape (n, p) +``` + +`verbosity` is `0` for silence, `1` for the convergence and +post-processing lines, and `2` to also print periodically. `maxit` must +be at least `1`. A `Xinit` you supply is used exactly as given; only the +default one is drawn and orthonormalized. + +### `smopt.SLPG_smooth` + +```python +SLPG_smooth( + obj_fun, + manifold, + Xinit=None, + maxit=100, + gtol=1e-5, + post_process=True, + verbosity=2, +) +``` + +For a smooth objective. Prints every 20th iteration at `verbosity=2`. + +### `smopt.SLPG` + +```python +SLPG( + obj_fun, + manifold, + Xinit=None, + maxit=100, + prox=None, + gtol=1e-5, + post_process=True, + verbosity=2, +) +``` + +For `f(X) + r(X)` with `r` reached through `prox(X, eta)`, which must +minimize $\|Y - X\|_F^2/(2\eta) + r(Y)$. `prox` defaults to the identity, +recovering the smooth case. Prints every 50th iteration at `verbosity=2`. + +### `smopt.SLPG_l21` + +```python +SLPG_l21( + obj_fun, + manifold, + Xinit=None, + maxit=100, + gamma=0, + gtol=1e-5, + post_process=True, + verbosity=2, +) +``` + +For $f(X) + \gamma\|X\|_{2,1}$, which induces row sparsity. Prints every +50th iteration at `verbosity=2`. + +### `smopt.PenCF` + +```python +PenCF( + Xinit, + obj_fun, + manifold, + beta=None, + maxit=100, + gtol=1e-5, + post_process=True, + verbosity=2, +) +``` + +A constraint dissolving penalty method. Note that the starting point +comes **first**. `beta` defaults to $0.1\|\nabla f(X_0)\|_F$; the value +actually used is reported as `out["beta"]`. Prints every 20th iteration +at `verbosity=2`. + +## Proximal operators + +### `smopt.prox_l1` + +```python +prox_l1(X_input, eta, gamma=0) +``` + +Proximal operator of $\gamma\|X\|_1$: entrywise soft thresholding. + +### `smopt.prox_l21` + +```python +prox_l21(X_input, eta, gamma=0) +``` + +Proximal operator of $\gamma\|X\|_{2,1}$: shrinks whole rows towards the +origin. + +Both are shaped so they can be handed straight to `SLPG`: + +```python +X, out = SLPG(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=0.05)) +``` diff --git a/docs/index.md b/docs/index.md index 9477141..e434d98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,58 @@ -# +# smopt -**** +**Stiefel manifold optimization, with all numerics in Fortran 77** - +`smopt` minimizes a smooth function, optionally plus a nonsmooth +regularizer, over the set of matrices with orthonormal columns: + +$$ +\min_{X \in \mathbb{R}^{n\times p}} f(X) + r(X) +\quad \text{subject to} \quad X^\top X = I_p. +$$ ## Overview +The solvers are **penalty-free first-order methods**. Rather than +retracting along geodesics, they work in the ambient space and dissolve +the orthogonality constraint with a cheap feasibility restoring map, so +an iteration costs little more than a gradient evaluation and a couple +of small matrix products. + +Everything numerical — the manifold geometry, the proximal operators, +the Barzilai-Borwein step sizes and the solver loops themselves — is +implemented in Fortran 77 and reached through f2py. Python supplies the +objective through a callback and handles reporting; NumPy is the only +runtime dependency. + +```python +import numpy as np +from smopt import SLPG_smooth, Stiefel + +M = Stiefel(1000, 10) +A = np.diag(np.arange(1000, dtype=float)) + + +def obj_fun(X): + AX = A @ X + return float(np.sum(X * AX)), 2.0 * AX + + +X, out = SLPG_smooth(obj_fun, M) +``` + +## Solvers + +| Name | Use when | +| --- | --- | +| `SLPG_smooth` | the objective is smooth | +| `SLPG` | there is a nonsmooth term with a known proximal operator | +| `SLPG_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | +| `PenCF` | a constraint dissolving penalty method is preferred | + ## Documentation -- [Theory](theory.md) - mathematical background, hierarchical basis, algorithms +- [Theory](theory.md) - the manifold, the constraint dissolving map, the algorithms - [Installation](installation.md) - installation guide - [Quickstart](quickstart.md) - runnable examples -- [API Reference](api.md) - class and function signature and arguments +- [API Reference](api.md) - class and function signatures and arguments - [References](references.md) - literature citations diff --git a/docs/installation.md b/docs/installation.md index 39538b7..6bf258e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,10 +1,10 @@ # Installation -`` can be installed from PyPI or directly from source via GitHub. +`smopt` can be installed from PyPI or directly from source via GitHub. --- -## [PyPI](https://pypi.org/project/) +## [PyPI](https://pypi.org/project/smopt) For using the PyPI package in your project, add it to your configuration file: @@ -12,7 +12,7 @@ For using the PyPI package in your project, add it to your configuration file: ```toml [project.dependencies] - = "*" # (1)! + smopt = "*" # (1)! ``` 1. Specifying a version is recommended @@ -20,7 +20,7 @@ For using the PyPI package in your project, add it to your configuration file: === "requirements.txt" ``` - >=0.1.0 + smopt>=0.1.0 ``` ### pip @@ -28,7 +28,7 @@ For using the PyPI package in your project, add it to your configuration file: === "Installation for user" ```bash - pip install --upgrade --user # (1)! + pip install --upgrade --user smopt # (1)! ``` 1. You may need to use `pip3` instead of `pip` depending on your Python installation. @@ -38,7 +38,7 @@ For using the PyPI package in your project, add it to your configuration file: ```bash python -m venv .venv source .venv/bin/activate - pip install --require-virtualenv --upgrade # (1)! + pip install --require-virtualenv --upgrade smopt # (1)! ``` 1. You may need to use `pip3` instead of `pip` depending on your Python installation. @@ -52,7 +52,7 @@ For using the PyPI package in your project, add it to your configuration file: === "Adding to uv project" ```bash - uv add + uv add smopt uv sync ``` @@ -60,41 +60,41 @@ For using the PyPI package in your project, add it to your configuration file: ```bash uv venv - uv pip install + uv pip install smopt ``` ### pipenv ```bash -pipenv install +pipenv install smopt ``` ### poetry ```bash -poetry add +poetry add smopt ``` ### pdm ```bash -pdm add +pdm add smopt ``` ### hatch ```bash -hatch add +hatch add smopt ``` --- -## [GitHub](https://github.com/eggzec/) +## [GitHub](https://github.com/eggzec/smopt) Install the latest development version directly from the repository: ```bash -pip install --upgrade "git+https://github.com/eggzec/.git#egg=" +pip install --upgrade "git+https://github.com/eggzec/smopt.git#egg=smopt" ``` ### Building locally @@ -102,8 +102,8 @@ pip install --upgrade "git+https://github.com/eggzec/.git#egg=.git -cd +git clone https://github.com/eggzec/smopt.git +cd smopt pip install -e . ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index acb9843..985c4d5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1 +1,162 @@ # Quickstart + +## A nonlinear eigenvalue problem + +The following problem is the standard smoke test for Stiefel solvers: + +$$ +\min_{X \in \mathcal{S}_{n, p}} ~ \frac{1}{2}\mathrm{tr}(X^\top L X) + \frac{\alpha}{4} \rho^\top L^{\dagger} \rho, +$$ + +where $\rho = \mathrm{Diag}(XX^\top)$ and $L^{\dagger}$ is the +pseudo-inverse of the positive definite $L$. The cost function and its +**Euclidean gradient** are + +$$ +\begin{aligned} + f(X) ={}& \frac{1}{2}\mathrm{tr}(X^\top L X) + \frac{\alpha}{4} \rho^\top L^{\dagger} \rho,\ + \nabla f(X) ={}& LX + \alpha \, \mathrm{diag}(L^{\dagger}\rho)X. +\end{aligned} +$$ + +Taking $L$ tridiagonal, so that $L^{\dagger} = L^{-1}$: + +```python +import numpy as np +from scipy.sparse import diags +from scipy.sparse.linalg import spsolve + +from smopt import SLPG_smooth, Stiefel + +n, p, alpha = 1000, 10, 1.0 +M = Stiefel(n, p) + +L = diags([-1, 2, -1], [1, 0, -1], shape=(n, n)).tocsc() + + +def obj_fun(X): + LX = L @ X + rho = np.sum(X * X, 1) + Lrho = spsolve(L, rho) + fval = 0.5 * np.sum(X * LX) + (alpha / 4) * np.sum(rho * Lrho) + grad = LX + alpha * Lrho[:, np.newaxis] * X + return fval, grad + + +X, out = SLPG_smooth(obj_fun, M) +``` + +!!! note + SciPy is only used to build this example's data; `smopt` itself + depends on NumPy alone. + +## Step by step + +### 1. Fix the manifold + +The dimensions are carried by a [`Stiefel`](api.md) instance, which also +exposes the geometric maps the solvers use: + +```python +from smopt import Stiefel + +M = Stiefel(1000, 10) +``` + +### 2. Define the objective + +A solver expects **one** callable returning the value and the Euclidean +gradient together: + +```python +def obj_fun(X): + ... + return fval, grad +``` + +Returning both at once is usually far cheaper than computing them +separately, even with caching, so `smopt` asks for them in a single +call. `X` arrives as an `(n, p)` array and `grad` must have the same +shape. + +### 3. Run a solver + +```python +X, out = SLPG_smooth(obj_fun, M) +``` + +`X` is the solution and `out` is a dictionary of log information: + +| Key | Meaning | +| --- | --- | +| `fvals`, `kkts`, `feas` | per-iteration histories | +| `fval`, `kkt`, `fea` | the final objective, stationarity and feasibility | +| `beta` | the penalty `PenCF` actually used | + +## Nonsmooth problems + +### A regularizer of your own + +Pass any `prox(X, eta)` that minimizes +$\|Y - X\|_F^2 / (2\eta) + r(Y)$. Here is $\ell_1$ regularization built +from the operator shipped with the package: + +```python +from smopt import SLPG, prox_l1 + +gamma = 0.05 +X, out = SLPG(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=gamma)) +``` + +### Row sparsity + +For $r(X) = \gamma\|X\|_{2,1}$ use the dedicated driver, which knows the +prox and the constraint multiplier in closed form: + +```python +from smopt import SLPG_l21 + +X, out = SLPG_l21(obj_fun, M, gamma=1.0) +``` + +Whole rows of `X` are driven to zero, which selects variables: + +```python +import numpy as np + +live = np.linalg.norm(X, axis=1) > 1e-6 +print(f"{live.sum()} of {len(live)} rows survive") +``` + +## Choosing a solver + +| Solver | Use when | +| --- | --- | +| `SLPG_smooth` | the objective is smooth | +| `SLPG` | there is a nonsmooth term with a known prox | +| `SLPG_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | +| `PenCF` | a constraint dissolving penalty method is preferred | + +## Common options + +Every solver accepts: + +```python +X, out = SLPG_smooth( + obj_fun, + M, + Xinit=None, # starting point; random feasible point if omitted + maxit=100, # iteration budget + gtol=1e-5, # stationarity tolerance + post_process=True, # round the answer onto the manifold + verbosity=2, # 0 silent, 1 final lines, 2 periodic +) +``` + +`PenCF` takes the starting point first and adds `beta`: + +```python +from smopt import PenCF + +X, out = PenCF(Xinit, obj_fun, M, beta=None) +``` diff --git a/docs/references.md b/docs/references.md index b18be66..25cd276 100644 --- a/docs/references.md +++ b/docs/references.md @@ -1 +1,36 @@ # References + +The solvers implemented here follow the constraint dissolving and +penalty-free line of work on Stiefel manifold optimization. + +- Xiao, N., Liu, X., and Yuan, Y. (2022). *A class of smooth exact + penalty function methods for optimization problems with orthogonality + constraints.* Optimization Methods and Software, 37(4), 1205-1241. + +- Xiao, N., Liu, X., and Yuan, Y. (2022). *Exact penalty function for + $\ell_{2,1}$ norm minimization over the Stiefel manifold.* SIAM + Journal on Optimization, 31(4), 3097-3126. + +- Xiao, N., Liu, X., and Toh, K.-C. (2023). *Dissolving constraints for + Riemannian optimization.* Mathematics of Operations Research. + +- Barzilai, J. and Borwein, J. M. (1988). *Two-point step size gradient + methods.* IMA Journal of Numerical Analysis, 8(1), 141-148. + +- Edelman, A., Arias, T. A., and Smith, S. T. (1998). *The geometry of + algorithms with orthogonality constraints.* SIAM Journal on Matrix + Analysis and Applications, 20(2), 303-353. + +- Absil, P.-A., Mahony, R., and Sepulchre, R. (2008). *Optimization + Algorithms on Matrix Manifolds.* Princeton University Press. + +- Golub, G. H. and Van Loan, C. F. (2013). *Matrix Computations*, 4th + edition. Johns Hopkins University Press. Chapter 8 covers the cyclic + Jacobi eigenvalue algorithm used for the polar factor. + +## Provenance + +The algorithm ported here originates in the **STOP** toolbox by Nachuan +Xiao, Lei Wang, Bin Gao, Xin Liu and Ya-xiang Yuan, distributed at +. `smopt` re-implements its numerics in +Fortran 77 behind the same solver interface. diff --git a/docs/theory.md b/docs/theory.md index 006f9c5..6885ee3 100644 --- a/docs/theory.md +++ b/docs/theory.md @@ -1 +1,134 @@ # Theory + +## The problem + +`smopt` solves + +$$ +\begin{aligned} + \min_{X \in \mathbb{R}^{n\times p}} ~ & f(X) + r(X)\ + \text{subject to}~& X^\top X = I_p, +\end{aligned} +$$ + +where $f$ is smooth and $r$ is a convex, possibly nonsmooth regularizer +reachable through its proximal operator. The feasible set + +$$ +\mathcal{S}_{n,p} := \left\{X \in \mathbb{R}^{n\times p}: X^\top X = I_p \right\} +$$ + +is the **Stiefel manifold**, a smooth embedded submanifold of +$\mathbb{R}^{n \times p}$ of dimension $np - p(p+1)/2$. + +## Constraint dissolving + +Classical Riemannian methods keep every iterate exactly on +$\mathcal{S}_{n,p}$, which costs a matrix decomposition per step. The +solvers here instead *dissolve* the constraint: they work in the ambient +space $\mathbb{R}^{n \times p}$ and rely on a cheap map that pulls a +drifting iterate back towards the manifold. + +That map is + +$$ +\mathcal{A}(X) = +\begin{cases} +\tfrac{3}{2} X - \tfrac{1}{2} X X^\top X, & \|X^\top X - I_p\|_F < \tfrac{1}{2},\[4pt] +X \left( \tfrac{1}{2}\left(X^\top X + I_p\right) \right)^{-1}, & \text{otherwise.} +\end{cases} +$$ + +The first branch is the second-order expansion of $X(X^\top X)^{-1/2}$ +about a feasible point and costs only matrix products. The second branch +is exact and needs a Cholesky factorization of order $p$, which is cheap +because $p \ll n$ in the problems of interest. Both branches fix the +manifold: $\mathcal{A}(X) = X$ whenever $X^\top X = I_p$. + +## Ingredients + +Writing $\Phi(M) = (M + M^\top)/2$ for the symmetrizing operator, the +solvers are built from + +| Map | Definition | Role | +| --- | --- | --- | +| $C(X)$ | $X^\top X - I_p$ | constraint violation | +| $\mathcal{J}_C(X)[\Lambda]$ | $X\,\Phi(\Lambda)$ | constraint Jacobian | +| $\mathcal{J}_C^\ast(X)[D]$ | $\Phi(X^\top D)$ | its adjoint | +| $\mathcal{J}_A(X)[G]$ | $G - X\,\Phi(X^\top G)$ | projected gradient | +| $\mathcal{A}(X)$ | above | feasibility restoration | + +Feasibility is measured by $\|C(X)\|_F$ throughout. + +## The solvers + +### SLPG + +The SLPG family takes a Barzilai-Borwein step along +$\mathcal{J}_A(X)[\nabla f(X)]$, applies the proximal operator of $r$, +and restores feasibility with $\mathcal{A}$. The step size uses one of +the two BB formulas + +$$ +\eta_k = \left| \frac{\langle S_k, Y_k\rangle}{\langle Y_k, Y_k \rangle} \right| +\qquad\text{or}\qquad +\eta_k = \left| \frac{\langle S_k, S_k\rangle}{\langle S_k, Y_k \rangle} \right|, +$$ + +with $S_k = X_k - X_{k-1}$ and $Y_k$ the corresponding change in the +search direction. The first few iterations use a conservative $c/L$ +instead, where $L$ is estimated from the gradient at the starting point. + +Three drivers are provided: + +- `SLPG_smooth` for $r \equiv 0$. +- `SLPG` for a general $r$, whose constraint multiplier $\Lambda$ is + tracked by an inner **Arrow-Hurwicz** iteration so that no penalty + parameter has to be tuned. +- `SLPG_l21` for $r(X) = \gamma\|X\|_{2,1}$, where both the prox and the + multiplier + + $$ + \Lambda(X) = -\gamma\, X^\top \operatorname{diag}\!\left(\frac{1}{\|X_{i,:}\|_2}\right) X + $$ + + are available in closed form, so no inner iteration is needed. The + $\ell_{2,1}$ norm sums the Euclidean norms of the rows of $X$ and + therefore drives whole rows to zero, which is how sparse principal + component analysis and related models select variables. + +### PenCF + +`PenCF` adds an explicit penalty to the search direction, + +$$ +\mathcal{G}(X) = \mathcal{J}_A(X)[\nabla f(X)] + \beta\, \mathcal{J}_C(X)[C(X)], +$$ + +and restores feasibility only once $\|C(X)\|_F$ exceeds $10^{-1}$, +capping $\|X\|_F$ at $1.001\sqrt{p}$ to keep the iteration bounded. The +default $\beta$ is $0.1\|\nabla f(X_0)\|_F$. + +## Post-processing + +Because the iterates are only approximately feasible, every solver +optionally rounds the final point onto the manifold with the orthogonal +polar factor + +$$ +\mathcal{P}(X) = U V^\top = X \left(X^\top X\right)^{-1/2}, +\qquad X = U \Sigma V^\top, +$$ + +which is the nearest point of $\mathcal{S}_{n,p}$ in the Frobenius norm. +`smopt` computes it from a Jacobi eigendecomposition of the $p \times p$ +matrix $X^\top X$ rather than from a singular value decomposition of +$X$, which keeps the cost at $O(np^2 + p^3)$. + +## Implementation + +Every formula on this page is evaluated in Fortran 77, including the +solver loops themselves. Python supplies the objective through a +callback, and the linear algebra — matrix products, Cholesky, the Jacobi +eigensolver, modified Gram-Schmidt — is hand-written in the same +sources, so the extension links against nothing but the Fortran runtime. diff --git a/meson.build b/meson.build index 144a461..c51c048 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,11 @@ project( - '', + 'smopt', 'fortran', 'c', version: run_command(['python', 'bin/get_version.py'], check: true).stdout().strip() ) +# Passed down to src/ ; see the script for the f2py quoting bug it works +# around. +f2py_runner = files('bin/run_f2py.py') + subdir('src') diff --git a/mkdocs.yml b/mkdocs.yml index 0623e93..7771edc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,11 +1,11 @@ -site_name: -site_description: -site_url: https://eggzec.github.io// +site_name: smopt +site_description: Stiefel manifold optimization with all numerics in Fortran 77 +site_url: https://eggzec.github.io/smopt/ docs_dir: docs site_dir: site -repo_name: eggzec/ -repo_url: https://github.com/eggzec/ +repo_name: eggzec/smopt +repo_url: https://github.com/eggzec/smopt # Copyright copyright: Copyright © 2026 eggzec @@ -51,8 +51,8 @@ theme: name: Switch to light mode # Logo and favicon - favicon: assets/images/.ico - logo: assets/images/.png + favicon: assets/images/smopt.ico + logo: assets/images/smopt.png # Font configuration font: @@ -101,9 +101,9 @@ extra_css: # https://squidfunk.github.io/mkdocs-material/customization/#addit extra: social: - icon: fontawesome/brands/github - link: https://github.com/eggzec/ + link: https://github.com/eggzec/smopt - icon: fontawesome/brands/python - link: https://pypi.org/project// + link: https://pypi.org/project/smopt/ extra_javascript: - assets/javascripts/katex.js diff --git a/pyproject.toml b/pyproject.toml index 51a6442..a9a6534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,9 @@ requires = [ build-backend = "mesonpy" [project] -name = "" +name = "smopt" dynamic = ["version"] -description = "" +description = "Stiefel manifold optimization with all numerics in Fortran 77" authors = [ { name = "Saud Zahir", email = "m.saud.zahir@gmail.com" }, ] @@ -21,13 +21,16 @@ maintainers = [ readme = "README.md" license = "GPL-3.0" keywords = [ - "python" + "optimization", + "manifold optimization", + "Stiefel manifold", + "fortran", + "f2py" ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python", "Programming Language :: C", "Programming Language :: Fortran", @@ -50,12 +53,12 @@ dependencies = [ requires-python = ">=3.10" [project.urls] -homepage = "https://eggzec.github.io//" -documentation = "https://eggzec.github.io//" -source = "https://github.com/eggzec/" -changelog = "https://eggzec.github.io//changelog/" -releasenotes = "https://github.com/eggzec//releases/latest" -issues = "https://github.com/eggzec//issues" +homepage = "https://eggzec.github.io/smopt/" +documentation = "https://eggzec.github.io/smopt/" +source = "https://github.com/eggzec/smopt" +changelog = "https://eggzec.github.io/smopt/changelog/" +releasenotes = "https://github.com/eggzec/smopt/releases/latest" +issues = "https://github.com/eggzec/smopt/issues" [dependency-groups] dev = [ @@ -79,12 +82,12 @@ test = [ source = "vcs" [tool.hatch.build.targets.wheel] -include = [ "/" ] +include = [ "/smopt" ] [tool.ruff] -# CI workflow definitions and the helper scripts they run are not +# CI workflow definitions and the build helper scripts they run are not # part of the published package, so they are out of scope here. -extend-exclude = [".github"] +extend-exclude = [".github", "bin"] line-length = 80 indent-width = 4 preview = true @@ -125,7 +128,40 @@ ignore = [] [tool.ruff.lint.per-file-ignores] "**/__init__.py" = ["non-empty-init-module"] -"tests/*.py" = [] +"src/smopt/**/*.py" = [ + # The subpackages are one installable unit, so they reach each other + # with relative imports rather than by naming the distribution. + "relative-imports", + # The solver signatures are fixed by the algorithm this package + # ports; their arity is not ours to trim. + "too-many-arguments", + "too-many-positional-arguments", + # noqa is the portable spelling, and the one every reader knows. + "noqa-comments", +] +"tests/*.py" = [ + # A test asserts with assert. + "assert", + # Tolerances, sizes and iteration counts read better inline. + "magic-value-comparison", + # Annotating every fixture and parametrized case adds no clarity. + "ANN", + # The suite mirrors a public API that is deliberately CamelCase, and + # tests/reference.py is a faithful transcription of the original. + "invalid-function-name", + "invalid-argument-name", + "non-lowercase-variable-in-function", + "docstring-missing-returns", + "too-many-arguments", + "too-many-locals", + "too-many-positional-arguments", + # Parametrizing over a boolean flag is the point of those tests. + "boolean-type-hint-positional-argument", + "boolean-default-value-positional-argument", + "no-self-use", + "non-augmented-assignment", + "compare-to-empty-string", +] [tool.ruff.format] docstring-code-format = true diff --git a/src/_smopt.pyf b/src/_smopt.pyf new file mode 100644 index 0000000..0b0e757 --- /dev/null +++ b/src/_smopt.pyf @@ -0,0 +1,280 @@ +! -*- f90 -*- +! f2py signature file for the SMOPT Fortran 77 core. +! +! Every workspace array is declared intent(cache,hide) so that it is +! allocated by f2py and never appears in the Python signature. The +! objective, the proximal operator and the progress logger are Python +! callbacks; matrices cross the boundary flattened in column major +! order, which is how the Fortran side stores them anyway. + +python module __user__routines + interface + subroutine objfun(np,x,f,g) + integer intent(in) :: np + double precision dimension(np),intent(in) :: x + double precision intent(out) :: f + double precision dimension(np),intent(out),depend(np) :: g + end subroutine objfun + subroutine proxfn(np,x,eta,y) + integer intent(in) :: np + double precision dimension(np),intent(in) :: x + double precision intent(in) :: eta + double precision dimension(np),intent(out),depend(np) :: y + end subroutine proxfn + subroutine logfun(it,fval,kkt,fea,stage) + integer intent(in) :: it + double precision intent(in) :: fval + double precision intent(in) :: kkt + double precision intent(in) :: fea + integer intent(in) :: stage + end subroutine logfun + end interface +end python module __user__routines + +python module _smopt + interface + + subroutine smsymm(p,a) + integer intent(hide),depend(a) :: p = shape(a,0) + double precision dimension(p,p),intent(in,out,copy) :: a + end subroutine smsymm + + subroutine smcmap(n,p,x,c) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision dimension(p,p),intent(out),depend(p) :: c + end subroutine smcmap + + double precision function smfeas(n,p,x,wp) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp + end function smfeas + + subroutine smja(n,p,x,g,r,wp) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision dimension(n,p),intent(in),depend(n,p) :: g + double precision dimension(n,p),intent(out),depend(n,p) :: r + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp + end subroutine smja + + subroutine smjc(n,p,x,lam,r,wp) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision dimension(p,p),intent(in),depend(p) :: lam + double precision dimension(n,p),intent(out),depend(n,p) :: r + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp + end subroutine smjc + + subroutine smjct(n,p,x,d,r) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision dimension(n,p),intent(in),depend(n,p) :: d + double precision dimension(p,p),intent(out),depend(p) :: r + end subroutine smjct + + subroutine smamap(n,p,x,wp1,wp2,row) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smamap + + subroutine smfix(n,p,x,wp1,wp2,row) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smfix + + subroutine smpost(n,p,x,wp1,wp2,wp3,w,row) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp3 + double precision dimension(p),intent(cache,hide),depend(p) :: w + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smpost + + subroutine sminit(n,p,x,wp) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp + end subroutine sminit + + subroutine smpl1(n,p,x,eta,gam,y) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision intent(in) :: eta + double precision intent(in) :: gam + double precision dimension(n,p),intent(out),depend(n,p) :: y + end subroutine smpl1 + + subroutine smpl21(n,p,x,eta,gam,eps,y) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision intent(in) :: eta + double precision intent(in) :: gam + double precision intent(in) :: eps + double precision dimension(n,p),intent(out),depend(n,p) :: y + end subroutine smpl21 + + subroutine smlm21(n,p,x,gam,lam) + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in) :: x + double precision intent(in) :: gam + double precision dimension(p,p),intent(out),depend(p) :: lam + end subroutine smlm21 + + subroutine smslps(n,p,x,maxit,gtol,ipost,objfun,logfun,nit,fvals,kkts,feasv,fval,kkt,fea,gf,gr,grp,s,y,xp,wp1,wp2,wp3,weig,row) + use __user__routines + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + integer intent(in) :: maxit + double precision intent(in) :: gtol + integer intent(in) :: ipost + external objfun + external logfun + integer intent(out) :: nit + double precision dimension(maxit),intent(out),depend(maxit) :: fvals + double precision dimension(maxit),intent(out),depend(maxit) :: kkts + double precision dimension(maxit),intent(out),depend(maxit) :: feasv + double precision intent(out) :: fval + double precision intent(out) :: kkt + double precision intent(out) :: fea + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gf + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gr + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: s + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: y + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xp + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp3 + double precision dimension(p),intent(cache,hide),depend(p) :: weig + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smslps + + subroutine smslpg(n,p,x,maxit,gtol,ipost,objfun,proxfn,logfun,nit,fvals,kkts,feasv,fval,kkt,fea,gf,gr,grad,grdp,s,y,xp,z,xt,dx,lam,wp1,wp2,wp3,weig,row,steps) + use __user__routines + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + integer intent(in) :: maxit + double precision intent(in) :: gtol + integer intent(in) :: ipost + external objfun + external proxfn + external logfun + integer intent(out) :: nit + double precision dimension(maxit),intent(out),depend(maxit) :: fvals + double precision dimension(maxit),intent(out),depend(maxit) :: kkts + double precision dimension(maxit),intent(out),depend(maxit) :: feasv + double precision intent(out) :: fval + double precision intent(out) :: kkt + double precision intent(out) :: fea + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gf + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gr + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grad + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grdp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: s + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: y + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: z + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xt + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: dx + double precision dimension(p,p),intent(cache,hide),depend(p) :: lam + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp3 + double precision dimension(p),intent(cache,hide),depend(p) :: weig + double precision dimension(p),intent(cache,hide),depend(p) :: row + double precision dimension(maxit),intent(cache,hide),depend(maxit) :: steps + end subroutine smslpg + + subroutine smsl21(n,p,x,maxit,gam,gtol,ipost,objfun,logfun,nit,fvals,kkts,feasv,fval,kkt,fea,gf,gr,grad,grdp,s,y,xp,xt,lam,wp1,wp2,wp3,weig,row) + use __user__routines + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + integer intent(in) :: maxit + double precision intent(in) :: gam + double precision intent(in) :: gtol + integer intent(in) :: ipost + external objfun + external logfun + integer intent(out) :: nit + double precision dimension(maxit),intent(out),depend(maxit) :: fvals + double precision dimension(maxit),intent(out),depend(maxit) :: kkts + double precision dimension(maxit),intent(out),depend(maxit) :: feasv + double precision intent(out) :: fval + double precision intent(out) :: kkt + double precision intent(out) :: fea + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gf + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gr + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grad + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grdp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: s + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: y + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xt + double precision dimension(p,p),intent(cache,hide),depend(p) :: lam + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp3 + double precision dimension(p),intent(cache,hide),depend(p) :: weig + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smsl21 + + subroutine smpcf(n,p,x,beta,maxit,gtol,ipost,objfun,logfun,nit,fvals,kkts,feasv,fval,kkt,fea,betout,gf,gr,gc,grp,s,y,xp,wp1,wp2,wp3,weig,row) + use __user__routines + integer intent(hide),depend(x) :: n = shape(x,0) + integer intent(hide),depend(x) :: p = shape(x,1) + double precision dimension(n,p),intent(in,out,copy) :: x + double precision intent(in) :: beta + integer intent(in) :: maxit + double precision intent(in) :: gtol + integer intent(in) :: ipost + external objfun + external logfun + integer intent(out) :: nit + double precision dimension(maxit),intent(out),depend(maxit) :: fvals + double precision dimension(maxit),intent(out),depend(maxit) :: kkts + double precision dimension(maxit),intent(out),depend(maxit) :: feasv + double precision intent(out) :: fval + double precision intent(out) :: kkt + double precision intent(out) :: fea + double precision intent(out) :: betout + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gf + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gr + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: gc + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: grp + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: s + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: y + double precision dimension(n,p),intent(cache,hide),depend(n,p) :: xp + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp1 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp2 + double precision dimension(p,p),intent(cache,hide),depend(p) :: wp3 + double precision dimension(p),intent(cache,hide),depend(p) :: weig + double precision dimension(p),intent(cache,hide),depend(p) :: row + end subroutine smpcf + + end interface +end python module _smopt diff --git a/src/meson.build b/src/meson.build index 66d0296..f4ab108 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,12 +1,9 @@ - add_languages('fortran') py_mod = import('python') py = py_mod.find_installation(pure: false) py_dep = py.dependency() -f2py = find_program('f2py', required: true) - incdir_numpy = run_command(py, ['-c', 'import numpy; print(numpy.get_include())'], check: true @@ -22,40 +19,58 @@ fortranobject_c = run_command(py, check: true ).stdout().strip() -py_mod_name = '' +py_pkg_name = 'smopt' +py_mod_name = '_smopt' -fortran_sources = run_command(py, ['-c', - 'import glob; print(" ".join(glob.glob("*.f")))' -], check: true).stdout().strip().split() -message('Fortran sources: ', fortran_sources) - -python_sources = run_command(py, ['-c', - 'import glob; print(" ".join(glob.glob("*.py")))' -], check: true).stdout().strip().split() -message('Python sources: ', python_sources) +# Listed explicitly rather than globbed so that Meson can track them as +# real build inputs and reconfigure when one of them changes. +fortran_sources = [ + 'smblas.f', + 'smman.f', + 'smprox.f', + 'smslpg.f', + 'smpencf.f', +] pyf_sources = [ - '.pyf' + py_mod_name + '.pyf' ] message('Fortran sources: ', fortran_sources + pyf_sources) +# Driven through bin/run_f2py.py rather than f2py directly: f2py splits +# its own command line on whitespace, so passing a build directory that +# contains a space fails outright. f2py_target = custom_target( py_mod_name + '_f2py', input: pyf_sources, output: [py_mod_name + 'module.c', py_mod_name + '-f2pywrappers.f'], - command: [f2py, '@INPUT@', '--lower', '--build-dir', meson.current_build_dir()] + command: [py, f2py_runner, '@INPUT@', meson.current_build_dir(), '--lower'] ) +# Windows wheels must carry the Fortran runtime, since a machine with +# Python but no MinGW has no libgfortran to load. The -static-* flags +# are understood by the gfortran driver but not by gcc, and Meson would +# otherwise link this mixed C/Fortran target with gcc, so the link +# language is pinned below. -static-libquadmath in particular only +# exists from GCC 13 on, hence the capability probe rather than a +# hardcoded list. +fc = meson.get_compiler('fortran') + if host_machine.system() == 'windows' - fortran_link_args = [ + static_runtime = ['-Wl,-Bstatic', '-lwinpthread', '-Wl,-Bdynamic'] + if not fc.has_link_argument('-static-libquadmath') + # libgfortran pulls in libquadmath, so without this the wheel would + # still ask for libquadmath-0.dll at import time. + static_runtime = ['-Wl,-Bstatic', '-lquadmath', '-lwinpthread', + '-Wl,-Bdynamic'] + endif + + fortran_link_args = fc.get_supported_link_arguments([ '-static-libgfortran', '-static-libgcc', '-static-libquadmath', - '-Wl,-Bstatic', - '-lwinpthread', - '-Wl,-Bdynamic', - ] + ]) + static_runtime else fortran_link_args = [] endif @@ -65,6 +80,41 @@ py.extension_module( fortran_sources + [f2py_target, fortranobject_c], c_args: ['-I' + incdir_numpy, '-I' + incdir_f2py], link_args: fortran_link_args, + link_language: 'fortran', dependencies: py_dep, + subdir: py_pkg_name, install: true ) + +py.install_sources( + [ + 'smopt/__init__.py', + 'smopt/_bridge.py', + ], + subdir: py_pkg_name +) + +py.install_sources( + [ + 'smopt/manifold/__init__.py', + 'smopt/manifold/stiefel.py', + ], + subdir: py_pkg_name / 'manifold' +) + +py.install_sources( + [ + 'smopt/solver/__init__.py', + 'smopt/solver/pencf.py', + 'smopt/solver/slpg.py', + ], + subdir: py_pkg_name / 'solver' +) + +py.install_sources( + [ + 'smopt/utility/__init__.py', + 'smopt/utility/utility.py', + ], + subdir: py_pkg_name / 'utility' +) diff --git a/src/smblas.f b/src/smblas.f new file mode 100644 index 0000000..98751c1 --- /dev/null +++ b/src/smblas.f @@ -0,0 +1,330 @@ +c----------------------------------------------------------------------- +c Dense linear algebra kernels used throughout SMOPT. +c +c Everything the solvers need is implemented here, so the extension +c module builds against nothing but a Fortran compiler and keeps the +c dependency footprint of the project template. +c +c All matrices are double precision and stored column major, which +c is the layout f2py hands over from NumPy. +c----------------------------------------------------------------------- + + DOUBLE PRECISION FUNCTION SMFRO(N, M, A) +c Frobenius norm of the N by M matrix A. + INTEGER N, M + DOUBLE PRECISION A(N,M) + INTEGER I, J + DOUBLE PRECISION T + + T = 0.0D0 + DO 20 J = 1, M + DO 10 I = 1, N + T = T + A(I,J)*A(I,J) + 10 CONTINUE + 20 CONTINUE + SMFRO = DSQRT(T) + RETURN + END + + + DOUBLE PRECISION FUNCTION SMDOT(N, M, A, B) +c Sum of the elementwise product of two N by M matrices. + INTEGER N, M + DOUBLE PRECISION A(N,M), B(N,M) + INTEGER I, J + DOUBLE PRECISION T + + T = 0.0D0 + DO 20 J = 1, M + DO 10 I = 1, N + T = T + A(I,J)*B(I,J) + 10 CONTINUE + 20 CONTINUE + SMDOT = T + RETURN + END + + + SUBROUTINE SMCOPY(N, M, A, B) +c B := A for N by M matrices. + INTEGER N, M + DOUBLE PRECISION A(N,M), B(N,M) + INTEGER I, J + + DO 20 J = 1, M + DO 10 I = 1, N + B(I,J) = A(I,J) + 10 CONTINUE + 20 CONTINUE + RETURN + END + + + SUBROUTINE SMSCAL(N, M, A, S) +c A := S*A for the N by M matrix A. + INTEGER N, M + DOUBLE PRECISION A(N,M), S + INTEGER I, J + + DO 20 J = 1, M + DO 10 I = 1, N + A(I,J) = S*A(I,J) + 10 CONTINUE + 20 CONTINUE + RETURN + END + + + SUBROUTINE SMMM(N, K, M, A, B, C) +c C := A*B with A(N,K), B(K,M) and C(N,M). + INTEGER N, K, M + DOUBLE PRECISION A(N,K), B(K,M), C(N,M) + INTEGER I, J, L + DOUBLE PRECISION T + + DO 40 J = 1, M + DO 10 I = 1, N + C(I,J) = 0.0D0 + 10 CONTINUE + DO 30 L = 1, K + T = B(L,J) + IF (T .NE. 0.0D0) THEN + DO 20 I = 1, N + C(I,J) = C(I,J) + A(I,L)*T + 20 CONTINUE + END IF + 30 CONTINUE + 40 CONTINUE + RETURN + END + + + SUBROUTINE SMMTM(N, K, M, A, B, C) +c C := A**T*B with A(N,K), B(N,M) and C(K,M). + INTEGER N, K, M + DOUBLE PRECISION A(N,K), B(N,M), C(K,M) + INTEGER I, J, L + DOUBLE PRECISION T + + DO 30 J = 1, M + DO 20 I = 1, K + T = 0.0D0 + DO 10 L = 1, N + T = T + A(L,I)*B(L,J) + 10 CONTINUE + C(I,J) = T + 20 CONTINUE + 30 CONTINUE + RETURN + END + + + SUBROUTINE SMRMM(N, P, X, W, ROW) +c X := X*W in place, with X(N,P) and W(P,P). ROW is a workspace +c vector of length P holding one transformed row at a time, which +c keeps the update free of any N by P scratch storage. + INTEGER N, P + DOUBLE PRECISION X(N,P), W(P,P), ROW(P) + INTEGER I, J, K + DOUBLE PRECISION T + + DO 40 I = 1, N + DO 20 J = 1, P + T = 0.0D0 + DO 10 K = 1, P + T = T + X(I,K)*W(K,J) + 10 CONTINUE + ROW(J) = T + 20 CONTINUE + DO 30 J = 1, P + X(I,J) = ROW(J) + 30 CONTINUE + 40 CONTINUE + RETURN + END + + + SUBROUTINE SMSYMM(P, A) +c A := (A + A**T)/2, the symmetrizing operator written Phi in the +c accompanying papers. + INTEGER P + DOUBLE PRECISION A(P,P) + INTEGER I, J + DOUBLE PRECISION T + + DO 20 J = 1, P + DO 10 I = J+1, P + T = 0.5D0*(A(I,J) + A(J,I)) + A(I,J) = T + A(J,I) = T + 10 CONTINUE + 20 CONTINUE + RETURN + END + + + SUBROUTINE SMCHOL(P, A, INFO) +c Cholesky factorization A = L*L**T of a symmetric positive +c definite matrix. L overwrites the lower triangle of A. INFO is +c zero on success and the index of the failing column otherwise. + INTEGER P, INFO + DOUBLE PRECISION A(P,P) + INTEGER I, J, K + DOUBLE PRECISION T + + INFO = 0 + DO 40 J = 1, P + T = A(J,J) + DO 10 K = 1, J-1 + T = T - A(J,K)*A(J,K) + 10 CONTINUE + IF (T .LE. 0.0D0) THEN + INFO = J + RETURN + END IF + A(J,J) = DSQRT(T) + DO 30 I = J+1, P + T = A(I,J) + DO 20 K = 1, J-1 + T = T - A(I,K)*A(J,K) + 20 CONTINUE + A(I,J) = T/A(J,J) + 30 CONTINUE + 40 CONTINUE + RETURN + END + + + SUBROUTINE SMSOLR(P, L, N, X) +c Solve M*z = x for every row x of X(N,P), where the symmetric +c positive definite M is supplied through its Cholesky factor L. +c X is overwritten by the solutions, that is X := X*M**(-1). + INTEGER P, N + DOUBLE PRECISION L(P,P), X(N,P) + INTEGER I, J, K + DOUBLE PRECISION T + + DO 50 I = 1, N + DO 20 J = 1, P + T = X(I,J) + DO 10 K = 1, J-1 + T = T - L(J,K)*X(I,K) + 10 CONTINUE + X(I,J) = T/L(J,J) + 20 CONTINUE + DO 40 J = P, 1, -1 + T = X(I,J) + DO 30 K = J+1, P + T = T - L(K,J)*X(I,K) + 30 CONTINUE + X(I,J) = T/L(J,J) + 40 CONTINUE + 50 CONTINUE + RETURN + END + + + SUBROUTINE SMJACO(P, A, W, V) +c Cyclic Jacobi eigenvalue decomposition of the symmetric matrix A, +c producing A = V*diag(W)*V**T. A is destroyed. The matrices met +c here are of order P, the column count of the iterate, so a few +c sweeps always suffice. + INTEGER P + DOUBLE PRECISION A(P,P), W(P), V(P,P) + INTEGER I, J, K, ISW + DOUBLE PRECISION OFF, THETA, T, C, S, H, U1, U2 + DOUBLE PRECISION TOL + PARAMETER (TOL = 1.0D-30) + + DO 20 J = 1, P + DO 10 I = 1, P + V(I,J) = 0.0D0 + 10 CONTINUE + V(J,J) = 1.0D0 + 20 CONTINUE + + DO 100 ISW = 1, 100 + OFF = 0.0D0 + DO 40 J = 2, P + DO 30 I = 1, J-1 + OFF = OFF + A(I,J)*A(I,J) + 30 CONTINUE + 40 CONTINUE + IF (OFF .LE. TOL) GO TO 110 + + DO 90 J = 2, P + DO 80 I = 1, J-1 + IF (A(I,J) .NE. 0.0D0) THEN + H = A(J,J) - A(I,I) + IF (DABS(H) + DABS(A(I,J)) .EQ. DABS(H)) THEN + T = A(I,J)/H + ELSE + THETA = 0.5D0*H/A(I,J) + T = 1.0D0/(DABS(THETA) + $ + DSQRT(1.0D0 + THETA*THETA)) + IF (THETA .LT. 0.0D0) T = -T + END IF + C = 1.0D0/DSQRT(1.0D0 + T*T) + S = T*C + DO 50 K = 1, P + U1 = A(I,K) + U2 = A(J,K) + A(I,K) = C*U1 - S*U2 + A(J,K) = S*U1 + C*U2 + 50 CONTINUE + DO 60 K = 1, P + U1 = A(K,I) + U2 = A(K,J) + A(K,I) = C*U1 - S*U2 + A(K,J) = S*U1 + C*U2 + 60 CONTINUE + DO 70 K = 1, P + U1 = V(K,I) + U2 = V(K,J) + V(K,I) = C*U1 - S*U2 + V(K,J) = S*U1 + C*U2 + 70 CONTINUE + END IF + 80 CONTINUE + 90 CONTINUE + 100 CONTINUE + + 110 CONTINUE + DO 120 I = 1, P + W(I) = A(I,I) + 120 CONTINUE + RETURN + END + + + SUBROUTINE SMMGS(N, P, X) +c Modified Gram-Schmidt orthonormalization. X is overwritten by an +c N by P matrix with orthonormal columns. + INTEGER N, P + DOUBLE PRECISION X(N,P) + INTEGER I, J, K + DOUBLE PRECISION T + + DO 60 J = 1, P + DO 30 K = 1, J-1 + T = 0.0D0 + DO 10 I = 1, N + T = T + X(I,K)*X(I,J) + 10 CONTINUE + DO 20 I = 1, N + X(I,J) = X(I,J) - T*X(I,K) + 20 CONTINUE + 30 CONTINUE + T = 0.0D0 + DO 40 I = 1, N + T = T + X(I,J)*X(I,J) + 40 CONTINUE + T = DSQRT(T) + IF (T .GT. 0.0D0) THEN + DO 50 I = 1, N + X(I,J) = X(I,J)/T + 50 CONTINUE + END IF + 60 CONTINUE + RETURN + END diff --git a/src/smman.f b/src/smman.f new file mode 100644 index 0000000..4d96f66 --- /dev/null +++ b/src/smman.f @@ -0,0 +1,281 @@ +c----------------------------------------------------------------------- +c Geometry of the Stiefel manifold +c +c S(n,p) = { X in R^(n x p) : X**T*X = I_p }. +c +c The routines below are the Fortran 77 counterparts of the manifold +c class exposed by SMOPT: the constraint map C, its Jacobian JC and +c adjoint, the projection JA onto the tangent-like space, the +c feasibility restoring map A, and the polar retraction used to +c round the final iterate back onto the manifold. +c----------------------------------------------------------------------- + + SUBROUTINE SMCMAP(N, P, X, C) +c C := X**T*X - I, the constraint violation of X. + INTEGER N, P + DOUBLE PRECISION X(N,P), C(P,P) + INTEGER I + + CALL SMMTM(N, P, P, X, X, C) + DO 10 I = 1, P + C(I,I) = C(I,I) - 1.0D0 + 10 CONTINUE + RETURN + END + + + DOUBLE PRECISION FUNCTION SMFEAS(N, P, X, WP) +c Feasibility measure ||X**T*X - I||_F. WP is P by P workspace. + INTEGER N, P + DOUBLE PRECISION X(N,P), WP(P,P) + DOUBLE PRECISION SMFRO + EXTERNAL SMFRO + + CALL SMCMAP(N, P, X, WP) + SMFEAS = SMFRO(P, P, WP) + RETURN + END + + + SUBROUTINE SMJA(N, P, X, G, R, WP) +c R := G - X*Phi(X**T*G), the projection that turns a Euclidean +c gradient G at X into the search direction used by the solvers. + INTEGER N, P + DOUBLE PRECISION X(N,P), G(N,P), R(N,P), WP(P,P) + INTEGER I, J + + CALL SMMTM(N, P, P, X, G, WP) + CALL SMSYMM(P, WP) + CALL SMMM(N, P, P, X, WP, R) + DO 20 J = 1, P + DO 10 I = 1, N + R(I,J) = G(I,J) - R(I,J) + 10 CONTINUE + 20 CONTINUE + RETURN + END + + + SUBROUTINE SMJC(N, P, X, LAM, R, WP) +c R := X*Phi(LAM), the Jacobian of the constraint map applied to a +c multiplier LAM. LAM is left untouched. + INTEGER N, P + DOUBLE PRECISION X(N,P), LAM(P,P), R(N,P), WP(P,P) + + CALL SMCOPY(P, P, LAM, WP) + CALL SMSYMM(P, WP) + CALL SMMM(N, P, P, X, WP, R) + RETURN + END + + + SUBROUTINE SMJCT(N, P, X, D, R) +c R := Phi(X**T*D), the adjoint of SMJC applied to a direction D. + INTEGER N, P + DOUBLE PRECISION X(N,P), D(N,P), R(P,P) + + CALL SMMTM(N, P, P, X, D, R) + CALL SMSYMM(P, R) + RETURN + END + + + SUBROUTINE SMAMAP(N, P, X, WP1, WP2, ROW) +c X := A(X), the feasibility restoring map. Close to the manifold +c the cheap second order expansion 1.5*X - X*(X**T*X)/2 is used, +c and the exact map X*((X**T*X + I)/2)**(-1) otherwise. + INTEGER N, P + DOUBLE PRECISION X(N,P), WP1(P,P), WP2(P,P), ROW(P) + INTEGER I, J, INFO + DOUBLE PRECISION FEAS + DOUBLE PRECISION SMFRO + EXTERNAL SMFRO + + CALL SMMTM(N, P, P, X, X, WP1) + DO 20 J = 1, P + DO 10 I = 1, P + WP2(I,J) = WP1(I,J) + 10 CONTINUE + WP2(J,J) = WP2(J,J) - 1.0D0 + 20 CONTINUE + FEAS = SMFRO(P, P, WP2) + + IF (FEAS .LT. 0.5D0) THEN + DO 40 J = 1, P + DO 30 I = 1, P + WP2(I,J) = -0.5D0*WP1(I,J) + 30 CONTINUE + WP2(J,J) = WP2(J,J) + 1.5D0 + 40 CONTINUE + CALL SMRMM(N, P, X, WP2, ROW) + ELSE + DO 60 J = 1, P + DO 50 I = 1, P + WP2(I,J) = 0.5D0*WP1(I,J) + 50 CONTINUE + WP2(J,J) = WP2(J,J) + 0.5D0 + 60 CONTINUE + CALL SMCHOL(P, WP2, INFO) + IF (INFO .EQ. 0) CALL SMSOLR(P, WP2, N, X) + END IF + RETURN + END + + + SUBROUTINE SMFIX(N, P, X, WP1, WP2, ROW) +c Feasibility restoration used by PENCF. Unlike SMAMAP the map is +c applied only once X has drifted appreciably off the manifold, and +c the result is capped in Frobenius norm to keep the penalized +c iteration bounded. + INTEGER N, P + DOUBLE PRECISION X(N,P), WP1(P,P), WP2(P,P), ROW(P) + INTEGER I, J, INFO + DOUBLE PRECISION FEAS, XN, CAP + DOUBLE PRECISION SMFRO + EXTERNAL SMFRO + + CALL SMMTM(N, P, P, X, X, WP1) + DO 20 J = 1, P + DO 10 I = 1, P + WP2(I,J) = WP1(I,J) + 10 CONTINUE + WP2(J,J) = WP2(J,J) - 1.0D0 + 20 CONTINUE + FEAS = SMFRO(P, P, WP2) + + IF (FEAS .GT. 1.0D-1) THEN + IF (FEAS .LT. 0.5D0) THEN + DO 40 J = 1, P + DO 30 I = 1, P + WP2(I,J) = -0.5D0*WP1(I,J) + 30 CONTINUE + WP2(J,J) = WP2(J,J) + 1.5D0 + 40 CONTINUE + CALL SMRMM(N, P, X, WP2, ROW) + ELSE + DO 60 J = 1, P + DO 50 I = 1, P + WP2(I,J) = 0.5D0*WP1(I,J) + 50 CONTINUE + WP2(J,J) = WP2(J,J) + 0.5D0 + 60 CONTINUE + CALL SMCHOL(P, WP2, INFO) + IF (INFO .EQ. 0) CALL SMSOLR(P, WP2, N, X) + END IF + END IF + + CAP = 1.001D0*DSQRT(DBLE(P)) + XN = SMFRO(N, P, X) + IF (XN .GT. CAP) CALL SMSCAL(N, P, X, CAP/XN) + RETURN + END + + + SUBROUTINE SMPOST(N, P, X, WP1, WP2, WP3, W, ROW) +c X := U*V**T where X = U*S*V**T is a thin singular value +c decomposition. When X has full column rank that orthogonal polar +c factor equals X*(X**T*X)**(-1/2), obtained here from a Jacobi +c eigendecomposition of the P by P matrix X**T*X rather than from a +c decomposition of X itself. +c +c A rank deficient X has no polar factor: the directions belonging +c to a vanishing singular value are unconstrained. Those columns +c are filled with an arbitrary orthonormal completion so that the +c result still lands on the manifold, which is what a singular value +c decomposition would hand back as well. Regularized solvers reach +c this case whenever they zero out enough rows. + INTEGER N, P + DOUBLE PRECISION X(N,P), WP1(P,P), WP2(P,P), WP3(P,P) + DOUBLE PRECISION W(P), ROW(P) + INTEGER I, J, K, M + DOUBLE PRECISION T, WMAX, TOL, THR + DOUBLE PRECISION EPS + PARAMETER (EPS = 2.220446049250313D-16) + + CALL SMMTM(N, P, P, X, X, WP1) + CALL SMSYMM(P, WP1) + CALL SMJACO(P, WP1, W, WP2) + +c X := X*V, whose K-th column is the K-th singular value times the +c corresponding left singular vector. + CALL SMRMM(N, P, X, WP2, ROW) + + WMAX = 0.0D0 + DO 10 K = 1, P + IF (W(K) .GT. WMAX) WMAX = W(K) + 10 CONTINUE + TOL = DBLE(P)*EPS*WMAX + +c Normalize the columns that carry a nonzero singular value and +c blank the rest, so the completion below sees only real vectors. + DO 40 K = 1, P + IF (W(K) .GT. TOL) THEN + T = 1.0D0/DSQRT(W(K)) + DO 20 I = 1, N + X(I,K) = T*X(I,K) + 20 CONTINUE + ELSE + DO 30 I = 1, N + X(I,K) = 0.0D0 + 30 CONTINUE + END IF + 40 CONTINUE + +c Complete the blanked columns. Projecting the N coordinate axes +c off the P-1 columns already in place leaves a total squared norm +c of N-P+1, so at least one axis clears the threshold below. + THR = 0.5D0*DSQRT(DBLE(N-P+1)/DBLE(N)) + DO 130 K = 1, P + IF (W(K) .LE. TOL) THEN + DO 120 J = 1, N + DO 50 I = 1, N + X(I,K) = 0.0D0 + 50 CONTINUE + X(J,K) = 1.0D0 + DO 80 M = 1, P + IF (M .NE. K) THEN + T = 0.0D0 + DO 60 I = 1, N + T = T + X(I,M)*X(I,K) + 60 CONTINUE + DO 70 I = 1, N + X(I,K) = X(I,K) - T*X(I,M) + 70 CONTINUE + END IF + 80 CONTINUE + T = 0.0D0 + DO 90 I = 1, N + T = T + X(I,K)*X(I,K) + 90 CONTINUE + T = DSQRT(T) + IF (T .GT. THR) THEN + DO 100 I = 1, N + X(I,K) = X(I,K)/T + 100 CONTINUE + GO TO 130 + END IF + 120 CONTINUE + END IF + 130 CONTINUE + +c X := U*V**T. + DO 150 J = 1, P + DO 140 I = 1, P + WP3(I,J) = WP2(J,I) + 140 CONTINUE + 150 CONTINUE + CALL SMRMM(N, P, X, WP3, ROW) + RETURN + END + + + SUBROUTINE SMINIT(N, P, X, WP) +c Orthonormalize X unless it already sits on the Stiefel manifold. + INTEGER N, P + DOUBLE PRECISION X(N,P), WP(P,P) + DOUBLE PRECISION SMFEAS + EXTERNAL SMFEAS + + IF (SMFEAS(N, P, X, WP) .GT. 1.0D-6) CALL SMMGS(N, P, X) + RETURN + END diff --git a/src/smopt/__init__.py b/src/smopt/__init__.py new file mode 100644 index 0000000..aa3467b --- /dev/null +++ b/src/smopt/__init__.py @@ -0,0 +1,55 @@ +r"""SMOPT, a toolbox for optimization over the Stiefel manifold. + +The problems addressed here read + +.. math:: + + \min_{X \in \mathbb{R}^{n \times p}} f(X) + r(X) + \quad \text{subject to} \quad X^\top X = I_p, + +whose feasible set is the Stiefel manifold :math:`\mathcal{S}_{n,p}`. +The smooth part ``f`` is supplied as a Python callable returning the +function value and its Euclidean gradient together; the optional +nonsmooth part ``r`` is reached through its proximal operator. + +All of the numerics, including the solver iterations themselves, are +implemented in Fortran 77 and reached through f2py. Python is +responsible only for marshalling arguments, calling back into the user +objective, and reporting progress. + +Examples: + >>> import numpy as np + >>> from smopt import SLPG_smooth, Stiefel + >>> M = Stiefel(6, 2) + >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(X): + ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) + >>> X, out = SLPG_smooth( + ... obj, M, Xinit=np.arange(12.0).reshape(6, 2), verbosity=0 + ... ) + >>> bool(M.Feas_eval(X) < 1e-8) + True +""" + +from importlib.metadata import PackageNotFoundError, version + +from .manifold import Stiefel +from .solver import SLPG, PenCF, SLPG_l21, SLPG_smooth +from .utility import prox_l1, prox_l21 + + +try: + __version__ = version("smopt") +except PackageNotFoundError: # pragma: no cover - source checkout + __version__ = "0.0.0" + +__all__: list[str] = [ + "SLPG", + "PenCF", + "SLPG_l21", + "SLPG_smooth", + "Stiefel", + "__version__", + "prox_l1", + "prox_l21", +] diff --git a/src/smopt/_bridge.py b/src/smopt/_bridge.py new file mode 100644 index 0000000..b1f2c25 --- /dev/null +++ b/src/smopt/_bridge.py @@ -0,0 +1,175 @@ +"""Plumbing between the Python API and the Fortran 77 core. + +The Fortran drivers reach back into Python for three things: the +objective, the proximal operator of a nonsmooth regularizer, and +progress reporting. Matrices cross that boundary flattened in column +major order, which is how Fortran stores them, so the adapters here +reshape in both directions and keep every user facing array in the +familiar ``(n, p)`` form. +""" + +from collections.abc import Callable + +import numpy as np +from numpy.typing import NDArray + + +Matrix = NDArray[np.float64] +ObjFun = Callable[[Matrix], tuple[float, Matrix]] +Prox = Callable[[Matrix, float], Matrix] + +_LINE = "Iter:{} fval:{:.3e} kkts:{:.3e} feas:{:3e}" + +#: ``stage`` values passed to the logging callback by the Fortran side. +_STAGE_ITERATION = 0 +_STAGE_CONVERGED = 1 + +#: Verbosity at which the periodic progress lines are printed. +_VERBOSE_PERIODIC = 2 + + +def as_matrix(x: Matrix, n: int, p: int, name: str = "X") -> Matrix: + """Return ``x`` as a contiguous ``(n, p)`` array of doubles. + + Args: + x: Array to validate and convert. + n: Expected row count. + p: Expected column count. + name: Name used in the error message. + + Returns: + A float64 array of shape ``(n, p)``. + + Raises: + ValueError: If ``x`` does not have shape ``(n, p)``. + """ + out = np.asarray(x, dtype=np.float64) + if out.shape != (n, p): + msg = f"{name} must have shape {(n, p)}, got {out.shape}" + raise ValueError(msg) + return out + + +def check_maxit(maxit: int) -> int: + """Validate the iteration budget. + + Args: + maxit: Requested maximum number of iterations. + + Returns: + The validated budget. + + Raises: + ValueError: If ``maxit`` is not at least one. + """ + maxit = int(maxit) + if maxit < 1: + msg = f"maxit must be at least 1, got {maxit}" + raise ValueError(msg) + return maxit + + +def obj_callback(obj_fun: ObjFun, n: int, p: int) -> Callable[..., object]: + """Adapt a user objective for the Fortran ``OBJFUN`` callback. + + Args: + obj_fun: Callable mapping ``X`` to ``(fval, grad)``. + n: Row count of the iterate. + p: Column count of the iterate. + + Returns: + A callable taking the flattened iterate and returning the + function value together with the flattened gradient. + """ + + def objfun(x: Matrix) -> tuple[float, Matrix]: + fval, grad = obj_fun(x.reshape((n, p), order="F")) + flat = np.asarray(grad, dtype=np.float64).reshape(-1, order="F") + return float(fval), flat + + return objfun + + +def prox_callback(prox: Prox, n: int, p: int) -> Callable[..., object]: + """Adapt a user proximal operator for the Fortran ``PROXFN`` callback. + + Args: + prox: Callable mapping ``(X, eta)`` to the proximal point. + n: Row count of the iterate. + p: Column count of the iterate. + + Returns: + A callable taking the flattened point and the step size, and + returning the flattened proximal point. + """ + + def proxfn(x: Matrix, eta: float) -> Matrix: + y = prox(x.reshape((n, p), order="F"), float(eta)) + return np.asarray(y, dtype=np.float64).reshape(-1, order="F") + + return proxfn + + +def log_callback(verbosity: int, period: int) -> Callable[..., object]: + """Build the progress reporter handed to the Fortran drivers. + + The Fortran side reports every iteration and leaves the decision of + what to print here, so the printing policy stays in Python. + + Args: + verbosity: ``0`` silences output, ``1`` prints only the final + and post-processing lines, ``2`` also prints periodically. + period: Iteration stride between periodic lines. + + Returns: + A callable accepting ``(it, fval, kkt, fea, stage)``. + """ + + def logfun( + it: int, fval: float, kkt: float, fea: float, stage: int + ) -> None: + if stage == _STAGE_ITERATION: + if verbosity == _VERBOSE_PERIODIC and it % period == 0: + print(_LINE.format(it, fval, kkt, fea)) + elif stage == _STAGE_CONVERGED: + if verbosity >= 1: + print(_LINE.format(it, fval, kkt, fea)) + elif verbosity >= 1: + print("Post-processing") + print(_LINE.format(it, fval, kkt, fea)) + + return logfun + + +def output_dict( + nit: int, + fvals: Matrix, + kkts: Matrix, + feasv: Matrix, + fval: float, + kkt: float, + fea: float, +) -> dict[str, object]: + """Assemble the log dictionary returned alongside the solution. + + Args: + nit: Number of iterations actually performed. + fvals: Objective value history, padded to the iteration budget. + kkts: Stationarity history, padded to the iteration budget. + feasv: Feasibility history, padded to the iteration budget. + fval: Final objective value. + kkt: Final stationarity measure. + fea: Final feasibility measure. + + Returns: + A dictionary with the per-iteration histories truncated to the + iterations that ran, plus the final scalars. + """ + return { + "kkts": kkts[:nit].tolist(), + "fvals": fvals[:nit].tolist(), + "fea": fea, + "kkt": kkt, + "fval": fval, + "feas": feasv[:nit].tolist(), + } diff --git a/src/smopt/manifold/__init__.py b/src/smopt/manifold/__init__.py new file mode 100644 index 0000000..cf4580a --- /dev/null +++ b/src/smopt/manifold/__init__.py @@ -0,0 +1,6 @@ +"""Manifolds on which SMOPT can optimize.""" + +from .stiefel import Stiefel + + +__all__: list[str] = ["Stiefel"] diff --git a/src/smopt/manifold/stiefel.py b/src/smopt/manifold/stiefel.py new file mode 100644 index 0000000..6aa25e5 --- /dev/null +++ b/src/smopt/manifold/stiefel.py @@ -0,0 +1,164 @@ +"""The Stiefel manifold and the maps the solvers need on it.""" + +import numpy as np + +from .. import _smopt +from .._bridge import Matrix, as_matrix + + +class Stiefel: + r"""The Stiefel manifold :math:`\{X \in R^{n \times p} : X^T X = I_p\}`. + + The object carries the dimensions and exposes the maps the solvers + need. Every one of them is evaluated by the Fortran 77 core. + + Args: + n: Number of rows of the iterate. + p: Number of columns of the iterate. + + Raises: + ValueError: If the dimensions are not positive or ``p`` exceeds + ``n``. + + Examples: + >>> import numpy as np + >>> from smopt import Stiefel + >>> M = Stiefel(4, 2) + >>> X = M.Init_point(np.eye(4, 2)) + >>> bool(M.Feas_eval(X) < 1e-12) + True + """ + + def __init__(self, n: int, p: int) -> None: + n, p = int(n), int(p) + if n < 1 or p < 1: + msg = f"n and p must be positive, got n={n}, p={p}" + raise ValueError(msg) + if p > n: + msg = f"p must not exceed n, got n={n}, p={p}" + raise ValueError(msg) + self._n = n + self._p = p + self.dim = n * p + + def _mat(self, x: Matrix, name: str = "X") -> Matrix: + return as_matrix(x, self._n, self._p, name) + + def _sq(self, m: Matrix, name: str) -> Matrix: + return as_matrix(m, self._p, self._p, name) + + def Phi(self, M: Matrix) -> Matrix: # noqa: N802, N803 + """Symmetrize a square matrix. + + Args: + M: A ``(p, p)`` matrix. + + Returns: + ``(M + M.T) / 2``. + """ + return _smopt.smsymm(self._sq(M, "M")) + + def A(self, X: Matrix) -> Matrix: # noqa: N802, N803 + """Pull a point back towards the manifold. + + Close to the manifold a second order expansion is used, and the + exact map ``X (X^T X + I)^{-1} 2`` otherwise. + + Args: + X: An ``(n, p)`` matrix. + + Returns: + The restored point. + """ + return _smopt.smamap(self._mat(X)) + + def JA(self, X: Matrix, G: Matrix) -> Matrix: # noqa: N802, N803 + """Project a Euclidean gradient onto the search direction. + + Args: + X: An ``(n, p)`` matrix. + G: The Euclidean gradient at ``X``. + + Returns: + ``G - X Phi(X^T G)``. + """ + return _smopt.smja(self._mat(X), self._mat(G, "G")) + + def JC(self, X: Matrix, Lambda: Matrix) -> Matrix: # noqa: N802, N803 + """Apply the constraint Jacobian to a multiplier. + + Args: + X: An ``(n, p)`` matrix. + Lambda: A ``(p, p)`` multiplier. + + Returns: + ``X Phi(Lambda)``. + """ + return _smopt.smjc(self._mat(X), self._sq(Lambda, "Lambda")) + + def JC_transpose(self, X: Matrix, D: Matrix) -> Matrix: # noqa: N802, N803 + """Apply the adjoint of the constraint Jacobian to a direction. + + Args: + X: An ``(n, p)`` matrix. + D: An ``(n, p)`` direction. + + Returns: + ``Phi(X^T D)``. + """ + return _smopt.smjct(self._mat(X), self._mat(D, "D")) + + def C(self, X: Matrix) -> Matrix: # noqa: N802, N803 + """Evaluate the constraint violation. + + Args: + X: An ``(n, p)`` matrix. + + Returns: + ``X^T X - I``. + """ + return _smopt.smcmap(self._mat(X)) + + def Feas_eval(self, X: Matrix) -> float: # noqa: N802, N803 + """Measure how far a point sits from the manifold. + + Args: + X: An ``(n, p)`` matrix. + + Returns: + The Frobenius norm of ``X^T X - I``. + """ + return float(_smopt.smfeas(self._mat(X))) + + def Init_point(self, Xinit: Matrix | None = None) -> Matrix: # noqa: N802, N803 + """Produce a feasible starting point. + + Args: + Xinit: Optional starting matrix. A standard normal matrix is + drawn when it is omitted. Either way the result is + orthonormalized unless it is already feasible. + + Returns: + An ``(n, p)`` matrix on the manifold. + """ + start = ( + np.random.randn(self._n, self._p) + if Xinit is None + else self._mat(Xinit, "Xinit") + ) + return _smopt.sminit(start) + + def Post_process(self, X: Matrix) -> Matrix: # noqa: N802, N803 + """Round a point onto the manifold. + + Args: + X: An ``(n, p)`` matrix. + + Returns: + The orthogonal polar factor ``U V^T`` of ``X``, where + ``X = U S V^T`` is a thin singular value decomposition. + """ + return _smopt.smpost(self._mat(X)) + + +__all__: list[str] = ["Stiefel"] diff --git a/src/smopt/solver/__init__.py b/src/smopt/solver/__init__.py new file mode 100644 index 0000000..fb4867c --- /dev/null +++ b/src/smopt/solver/__init__.py @@ -0,0 +1,7 @@ +"""Solvers for optimization over the Stiefel manifold.""" + +from .pencf import PenCF +from .slpg import SLPG, SLPG_l21, SLPG_smooth + + +__all__: list[str] = ["SLPG", "PenCF", "SLPG_l21", "SLPG_smooth"] diff --git a/src/smopt/solver/pencf.py b/src/smopt/solver/pencf.py new file mode 100644 index 0000000..79adfb3 --- /dev/null +++ b/src/smopt/solver/pencf.py @@ -0,0 +1,100 @@ +"""PenCF, the constraint dissolving penalty solver.""" + +from typing import Any + +from .. import _smopt +from .._bridge import ( + Matrix, + ObjFun, + as_matrix, + check_maxit, + log_callback, + obj_callback, + output_dict, +) +from ..manifold import Stiefel + + +#: Iteration stride between the periodic progress lines. +_PERIOD = 20 + +#: Sentinel handed to the Fortran driver to request the default penalty. +_AUTO_BETA = -1.0 + + +def PenCF( # noqa: N802 + Xinit: Matrix, # noqa: N803 + obj_fun: ObjFun, + manifold: Stiefel, + beta: float | None = None, + maxit: int = 100, + gtol: float = 1e-5, + post_process: bool = True, # noqa: FBT001, FBT002 + verbosity: int = 2, + **kwargs: Any, # noqa: ANN401 +) -> tuple[Matrix, dict[str, object]]: + r"""Minimize a smooth objective with a constraint dissolving penalty. + + The search direction adds ``beta JC(X, C(X))`` to the projected + gradient, and feasibility is restored only once the iterate has + drifted appreciably off the manifold. + + Args: + Xinit: Starting point. A random feasible point is drawn when it + is ``None``. + obj_fun: Callable mapping ``X`` to ``(fval, grad)``, where + ``grad`` is the Euclidean gradient. + manifold: The :class:`~smopt.manifold.Stiefel` instance fixing + the dimensions. + beta: Penalty weight. Defaults to ``0.1`` times the Frobenius + norm of the gradient at the starting point. + maxit: Maximum number of iterations. + gtol: Stationarity tolerance that stops the iteration. + post_process: Whether to round the final iterate onto the + manifold. + verbosity: ``0`` silences output, ``1`` prints the final lines, + ``2`` also prints every twentieth iteration. + **kwargs: Ignored, accepted so solvers stay interchangeable. + + Returns: + The solution and a dictionary holding the ``fvals``, ``kkts`` + and ``feas`` histories, the final ``fval``, ``kkt`` and ``fea`` + values, and the ``beta`` actually used. + + Examples: + >>> import numpy as np + >>> from smopt import PenCF, Stiefel + >>> M = Stiefel(6, 2) + >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(X): + ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) + >>> X0 = np.arange(12.0).reshape(6, 2) + >>> X, out = PenCF(X0, obj, M, verbosity=0) + >>> bool(M.Feas_eval(X) < 1e-8) + True + """ + maxit = check_maxit(maxit) + n, p = manifold._n, manifold._p + # A caller supplied starting point is used as given; only the + # default one is drawn and orthonormalized. + x0 = ( + manifold.Init_point() + if Xinit is None + else as_matrix(Xinit, n, p, "Xinit") + ) + + x, nit, fvals, kkts, feasv, fval, kkt, fea, betout = _smopt.smpcf( + x0, + _AUTO_BETA if beta is None else float(beta), + maxit, + gtol, + int(post_process), + obj_callback(obj_fun, n, p), + log_callback(verbosity, _PERIOD), + ) + out = output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) + out["beta"] = betout + return x, out + + +__all__: list[str] = ["PenCF"] diff --git a/src/smopt/solver/slpg.py b/src/smopt/solver/slpg.py new file mode 100644 index 0000000..7f12863 --- /dev/null +++ b/src/smopt/solver/slpg.py @@ -0,0 +1,224 @@ +"""SLPG, the penalty-free first-order solver family. + +Each entry point marshals its arguments, hands control to the Fortran 77 +driver, and turns the histories the driver fills in into the log +dictionary returned alongside the solution. +""" + +from typing import Any + +from .. import _smopt +from .._bridge import ( + Matrix, + ObjFun, + Prox, + as_matrix, + check_maxit, + log_callback, + obj_callback, + output_dict, + prox_callback, +) +from ..manifold import Stiefel + + +#: Iteration stride between the periodic progress lines. +_SMOOTH_PERIOD = 20 +_PROX_PERIOD = 50 + + +def SLPG_smooth( # noqa: N802 + obj_fun: ObjFun, + manifold: Stiefel, + Xinit: Matrix | None = None, # noqa: N803 + maxit: int = 100, + gtol: float = 1e-5, + post_process: bool = True, # noqa: FBT001, FBT002 + verbosity: int = 2, + **kwargs: Any, # noqa: ANN401 +) -> tuple[Matrix, dict[str, object]]: + r"""Minimize a smooth objective over the Stiefel manifold. + + Args: + obj_fun: Callable mapping ``X`` to ``(fval, grad)``, where + ``grad`` is the Euclidean gradient. Returning both at once + is usually much cheaper than computing them separately. + manifold: The :class:`~smopt.manifold.Stiefel` instance fixing + the dimensions. + Xinit: Starting point. A random feasible point is drawn when it + is omitted. + maxit: Maximum number of iterations. + gtol: Stationarity tolerance that stops the iteration. + post_process: Whether to round the final iterate onto the + manifold. + verbosity: ``0`` silences output, ``1`` prints the final lines, + ``2`` also prints every twentieth iteration. + **kwargs: Ignored, accepted so solvers stay interchangeable. + + Returns: + The solution and a dictionary holding the ``fvals``, ``kkts`` + and ``feas`` histories together with the final ``fval``, ``kkt`` + and ``fea`` values. + + Examples: + >>> import numpy as np + >>> from smopt import SLPG_smooth, Stiefel + >>> M = Stiefel(6, 2) + >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(X): + ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) + >>> X0 = np.arange(12.0).reshape(6, 2) + >>> X, out = SLPG_smooth(obj, M, Xinit=X0, verbosity=0) + >>> bool(M.Feas_eval(X) < 1e-8) + True + """ + maxit = check_maxit(maxit) + n, p = manifold._n, manifold._p + # A caller supplied starting point is used as given; only the + # default one is drawn and orthonormalized. + x0 = ( + manifold.Init_point() + if Xinit is None + else as_matrix(Xinit, n, p, "Xinit") + ) + + x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smslps( + x0, + maxit, + gtol, + int(post_process), + obj_callback(obj_fun, n, p), + log_callback(verbosity, _SMOOTH_PERIOD), + ) + return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) + + +def SLPG( # noqa: N802 + obj_fun: ObjFun, + manifold: Stiefel, + Xinit: Matrix | None = None, # noqa: N803 + maxit: int = 100, + prox: Prox | None = None, + gtol: float = 1e-5, + post_process: bool = True, # noqa: FBT001, FBT002 + verbosity: int = 2, + **kwargs: Any, # noqa: ANN401 +) -> tuple[Matrix, dict[str, object]]: + r"""Minimize ``f(X) + r(X)`` over the Stiefel manifold. + + The regularizer ``r`` is reached only through its proximal + operator. The multiplier of the orthogonality constraint is tracked + by an inner Arrow-Hurwicz iteration, so no penalty parameter has to + be tuned. + + Args: + obj_fun: Callable mapping ``X`` to ``(fval, grad)`` for the + smooth part ``f``. + manifold: The :class:`~smopt.manifold.Stiefel` instance fixing + the dimensions. + Xinit: Starting point. A random feasible point is drawn when it + is omitted. + maxit: Maximum number of iterations. + prox: Callable mapping ``(X, eta)`` to the minimizer of + ``||Y - X||_F^2 / (2 eta) + r(Y)``. Defaults to the identity, + which recovers the smooth case. + gtol: Stationarity tolerance that stops the iteration. + post_process: Whether to round the final iterate onto the + manifold. + verbosity: ``0`` silences output, ``1`` prints the final lines, + ``2`` also prints every fiftieth iteration. + **kwargs: Ignored, accepted so solvers stay interchangeable. + + Returns: + The solution and a dictionary holding the ``fvals``, ``kkts`` + and ``feas`` histories together with the final ``fval``, ``kkt`` + and ``fea`` values. + """ + maxit = check_maxit(maxit) + n, p = manifold._n, manifold._p + # A caller supplied starting point is used as given; only the + # default one is drawn and orthonormalized. + x0 = ( + manifold.Init_point() + if Xinit is None + else as_matrix(Xinit, n, p, "Xinit") + ) + + if prox is None: + + def prox(x: Matrix, eta: float) -> Matrix: + return x + + x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smslpg( + x0, + maxit, + gtol, + int(post_process), + obj_callback(obj_fun, n, p), + prox_callback(prox, n, p), + log_callback(verbosity, _PROX_PERIOD), + ) + return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) + + +def SLPG_l21( # noqa: N802 + obj_fun: ObjFun, + manifold: Stiefel, + Xinit: Matrix | None = None, # noqa: N803 + maxit: int = 100, + gamma: float = 0, + gtol: float = 1e-5, + post_process: bool = True, # noqa: FBT001, FBT002 + verbosity: int = 2, + **kwargs: Any, # noqa: ANN401 +) -> tuple[Matrix, dict[str, object]]: + r"""Minimize ``f(X) + gamma ||X||_{2,1}`` over the Stiefel manifold. + + The row-sparsity inducing :math:`\ell_{2,1}` norm has a proximal + operator and a constraint multiplier available in closed form, so + this driver needs no inner iteration. + + Args: + obj_fun: Callable mapping ``X`` to ``(fval, grad)`` for the + smooth part ``f``. + manifold: The :class:`~smopt.manifold.Stiefel` instance fixing + the dimensions. + Xinit: Starting point. A random feasible point is drawn when it + is omitted. + maxit: Maximum number of iterations. + gamma: Weight of the regularization term. + gtol: Stationarity tolerance that stops the iteration. + post_process: Whether to round the final iterate onto the + manifold. + verbosity: ``0`` silences output, ``1`` prints the final lines, + ``2`` also prints every fiftieth iteration. + **kwargs: Ignored, accepted so solvers stay interchangeable. + + Returns: + The solution and a dictionary holding the ``fvals``, ``kkts`` + and ``feas`` histories together with the final ``fval``, ``kkt`` + and ``fea`` values. + """ + maxit = check_maxit(maxit) + n, p = manifold._n, manifold._p + # A caller supplied starting point is used as given; only the + # default one is drawn and orthonormalized. + x0 = ( + manifold.Init_point() + if Xinit is None + else as_matrix(Xinit, n, p, "Xinit") + ) + + x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smsl21( + x0, + maxit, + gamma, + gtol, + int(post_process), + obj_callback(obj_fun, n, p), + log_callback(verbosity, _PROX_PERIOD), + ) + return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) + + +__all__: list[str] = ["SLPG", "SLPG_l21", "SLPG_smooth"] diff --git a/src/smopt/utility/__init__.py b/src/smopt/utility/__init__.py new file mode 100644 index 0000000..66379e8 --- /dev/null +++ b/src/smopt/utility/__init__.py @@ -0,0 +1,6 @@ +"""Proximal operators of the regularizers SMOPT supports.""" + +from .utility import prox_l1, prox_l21 + + +__all__: list[str] = ["prox_l1", "prox_l21"] diff --git a/src/smopt/utility/utility.py b/src/smopt/utility/utility.py new file mode 100644 index 0000000..418467c --- /dev/null +++ b/src/smopt/utility/utility.py @@ -0,0 +1,70 @@ +"""Proximal operators of the regularizers SMOPT supports. + +The proximal operator of a function :math:`r` at :math:`X` with step +:math:`\\eta` is the minimizer of + +.. math:: \\frac{1}{2\\eta} \\|Y - X\\|_F^2 + r(Y). + +Both operators below are evaluated by the Fortran 77 core, and both are +shaped so they can be handed straight to :func:`~smopt.solver.SLPG`. +""" + +from .. import _smopt +from .._bridge import Matrix + + +#: Guard against dividing by a vanishing row norm. +_EPS = 1e-14 + + +def prox_l1(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: N803 + r"""Proximal operator of :math:`\gamma \|X\|_1`. + + Args: + X_input: The point at which to evaluate the operator. + eta: The proximal step size. + gamma: Weight of the regularization term. + + Returns: + The soft-thresholded matrix, entry by entry. + + Examples: + >>> import numpy as np + >>> from smopt import prox_l1 + >>> Y = prox_l1(np.array([[-3.0, 0.5, 2.0]]), 1.0, gamma=1.0) + >>> bool(np.allclose(Y, [[-2.0, 0.0, 1.0]])) + True + """ + return _smopt.smpl1(X_input, eta, gamma) + + +def prox_l21(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: N803 + r"""Proximal operator of :math:`\gamma \|X\|_{2,1}`. + + The :math:`\ell_{2,1}` norm sums the Euclidean norms of the rows of + ``X``, so the operator shrinks whole rows towards the origin and + induces row sparsity. + + Args: + X_input: The point at which to evaluate the operator. + eta: The proximal step size. + gamma: Weight of the regularization term. + + Returns: + The row-wise shrunk matrix. + + Examples: + >>> import numpy as np + >>> from smopt import prox_l21 + >>> X = np.array([[3.0, 4.0], [0.3, 0.4]]) + >>> bool( + ... np.allclose( + ... prox_l21(X, 1.0, gamma=1.0), [[2.4, 3.2], [0.0, 0.0]] + ... ) + ... ) + True + """ + return _smopt.smpl21(X_input, eta, gamma, _EPS) + + +__all__: list[str] = ["prox_l1", "prox_l21"] diff --git a/src/smpencf.f b/src/smpencf.f new file mode 100644 index 0000000..638ad76 --- /dev/null +++ b/src/smpencf.f @@ -0,0 +1,116 @@ +c----------------------------------------------------------------------- +c PENCF, a constraint dissolving penalty method for +c +c min f(X) subject to X**T*X = I_p. +c +c The search direction combines the projected gradient with a +c penalty term BETA*JC(X, C(X)) that pushes the iterate back onto +c the manifold, and feasibility is restored only once the iterate +c has drifted appreciably away from it. +c----------------------------------------------------------------------- + + SUBROUTINE SMPCF(N, P, X, BETA, MAXIT, GTOL, IPOST, OBJFUN, + $ LOGFUN, NIT, FVALS, KKTS, FEASV, FVAL, KKT, + $ FEA, BETOUT, GF, GR, GC, GRP, S, Y, XP, WP1, + $ WP2, WP3, WEIG, ROW) +c A negative BETA asks for the default 0.1*||grad f(X0)||_F, so an +c explicit BETA of zero is honoured. The value actually used is +c returned in BETOUT. + INTEGER N, P, MAXIT, IPOST, NIT + DOUBLE PRECISION X(N,P), BETA, GTOL, FVAL, KKT, FEA, BETOUT + DOUBLE PRECISION FVALS(MAXIT), KKTS(MAXIT), FEASV(MAXIT) + DOUBLE PRECISION GF(N,P), GR(N,P), GC(N,P), GRP(N,P) + DOUBLE PRECISION S(N,P), Y(N,P), XP(N,P) + DOUBLE PRECISION WP1(P,P), WP2(P,P), WP3(P,P) + DOUBLE PRECISION WEIG(P), ROW(P) + EXTERNAL OBJFUN, LOGFUN + INTEGER I, J, JJ, NP + DOUBLE PRECISION L, STEP, BT + DOUBLE PRECISION SMFRO, SMDOT, SMFEAS, SMSTEP + EXTERNAL SMFRO, SMDOT, SMFEAS, SMSTEP + + NP = N*P + NIT = 0 + KKT = 0.0D0 + FEA = 0.0D0 + + CALL OBJFUN(NP, X, FVAL, GF) + + BT = BETA + IF (BT .LT. 0.0D0) BT = 0.1D0*SMFRO(N, P, GF) + BETOUT = BT + + CALL SMJA(N, P, X, GF, GR, WP1) + CALL SMCMAP(N, P, X, WP2) + CALL SMJC(N, P, X, WP2, GC, WP1) + DO 20 J = 1, P + DO 10 I = 1, N + GR(I,J) = GR(I,J) + BT*GC(I,J) + 10 CONTINUE + 20 CONTINUE + + L = SMFRO(N, P, GF) + SMFRO(N, P, GR) + + DO 100 JJ = 1, MAXIT + IF (JJ .LE. 3) THEN + STEP = 0.01D0/L + ELSE + STEP = SMSTEP(SMDOT(N, P, S, Y), SMDOT(N, P, Y, Y), + $ 1.0D10) + END IF + + CALL SMCOPY(N, P, X, XP) + DO 40 J = 1, P + DO 30 I = 1, N + X(I,J) = X(I,J) - STEP*GR(I,J) + 30 CONTINUE + 40 CONTINUE + CALL SMFIX(N, P, X, WP1, WP2, ROW) + + DO 60 J = 1, P + DO 50 I = 1, N + S(I,J) = X(I,J) - XP(I,J) + 50 CONTINUE + 60 CONTINUE + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMCOPY(N, P, GR, GRP) + CALL SMJA(N, P, X, GF, GR, WP1) + CALL SMCMAP(N, P, X, WP2) + CALL SMJC(N, P, X, WP2, GC, WP1) + DO 80 J = 1, P + DO 70 I = 1, N + GR(I,J) = GR(I,J) + BT*GC(I,J) + Y(I,J) = GR(I,J) - GRP(I,J) + 70 CONTINUE + 80 CONTINUE + + KKT = SMFRO(N, P, GR) + FEA = SMFEAS(N, P, X, WP1) + + NIT = JJ + FVALS(JJ) = FVAL + KKTS(JJ) = KKT + FEASV(JJ) = FEA + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 0) + + IF (KKT .LT. GTOL) THEN + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 1) + GO TO 110 + END IF + 100 CONTINUE + + 110 CONTINUE + IF (IPOST .NE. 0 .AND. NIT .GE. 1) THEN + CALL SMPOST(N, P, X, WP1, WP2, WP3, WEIG, ROW) + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMJA(N, P, X, GF, GR, WP1) + KKT = SMFRO(N, P, GR) + FEA = SMFEAS(N, P, X, WP1) + CALL LOGFUN(NIT-1, FVAL, KKT, FEA, 2) + FVALS(NIT) = FVAL + KKTS(NIT) = KKT + FEASV(NIT) = FEA + END IF + RETURN + END diff --git a/src/smprox.f b/src/smprox.f new file mode 100644 index 0000000..21040ac --- /dev/null +++ b/src/smprox.f @@ -0,0 +1,84 @@ +c----------------------------------------------------------------------- +c Proximal operators of the regularizers supported by SMOPT, plus +c the closed form multiplier attached to the l_{2,1} penalty. +c +c The prox of a function r at X with step ETA is the minimizer of +c +c (1/(2*ETA))*||Y - X||_F^2 + r(Y). +c----------------------------------------------------------------------- + + SUBROUTINE SMPL1(N, P, X, ETA, GAM, Y) +c Y := prox of GAM*||.||_1 at X with step ETA, that is +c max(X - GAM*ETA, 0) + min(X + GAM*ETA, 0) entrywise. + INTEGER N, P + DOUBLE PRECISION X(N,P), Y(N,P), ETA, GAM + INTEGER I, J + DOUBLE PRECISION T + + T = GAM*ETA + DO 20 J = 1, P + DO 10 I = 1, N + Y(I,J) = DMAX1(X(I,J) - T, 0.0D0) + $ + DMIN1(X(I,J) + T, 0.0D0) + 10 CONTINUE + 20 CONTINUE + RETURN + END + + + SUBROUTINE SMPL21(N, P, X, ETA, GAM, EPS, Y) +c Y := prox of GAM*||.||_{2,1} at X with step ETA. The l_{2,1} +c norm sums the Euclidean norms of the rows of X, so the prox +c shrinks each row towards the origin. EPS guards the division by +c a vanishing row norm. + INTEGER N, P + DOUBLE PRECISION X(N,P), Y(N,P), ETA, GAM, EPS + INTEGER I, J + DOUBLE PRECISION T, R, SC + + T = GAM*ETA + DO 30 I = 1, N + R = 0.0D0 + DO 10 J = 1, P + R = R + X(I,J)*X(I,J) + 10 CONTINUE + R = DSQRT(R) + SC = DMAX1(R - T, 0.0D0)/(R + EPS) + DO 20 J = 1, P + Y(I,J) = SC*X(I,J) + 20 CONTINUE + 30 CONTINUE + RETURN + END + + + SUBROUTINE SMLM21(N, P, X, GAM, LAM) +c LAM := -GAM*X**T*diag(w)*X with w(i) = 1/(1.0D-14 + ||X(i,.)||), +c the multiplier that the l_{2,1} regularized solver attaches to +c the orthogonality constraint. + INTEGER N, P + DOUBLE PRECISION X(N,P), LAM(P,P), GAM + INTEGER I, J, K + DOUBLE PRECISION R, T + + DO 20 J = 1, P + DO 10 I = 1, P + LAM(I,J) = 0.0D0 + 10 CONTINUE + 20 CONTINUE + + DO 60 K = 1, N + R = 0.0D0 + DO 30 J = 1, P + R = R + X(K,J)*X(K,J) + 30 CONTINUE + R = 1.0D0/(1.0D-14 + DSQRT(R)) + DO 50 J = 1, P + T = GAM*R*X(K,J) + DO 40 I = 1, P + LAM(I,J) = LAM(I,J) - X(K,I)*T + 40 CONTINUE + 50 CONTINUE + 60 CONTINUE + RETURN + END diff --git a/src/smslpg.f b/src/smslpg.f new file mode 100644 index 0000000..dd7db27 --- /dev/null +++ b/src/smslpg.f @@ -0,0 +1,389 @@ +c----------------------------------------------------------------------- +c SLPG, the penalty-free first-order family of solvers for +c +c min f(X) + r(X) subject to X**T*X = I_p. +c +c Three drivers are provided: SMSLPS for a smooth f, SMSLPG for a +c general r reachable through its proximal operator, and SMSL21 for +c the l_{2,1} regularizer whose prox and multiplier are known in +c closed form. +c +c The objective is supplied by the caller through the OBJFUN +c callback, which receives the flattened iterate and returns the +c function value together with its Euclidean gradient. LOGFUN +c reports progress; the STAGE argument is 0 for an ordinary +c iteration, 1 for the line printed on convergence and 2 for the +c line printed after post-processing. +c----------------------------------------------------------------------- + + DOUBLE PRECISION FUNCTION SMSTEP(NUM, DEN, CAP) +c Barzilai-Borwein trial step |NUM/DEN| truncated at CAP. A zero +c denominator leaves the ratio unbounded, so the cap is returned. + DOUBLE PRECISION NUM, DEN, CAP + + IF (DEN .EQ. 0.0D0) THEN + SMSTEP = CAP + ELSE + SMSTEP = DMIN1(DABS(NUM/DEN), CAP) + END IF + RETURN + END + + + SUBROUTINE SMAHUR(N, P, X, G, ETA, TOL, LAM, PROXFN, + $ Z, XT, DX, WP) +c Five steps of the Arrow-Hurwicz iteration that updates the +c multiplier LAM of the orthogonality constraint at X. The loop +c stops early once the multiplier increment drops below TOL. + INTEGER N, P + DOUBLE PRECISION X(N,P), G(N,P), LAM(P,P), ETA, TOL + DOUBLE PRECISION Z(N,P), XT(N,P), DX(N,P), WP(P,P) + EXTERNAL PROXFN + INTEGER I, J, JR, NP + DOUBLE PRECISION SMFRO + EXTERNAL SMFRO + + NP = N*P + DO 20 J = 1, P + DO 10 I = 1, N + Z(I,J) = X(I,J) - ETA*G(I,J) + 10 CONTINUE + 20 CONTINUE + + DO 100 JR = 1, 5 + CALL SMJC(N, P, X, LAM, DX, WP) + DO 40 J = 1, P + DO 30 I = 1, N + XT(I,J) = Z(I,J) - ETA*DX(I,J) + 30 CONTINUE + 40 CONTINUE + CALL PROXFN(NP, XT, ETA, DX) + DO 60 J = 1, P + DO 50 I = 1, N + DX(I,J) = (DX(I,J) - X(I,J))/ETA + 50 CONTINUE + 60 CONTINUE + CALL SMJCT(N, P, X, DX, WP) + DO 80 J = 1, P + DO 70 I = 1, P + LAM(I,J) = LAM(I,J) + WP(I,J) + 70 CONTINUE + 80 CONTINUE + IF (SMFRO(P, P, WP) .LT. TOL) RETURN + 100 CONTINUE + RETURN + END + + + SUBROUTINE SMSLPS(N, P, X, MAXIT, GTOL, IPOST, OBJFUN, LOGFUN, + $ NIT, FVALS, KKTS, FEASV, FVAL, KKT, FEA, + $ GF, GR, GRP, S, Y, XP, WP1, WP2, WP3, WEIG, + $ ROW) +c SLPG for a smooth objective. A Barzilai-Borwein step is taken +c along the projected gradient and the iterate is pulled back onto +c the manifold by the feasibility restoring map A. + INTEGER N, P, MAXIT, IPOST, NIT + DOUBLE PRECISION X(N,P), GTOL, FVAL, KKT, FEA + DOUBLE PRECISION FVALS(MAXIT), KKTS(MAXIT), FEASV(MAXIT) + DOUBLE PRECISION GF(N,P), GR(N,P), GRP(N,P) + DOUBLE PRECISION S(N,P), Y(N,P), XP(N,P) + DOUBLE PRECISION WP1(P,P), WP2(P,P), WP3(P,P) + DOUBLE PRECISION WEIG(P), ROW(P) + EXTERNAL OBJFUN, LOGFUN + INTEGER I, J, JJ, NP + DOUBLE PRECISION L, STEP + DOUBLE PRECISION SMFRO, SMDOT, SMFEAS, SMSTEP + EXTERNAL SMFRO, SMDOT, SMFEAS, SMSTEP + + NP = N*P + NIT = 0 + KKT = 0.0D0 + FEA = 0.0D0 + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMJA(N, P, X, GF, GR, WP1) + L = SMFRO(N, P, GF) + SMFRO(N, P, GR) + + DO 100 JJ = 1, MAXIT + IF (JJ .LE. 3) THEN + STEP = 0.01D0/L + ELSE + STEP = SMSTEP(SMDOT(N, P, S, Y), SMDOT(N, P, Y, Y), + $ 1.0D10) + END IF + + CALL SMCOPY(N, P, X, XP) + DO 20 J = 1, P + DO 10 I = 1, N + X(I,J) = X(I,J) - STEP*GR(I,J) + 10 CONTINUE + 20 CONTINUE + CALL SMAMAP(N, P, X, WP1, WP2, ROW) + + DO 40 J = 1, P + DO 30 I = 1, N + S(I,J) = X(I,J) - XP(I,J) + 30 CONTINUE + 40 CONTINUE + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMCOPY(N, P, GR, GRP) + CALL SMJA(N, P, X, GF, GR, WP1) + DO 60 J = 1, P + DO 50 I = 1, N + Y(I,J) = GR(I,J) - GRP(I,J) + 50 CONTINUE + 60 CONTINUE + + KKT = SMFRO(N, P, GR) + FEA = SMFEAS(N, P, X, WP1) + + NIT = JJ + FVALS(JJ) = FVAL + KKTS(JJ) = KKT + FEASV(JJ) = FEA + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 0) + + IF (KKT .LT. GTOL) THEN + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 1) + GO TO 110 + END IF + 100 CONTINUE + + 110 CONTINUE + IF (IPOST .NE. 0 .AND. NIT .GE. 1) THEN + CALL SMPOST(N, P, X, WP1, WP2, WP3, WEIG, ROW) + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMJA(N, P, X, GF, GR, WP1) + KKT = SMFRO(N, P, GR) + FEA = SMFEAS(N, P, X, WP1) + CALL LOGFUN(NIT-1, FVAL, KKT, FEA, 2) + FVALS(NIT) = FVAL + KKTS(NIT) = KKT + FEASV(NIT) = FEA + END IF + RETURN + END + + + SUBROUTINE SMSLPG(N, P, X, MAXIT, GTOL, IPOST, OBJFUN, PROXFN, + $ LOGFUN, NIT, FVALS, KKTS, FEASV, FVAL, KKT, + $ FEA, GF, GR, GRAD, GRDP, S, Y, XP, Z, XT, DX, + $ LAM, WP1, WP2, WP3, WEIG, ROW, STEPS) +c SLPG for a nonsmooth regularizer reached through its proximal +c operator PROXFN. The multiplier of the orthogonality constraint +c is tracked by an Arrow-Hurwicz inner iteration so that no penalty +c parameter has to be tuned. + INTEGER N, P, MAXIT, IPOST, NIT + DOUBLE PRECISION X(N,P), GTOL, FVAL, KKT, FEA + DOUBLE PRECISION FVALS(MAXIT), KKTS(MAXIT), FEASV(MAXIT) + DOUBLE PRECISION GF(N,P), GR(N,P), GRAD(N,P), GRDP(N,P) + DOUBLE PRECISION S(N,P), Y(N,P), XP(N,P) + DOUBLE PRECISION Z(N,P), XT(N,P), DX(N,P) + DOUBLE PRECISION LAM(P,P), WP1(P,P), WP2(P,P), WP3(P,P) + DOUBLE PRECISION WEIG(P), ROW(P), STEPS(MAXIT) + EXTERNAL OBJFUN, PROXFN, LOGFUN + INTEGER I, J, K, JJ, M1, NP + DOUBLE PRECISION L, STEP, STRY, T + DOUBLE PRECISION SMFRO, SMDOT, SMFEAS, SMSTEP + EXTERNAL SMFRO, SMDOT, SMFEAS, SMSTEP + + NP = N*P + NIT = 0 + KKT = 0.0D0 + FEA = 0.0D0 + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMJA(N, P, X, GF, GR, WP1) + L = SMFRO(N, P, GF) + SMFRO(N, P, GR) + + DO 20 J = 1, P + DO 10 I = 1, P + LAM(I,J) = 0.0D0 + 10 CONTINUE + 20 CONTINUE + CALL SMAHUR(N, P, X, GR, 0.01D0/L, 0.0D0, LAM, PROXFN, + $ Z, XT, DX, WP1) + CALL SMJC(N, P, X, LAM, GRAD, WP1) + DO 40 J = 1, P + DO 30 I = 1, N + GRAD(I,J) = GR(I,J) + GRAD(I,J) + 30 CONTINUE + 40 CONTINUE + + DO 200 JJ = 1, MAXIT + IF (JJ .LE. 5) THEN + STEP = 0.01D0/L + ELSE + STEP = SMSTEP(SMDOT(N, P, S, S), SMDOT(N, P, S, Y), + $ 1.0D10) + END IF + STEPS(JJ) = STEP + + CALL SMCOPY(N, P, X, XP) + DO 60 J = 1, P + DO 50 I = 1, N + XT(I,J) = X(I,J) - STEP*GRAD(I,J) + 50 CONTINUE + 60 CONTINUE + CALL PROXFN(NP, XT, STEP, X) + CALL SMAMAP(N, P, X, WP1, WP2, ROW) + + DO 80 J = 1, P + DO 70 I = 1, N + S(I,J) = X(I,J) - XP(I,J) + 70 CONTINUE + 80 CONTINUE + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMCOPY(N, P, GRAD, GRDP) + CALL SMJA(N, P, X, GF, GR, WP1) + + M1 = MAX0(1, JJ-10) + T = 0.0D0 + DO 90 K = M1, JJ + T = T + STEPS(K) + 90 CONTINUE + STRY = T/DBLE(JJ - M1 + 1) + STRY = DMIN1(DMAX1(STRY, 1.0D-5/L), 1.0D10/L) + + FEA = SMFEAS(N, P, X, WP1) + CALL SMAHUR(N, P, X, GR, STRY, 1.0D3*FEA, LAM, PROXFN, + $ Z, XT, DX, WP1) + + CALL SMJC(N, P, X, LAM, GRAD, WP1) + DO 110 J = 1, P + DO 100 I = 1, N + GRAD(I,J) = GR(I,J) + GRAD(I,J) + Y(I,J) = GRAD(I,J) - GRDP(I,J) + 100 CONTINUE + 110 CONTINUE + + KKT = SMFRO(N, P, S)/STEP + + NIT = JJ + FVALS(JJ) = FVAL + KKTS(JJ) = KKT + FEASV(JJ) = FEA + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 0) + + IF (KKT .LT. GTOL) THEN + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 1) + GO TO 210 + END IF + 200 CONTINUE + + 210 CONTINUE + IF (IPOST .NE. 0 .AND. NIT .GE. 1) THEN + CALL SMPOST(N, P, X, WP1, WP2, WP3, WEIG, ROW) + CALL OBJFUN(NP, X, FVAL, GF) + FEA = SMFEAS(N, P, X, WP1) + CALL LOGFUN(NIT-1, FVAL, KKT, FEA, 2) + FVALS(NIT) = FVAL + KKTS(NIT) = KKT + FEASV(NIT) = FEA + END IF + RETURN + END + + + SUBROUTINE SMSL21(N, P, X, MAXIT, GAM, GTOL, IPOST, OBJFUN, + $ LOGFUN, NIT, FVALS, KKTS, FEASV, FVAL, KKT, + $ FEA, GF, GR, GRAD, GRDP, S, Y, XP, XT, LAM, + $ WP1, WP2, WP3, WEIG, ROW) +c SLPG for the l_{2,1} regularized objective f(X) + GAM*||X||_{2,1}. +c Both the prox and the constraint multiplier are available in +c closed form, so no inner iteration is needed. + INTEGER N, P, MAXIT, IPOST, NIT + DOUBLE PRECISION X(N,P), GAM, GTOL, FVAL, KKT, FEA + DOUBLE PRECISION FVALS(MAXIT), KKTS(MAXIT), FEASV(MAXIT) + DOUBLE PRECISION GF(N,P), GR(N,P), GRAD(N,P), GRDP(N,P) + DOUBLE PRECISION S(N,P), Y(N,P), XP(N,P), XT(N,P) + DOUBLE PRECISION LAM(P,P), WP1(P,P), WP2(P,P), WP3(P,P) + DOUBLE PRECISION WEIG(P), ROW(P) + EXTERNAL OBJFUN, LOGFUN + INTEGER I, J, JJ, NP + DOUBLE PRECISION L, STEP + DOUBLE PRECISION SMFRO, SMDOT, SMFEAS, SMSTEP + EXTERNAL SMFRO, SMDOT, SMFEAS, SMSTEP + + NP = N*P + NIT = 0 + KKT = 0.0D0 + FEA = 0.0D0 + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMJA(N, P, X, GF, GR, WP1) + L = SMFRO(N, P, GF) + SMFRO(N, P, GR) + + CALL SMLM21(N, P, X, GAM, LAM) + CALL SMJC(N, P, X, LAM, GRAD, WP1) + DO 20 J = 1, P + DO 10 I = 1, N + GRAD(I,J) = GR(I,J) + GRAD(I,J) + 10 CONTINUE + 20 CONTINUE + + DO 100 JJ = 1, MAXIT + IF (JJ .LE. 5) THEN + STEP = 0.001D0/L + ELSE + STEP = SMSTEP(SMDOT(N, P, S, S), SMDOT(N, P, S, Y), + $ 1.0D5) + END IF + + CALL SMCOPY(N, P, X, XP) + DO 40 J = 1, P + DO 30 I = 1, N + XT(I,J) = X(I,J) - STEP*GRAD(I,J) + 30 CONTINUE + 40 CONTINUE + CALL SMPL21(N, P, XT, STEP, GAM, 1.0D-16, X) + CALL SMAMAP(N, P, X, WP1, WP2, ROW) + + DO 60 J = 1, P + DO 50 I = 1, N + S(I,J) = X(I,J) - XP(I,J) + 50 CONTINUE + 60 CONTINUE + + CALL OBJFUN(NP, X, FVAL, GF) + CALL SMCOPY(N, P, GRAD, GRDP) + CALL SMJA(N, P, X, GF, GR, WP1) + + CALL SMLM21(N, P, X, GAM, LAM) + CALL SMJC(N, P, X, LAM, GRAD, WP1) + DO 80 J = 1, P + DO 70 I = 1, N + GRAD(I,J) = GR(I,J) + GRAD(I,J) + Y(I,J) = GRAD(I,J) - GRDP(I,J) + 70 CONTINUE + 80 CONTINUE + + KKT = SMFRO(N, P, S)/STEP + FEA = SMFEAS(N, P, X, WP1) + + NIT = JJ + FVALS(JJ) = FVAL + KKTS(JJ) = KKT + FEASV(JJ) = FEA + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 0) + + IF (KKT .LT. GTOL) THEN + CALL LOGFUN(JJ-1, FVAL, KKT, FEA, 1) + GO TO 110 + END IF + 100 CONTINUE + + 110 CONTINUE + IF (IPOST .NE. 0 .AND. NIT .GE. 1) THEN + CALL SMPOST(N, P, X, WP1, WP2, WP3, WEIG, ROW) + CALL OBJFUN(NP, X, FVAL, GF) + FEA = SMFEAS(N, P, X, WP1) + CALL LOGFUN(NIT-1, FVAL, KKT, FEA, 2) + FVALS(NIT) = FVAL + KKTS(NIT) = KKT + FEASV(NIT) = FEA + END IF + RETURN + END diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..149609e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Shared fixtures and problem builders for the SMOPT test suite.""" + +import numpy as np +import pytest +import reference + +import smopt + + +@pytest.fixture +def rng() -> np.random.Generator: + """A seeded generator so every test is reproducible.""" + return np.random.default_rng(20260825) + + +def orthonormal(rng: np.random.Generator, n: int, p: int) -> np.ndarray: + """Draw a random point on the Stiefel manifold.""" + q, _ = np.linalg.qr(rng.standard_normal((n, p))) + return np.ascontiguousarray(q[:, :p]) + + +def quadratic(a_diag: np.ndarray): + """Build ``tr(X^T A X)`` and its gradient for a diagonal ``A``.""" + a = np.asarray(a_diag, dtype=np.float64) + + def obj(x: np.ndarray) -> tuple[float, np.ndarray]: + ax = a[:, None] * x + return float(np.sum(x * ax)), 2.0 * ax + + return obj + + +def manifolds(n: int, p: int) -> tuple[smopt.Stiefel, reference.Stiefel]: + """Return the shipped manifold and its reference twin.""" + return smopt.Stiefel(n, p), reference.Stiefel(n, p) diff --git a/tests/reference.py b/tests/reference.py new file mode 100644 index 0000000..ed1dd56 --- /dev/null +++ b/tests/reference.py @@ -0,0 +1,406 @@ +"""A NumPy transcription of the algorithm SMOPT ports to Fortran 77. + +This module exists purely as a test oracle. It mirrors the original +implementation statement by statement so that the Fortran drivers can be +checked against it, and it is deliberately kept free of the validation, +packaging and reporting concerns that the shipped package handles. + +The single intentional departure is :meth:`Stiefel.Init_point`, whose +``Xinit == None`` test raises on array input; it is spelled ``is None`` +here so the reference can be driven from the tests. +""" + +import numpy as np +from numpy.linalg import norm, svd + + +class Stiefel: + """Reference geometry of the Stiefel manifold.""" + + def __init__(self, n, p): + self._n = n + self._p = p + self.dim = n * p + + def Phi(self, M): + return (M + M.T) / 2 + + def A(self, X): + XX = X.T @ X + feas_tmp = norm(XX - np.eye(self._p), "fro") + if feas_tmp < 0.5: + return 1.5 * X - X @ (XX / 2) + return np.linalg.solve((XX + np.eye(self._p)) / 2, X.T).T + + def JA(self, X, G): + return G - X @ self.Phi(X.T @ G) + + def JC(self, X, Lambda): + return X @ self.Phi(Lambda) + + def JC_transpose(self, X, D): + return self.Phi(X.T @ D) + + def C(self, X): + return X.T @ X - np.eye(self._p) + + def Feas_eval(self, X): + return norm(self.C(X), "fro") + + def Init_point(self, Xinit=None): + if Xinit is None: + Xinit = np.random.randn(self._n, self._p) + if norm(Xinit.T @ Xinit - np.eye(self._p), "fro") > 1e-6: + Xinit, _ = np.linalg.qr(Xinit) + return Xinit + + def Post_process(self, X): + UX, _, VX = svd(X, full_matrices=False) + return UX @ VX + + +def SLPG_smooth( + obj_fun, + manifold, + Xinit=None, + maxit=100, + gtol=1e-5, + post_process=True, + verbosity=0, +): + """Reference SLPG for a smooth objective.""" + kkts, feas, fvals = [], [], [] + + if Xinit is None: + Xinit = manifold.Init_point() + + X = Xinit + fval, gradf = obj_fun(X) + gradr = manifold.JA(X, gradf) + L = norm(gradf, "fro") + norm(gradr, "fro") + + S = Y = None + for jj in range(maxit): + if jj < 3: + stepsize = 0.01 / L + else: + stepsize = np.abs(np.sum(S * Y) / np.sum(Y * Y)) + stepsize = np.min((stepsize, 1e10)) + + X_p = X + X = X - stepsize * gradr + X = manifold.A(X) + S = X - X_p + + fval, gradf = obj_fun(X) + gradr_p = gradr + gradr = manifold.JA(X, gradf) + Y = gradr - gradr_p + + substationarity = norm(gradr, "fro") + feasibility = manifold.Feas_eval(X) + + kkts.append(substationarity) + feas.append(feasibility) + fvals.append(fval) + + if substationarity < gtol: + break + + if post_process: + X = manifold.Post_process(X) + fval, gradf = obj_fun(X) + gradr = manifold.JA(X, gradf) + substationarity = norm(gradr, "fro") + feasibility = manifold.Feas_eval(X) + kkts[-1] = substationarity + feas[-1] = feasibility + fvals[-1] = fval + + return X, { + "kkts": kkts, + "fvals": fvals, + "fea": feasibility, + "kkt": substationarity, + "fval": fval, + "feas": feas, + } + + +def Arrow_Hurwicz_SLPG(X, G, eta, prox, Lambda, manifold, tol=0): + """Reference Arrow-Hurwicz multiplier update.""" + Lambda_temp = Lambda + try_stepsize = eta + Z_tmp = X - try_stepsize * G + for _ in range(5): + X_try = prox( + Z_tmp - try_stepsize * manifold.JC(X, Lambda_temp), try_stepsize + ) + D_X = 1 / try_stepsize * (X_try - X) + Lambda_inc = manifold.JC_transpose(X, D_X) + Lambda_temp = Lambda_temp + Lambda_inc + if norm(Lambda_inc, "fro") < tol: + break + return Lambda_temp + + +def SLPG( + obj_fun, + manifold, + Xinit=None, + maxit=100, + prox=lambda X, eta: X, + gtol=1e-5, + post_process=True, + verbosity=0, +): + """Reference SLPG for a proximable regularizer.""" + kkts, feas, fvals, steps = [], [], [], [] + + if Xinit is None: + Xinit = manifold.Init_point() + + p = manifold._p + X = Xinit + fval, gradf = obj_fun(X) + gradr = manifold.JA(X, gradf) + L = norm(gradf, "fro") + norm(gradr, "fro") + + Lambda_r = np.zeros([p, p]) + Lambda_r = Arrow_Hurwicz_SLPG(X, gradr, 0.01 / L, prox, Lambda_r, manifold) + Grad = gradr + manifold.JC(X, Lambda_r) + + S = Y = None + for jj in range(maxit): + if jj < 5: + stepsize = 0.01 / L + else: + stepsize = np.abs(np.sum(S * S) / np.sum(S * Y)) + stepsize = np.min((stepsize, 1e10)) + + X_p = X + steps.append(stepsize) + + X = prox(X - stepsize * (gradr + manifold.JC(X, Lambda_r)), stepsize) + X = manifold.A(X) + S = X - X_p + + fval, gradf = obj_fun(X) + Grad_p = Grad + gradr = manifold.JA(X, gradf) + + stepsize_try = np.average(steps[np.maximum(0, jj - 10) :]) + stepsize_try = np.minimum(np.maximum(stepsize_try, 1e-5 / L), 1e10 / L) + + tol_AW = 1000 * manifold.Feas_eval(X) + Lambda_r = Arrow_Hurwicz_SLPG( + X, gradr, stepsize_try, prox, Lambda_r, manifold, tol=tol_AW + ) + Grad = gradr + manifold.JC(X, Lambda_r) + Y = Grad - Grad_p + + substationarity = norm(S / stepsize, "fro") + feasibility = manifold.Feas_eval(X) + + kkts.append(substationarity) + feas.append(feasibility) + fvals.append(fval) + + if substationarity < gtol: + break + + if post_process: + X = manifold.Post_process(X) + fval, gradf = obj_fun(X) + feasibility = manifold.Feas_eval(X) + kkts[-1] = substationarity + feas[-1] = feasibility + fvals[-1] = fval + + return X, { + "kkts": kkts, + "fvals": fvals, + "fea": feasibility, + "kkt": substationarity, + "fval": fval, + "feas": feas, + } + + +def SLPG_l21( + obj_fun, + manifold, + Xinit=None, + maxit=100, + gamma=0, + gtol=1e-5, + post_process=True, + verbosity=0, +): + """Reference SLPG for the l_{2,1} regularized objective.""" + + def prox(X_input, eta): + X_ref = np.sqrt(np.sum(X_input**2, axis=1, keepdims=True)) + X_ref_reduce = np.maximum(X_ref - gamma * eta, 0) + return (X_ref_reduce / (X_ref + 1e-16)) * X_input + + def generate_Lambda_r(X_input): + X_ref = 1 / (1e-14 + np.sqrt(np.sum(X_input**2, axis=1, keepdims=True))) + return -X_input.T @ (X_ref * X_input) + + kkts, feas, fvals = [], [], [] + + if Xinit is None: + Xinit = manifold.Init_point() + + X = Xinit + fval, gradf = obj_fun(X) + gradr = manifold.JA(X, gradf) + L = norm(gradf, "fro") + norm(gradr, "fro") + + Lambda_r = gamma * generate_Lambda_r(X) + Grad = gradr + manifold.JC(X, Lambda_r) + + S = Y = None + for jj in range(maxit): + if jj < 5: + stepsize = 0.001 / L + else: + stepsize = np.abs(np.sum(S * S) / np.sum(S * Y)) + stepsize = np.min((stepsize, 1e5)) + + X_p = X + X = prox(X - stepsize * Grad, stepsize) + X = manifold.A(X) + S = X - X_p + + fval, gradf = obj_fun(X) + Grad_p = Grad + gradr = manifold.JA(X, gradf) + + Lambda_r = gamma * generate_Lambda_r(X) + Grad = gradr + manifold.JC(X, Lambda_r) + Y = Grad - Grad_p + + substationarity = norm(S / stepsize, "fro") + feasibility = manifold.Feas_eval(X) + + kkts.append(substationarity) + feas.append(feasibility) + fvals.append(fval) + + if substationarity < gtol: + break + + if post_process: + X = manifold.Post_process(X) + fval, gradf = obj_fun(X) + feasibility = manifold.Feas_eval(X) + kkts[-1] = substationarity + feas[-1] = feasibility + fvals[-1] = fval + + return X, { + "kkts": kkts, + "fvals": fvals, + "fea": feasibility, + "kkt": substationarity, + "fval": fval, + "feas": feas, + } + + +def PenCF( + Xinit, + obj_fun, + manifold, + beta=None, + maxit=100, + gtol=1e-5, + post_process=True, + verbosity=0, +): + """Reference PenCF.""" + kkts, feas, fvals = [], [], [] + + p = manifold._p + X = Xinit + fval, gradf = obj_fun(X) + + if beta is None: + beta = 0.1 * norm(gradf, "fro") + + gradr = manifold.JA(X, gradf) + beta * manifold.JC(X, manifold.C(X)) + L = norm(gradf, "fro") + norm(gradr, "fro") + + S = Y = None + for jj in range(maxit): + if jj < 3: + stepsize = 0.01 / L + else: + stepsize = np.abs(np.sum(S * Y) / np.sum(Y * Y)) + stepsize = np.min((stepsize, 1e10)) + + X_p = X + X = X - stepsize * gradr + + XX = X.T @ X + feas_tmp = manifold.Feas_eval(X) + if feas_tmp > 1e-1: + if feas_tmp < 0.5: + X = 1.5 * X - X @ (XX / 2) + else: + X = np.linalg.solve((XX + np.eye(p)) / 2, X.T).T + + if norm(X, "fro") > 1.001 * np.sqrt(p): + X = X * (1.001 * np.sqrt(p) / norm(X, "fro")) + + S = X - X_p + + fval, gradf = obj_fun(X) + gradr_p = gradr + gradr = manifold.JA(X, gradf) + beta * manifold.JC(X, manifold.C(X)) + Y = gradr - gradr_p + + substationarity = norm(gradr, "fro") + feasibility = manifold.Feas_eval(X) + + kkts.append(substationarity) + feas.append(feasibility) + fvals.append(fval) + + if substationarity < gtol: + break + + if post_process: + X = manifold.Post_process(X) + fval, gradf = obj_fun(X) + gradr = manifold.JA(X, gradf) + substationarity = norm(gradr, "fro") + feasibility = manifold.Feas_eval(X) + kkts[-1] = substationarity + feas[-1] = feasibility + fvals[-1] = fval + + return X, { + "kkts": kkts, + "fvals": fvals, + "fea": feasibility, + "kkt": substationarity, + "fval": fval, + "feas": feas, + } + + +def prox_l1(X_input, eta, gamma=0): + """Reference proximal operator of the l_1 norm.""" + return np.maximum(X_input - gamma * eta, 0) + np.minimum( + X_input + gamma * eta, 0 + ) + + +def prox_l21(X_input, eta, gamma=0): + """Reference proximal operator of the l_{2,1} norm.""" + X_ref = np.sqrt(np.sum(X_input**2, axis=1, keepdims=True)) + X_ref_reduce = np.maximum(X_ref - gamma * eta, 0) + return X_ref_reduce / (X_ref + 1e-14) * X_input diff --git a/tests/test_manifold.py b/tests/test_manifold.py new file mode 100644 index 0000000..30795e2 --- /dev/null +++ b/tests/test_manifold.py @@ -0,0 +1,172 @@ +"""The Fortran manifold maps must agree with the NumPy reference.""" + +import numpy as np +import pytest +import reference +from conftest import orthonormal + +import smopt + + +SHAPES = [(1, 1), (5, 1), (12, 3), (40, 7), (9, 9)] + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_phi_symmetrizes(rng: np.random.Generator, n: int, p: int) -> None: + m = smopt.Stiefel(n, p) + a = rng.standard_normal((p, p)) + got = m.Phi(a) + assert np.allclose(got, reference.Stiefel(n, p).Phi(a)) + assert np.allclose(got, got.T) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_phi_does_not_mutate_input( + rng: np.random.Generator, n: int, p: int +) -> None: + m = smopt.Stiefel(n, p) + a = rng.standard_normal((p, p)) + before = a.copy() + m.Phi(a) + assert np.array_equal(a, before) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_constraint_and_feasibility( + rng: np.random.Generator, n: int, p: int +) -> None: + m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) + x = rng.standard_normal((n, p)) + assert np.allclose(m.C(x), ref.C(x)) + assert m.Feas_eval(x) == pytest.approx(ref.Feas_eval(x)) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_feasibility_vanishes_on_the_manifold( + rng: np.random.Generator, n: int, p: int +) -> None: + m = smopt.Stiefel(n, p) + assert m.Feas_eval(orthonormal(rng, n, p)) < 1e-12 + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_jacobians(rng: np.random.Generator, n: int, p: int) -> None: + m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) + x = orthonormal(rng, n, p) + g = rng.standard_normal((n, p)) + d = rng.standard_normal((n, p)) + lam = rng.standard_normal((p, p)) + + assert np.allclose(m.JA(x, g), ref.JA(x, g)) + assert np.allclose(m.JC(x, lam), ref.JC(x, lam)) + assert np.allclose(m.JC_transpose(x, d), ref.JC_transpose(x, d)) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_ja_output_is_tangent(rng: np.random.Generator, n: int, p: int) -> None: + """``JA`` removes the symmetric part of ``X^T G``.""" + m = smopt.Stiefel(n, p) + x = orthonormal(rng, n, p) + g = rng.standard_normal((n, p)) + r = m.JA(x, g) + xtr = x.T @ r + assert np.allclose(xtr, -xtr.T, atol=1e-10) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +@pytest.mark.parametrize("scale", [1e-3, 0.05, 0.4, 3.0]) +def test_a_map_matches_reference( + rng: np.random.Generator, n: int, p: int, scale: float +) -> None: + """Both the near-manifold expansion and the exact solve branch.""" + m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) + x = orthonormal(rng, n, p) + scale * rng.standard_normal((n, p)) + assert np.allclose(m.A(x), ref.A(x), atol=1e-10) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_a_map_improves_feasibility( + rng: np.random.Generator, n: int, p: int +) -> None: + m = smopt.Stiefel(n, p) + x = orthonormal(rng, n, p) + 0.05 * rng.standard_normal((n, p)) + assert m.Feas_eval(m.A(x)) < m.Feas_eval(x) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_post_process_is_the_polar_factor( + rng: np.random.Generator, n: int, p: int +) -> None: + m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) + x = rng.standard_normal((n, p)) + got = m.Post_process(x) + assert np.allclose(got, ref.Post_process(x), atol=1e-9) + assert m.Feas_eval(got) < 1e-12 + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_init_point_lands_on_the_manifold( + rng: np.random.Generator, n: int, p: int +) -> None: + m = smopt.Stiefel(n, p) + assert m.Feas_eval(m.Init_point(rng.standard_normal((n, p)))) < 1e-12 + assert m.Feas_eval(m.Init_point()) < 1e-12 + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +def test_init_point_keeps_a_feasible_argument( + rng: np.random.Generator, n: int, p: int +) -> None: + m = smopt.Stiefel(n, p) + x = orthonormal(rng, n, p) + assert np.allclose(m.Init_point(x), x) + + +def test_rejects_bad_dimensions() -> None: + with pytest.raises(ValueError, match="must be positive"): + smopt.Stiefel(0, 1) + with pytest.raises(ValueError, match="must not exceed"): + smopt.Stiefel(2, 3) + + +def test_rejects_mismatched_shapes(rng: np.random.Generator) -> None: + m = smopt.Stiefel(6, 2) + with pytest.raises(ValueError, match="must have shape"): + m.C(rng.standard_normal((6, 3))) + with pytest.raises(ValueError, match="must have shape"): + m.JC(orthonormal(rng, 6, 2), rng.standard_normal((3, 3))) + + +def test_dim_attribute() -> None: + assert smopt.Stiefel(7, 3).dim == 21 + + +@pytest.mark.parametrize("rank", [0, 1, 2]) +def test_post_process_completes_a_rank_deficient_point( + rng: np.random.Generator, rank: int +) -> None: + """A vanishing singular value leaves its direction unconstrained. + + There is no polar factor then, so the missing directions get an + arbitrary orthonormal completion rather than dividing by zero. + """ + n, p = 9, 3 + m = smopt.Stiefel(n, p) + x = np.zeros((n, p)) + if rank: + x[:, :rank] = orthonormal(rng, n, rank) + + got = m.Post_process(x) + + assert np.all(np.isfinite(got)) + assert m.Feas_eval(got) < 1e-12 + # Whatever the completion picks, it must not disturb the directions + # the input did pin down. + assert np.allclose(got @ got.T @ x, x, atol=1e-10) + + +def test_post_process_of_a_zero_matrix_is_still_feasible() -> None: + m = smopt.Stiefel(5, 2) + got = m.Post_process(np.zeros((5, 2))) + assert np.all(np.isfinite(got)) + assert m.Feas_eval(got) < 1e-12 diff --git a/tests/test_solvers.py b/tests/test_solvers.py new file mode 100644 index 0000000..574de50 --- /dev/null +++ b/tests/test_solvers.py @@ -0,0 +1,328 @@ +"""The Fortran solver drivers must reproduce the reference iteration. + +Each solver is run side by side with the NumPy transcription in +``reference.py`` from the same starting point, and the whole trajectory +is compared, not just the final answer. +""" + +import numpy as np +import pytest +import reference +from conftest import orthonormal, quadratic + +import smopt + + +# The two implementations are transcriptions of one another, not +# bit-identical: NumPy sums through BLAS while the Fortran kernels sum in +# their own order. That seed then amplifies, because the +# Barzilai-Borwein step divides differences of nearly equal quantities +# and the l_{2,1} prox thresholds whole rows on or off. Iterates agree to +# ~1e-16 at the start, ~6e-10 by iteration 10 and only ~3e-7 by iteration +# 15. These tests therefore compare a short horizon strictly; that the +# solvers actually converge is checked separately below. +TRACE_MAXIT = 10 +TRACE_RTOL = 1e-7 +TRACE_ATOL = 1e-10 +TRACE_X_ATOL = 1e-7 + + +def eig_problem(rng: np.random.Generator, n: int, p: int): + """A trace minimization problem with a known optimal value.""" + a = np.sort(rng.uniform(0.5, 10.0, size=n)) + return quadratic(a), float(np.sum(a[:p])) + + +def assert_same_trace(got: dict, want: dict) -> None: + """Compare a Fortran trajectory with the reference one.""" + kw = {"rtol": TRACE_RTOL, "atol": TRACE_ATOL} + assert len(got["fvals"]) == len(want["fvals"]) + assert np.allclose(got["fvals"], want["fvals"], **kw) + assert np.allclose(got["kkts"], want["kkts"], **kw) + assert np.allclose(got["feas"], want["feas"], **kw) + scalar = {"rel": TRACE_RTOL, "abs": TRACE_ATOL} + assert got["fval"] == pytest.approx(want["fval"], **scalar) + assert got["kkt"] == pytest.approx(want["kkt"], **scalar) + assert got["fea"] == pytest.approx(want["fea"], **scalar) + + +@pytest.mark.parametrize(("n", "p"), [(20, 3), (60, 5), (8, 8)]) +@pytest.mark.parametrize("post_process", [True, False]) +def test_slpg_smooth_matches_reference( + rng: np.random.Generator, n: int, p: int, post_process: bool +) -> None: + m, ref_m = smopt.Stiefel(n, p), reference.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + got_x, got = smopt.SLPG_smooth( + obj, + m, + Xinit=x0.copy(), + maxit=TRACE_MAXIT, + post_process=post_process, + verbosity=0, + ) + want_x, want = reference.SLPG_smooth( + obj, + ref_m, + Xinit=x0.copy(), + maxit=TRACE_MAXIT, + post_process=post_process, + ) + + assert_same_trace(got, want) + assert np.allclose(got_x, want_x, atol=TRACE_X_ATOL) + + +@pytest.mark.parametrize(("n", "p"), [(20, 3), (60, 5)]) +def test_slpg_matches_reference( + rng: np.random.Generator, n: int, p: int +) -> None: + m, ref_m = smopt.Stiefel(n, p), reference.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + gamma = 0.05 + + def prox(x: np.ndarray, eta: float) -> np.ndarray: + return smopt.prox_l1(x, eta, gamma=gamma) + + def ref_prox(x: np.ndarray, eta: float) -> np.ndarray: + return reference.prox_l1(x, eta, gamma=gamma) + + got_x, got = smopt.SLPG( + obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, prox=prox, verbosity=0 + ) + want_x, want = reference.SLPG( + obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT, prox=ref_prox + ) + + assert_same_trace(got, want) + assert np.allclose(got_x, want_x, atol=TRACE_X_ATOL) + + +@pytest.mark.parametrize(("n", "p"), [(20, 3), (60, 5)]) +def test_slpg_without_a_prox_matches_reference( + rng: np.random.Generator, n: int, p: int +) -> None: + """The default prox is the identity, recovering the smooth case.""" + m, ref_m = smopt.Stiefel(n, p), reference.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + got_x, got = smopt.SLPG( + obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, verbosity=0 + ) + want_x, want = reference.SLPG( + obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT + ) + + assert_same_trace(got, want) + assert np.allclose(got_x, want_x, atol=TRACE_X_ATOL) + + +@pytest.mark.parametrize(("n", "p"), [(20, 3), (60, 5)]) +@pytest.mark.parametrize("gamma", [0.0, 0.02]) +def test_slpg_l21_matches_reference( + rng: np.random.Generator, n: int, p: int, gamma: float +) -> None: + m, ref_m = smopt.Stiefel(n, p), reference.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + got_x, got = smopt.SLPG_l21( + obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma, verbosity=0 + ) + want_x, want = reference.SLPG_l21( + obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma + ) + + assert_same_trace(got, want) + assert np.allclose(got_x, want_x, atol=TRACE_X_ATOL) + + +@pytest.mark.parametrize(("n", "p"), [(20, 3), (60, 5)]) +@pytest.mark.parametrize("beta", [None, 0.5]) +def test_pencf_matches_reference( + rng: np.random.Generator, n: int, p: int, beta: float | None +) -> None: + m, ref_m = smopt.Stiefel(n, p), reference.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + got_x, got = smopt.PenCF( + x0.copy(), obj, m, beta=beta, maxit=TRACE_MAXIT, verbosity=0 + ) + want_x, want = reference.PenCF( + x0.copy(), obj, ref_m, beta=beta, maxit=TRACE_MAXIT + ) + + assert_same_trace(got, want) + assert np.allclose(got_x, want_x, atol=TRACE_X_ATOL) + + +def test_pencf_reports_the_beta_it_used(rng: np.random.Generator) -> None: + n, p = 20, 3 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + _, out = smopt.PenCF(x0.copy(), obj, m, maxit=5, verbosity=0) + _, grad = obj(x0) + assert out["beta"] == pytest.approx(0.1 * np.linalg.norm(grad, "fro")) + + _, out = smopt.PenCF(x0.copy(), obj, m, beta=0.0, maxit=5, verbosity=0) + assert out["beta"] == pytest.approx(0.0) + + +SOLVERS = ["SLPG_smooth", "SLPG", "SLPG_l21"] + + +@pytest.mark.parametrize("name", SOLVERS) +def test_solvers_reach_the_known_minimum( + rng: np.random.Generator, name: str +) -> None: + """Trace minimization converges to the sum of the smallest values.""" + n, p = 50, 4 + m = smopt.Stiefel(n, p) + obj, best = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + solver = getattr(smopt, name) + x, out = solver(obj, m, Xinit=x0, maxit=3000, gtol=1e-9, verbosity=0) + + assert m.Feas_eval(x) < 1e-10 + assert out["fval"] == pytest.approx(best, rel=1e-5) + + +def test_pencf_reaches_the_known_minimum(rng: np.random.Generator) -> None: + n, p = 50, 4 + m = smopt.Stiefel(n, p) + obj, best = eig_problem(rng, n, p) + + x, out = smopt.PenCF( + orthonormal(rng, n, p), obj, m, maxit=3000, gtol=1e-9, verbosity=0 + ) + + assert m.Feas_eval(x) < 1e-10 + assert out["fval"] == pytest.approx(best, rel=1e-5) + + +def test_l21_regularization_induces_row_sparsity( + rng: np.random.Generator, +) -> None: + n, p = 40, 3 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + dense, _ = smopt.SLPG_l21( + obj, m, Xinit=x0.copy(), maxit=500, gamma=0.0, verbosity=0 + ) + sparse, _ = smopt.SLPG_l21( + obj, m, Xinit=x0.copy(), maxit=500, gamma=1.0, verbosity=0 + ) + + def live_rows(x: np.ndarray) -> int: + return int(np.sum(np.linalg.norm(x, axis=1) > 1e-6)) + + assert live_rows(sparse) < live_rows(dense) + + +@pytest.mark.parametrize("name", SOLVERS) +def test_histories_have_one_entry_per_iteration( + rng: np.random.Generator, name: str +) -> None: + n, p = 15, 2 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + + solver = getattr(smopt, name) + _, out = solver( + obj, m, Xinit=orthonormal(rng, n, p), maxit=7, gtol=0.0, verbosity=0 + ) + + assert len(out["fvals"]) == 7 + assert len(out["kkts"]) == 7 + assert len(out["feas"]) == 7 + + +@pytest.mark.parametrize("name", SOLVERS) +def test_a_random_start_is_drawn_when_none_is_given( + rng: np.random.Generator, name: str +) -> None: + n, p = 15, 2 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + + x, _ = getattr(smopt, name)(obj, m, maxit=20, verbosity=0) + assert x.shape == (n, p) + assert m.Feas_eval(x) < 1e-10 + + +@pytest.mark.parametrize("name", SOLVERS) +def test_rejects_an_empty_iteration_budget( + rng: np.random.Generator, name: str +) -> None: + m = smopt.Stiefel(6, 2) + obj, _ = eig_problem(rng, 6, 2) + with pytest.raises(ValueError, match="maxit must be at least 1"): + getattr(smopt, name)(obj, m, maxit=0, verbosity=0) + + +def test_verbosity_controls_printing( + rng: np.random.Generator, capsys: pytest.CaptureFixture +) -> None: + n, p = 15, 2 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + x0 = orthonormal(rng, n, p) + + smopt.SLPG_smooth(obj, m, Xinit=x0.copy(), maxit=25, verbosity=0) + assert capsys.readouterr().out == "" + + smopt.SLPG_smooth(obj, m, Xinit=x0.copy(), maxit=25, verbosity=2) + out = capsys.readouterr().out + assert "Iter:0" in out + assert "Iter:20" in out + assert "Post-processing" in out + + +def test_the_objective_sees_the_iterate_as_a_matrix( + rng: np.random.Generator, +) -> None: + """The callback must receive an ``(n, p)`` array, not a flat one.""" + n, p = 12, 3 + m = smopt.Stiefel(n, p) + seen = [] + + def obj(x: np.ndarray) -> tuple[float, np.ndarray]: + seen.append(x.shape) + return float(np.sum(x * x)), 2.0 * x + + smopt.SLPG_smooth( + obj, m, Xinit=orthonormal(rng, n, p), maxit=3, verbosity=0 + ) + assert seen + assert set(seen) == {(n, p)} + + +def test_l21_stays_feasible_when_it_collapses_the_rank( + rng: np.random.Generator, +) -> None: + """A heavy penalty can zero enough rows to make the iterate singular. + + Post-processing must still return a point on the manifold instead of + dividing by a vanishing singular value. + """ + n, p = 20, 3 + m = smopt.Stiefel(n, p) + obj, _ = eig_problem(rng, n, p) + + x, out = smopt.SLPG_l21( + obj, m, Xinit=orthonormal(rng, n, p), maxit=40, gamma=0.1, verbosity=0 + ) + + assert np.all(np.isfinite(x)) + assert m.Feas_eval(x) < 1e-10 + assert np.isfinite(out["fval"]) diff --git a/tests/test_utility.py b/tests/test_utility.py new file mode 100644 index 0000000..b54294a --- /dev/null +++ b/tests/test_utility.py @@ -0,0 +1,77 @@ +"""The Fortran proximal operators must agree with the NumPy reference.""" + +import numpy as np +import pytest +import reference + +import smopt + + +SHAPES = [(1, 1), (5, 1), (12, 3), (40, 7)] +GAMMAS = [0.0, 0.05, 0.5, 4.0] + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +@pytest.mark.parametrize("gamma", GAMMAS) +def test_prox_l1_matches_reference( + rng: np.random.Generator, n: int, p: int, gamma: float +) -> None: + x = rng.standard_normal((n, p)) + got = smopt.prox_l1(x, 0.7, gamma=gamma) + assert np.allclose(got, reference.prox_l1(x, 0.7, gamma=gamma)) + + +@pytest.mark.parametrize(("n", "p"), SHAPES) +@pytest.mark.parametrize("gamma", GAMMAS) +def test_prox_l21_matches_reference( + rng: np.random.Generator, n: int, p: int, gamma: float +) -> None: + x = rng.standard_normal((n, p)) + got = smopt.prox_l21(x, 0.7, gamma=gamma) + assert np.allclose(got, reference.prox_l21(x, 0.7, gamma=gamma)) + + +def test_prox_with_zero_weight_is_the_identity( + rng: np.random.Generator, +) -> None: + x = rng.standard_normal((10, 3)) + assert np.allclose(smopt.prox_l1(x, 1.0), x) + assert np.allclose(smopt.prox_l21(x, 1.0), x) + + +def test_prox_l1_soft_thresholds() -> None: + x = np.array([[-3.0, -0.5, 0.0, 0.5, 3.0]]) + got = smopt.prox_l1(x, 1.0, gamma=1.0) + assert np.allclose(got, [[-2.0, 0.0, 0.0, 0.0, 2.0]]) + + +def test_prox_l21_shrinks_whole_rows() -> None: + x = np.array([[3.0, 4.0], [0.3, 0.4]]) + got = smopt.prox_l21(x, 1.0, gamma=1.0) + # The first row has norm 5 and keeps its direction scaled by 4/5; + # the second has norm 0.5 and is annihilated. + assert np.allclose(got, [[2.4, 3.2], [0.0, 0.0]]) + + +def test_prox_l21_survives_a_zero_row() -> None: + x = np.zeros((3, 2)) + assert np.allclose(smopt.prox_l21(x, 1.0, gamma=1.0), 0.0) + + +@pytest.mark.parametrize("gamma", [0.1, 1.0]) +def test_prox_l1_solves_its_own_subproblem( + rng: np.random.Generator, gamma: float +) -> None: + """The prox must beat nearby points on its defining objective.""" + x = rng.standard_normal((8, 3)) + eta = 0.3 + y = smopt.prox_l1(x, eta, gamma=gamma) + + def value(z: np.ndarray) -> float: + return float( + np.sum((z - x) ** 2) / (2 * eta) + gamma * np.sum(np.abs(z)) + ) + + best = value(y) + for _ in range(20): + assert best <= value(y + 1e-3 * rng.standard_normal(y.shape)) + 1e-12 From 59e6847602af82315c9ff3d99a36a45302899e69 Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 17:25:07 +0500 Subject: [PATCH 2/8] Link the Fortran runtime statically and correct PenCF's tolerance Two CI failures, both real. The Windows extension did not import at all: "DLL load failed while importing _smopt". The wheel still depended on libwinpthread-1.dll, so it only worked on a machine that happened to have MinGW installed -- which the runners do not, and neither do users. The -static-lib* flags cover only the libraries they name, and the gfortran driver appends its own -l entries after our link_args, by which point -Wl,-Bdynamic is in effect again, so the trailing -lwinpthread resolved against the DLL. -static sets static resolution for every -l the driver adds. The built extension now imports KERNEL32, msvcrt and python3xx only. This affected the published Windows wheel too, not just CI: the build job passed because cibuildwheel builds without importing. test_pencf_reaches_the_known_minimum asserted rel=1e-5 against the known optimum, which PenCF does not deliver. Holding the test problem fixed and varying only the starting point over 60 draws, the relative error has median 7.6e-07 and worst case 1.2e-04; a 150-seed sweep over problems tops out at 1.0e-04, and the NumPy reference in tests/reference.py is worse still at 1.7e-04. So this is the penalty method, not the port -- the SLPG solvers reach 3.7e-16 on the same instance and keep their tight tolerance. The assertion is now rel=1e-3, with feasibility, which the method does guarantee to ~3e-15, still asserted tightly, plus a check that the objective never drops below the true minimum. The Codecov 404s in the same run are left alone deliberately. Every one of the eight earlier runs had that step skipped because pytest failed first, so this was the first run to reach the endpoint and no repository record existed yet; uploads at or after 11:23:46Z in the very same run succeeded. That race is over, so weakening fail_ci_if_error would be papering over a resolved transient. Co-Authored-By: Claude Opus 5 (1M context) --- src/meson.build | 32 ++++++++++++++++---------------- tests/test_solvers.py | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/meson.build b/src/meson.build index f4ab108..4f084bc 100644 --- a/src/meson.build +++ b/src/meson.build @@ -48,29 +48,29 @@ f2py_target = custom_target( command: [py, f2py_runner, '@INPUT@', meson.current_build_dir(), '--lower'] ) -# Windows wheels must carry the Fortran runtime, since a machine with -# Python but no MinGW has no libgfortran to load. The -static-* flags -# are understood by the gfortran driver but not by gcc, and Meson would -# otherwise link this mixed C/Fortran target with gcc, so the link -# language is pinned below. -static-libquadmath in particular only -# exists from GCC 13 on, hence the capability probe rather than a -# hardcoded list. +# Windows wheels must carry the Fortran runtime: a machine with Python +# but no MinGW has no libgfortran, libquadmath or libwinpthread to load, +# and the extension simply fails to import. +# +# -static is what actually achieves that. The -static-lib* flags cover +# only the libraries named, and the gfortran driver appends its own -l +# entries after our link_args, by which point -Wl,-Bdynamic is in effect +# again -- so a trailing -lwinpthread still resolved against the DLL. +# -static sets static resolution for every -l the driver adds, including +# those trailing ones. Import libraries such as libmsvcrt.a and the +# Python one are unaffected, since they are import stubs either way. +# +# These are gfortran driver flags, and Meson would otherwise link this +# mixed C/Fortran target with gcc, hence the pinned link language below. fc = meson.get_compiler('fortran') if host_machine.system() == 'windows' - static_runtime = ['-Wl,-Bstatic', '-lwinpthread', '-Wl,-Bdynamic'] - if not fc.has_link_argument('-static-libquadmath') - # libgfortran pulls in libquadmath, so without this the wheel would - # still ask for libquadmath-0.dll at import time. - static_runtime = ['-Wl,-Bstatic', '-lquadmath', '-lwinpthread', - '-Wl,-Bdynamic'] - endif - fortran_link_args = fc.get_supported_link_arguments([ + '-static', '-static-libgfortran', '-static-libgcc', '-static-libquadmath', - ]) + static_runtime + ]) else fortran_link_args = [] endif diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 574de50..75b755a 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -195,7 +195,18 @@ def test_solvers_reach_the_known_minimum( assert out["fval"] == pytest.approx(best, rel=1e-5) +#: PenCF lands near the optimum rather than on it. Holding the problem +#: below fixed and varying only the starting point over 60 draws, the +#: relative error has median 7.6e-07 and worst case 1.2e-04; a 150-seed +#: sweep over problems too tops out at 1.0e-04, and the NumPy reference +#: in tests/reference.py is worse still at 1.7e-04. That is the penalty +#: method, not this port. The SLPG solvers above reach machine precision +#: on the same instance, hence their much tighter tolerance. +PENALTY_RTOL = 1e-3 + + def test_pencf_reaches_the_known_minimum(rng: np.random.Generator) -> None: + """PenCF converges to the optimum to within a penalty method's reach.""" n, p = 50, 4 m = smopt.Stiefel(n, p) obj, best = eig_problem(rng, n, p) @@ -204,8 +215,13 @@ def test_pencf_reaches_the_known_minimum(rng: np.random.Generator) -> None: orthonormal(rng, n, p), obj, m, maxit=3000, gtol=1e-9, verbosity=0 ) + # Feasibility is the part the method does guarantee: it holds to + # ~3e-15 across every instance measured, so it is asserted tightly. assert m.Feas_eval(x) < 1e-10 - assert out["fval"] == pytest.approx(best, rel=1e-5) + assert out["fval"] == pytest.approx(best, rel=PENALTY_RTOL) + # A feasible point can never beat the true minimum; catching that + # would mean the objective and the constraint had come apart. + assert out["fval"] > best - 1e-8 def test_l21_regularization_induces_row_sparsity( From 7de73d39bf5a572959fe8f9ea7f29a3432ac2f89 Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 17:36:40 +0500 Subject: [PATCH 3/8] TEMP: diagnose the Windows import failure on CI Prints the extension's DLL imports and the exact Win32 load error so the missing dependency can be identified rather than guessed at. Reverted in the follow-up commit. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b271fb7..1d76827 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,23 @@ jobs: - name: Install project run: uv run bin/build.py install + # TEMPORARY diagnostic: identify what the Windows extension is + # actually missing at import time. Removed once fixed. + - name: Diagnose Windows extension (temporary) + if: runner.os == 'Windows' + continue-on-error: true + shell: bash + run: | + set -x + uv run --no-sync python -c "import sys; print(sys.executable); print(sys.version)" + PYD=$(uv run --no-sync python -c "import glob,os,sysconfig; print(next(iter(glob.glob(os.path.join(sysconfig.get_paths()['purelib'],'smopt','_smopt*.pyd'))),''))") + echo "PYD=$PYD" + ls -la "$(dirname "$PYD")" || true + which gfortran gcc cc objdump || true + gfortran --version | head -1 || true + objdump -p "$PYD" | grep -i "DLL Name" || true + uv run --no-sync python -c "import ctypes,glob,os,sysconfig; p=next(iter(glob.glob(os.path.join(sysconfig.get_paths()['purelib'],'smopt','_smopt*.pyd'))),None); print('loading',p); ctypes.CDLL(p); print('CDLL OK')" || true + - name: Run Pytest run: uv run --no-sync pytest -v -n auto --cov --cov-report=xml From 7b38ef337dc05ca749d6b5c32c8779e3c2ebbf9a Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 17:42:24 +0500 Subject: [PATCH 4/8] Force libwinpthread into the Windows extension with --whole-archive The CI diagnostic showed the extension still importing libwinpthread-1.dll even with -static on the link line: DLL Name: KERNEL32.dll DLL Name: api-ms-win-crt-*.dll DLL Name: libwinpthread-1.dll <- this DLL Name: python313.dll The DLL is present in C:\mingw64\bin and that directory is on PATH, but CPython 3.8 and later do not search PATH when resolving an extension's dependencies, so the import fails anyway. It has to be linked in. libgfortran.a refers to libwinpthread, and the gfortran driver appends its own -lwinpthread from the spec file after everything Meson passes, by which point the linker is back in dynamic mode. -static did not prevent that on the MinGW-Builds UCRT toolchain the runners use, even though it did on the older msvcrt toolchain used locally. --whole-archive defines those symbols unconditionally and independently of link order, so the trailing -lwinpthread has nothing left to import. Co-Authored-By: Claude Opus 5 (1M context) --- src/meson.build | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/meson.build b/src/meson.build index 4f084bc..7804c28 100644 --- a/src/meson.build +++ b/src/meson.build @@ -49,16 +49,20 @@ f2py_target = custom_target( ) # Windows wheels must carry the Fortran runtime: a machine with Python -# but no MinGW has no libgfortran, libquadmath or libwinpthread to load, -# and the extension simply fails to import. +# but no MinGW has no libgfortran, libquadmath or libwinpthread to load. +# Note that having them on PATH is not enough either -- CPython 3.8 and +# later do not search PATH when resolving an extension's dependencies -- +# so they have to be linked in, not merely present. # -# -static is what actually achieves that. The -static-lib* flags cover -# only the libraries named, and the gfortran driver appends its own -l -# entries after our link_args, by which point -Wl,-Bdynamic is in effect -# again -- so a trailing -lwinpthread still resolved against the DLL. -# -static sets static resolution for every -l the driver adds, including -# those trailing ones. Import libraries such as libmsvcrt.a and the -# Python one are unaffected, since they are import stubs either way. +# The -static-lib* flags cover the libraries they name. libwinpthread +# needs more than that: libgfortran.a refers to it, and the gfortran +# driver appends its own -lwinpthread from the spec file after every +# argument Meson passes, at which point the linker is back in dynamic +# mode and picks up the DLL. Even plain -static failed to prevent that +# on the MinGW-Builds UCRT toolchain used by the CI runners. Pulling the +# archive in with --whole-archive defines those symbols unconditionally +# and independently of ordering, so the trailing -lwinpthread finds +# nothing left to import. # # These are gfortran driver flags, and Meson would otherwise link this # mixed C/Fortran target with gcc, hence the pinned link language below. @@ -66,11 +70,14 @@ fc = meson.get_compiler('fortran') if host_machine.system() == 'windows' fortran_link_args = fc.get_supported_link_arguments([ - '-static', '-static-libgfortran', '-static-libgcc', '-static-libquadmath', - ]) + ]) + [ + '-Wl,--whole-archive,-Bstatic', + '-lwinpthread', + '-Wl,--no-whole-archive,-Bdynamic', + ] else fortran_link_args = [] endif From f08b756a88244927b7dfd420d42637782e5f9dac Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 17:47:02 +0500 Subject: [PATCH 5/8] Remove the temporary Windows import diagnostic It did its job: the extension now imports KERNEL32, the UCRT stubs and python3xx only, and ctypes.CDLL loads it cleanly on the runners. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d76827..b271fb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,23 +78,6 @@ jobs: - name: Install project run: uv run bin/build.py install - # TEMPORARY diagnostic: identify what the Windows extension is - # actually missing at import time. Removed once fixed. - - name: Diagnose Windows extension (temporary) - if: runner.os == 'Windows' - continue-on-error: true - shell: bash - run: | - set -x - uv run --no-sync python -c "import sys; print(sys.executable); print(sys.version)" - PYD=$(uv run --no-sync python -c "import glob,os,sysconfig; print(next(iter(glob.glob(os.path.join(sysconfig.get_paths()['purelib'],'smopt','_smopt*.pyd'))),''))") - echo "PYD=$PYD" - ls -la "$(dirname "$PYD")" || true - which gfortran gcc cc objdump || true - gfortran --version | head -1 || true - objdump -p "$PYD" | grep -i "DLL Name" || true - uv run --no-sync python -c "import ctypes,glob,os,sysconfig; p=next(iter(glob.glob(os.path.join(sysconfig.get_paths()['purelib'],'smopt','_smopt*.pyd'))),None); print('loading',p); ctypes.CDLL(p); print('CDLL OK')" || true - - name: Run Pytest run: uv run --no-sync pytest -v -n auto --cov --cov-report=xml From f6dd878990de28793b3322705912f6ee14b07643 Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 17:55:59 +0500 Subject: [PATCH 6/8] Add the brand assets and make the Python matrix real Artwork in the eggzec block style, built from the same rounded blocks on the same grey ground as the other project marks, with the lettering taken from the real eggzec-block.js rather than a lookalike. Colours are the Space berries palette. The mark is the orthonormality condition itself: a bracketed matrix whose diagonal is solid and whose off-diagonal entries have collapsed to points, which is what X'X = I looks like. Favicon sizes at or below 32 pixels use a simplified cut that drops the off-diagonal points, since they turn to mush and blur into the diagonal at that size; the .ico is assembled by hand because Pillow only rescales a single image. mkdocs.yml already pointed at four asset paths that did not exist: assets/images/smopt.ico, assets/images/smopt.png, assets/stylesheets/extra.css and assets/javascripts/katex.js. All four are now present. The stylesheet carries the palette into the theme, and katex.js renders what arithmatex leaves behind in generic mode, rebinding on Material's document$ so it survives instant navigation. Separately, the test matrix was not testing what it claimed. Its python-version axis was referenced only in the artifact name and never passed to setup-uv, so every job resolved to whichever interpreter uv picked by default -- the Windows job labelled 3.10 built and would have tested cp313. It is now passed through, so the five versions are really covered. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 5 ++ README.md | 4 ++ docs/assets/images/smopt-banner.png | Bin 0 -> 93725 bytes docs/assets/images/smopt-icon-small.svg | 1 + docs/assets/images/smopt-icon.svg | 1 + docs/assets/images/smopt.ico | Bin 0 -> 21006 bytes docs/assets/images/smopt.png | Bin 0 -> 28783 bytes docs/assets/javascripts/katex.js | 32 +++++++++ docs/assets/stylesheets/extra.css | 91 ++++++++++++++++++++++++ docs/index.md | 2 + 10 files changed, 136 insertions(+) create mode 100644 docs/assets/images/smopt-banner.png create mode 100644 docs/assets/images/smopt-icon-small.svg create mode 100644 docs/assets/images/smopt-icon.svg create mode 100644 docs/assets/images/smopt.ico create mode 100644 docs/assets/images/smopt.png create mode 100644 docs/assets/javascripts/katex.js create mode 100644 docs/assets/stylesheets/extra.css diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b271fb7..79968bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,8 +72,13 @@ jobs: echo "$(brew --prefix gcc)/bin" >> "$GITHUB_PATH" "$(brew --prefix gcc)/bin/gfortran" --version + # Without python-version every job resolves to whichever + # interpreter uv picks by default, so the matrix axis above was + # silently testing one version on all of them. - name: Setup uv uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} - name: Install project run: uv run bin/build.py install diff --git a/README.md b/README.md index f3f7209..b4ba2a6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ smopt +

+ # smopt **Stiefel manifold optimization, with all numerics in Fortran 77** diff --git a/docs/assets/images/smopt-banner.png b/docs/assets/images/smopt-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..1afe752d043530bf555b4c4fc102d1499d634c6c GIT binary patch literal 93725 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�DQ4rs_wRClWkkAVjXzNXhZuIjDvQP;O=7aQHv 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/smopt-icon-small.svg b/docs/assets/images/smopt-icon-small.svg new file mode 100644 index 0000000..a281183 --- /dev/null +++ b/docs/assets/images/smopt-icon-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/images/smopt-icon.svg b/docs/assets/images/smopt-icon.svg new file mode 100644 index 0000000..8170c25 --- /dev/null +++ b/docs/assets/images/smopt-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/images/smopt.ico b/docs/assets/images/smopt.ico new file mode 100644 index 0000000000000000000000000000000000000000..c660e10173cba703dcebf5852df62dfccd3ff5f1 GIT binary patch literal 21006 zcmc$_Wl&y0^DlUw2Y2@*xQF0w4+M92_uvk}-6goYdvJFN79hC0YjB6nzVH9bR&Cw8 z_tT!Lsp+1cnmKi<<=1^?000EQ0qE$!zn%p63I+gI008ju|I7P80YK}&F%pvh^7zmI zzzGWgOicgfxex$A9uWW_kpJ@S|L9OG0PyqsFK_gZ7XI{a!GF*Hy}l(002DFJmZK{my6O@nQW;QGWQynwCrSxVN|Gv=Pi02sbg=F@p@|zu zac;)>9N6i@^#PUjhv}!kL4XiJUvM}XAdc}DEJYV||#r|5kafnx*2Y77F z?E1Bc@v{W!SKa-E?gKbd1{%sgP{Rf$)?AW&2(!%P2$l-aZHUI^ z$_5(Xf(BK4WL*OytzQrWw41l7M%2;qwuM#lzXXxSNyIZpDVnvFtT?NyF7SV`^ze}4 z&9ze=^bFl-zhAvS-!WhUARkb-x>1{MYaeI*M1VMk*_oEEsnB2nj^i&lzhPpa6L39Q z{NBwFhD!%Bd!S+A{11&5UUshACyP{zo)SDWn&aG#z*6(dYKjbMxgjp@@4IiFC;i}# z#`-tSr3=bWFcbr=g)fHBH)D5q;`ImA@P){RyE`XG(Sq&QJD>5|i)(&$Ue+A7q#4X`V}3bP4D7k+1?t@J!q9@r+VKH0$5A?4;NJ*#6`gQ*OyxC=L_i6 zpLtY+nI#BmdLS5uFdBaF8bq$+)+8A1PJtS%6moWm0GEm1Sm=Y*0FP3klo{Vp#IG`N zCZM1UG8_MlZbU+IGlQl6D8`Ip4@0rBDdaCM(gt=8lE!g8+qC^&qTVKWaMY7JNn$RboA$KRT`2 zkY3SzKXTyAPJ%{-g#y*L4q}4iV^}K>=b(GPLP?HW8X*P>lNAS`6J=ZvB!T@J+IEn% zG~f^j{2P;fzTLgMhZ9yco4vt`qNEd=&xQSS{r^krBl#yL`H%Iz;GY%zv%Zc0T3=(b zzLm~D>-*=xS3S++Q`ux?2^*3FaOhz3x>Q8+iiJn06?&-!{m`C3*w4G3b8Z_?7$V3T zUNoW#Kc~f{q=+me6WDyI64IUzJ`?ZDQ?YWUq1y|td2+kGPw1Ivd86Xt;{Npwxe%zm z&P~{-`~4Gb7!(FX)nOSrVxU6`+L$&>yG%e}Llh>sRiS{~ykTY};6s0QYWO@D|0mvQ zmDEl~eZ@q7>(kn8Tun~4sVe+g9gK*5TxH@`>F+*oSIg_`wCJ_X&xf&9adtV~neLK* z0Wg!LA=$*c2bbfzz7J!&d%eJ#f`J7dp&xO=plNs9uoa|(glcx<+umK^Wi9M_+Dhx< zFqK;aD@=+T#i$UdTd*Aa@@20UDJ7q0I-A}2rFW-2c7k7g7Y2WchJbHi>XabwlwB5@o`LVHD(u_LL`%|Hu;rP>|qf5?CC94RYbPdU9Tc zK{T7f?{4}mO`qZKl(aW{}=6z6kxK}mW^>rMKJp8iHDG%o~&67bp2;b z1j(iH70ue{{tj(abdeunDH!I~EKw=-MS)SC~g*^kD{LKCdIK=G6vceC!um8I|m9)#S0q(`Fua# z^o}5nrA^u(D?*!DCa3;-m^l>ZA~rL%`s{SO@DlHVAr6Jz&tHqDAG4Nzh;3l-@d(9- zr{oel+E$)z`BMg={k(lRSz5lywet!G?0NhnYFw{J$I3!k43Y&i$>WDCk-ylXcezRc z0=b6kwi`Vj$=$7D*)3Lot~?qro!Quj8SK7@aj^v<*S`KHxf7?J@!j{r3JP8#GfSo> zYHoq-o+~omp5|*0tfgpMO06LiKi(bTopaW+J|4_(I81%ddXgHzWa@;rp$GC9Q=8E zl0%}8H@cgyMGt4U9opWcZzuF<=b6MCxMXvdsH|r^i)|UOB=q>^=mxL@Uv>KY7mz=* zh`>OnLTxvpEn^ZW+#+Ix-F@?Pw%DBeDiE@mW4Ix@h5tR2WQ=8TMEMir8&n+sIPEs1 z`pf^uFZ_dw0Mmc`!lHrg6ac`#|JN^^rAbHWh~tF3JCz@=pY!sg#S*g)eflNlfcYUq zR3acwgCYps2%&a32L6WuNJ4^SfGmiH2#kq@i!lN_qSO};BME`07B<9y1NrAtxTo@2 z|C0CkbT@u;vm;PGVfbf_m47GgC>%=!H_^lC*;UnB^&s;-v%J{&SIk!b?1L}L&nE9f zztePct=H1`U|P?T7T1?Xh@QHSeBnNnDH+5sclKivaur`Cj1t7(5UXK@Tip^~ypR|k z3-sw{kHI81%GO=Vn6k4Uz6Fi9mGj%%me!|KfUF_tQ`WjmQ`o4&_YF2M*YvL;Gt_}r zJTQ7~SHe)N%mt=jyG&E_WS)}R7EoXS!<7GUqbby-_}hNWh9qpD3;HqQOxbB7S3L;XRO2(x2pZ(C?p-S{@n`nz(8?0gi>V$@-kjJ~d6$q|Rd=ZZh|79lQ5}=;H== zN1zIkrjzAFQc7ovg{SDHNp zkS&SyR71ybF2B!PM(m4Qj)<^hC&MeuEsEAgQrJ8-ltuAYT>e#pH>-+nYWV7{{Yw$l zA5z6S;kdgQbzR|utNCjJ6H-e~b)w4o>jn{1@v#1wGw?f6ll2FB~i7zV&?4Z4fwTH3)Vl7l$7)ABX_DYT3 z!@=yYFMU?SR?#m*ECCIH6Vea8_3|G&vC-HYX|Bw&_uGx*PhgDpcuegaZxuN76ne)D z<78d}JF@T^;|(=VffnX{58-}K6=Y*O`Bfx z+rSyNx$o97)^NiZaJ_%!*SXIX!t zEnvFrAE!BqRtYt&;_CQ^RqyI5eoGNS#5cD5c5P1W^F8I%LDXXY#7WuwRJPXEvPJ*` zKz6^nKh0r1m-l{WAKK?kcA6>sof^Lg9vu_Kx1OE{R9&pIbDplPW(Ezg47S6sj0RJE z{%mZRAeSSn6x&gutKbOf{DA&*AyooP)K*g7TbZaO?0!8;bh!=>_=gnu7eO^FiXr#H z+%R}IC!VPe?5zbivt#jz-%`^v$~LIU=1#fW>8u$#jUa*nI<@uv!dk87Zxpw1Wceid zgaA{<9yTTd`KOkKoQ@#ZTAQT?Z~Bx|q@_5|i2hIXl0SObrF^qrYGbJE#%JM5b;GHu zvAN-L;~G1lrL$B->C!`Niu_#>w55q+Q2Y^xULWJMU&Mtp<@0hOH}iuFzWhz4LpE$X zW)P9YM^eKMtE1gy><~Y@Z^Z54%0oE$ZxH*g&T`F`k_Fp12d+3SNpt znDlM4+WYI*fIb#B&e@W~Rrxh{ge2G{wuzOoGE$4D2Q?m5!TvkE7?ftWNB z==#{}(ljmbU#qgOd)lDxq#wQgQd{EK5q?VcB~eUXjUNoGeP#b+R58I30f!lrFV=~w z$1&y7Bp{{h`e%0hF*N4=;m%S<=Jwb+y~I+wkWdA#;L46leqK(WM?+MIh1VsWdXx<0 z2ZN;IiK*9)S>l5;%mWcI07D@|_XOyt@xH3jYt*<-?X??Et}Vw(ME>oYF6(Ye1l*K6 zG?*vI$#n~H$&KdM9d+`)OE82VHgIsYG!jpo| zyS%qpms^kA5692dg}DtebetHb#u}cIz;XnNWopN5g6PR5VtF}lqpu*hY<8(uH z-6L7EOQ8hQHKGQxsXR&kdhuj2125uNjYxRN+(Xhw5{M{cdJ2W%Azz@(k2( zM(*yypli;Y-l|D{s5=pnrEQT#Z z{0xOiTS$uLf72*0xl$rf#29>&M;i17lJtehAmx3*$4XngT2%cY*<>PlEuG%VdY|n% z8QaZLmdab;xknJ`$JyV*U+FaQbl$nJoYQ$1C!O+TE2Z_Qz;&~rNgkT-_%7T} zeC=hx4rG%45#)n>4sf+7mpn01LHsh6lthL;OJ4RtHr#=r;wa!S)Td|Afp#nOZ} zA&K3yW}AsQAT^8SY14Y!t&WRF#u^}!&21BJ?*U0%QG{*dMcrxT!(dv~eW}N_Ixd*zk(R1LSEs0Ti3Z@%rDBV-C|^ z3^=9ltL0K3F%jJbNOnHCii>H0=81EE?^i#^XSo{m0G3>(C6IN>X8I22%xYARO#G6!a;3@abzblAkaM zn1qhB{MG0E1?!;e`oI?oBb79QljGcL4{l@hiY!2Ff_tt??_g5zrN6uFCgre1p$BFB zs^f3FSv|L_b2Jzf)%1gnRLSfc4a?Ycvb@iAi2gEyp$Pv7Ry6Kd{;((M*k@@x-K&#k zuf!FdkxdPQFB&9e`E>c^y?r&SjPlLjcsQs>(M3#VPftBGWC&0txQKIr5;+kIv3Rp% zHjy@8nEMG;*aEZb{cTNSgS+DVtV*P5cA{KTxw@*~WMeK4hNd(IJ=q};}S;mguFCO`s#Q4 z$GJxBX2$iPKLgKajV`X9`Hf5D?rI=t!YG_@y5dTx^Z~+HjQqn%wd$%>M!a@-sSEOZ zYscrr2aFJZ__4nU?PCiU+B$tfagnF2BWhU2X5wU!a9{aqCezM@uXi<2e^$=u0}OXD zEW}3k6B<3iAc^=JLl!m|r3%h-f%=m#lE&C(F5YS&?0NiGn$|1Jw|P9f;BWoX5VB}T zaq*8N&=`9K#Y*3~d%f7McRmqIqbpA#I{XD$wGKePs>%Ex1^JbXL&eU_l;GPcF!b_0fnZ@9)ON(;AKmanPy*%YNe#w6hn zdi*f;JGs zpBf8}Qz8vgZeH7XoZ(N!B8j-D+IT#^@0x2g4s2DFswYFzL}X-KE6dVlloq7#lnHI+ ztN*M!`_Nq7DmE!oa-k(y4gkFYaX9=)L4_xwgP%TC>!}1`_N>n!BHhj&FWG9PR{h3+ zg6_ONHVy)p8-;Mq4~K@9E1_3DYIyB-U@3CpEns?NH8YywK$u+rtld404xNeSgu!qv zY%H(p=-;njd@Y3IkB*vs_d45`d(IwX0hFZYtJj97XUisTE7M?p)W+Iw=szE$b-f1P zucbvR3}Dt}@_OH=1${^ao0|v}u;(xPzHUboUA$~WUNHORr@JUKKv85@AoEUQlXYHe z#=TeK$iby5;i^s`)7tcSv0z;*D&YC9-0JyS15Q38I1bLAnK-v$$VVrX8e9-r4kt;A zRL2jR2@aOn_9Ii8*Bv<&)Gp&a+)fZU>*hkyel`6#>jF<4iosjQPFqI#XtX=FUe5>f zlD5$9;hD!bZTDe_XyR}o&<=eL?CJmAg~~S`6P2o`S7=ZhwL*RQRC5+RsezSi|9QZu z4VUOc4y`IaK`TRNnC*}zTQg9i>eArxF)(^=g?@i*Et`O8^F^1HtoV85X3xBTi6p^A zAfWG%n5gE3%g2qDqF&@WlNr)96XTx*#?cHrS?rEvx4q|~s5pyq>%y2T&zDBfHrjD= zZ3=;oy5rv%VE1*~Qy!CC63}&WEd-ZqE$4W>ja3bSzoH+wPET|*bX%HoFUMh-9T(g$ z*uLH~epxX2se_-ICxIU9P2bYnk*L`1ZkeGzF}^$ZfoWl61}}%}y-`|Uh{o;W?xb1Z zWn^tnt5c|+){LTqapk_I`}+Ljg7QK6v+JcHOCoF#nNl0UYkER(W~u47KQ^JxC8teQ z5;@%?)ao|i{k!o|XX?V0@7dmO`~qE{_6@pr3`hZ-5!FUE2nfMMM{Y>VbYBD%M&m~U zRZlLMlTNfh@aqe*%SRr?<=NUA**o0M*E7BhjnvB;zM(f@?#A^I+}s%UcC8Ewfe^kGqPIb+ZnZ zXM3l!la1#)>4k>0^zXmh%J%cZ;3SoS0N8r(g9)*WdlEO>)UoTAD@jjAP%s%?b<rnB2}~3VtMWMNZKQ0;KORn&3oSEXo{ME>P5;2#+I)JJ#)|MxLOznb1P`E zSx_*ELLR}cGP6K#Q@bh|FrD&stZYgc66HgDjd3$e{Oh7CAyK|bGlo?^xLK0*`@70C zN|;PrV!u`N3{2`sxZ2}tnqC1a9y-Z|i9_z`?Lt%`yKaU7EroW8mG<+z7D#F|pFStX zQ$DzN0WH!p)cT zWMt*Pg2cYTzt`6T{WBF*4 zS>V{-oyklE`<$9>i5+#ff4>?eGA!%g457_GZ>bORGE4n#0Sfp*3;^NlWV)lzAUitD#o*J z39KrgoR57~egpW9S@8^a3sIWJJk`R&i59 z*0^0_h5{v%%GWZtlnDD95w?v_5zQvYVGgXt496=0w#6Un&0K`|Az3A^(v0FJLTSmY z{5VSPy3d+*>xm+lwX46zSDl`WY13mV-OL*Mb?IK1n2;UrCf1&R^WU#Jb$KQWJpG9^ z`nU$=)($0jra_%M_J7iTezRo(B$QVr@+F)$w4k99EzwER&jkQlgjCFA=if;h$mbAL z_*9IDq;Rbb9gjOIoY$+9sTY(nJ z-n>o1_U36(?96e6Som#yISJmtK3S*t&{pW8O$MS8XWRCyr5R) z%h92#JtC6y({9hrg&uyEMCH~zqfsP*fdpC*yJ9(S{gp?RLPbM~!o~e+P%7l&$DE&p zv4Oq@)Hlke;^6PhWL2IkM8}ta1*Ozak9;h z_k8ex1VC0~D$|>3XryX->Lop@eEz}_ArmQNA={IG!0g{?8rG=QZX3z@lN`gWqb;kG zBI)SZv=kEx&v>km6bTuEw1y0Z4)=Wa#5Bw;=lvqt+yBSLY)?pH59YUB8{y?##V1B< zMIz<7%dub26$V#ql~%@-8Yk$v16Ra*(`LLKjFmX_@R-9Lc2p5eKYj;Zdi8I@$A$ik zNe=ws52$KX7;|=-ck^z0$|k$38mUbxM^87marE?T#G;k`39MJX_gpjzmfRV zJd_NeVXudD$EE#C!+y@3KzSMaMQe|7cq4@mpdiqPZ|ha|Rd`wE{&a6x6v~|{xN`uTXXE_&gKRF{ zpy%3Q*RFpm9l{`j=ur)iDrei)uf04;Mm>R5N4We~f+=ih8ceW*m}jY!Boe$ zXdawCwhS1NhVi!Qcz&Qo#|m+74w2e56) zmh8CMk->fnmMIt#Y2=a`V0cY1RsQ?Jfo}tqMZM{|iQni4dvaKFxQPCHV*pE$A%^1~ z&4y^HJW?#W1$RzPhsS17f#+IOB&oE^$>e0ne@nCqBZEkJ$4;jm{i&C3JaC=Nz!s|2 zLX$k7CcN3M?Rhf$>GW@iEIOxMusV&w87Hdmv-HNR)E~wcH6$*qNs&4I#plBd!N+f7 zW?Qa`AgKZGOKsoRzHs8ifT^O0`>CT@MFw5TU{4Cb&NTm z%dKBeaGWZ`ML+!!=k%A~gBpF5GBjtklyYk4eXrK!XRCN7ykRR*z*8FQs&kYw<0433 zm;k(A?O(RnnI(6yF%`-3k5f|`#47E52G96wipr(b+{no1kwGY*t+vA}jh;=p|Awsi zh{mfay0eYz*4I+PFbBKv-6VG$xFC>8WFuEHgVn@NNt+hA=KLjEFf9f8+V0fn9AeUn zcq0I%AFijtHpi&WAKb7(Rv1>{eo38Il>(mT+EzrJjc6Jm#M?@^o^bNd3b3Vg7@CvP ztw>fq&Do_q4w3mybbVxZu4I{-KRMtPg6%rqJx{3R!>^xV%~^Mx&_y-4-nZ|^uo7U5 zXDr+@M`I_l7Oyu!9I&pWHu*Hr=CL2uF%-_PiW`OeO0i%2&?Y~m!+#4~l+m3O<1oH44T}`SAEyoRHVjHYzcNuUK>ezIV7a`F)E0`%5U)Dm) z4Kl2uaQl-qu;ygb8>6mjjDwlk9!dRV@ol|5@Q?z8sKMl~LoS*XjHn1QC=>M+fkB`` zbpOuDfWb$gG=SS@Ajxhb2ZLI(7R^c?d0rgit8s5|6+P$@0|evFW^Y;T@UfqA^l_Q6 zW<$H!sjjn_sAzX|M(fDVj?0tUWRGZq)X*7Okt==>o{@n58dNJjrfn~59JofyjQyoh zBF0sqa>{dj`<=$=y%TA5nKqP@f`n%Bk>yNB*(NuEN+K83;pDtyaaq*4U^cR1`|FO$gvADDv!f`Xtxj zZQj-mA$`9HTH)A6PR~0usC$(+^J%U6QT3?t^7rC~-xCW5?OUw7D-1tj1c?H2^TryI zee@_5IUM+7y#LytV!S}s5U|9BT)-*<30ZM*HhNk4XT~QI5;1U(Up;1z0(;xD^){KC zWvE>86kDc2YRU&LOFqr4%ISuY6u*${65KgF7%D}VglZ(q)WlaS`TCjs`1bMI=Dg46A9d3Gx+lf67^80 z!gf*?9~5Xr=xluTFXpB~*?j2YkY~0yGa{cte?@ z3M53;@+O4!pu?mNasCB{>*)TCx5v;8yX}Dq&q)=WY`4e36yKN5ufayCVI;jzoyJvCXx3;8hvieJXiy<<&#tqudMK7LltGB-nb6DXXOeV%I#Zh@atJU?o zSd5PW6*?ORmGpbf301`X_|`&Da!y5PMl(sFR+O`|buyq#zgRP=ht) zhmm?`9oF@Fu1-TZHKm?Woo%u+gW{rB6~YDi(l|R03qoRfN`nABxrm~~;TxAHjlW*U z8UCP@znMO~oOrPh3z;`_z_)xv{{ewzdvQLyM43j5U{xgf0ph$Fd3yG#c|Ylba`C`j zeS`)SEOL{9qD51gcN**uNtmo2Sdw8J8Yr3DB!VtrT)^)Mk7a~p~U7%6% z)j#dY?56V|lb+(eKGIzHc=L|y-e6UxGhzw(VOxMV&IrAj7!{`2iZvv~>{3GIb4Jhg zH9a%BCi3jhz2YuCvK^kLieXIxynnVI6G*)N&J~`apChiarRs-Ae4FSQJ z^g6+uH`=Ebt**z_fAO*t7tBVLUH;q4>m956uD3+7=JTIAgctjj&Eb;lS1+=*CLd86 z`EU6Y8E_l{vPx#(EiQ5*Xi|-Kr z4$PzqZ&wG+`%hxkqg+xHpq_D=NXD-N$sEz5u^(N~^kgU@k-F2RM@-{dI; zJ^2Ve;x)G&WvvLUkG9|zl*@$o&YxfoRMG2JRjQEW55!|wch1{VUfbqUI}2G=3~QMG z!GeBaBi9FOov*OAp7AT0RMroxCmSt+(c_Blv_)0s7Lh;xjlxk?%nOd7RaHG$#J8*y zKXKW0lhfdDGFIj!nZSJpg+6AN_LY*s+v>HBjx*rNsxn;$XBeNt=+N|j)>xB-Hq_we zR-;d0IPfjbFBpyn<^m3KT3s)ZHl8xjI-f6<=5Vaaz35Sk5jS|>k1vfwQQ-!eT8d@HYL&wF((q>C{^mfr@;u4|j3vhCCShM=LPnp}T5D~4nQeF^8|GRsIy4138VzUpv zKoeJ>Tg$RJ@FmHCZkI{7XRCFa_8MY|85pj#4AdxZL8pW;b*3Qw;Y-m6kzvk)WkO}k zJD7+4pvk6W=o^Ja2Nm>|w^S8}KxU8Iw9#b7X$CNRKZ+w{-v*XVs0jYD%~6#uAB3cm zoA4y&g0(ZlsOpNpC>bTO7}4X%kN5~k9!%fRYAO2(u}5^H3%t|c<`77nLNsxIDW~{i z$EAf)>$Sbb|H`JyLUX9Jl&TA?FfL~6gh1Wul~hdLypJ7ntv@GMn3oBVqQ~+XL0SCD zTFr^bWFq`#c;yvZ2(K=|p;FHPgD9FWg?~{n;W&i~iY7CXF)&b!XiA~KhEK#+C9XMT z(H^SLLQwpHv-%qDi(M!`g)W_TCZ%s8C zj;8uJlPmGjM%>Wh2A+uiB1_XqWQJEfAUb`HX3&%!9@)tw`a$`Fa;8F|oxTgD>XXF# zjo$^G7jIFr2a=4I>Qk$dX#%loR@!@#Ib$A|I@W-n!ClAxsgo_gt&j!?ozmfEnCP6) zeqS&9dF!?j6^w5$mDo4IR-ROxgIZOd1QmywK-jP-NM{h`7}SM;JR3w%V|zdeGqFHt z7KB_D-?S=HuV@s#-5`FZjc(xY+tF$`K-E7~tG#4UM$_%=-0$2+so(Db=QCb`MmVW8MU2OpYe54caHl64L3#LXU<0R{|kb!6~rTP%NIZ+l7yqQZ23_aLNNCdFZ=t z!7t*?tQ3>r7sCQ+X?e3Xki`Ru|KU-UwM0LU$#-%2dSu~pZ-_Azw`#t+YZ}{(<=aRl zFJi>v_Tilzs&GiOMo6I6BYe0gs9xxH0!0`W%p&4Uj?*Y6NwJ8|sD_(T#&l~$C_nyC;Vv63*j{{$Pb?Uy-J_%a zLffz-k^C`SiY5X3>sUTz1es7Ld;qkFkr2@Ce(IUMcAx2$3L~;z%UeIg%FFNVI|qXf zc<$TYKW-d+pAr|i!f@yN9^Bjx`ZVdf4YN7It_Ni6?EL>h7|*E2B++llS1ef>G_nDrDlNOTuX&fE=N)hDg;u#7!530fQeRbe}xx z-s!O-p>L@4RY!lnn%&k778^ru;|#&s`jsr+UB`l}@Prj$3uEwaFJG?4hmj)~_zFxV z3(B%>r~)fDT*4e~mwsU*C?!EWLVI`%y0f1nzAX;6bYX)8WQ9zyG_v^jX=&HSVs9&b zyZF*=PI=YP5XjeW>ga-eUGeadO+LHM^McU>a!b2$mr3u=K0HPawBD5^;!NLkAO4Nd zX3Sz*NybgE0ObRHXt6bXwLFd-XX`(;L2OB`Fb;7=Wr^p><)o?x^7wEH@QRkBS2xhA zb%maN8lbP=y6C|oWy6lr=sA|K7!?YZ?c}tTAP-I|Fkax6l#|9jv?;mVS$o+>UMbX; z#@A5~o2S)KE;amvJO$6BHODI-mIH(G8O)aG;pRo4%?8B=MPq+}Kv33~W^1nVita~O zcpV9_Jne8sK>rB9k&O&z|ERBQKD}slTZlxPn61Nr)#In^m>aYHP@KVAc#^_Y+3zwo z{c5N!SPT&=)n?LgxmhEQ?sTlfhIyJ7Hy(TzM<&ZxG5cPuwBF5i`F9u7!h3yMh`1tg z;G^>)NAy`rHj*EKAfKL|PJIaPPi@uA)L64g(Y(1a9$33qs7Nr(^t*@Q@sj0{L0&QJ z3_VY0ehg<$DOcV|+-|jY#dMQqi4j64jvrm>ZP8BAf*c-F?_O~Sa|rwKU);&2f*$xW zwBtXKNy6j<{+itO!s)$KtN_`paof(&@|q{z1jemH^g=AH0=6Z0yj+#0!>wdq&MO)h zI8kCrYQ|t(sw{Wj_6yLD9lY|_@|hH{@l2}!6N)Yohp!Sbu+13&O9)btgdT&zb&;#R zd`X$A`fi~wyG`$VY``Y4&6lWSD4*1YQ5v_&F=5PtMf zy*M?ZZE?qQS7JaA;77b_O9!*mN<)MU7O^;1tIYR&q5~+Q5hh}1wmORo)?}0@aBGF= zN;a~)gb;sm)n)iPlGp29N3$wm*d`it(cDgD97;8rLx(Xm^q+YtLF?vtdOpdY4BD)= zvrZ*`hyqd$o%?=~2)}#4o@r!fM=!ETMeuNjH{k1-w@5RU`WMPM_;$&GkiJ+g@~mD= zrg$_(3gH2P^Q8_IG8+r4+S9=73T?iyb0xnR3DOQb(<*WUpf&&Aywm=sc-C8o z`~2|xiQn^-Q~JFmX3@GX_-uqDkRAXNCY~mj`6_ub1=uJx+94SPuM-hJJVy_1eBom+ zhB<)q-!ip_>DlBtCXTuYYgjCd5PiRc$40Myi++fi4Be}vcQymp594a8p54?g=zh5MMbX0Ft~ z^?vzq^LZgZ!T;=pMhJi|f<1o=U-c2gB#z*A{?90l5JC(o$TllsW=02zcLkSHLHzO~ z;=g!~?iN2xV5$r+3=<*)s|S3X`s0xqu68o_aj(|xZ28v->2`*Ym!*TxU0<5xY`?M~RrTQQ z)Z#ypIy_m!zgth=YwNC-SBd;<=XEfOvrIbx4=|Q$`faz%=2Xq<`|$WfOyqobtsi!- zR>9NW#oHiW*K1?X&F&nNB182CbNvai?}IX&?IM(ANp3ip;>+XypNrR&lDE?7Qq88X zJEYq%KLo12++}gDzcC8jRrpEO&)?Z5<1W2zPrj|qdF;t&`x~x{(=tpOaZB1cxAb z<)DnCE9f%g^YM`Iu(@b#DRRR<+?2B1qG9C|QF3&NJmY5aB;dH(nLUSt4=7_tEji#@ zXqZe}ZdI;u%kg7m13;FW)RnB7Xd#|zfMCFt-_U#h5`2iG&Bg)~2L9tloUp7FFTz!u zkUaX0YGA$Ywi$K!zLVBtAej7omSVq{=7fN!1AxU)l3}#6k*)ee^krDu^LH98Vb~9g zJ`qv#WVVRgr)$SkDj!okIL$k}dn-Q>Wvv8qIxgaAhKnLHL6ln(lTqYizM;bkL7LVd z8P71}p|7_(y+t~TF^bkkmHDjpR?9*4lr;62xRu1N`q#u_Iz~A}S(#(-%bg@f0dwvOI1dmbc?;Io8@V%i*KF zw+1j;phe_#PPA9PrAlMciOe}2?Ca;CD=c|OH<4^zE%NvcR0Zv8#|Bj|blE^zY}+drrPqpXd>Dz@`b#`rR_ zJgro#gBy1?vXsc72OJeBP`PgVypH9(F80FxJ8djylt@T3N4jt)sz$SznU&@JR$Eh} zqk)|N6E6Wpks&yE!+T#^e4@kQUH2X*kSWTvvPn_rr*LmaTZ7lbCAIt~el-it_n(_E zQ&`xRQ5Z?$-Peupx2rq9{qC}ypUpWL3C=L_u=77@zEdI!-OR=vWcxg}Qf!_i?@rNw zSt?BB4VPYAE>)Q&t@CjQ-Jq&cPPsck=W{@q)sQv?b zg<)0yDu_rioc7=J>@gc=?guLD=ehr6Mru=cw{`7-I3dXI=;59 zXbx#HeOGDU(ZKrc=qxWwIBQSCU(oP*7?o&*X=YO}KfSk#mq&vVwyB$xS)R(9Zwv$e zJSCe=T{38z+r#84z!LA&&yN0nO|Cj#=f9hG3qy2jz@AjsnR8BsSSCzvRGQqV+8sev z*&UVId^LE*Hvz#ri}ZIR#&lZ5c2cjSRad`%y~yEp0;cq{-jinrl-St94fxerTK?Tt zMyG!jJF?2L{_I84RRF*m=+=k>T2U$4{O?seNjV~?RldUn79g70zS+2!gFfZhkc}o2 zoBRuJ%A=#xgs^f+O2vaJL9~CdW$KC+h_=iVt5sm7nAMG!utP~05phn!e$d>w{HrsP ziv;-TCHgBWg&@i7lGxZ%+QRfbr9a#J`wEygTC#l!v@A}Tl>m0_U^oFHX0UKsQS>aM z_X;tB6b%QnpeViR;wLnLY=H!Sj7`icGzbLKDC!!873WQGgvTH3a=Gg()uWRb%z5bvraZ0 zKodBSZnlMS={h4R2M#;&#*{R7N9#rAmV9Thk62`ba7H-U$_WKg56~9M1D%!}UqCfT zqClNbf>qEbb#9^SgCshq5d7cQIg_3{L_Y1#mg@GRiVm* z=n_0k5){s!r8zTvc#9X3r*+G*y#S<{bZRvc#9Pj-3^PRmGSK-kn66lHV~#On(sSjv zMvccvJ6XSn-T4>0&qBqZRSA+S4^xIafYcVkg)FBKSyp)9ZC%7^N<@fBwYa;=uK135|3+SOD^|PUxXaF)D%cTPD zn^S`Ci#2t%3JZ5bQC$Zg@7J3T&h_7~%teD~#eLpn8sX8^1^Z|CFLi_&h z=g;^0kDb)9e4K9MWqr4X|Cg;e%iT{(sU){L=+Z0AEAQ^B099_9mIm*V2$A{a*H|}! z{6mjBh0&y^Jk*z!BBrgshsWA~mwjBChcuNybf=}@*BdOLTMUKl1Q}vF0S?k83t9iD z{w(TM36u#FccC9vpv~>?bV%vAym*vRdEb30GVIGll#I}aQZ+FbN-V;%H#hF&FVlQ^ z^5;%gqH^HeLGEp2C$GVHe$)&9XvgUP2eGRSeBE7G7x?~?RwLU4GZlw)vsW5FVjLj! zbu}6`tlow-B~DeJL={EW-qE3YsZ@)@aimdKD8t1OI?Ra*eqv&xZ2+UbO=ovrG1ySR z)ipVnK?|*-inuXO+qmNqzC*^I#O@GxBq{;FZXTM z%$ZrU&YE@h>^)~rS6Dwcy}PQNs{1E1Gi^3Yvf-@CPusYQ0FLuum|D*I)@c$=dTRaY z*I6pgSO3)CD&)0~(`9=+HUOhiEr5mXHjE(hWa*=RPJA?U_5%Z!jnzw5Hv2AH)`Qne ze+Su==<_mi@9aWN{l=%>_VxZOH`U6_3wLntwe62M3!8we=KUkBBgSqXw2ynxx3|VJfVp!>=`ww+C zGa$Ud?mlgsg{5YeTa~7ViG5dtsYG;{-80sge-#fK^GRN#S;b3-e1vPI)k@!BpI9^+ zBya;ij}4`!3Z7q8ha^)+0`kZspymy65K;Af{KvY3#-K349%DNi=lPbNPzH3#7`tf4 zYeS`O*CN!mBX%jc`>5Wz<`?9tCD=M1{ch_Q^k$`|v54mji~P3C0baFbZ#zCdGD-P^ zAG-P&8A}m+Pr9KP0b}Vvm&|l}zK3E@w6k&@cEs6f??JhhAUtOMQ{+9(v&0MDkkgG5 zUa~se0AJmg<1QSCqJDW)n9N&_x%rX=M9MV0wT@>ucQ`AiOa=h>ZEk(jnH>0VtE!dl zhl)~}Qo71W{ISG?bIVx9`2LfVtcD$_H^m<86e(P`Ldhxmtu>sgyu>sb@J+f%-~2mo z9hECm@z8}~oQx&vUI#nrBU{EQ9;D~Fk)|#9Nu$Rl>;Z6jDz7(9d_gO}=Mjlj=`ice z7o@Svn~gatbnS{j-u9U;kUKw}edn;4l6SC)yf*>6<9e|DD-B43EITWVMKmz5+|lnJ zQgcW~*lSo@Dg>85;Pg)M1T-D*7gGwU%76}mH-w)Z_6=qZH~-OJT{Nunkvh3xSN?wM zsqDqtMOwyw*7r{4K%jfR(pMtN1ak%cb&^i6{}p1yiw%BS8Qq>^ys_;YlduPRbQ2L? z3!52`ujytH;)T|9Ww+>37k9mmSEYL1FwxHw;F9U1FajwuXAqzO>gi4%F@r$_(^#DBi3^#GT)+B-Qaxigz)Xb zKJ9L@1S#;1Q+Q@TZj0T>Cai$T3Q%bauKTspL@(Ln^G{sVq06n2f_&8NQG?#ZKp;vp z#pZdGNW#%LEb$;T%=F)$FSn^fp`sk+5|tkP5V#{lWFt%}blgvvMLy>b2&O`QXTlLZ z|Ei<2S*NesU?XX>8xTVYV$TC!_Y4<@q~Uu4o|=%(@CV7t?CW?-P9;1w9?sdBhY^34 zV){%jI|6t@BJ^I`wIzWLdjqc{w(7zv{*1+`v*bX6fKW!LRoMbxZtP; zI{j7ra7baxNT1h|kZTqr(N|DL3aRCgRddF8;E9++EIU~Aq#cjQqOV51PmGdSjCYuu z3nu)Lq_L{Io$Y{HEawEu6O;fjBf;S1yo8iIw&`etZOO6DYhq+HQ~^d39P!c!N5POs zR(%o4i(^jp(_^Q1Q}}doLw8f`e<|?HA`Zn>_$gy-!EhQ0+XJB`#U51?6ymKUFPB?i ziex2v%jP7TO@&#|6{1WoEi#%-i1^l;83Yu4&gI#HoNhJ3($i!WF)#&txanAUVC|XP z5K><(sI=iw4Xjm0#O_t9AJJC0Hkt7=XYcvtY@(jZy?8a(9te=CP{t?lINir=3;*M` zO^ty<;Fqh&F6uu+ee)=fk0av+FOOT8`m*7H=BwB|oGjqru2H3oA{cQw5bO)aWuaW2MM0U{qiRwZsn-k?$-5+Q9r zwHEAgZ#Q~dA%NrT2E_Mz?~}#|#F40NaHP#x!if|=kwhBje@+=<;W)$kEbB*a0{X*S z#h0_8)mns;My(0zCBMQ>GZSd;F`H9D>h|XwC-|mP`3#xa%{<@xcd9^uB6KtyG#MP! zt>=QLCo2Z?-lNe9su})hKtop_-7032xGw6#n(i^Cm_sctPYUY%A#i)n%wI_J?)MgQ z^HHiEF}!=zyV12qu!&!)NG4|CWG(}j;sJDLF$@=bGE{78VdgEi!!~=c$JSm&(U}=K zg!MEEAElt;NLz4I0dDXE5^Bu z21e3#_!R;>BD_tko@~Rn@Q@qA#V;J1c|yc34Gap_XLPf+BKntHMKUc(Peg4(i#Zu zsyJ$XihJ<^XyRCFicWM(zdz{3C+KiQ`&LVgJo#H;;PKYU*p{(ihJ~?-$!u5`FV{dB z*UIVNkwVGga!$%3@`THM$Pzqr?!&~ZLF1@|;>pxa&M>r9Wxbd}J}M~1)l1(bYWV8d zDC|9e6BmGBgYXAV?^@h!_SwC#e)Th~H|WsJSOAS;XGI7M!OtcMt9?i5OXun5oY^c( zKz^6$WhPRCCd11`&Ie}$^hr33N*(F&M4qt(Dy-%k( zor&d3DD**HUqfrN1%IYt+Z9s@k_z&eJ!p4mo;$?ZUrxL0+o!Lacf{B(aTX<1V zYvcMPr)dg$&o%3&`|~vc$^*d_j#Lj|5u-ZQL*>z;Ik-^Qhsy4308%jG%T;6gm%D04 z^t7tw?CGxowogkMC!9c-8CJ4D>n*F)AYijgjcD|9H@ewEW_`99^wPc}zkY(4+~8OE zddXV~M*jO_iL3XxdDR;U*qtA2Ohaa{(sf$#Z|70v<0<3-OVg?c5^5t}4%`H`Y|yZ6 zO&l@e%RO%A-@OyhU5_lu3}~Wpr(W1#;Q%(zskFEmR%f`pdM!d~Tb4xk8eHK&;WIxb zU%m6~(WRsY>BM^{&OR|xq7e+`He)(d}>70qL0XsbSNvg zdyjG$^7NNojhKRMacH%y)y`T*^HvQY4zBXMx2H42dAeS}2AmG+33*p*lJH?2!-M&W+$%#CkL7PL z?z!`Ut8eyonJJo8i!Jz#a2Hj+FrtB+6TP|9k- zL%o|z!U2~*y#Gn+$CL4dho_hb*?}0Gn*5raLu}z(W&KAX?G!}C9RQyL%Vjh_L{bx# z3TyFT%qLteewiYi0nS>pRm?2{{SG`ZTGLh!nc|5y(Rj=(^3j_tKB6AMk`HXnq3ab6 zZctlDKN6sMSC9~yBbr80#9X&aOKLVQY`z)Ci0XAXR8mF7Pd&~70`=}PYP;ZTDYzta zyW8z3pLOQ0;3?o-3M@CmI^~_7su)7d41Fh0igN`8Mi+7}cPA%BPhH=I68B_7TLvO9 zXO1w@nK#GZHY?I5uH%`wP;$OInF^`GmfynR{$a%c9W$;QWExgZOINGxg#gtriP!$ez=QW2rKTbDr?o2eYCyvM&36+$l&kA)XaTxc+$X*GBf2wXF=F*L{>o1FMi z`7>T|3JJb}vPN=TpKZHj^Ek1~@A?aQ6qE$1y3`CmY#%#Qh)e*GPNhBnQ@80NC?JL1()e(axR_=A+yUNhX$nQq2S^_gyB7u$^irROc;{;vQq2b3R! zl9?Y)(m3AWScw9fFE0HgLJU4Ungc%A?WQ_M09H#9lZBKxWj#Nmy6X01)b z4~0f_HGn}#IWDhj3VEZ8*3O{ z9;4X=`JX8(-`O2;!`bgi2qLLCoAkhu%j&Vc8Rw+vk|gh8r2 g5gZDUul(=y@c-50`N1_X)b+m3O!0!~|IIY`4@}5&z5oCK literal 0 HcmV?d00001 diff --git a/docs/assets/images/smopt.png b/docs/assets/images/smopt.png new file mode 100644 index 0000000000000000000000000000000000000000..a18f8f03b73c680d774fdafff33992e67a22b6cb GIT binary patch literal 28783 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 TUgbK_MyY^Mk;!{t^7`KZ@$^pX literal 0 HcmV?d00001 diff --git a/docs/assets/javascripts/katex.js b/docs/assets/javascripts/katex.js new file mode 100644 index 0000000..f973c74 --- /dev/null +++ b/docs/assets/javascripts/katex.js @@ -0,0 +1,32 @@ +/* Render the maths that pymdownx.arithmatex leaves in the page. + * + * mkdocs.yml sets arithmatex to generic mode, so formulas arrive as plain + * delimited text and KaTeX has to be run over the body. Material loads + * pages without a full reload, so the work is redone on every navigation + * via the document$ observable; the plain DOMContentLoaded path is the + * fallback for when that observable is not present. */ + +function renderMath(root) { + if (typeof renderMathInElement !== "function") return; + renderMathInElement(root, { + delimiters: [ + { left: "$$", right: "$$", display: true }, + { left: "$", right: "$", display: false }, + { left: "\\(", right: "\\)", display: false }, + { left: "\\[", right: "\\]", display: true }, + ], + // A failed formula should show as source, not blow up the page. + throwOnError: false, + ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"], + }); +} + +if (typeof document$ !== "undefined") { + document$.subscribe(function () { + renderMath(document.body); + }); +} else { + document.addEventListener("DOMContentLoaded", function () { + renderMath(document.body); + }); +} diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css new file mode 100644 index 0000000..3d67d3d --- /dev/null +++ b/docs/assets/stylesheets/extra.css @@ -0,0 +1,91 @@ +/* smopt brand styling. + * + * Palette: Space berries, the same four colours the logo is built from. + * The header takes the deep berry, links and hover states take the hot + * pink, and the orange is kept for the few places that need to stand + * apart from both. */ + +:root { + --smopt-pink: #fd3db5; + --smopt-pink-light: #ffb8dc; + --smopt-orange: #fb6a2c; + --smopt-berry: #8c1946; +} + +/* Light scheme -------------------------------------------------------- */ + +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--smopt-berry); + --md-primary-fg-color--light: #a8265a; + --md-primary-fg-color--dark: #6d1236; + --md-accent-fg-color: var(--smopt-pink); + --md-typeset-a-color: #b31a6e; +} + +/* Dark scheme --------------------------------------------------------- */ + +[data-md-color-scheme="slate"] { + --md-primary-fg-color: var(--smopt-berry); + --md-primary-fg-color--light: #a8265a; + --md-primary-fg-color--dark: #5c0f2e; + --md-accent-fg-color: var(--smopt-pink); + --md-typeset-a-color: var(--smopt-pink); +} + +/* The logo is a dark tile, so give it a little breathing room rather + than letting it butt against the header text. */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + border-radius: 4px; + height: 1.6rem; + width: 1.6rem; +} + +/* Tables carry the API and solver summaries, so make them legible + rather than decorative. */ +.md-typeset table:not([class]) th { + background-color: var(--smopt-berry); + color: #ffffff; + font-weight: 600; +} + +.md-typeset table:not([class]) tr:hover { + background-color: rgba(253, 61, 181, 0.06); +} + +/* Inline code reads as a term, not as a warning. */ +.md-typeset code { + border-radius: 3px; +} + +/* Admonitions in the brand's orange, which is otherwise unused and so + still registers as a signal. */ +.md-typeset .admonition.note, +.md-typeset details.note { + border-color: var(--smopt-orange); +} + +.md-typeset .note > .admonition-title, +.md-typeset .note > summary { + background-color: rgba(251, 106, 44, 0.1); +} + +.md-typeset .note > .admonition-title::before, +.md-typeset .note > summary::before { + background-color: var(--smopt-orange); +} + +/* KaTeX blocks are wide; let them scroll instead of stretching the page. */ +.md-typeset .arithmatex { + overflow-x: auto; + overflow-y: hidden; + padding: 0.2rem 0; +} + +/* The banner leads the index page. */ +.smopt-banner { + border-radius: 6px; + display: block; + margin: 0 auto 1.5rem; + max-width: 100%; +} diff --git a/docs/index.md b/docs/index.md index e434d98..841f41f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,5 @@ +![smopt](assets/images/smopt-banner.png){ .smopt-banner } + # smopt **Stiefel manifold optimization, with all numerics in Fortran 77** From 5f2e3f0fb536991bcb93431c308d778ce0f196a6 Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 18:01:00 +0500 Subject: [PATCH 7/8] Reference the banner by raw URL and keep a single icon Follows the convention the other eggzec projects use: the banner leads the README and the docs index through a raw.githubusercontent.com URL, so it renders on PyPI and anywhere else the README is displayed rather than only inside a repository checkout. The icon set collapses to one file. docs/assets/images/smopt-icon.svg is now both the theme logo and the favicon, which browsers have accepted as SVG for years, so the raster and multi-size .ico variants are gone along with the small-size cut that existed only for them. A vector banner sits alongside the raster one for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +--- docs/assets/images/smopt-banner.svg | 1 + docs/assets/images/smopt-icon-small.svg | 1 - docs/assets/images/smopt.ico | Bin 21006 -> 0 bytes docs/assets/images/smopt.png | Bin 28783 -> 0 bytes docs/index.md | 4 ++-- mkdocs.yml | 4 ++-- 7 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 docs/assets/images/smopt-banner.svg delete mode 100644 docs/assets/images/smopt-icon-small.svg delete mode 100644 docs/assets/images/smopt.ico delete mode 100644 docs/assets/images/smopt.png diff --git a/README.md b/README.md index b4ba2a6..0fd1840 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -

- smopt -

+![smopt](https://raw.githubusercontent.com/eggzec/smopt/master/docs/assets/images/smopt-banner.png) # smopt diff --git a/docs/assets/images/smopt-banner.svg b/docs/assets/images/smopt-banner.svg new file mode 100644 index 0000000..42272d2 --- /dev/null +++ b/docs/assets/images/smopt-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/images/smopt-icon-small.svg b/docs/assets/images/smopt-icon-small.svg deleted file mode 100644 index a281183..0000000 --- a/docs/assets/images/smopt-icon-small.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/assets/images/smopt.ico b/docs/assets/images/smopt.ico deleted file mode 100644 index c660e10173cba703dcebf5852df62dfccd3ff5f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21006 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 diff --git a/docs/assets/images/smopt.png b/docs/assets/images/smopt.png deleted file mode 100644 index a18f8f03b73c680d774fdafff33992e67a22b6cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28783 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 TUgbK_MyY^Mk;!{t^7`KZ@$^pX diff --git a/docs/index.md b/docs/index.md index 841f41f..c52d553 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ -![smopt](assets/images/smopt-banner.png){ .smopt-banner } - # smopt +![smopt](https://raw.githubusercontent.com/eggzec/smopt/master/docs/assets/images/smopt-banner.png) + **Stiefel manifold optimization, with all numerics in Fortran 77** `smopt` minimizes a smooth function, optionally plus a nonsmooth diff --git a/mkdocs.yml b/mkdocs.yml index 7771edc..19ff5cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,8 +51,8 @@ theme: name: Switch to light mode # Logo and favicon - favicon: assets/images/smopt.ico - logo: assets/images/smopt.png + favicon: assets/images/smopt-icon.svg + logo: assets/images/smopt-icon.svg # Font configuration font: From 4fc695da7d644616ffbc1da784c35f2d5e2c305b Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Tue, 25 Aug 2026 18:22:51 +0500 Subject: [PATCH 8/8] Rename the public API to PEP 8 The old signatures were carried over verbatim from the code this port came from, which meant the package shipped names its own linter rejects. The project rules win: N802 and N803 now hold everywhere and the naming exemptions are gone from pyproject.toml. SLPG_smooth -> slpg_smooth Phi -> phi A -> a SLPG -> slpg JA -> ja C -> c SLPG_l21 -> slpg_l21 JC -> jc Xinit -> xinit PenCF -> pencf JC_transpose -> jc_transpose Feas_eval -> feas_eval Init_point -> init_point Post_process -> post_process Stiefel keeps its capital, being a class. The short method names are the symbols the theory documentation already uses for those maps, so the two still read against each other one for one. tests/reference.py is renamed to match, so the oracle and the package under test are still reachable through the same attribute lookups, and its local variables follow suit; L became lip rather than l, which E741 rejects. Docs, README and the doctests are updated throughout, and the examples in them were run to confirm they work as written. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 14 +- docs/api.md | 62 +++---- docs/index.md | 12 +- docs/quickstart.md | 34 ++-- docs/theory.md | 10 +- src/smopt/__init__.py | 26 +-- src/smopt/manifold/stiefel.py | 91 ++++----- src/smopt/solver/__init__.py | 6 +- src/smopt/solver/pencf.py | 42 ++--- src/smopt/solver/slpg.py | 95 +++++----- src/smopt/utility/utility.py | 36 ++-- tests/reference.py | 339 +++++++++++++++++----------------- tests/test_manifold.py | 48 ++--- tests/test_solvers.py | 82 ++++---- 14 files changed, 450 insertions(+), 447 deletions(-) diff --git a/README.md b/README.md index 0fd1840..d3f8b15 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ runtime. ```python import numpy as np -from smopt import SLPG_smooth, Stiefel +from smopt import slpg_smooth, Stiefel n, p = 1000, 10 M = Stiefel(n, p) @@ -53,7 +53,7 @@ def obj_fun(X): return float(np.sum(X * AX)), 2.0 * AX -X, out = SLPG_smooth(obj_fun, M) +X, out = slpg_smooth(obj_fun, M) print(out["fval"], out["fea"]) ``` @@ -61,10 +61,10 @@ print(out["fval"], out["fea"]) | Name | Comment | Call | | --- | --- | --- | -| `SLPG_smooth` | penalty-free first-order method for smooth problems | `SLPG_smooth(obj_fun, M)` | -| `SLPG` | penalty-free first-order method for nonsmooth problems | `SLPG(obj_fun, M, prox=...)` | -| `SLPG_l21` | penalty-free first-order method for $\ell_{2,1}$ regularized problems | `SLPG_l21(obj_fun, M, gamma=...)` | -| `PenCF` | constraint dissolving penalty method | `PenCF(Xinit, obj_fun, M)` | +| `slpg_smooth` | penalty-free first-order method for smooth problems | `slpg_smooth(obj_fun, M)` | +| `slpg` | penalty-free first-order method for nonsmooth problems | `slpg(obj_fun, M, prox=...)` | +| `slpg_l21` | penalty-free first-order method for $\ell_{2,1}$ regularized problems | `slpg_l21(obj_fun, M, gamma=...)` | +| `pencf` | constraint dissolving penalty method | `pencf(xinit, obj_fun, M)` | Every solver returns `(X, out)`, where `out` carries the `fvals`, `kkts` and `feas` histories together with the final `fval`, `kkt` and `fea`. @@ -90,7 +90,7 @@ src/ smman.f Stiefel geometry: C, JA, JC, the A map, the polar retraction smprox.f proximal operators and the l_{2,1} multiplier smslpg.f the SLPG solver drivers and the Arrow-Hurwicz inner iteration - smpencf.f the PenCF driver + smpencf.f the pencf driver _smopt.pyf f2py signatures binding the above to Python smopt/ the thin Python layer: argument marshalling and reporting tests/ diff --git a/docs/api.md b/docs/api.md index 3c1e077..af878d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,8 @@ # API Reference Everything below is re-exported at the top level, so -`from smopt import SLPG_smooth` and -`from smopt.solver import SLPG_smooth` are equivalent. +`from smopt import slpg_smooth` and +`from smopt.solver import slpg_smooth` are equivalent. ## Manifold @@ -23,17 +23,17 @@ positive or if `p > n`. | Method | Returns | | --- | --- | -| `Phi(M)` | $(M + M^\top)/2$ for a `(p, p)` matrix | -| `C(X)` | $X^\top X - I_p$ | -| `Feas_eval(X)` | $\|X^\top X - I_p\|_F$, as a `float` | -| `JA(X, G)` | $G - X\,\Phi(X^\top G)$ | -| `JC(X, Lambda)` | $X\,\Phi(\Lambda)$ | -| `JC_transpose(X, D)` | $\Phi(X^\top D)$ | -| `A(X)` | the feasibility restoring map | -| `Post_process(X)` | the orthogonal polar factor $UV^\top$ | -| `Init_point(Xinit=None)` | a feasible starting point | - -`Init_point` draws a standard normal matrix when `Xinit` is omitted, and +| `phi(m)` | $(M + M^\top)/2$ for a `(p, p)` matrix | +| `c(x)` | $X^\top X - I_p$ | +| `feas_eval(x)` | $\|X^\top X - I_p\|_F$, as a `float` | +| `ja(x, g)` | $G - X\,\Phi(X^\top G)$ | +| `jc(x, lam)` | $X\,\Phi(\Lambda)$ | +| `jc_transpose(x, d)` | $\Phi(X^\top D)$ | +| `a(x)` | the feasibility restoring map | +| `post_process(x)` | the orthogonal polar factor $UV^\top$ | +| `init_point(xinit=None)` | a feasible starting point | + +`init_point` draws a standard normal matrix when `xinit` is omitted, and orthonormalizes whatever it ends up with unless it is already feasible. ## Solvers @@ -47,7 +47,7 @@ dictionary: | `kkts` | stationarity measure per iteration | | `feas` | feasibility measure per iteration | | `fval`, `kkt`, `fea` | the final values | -| `beta` | the penalty used, `PenCF` only | +| `beta` | the penalty used, `pencf` only | The objective is a single callable returning the value and the Euclidean gradient together: @@ -59,16 +59,16 @@ def obj_fun(X): # X has shape (n, p) `verbosity` is `0` for silence, `1` for the convergence and post-processing lines, and `2` to also print periodically. `maxit` must -be at least `1`. A `Xinit` you supply is used exactly as given; only the +be at least `1`. A `xinit` you supply is used exactly as given; only the default one is drawn and orthonormalized. -### `smopt.SLPG_smooth` +### `smopt.slpg_smooth` ```python -SLPG_smooth( +slpg_smooth( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, gtol=1e-5, post_process=True, @@ -78,13 +78,13 @@ SLPG_smooth( For a smooth objective. Prints every 20th iteration at `verbosity=2`. -### `smopt.SLPG` +### `smopt.slpg` ```python -SLPG( +slpg( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, prox=None, gtol=1e-5, @@ -97,13 +97,13 @@ For `f(X) + r(X)` with `r` reached through `prox(X, eta)`, which must minimize $\|Y - X\|_F^2/(2\eta) + r(Y)$. `prox` defaults to the identity, recovering the smooth case. Prints every 50th iteration at `verbosity=2`. -### `smopt.SLPG_l21` +### `smopt.slpg_l21` ```python -SLPG_l21( +slpg_l21( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, gamma=0, gtol=1e-5, @@ -115,11 +115,11 @@ SLPG_l21( For $f(X) + \gamma\|X\|_{2,1}$, which induces row sparsity. Prints every 50th iteration at `verbosity=2`. -### `smopt.PenCF` +### `smopt.pencf` ```python -PenCF( - Xinit, +pencf( + xinit, obj_fun, manifold, beta=None, @@ -140,7 +140,7 @@ at `verbosity=2`. ### `smopt.prox_l1` ```python -prox_l1(X_input, eta, gamma=0) +prox_l1(x, eta, gamma=0) ``` Proximal operator of $\gamma\|X\|_1$: entrywise soft thresholding. @@ -148,14 +148,14 @@ Proximal operator of $\gamma\|X\|_1$: entrywise soft thresholding. ### `smopt.prox_l21` ```python -prox_l21(X_input, eta, gamma=0) +prox_l21(x, eta, gamma=0) ``` Proximal operator of $\gamma\|X\|_{2,1}$: shrinks whole rows towards the origin. -Both are shaped so they can be handed straight to `SLPG`: +Both are shaped so they can be handed straight to `slpg`: ```python -X, out = SLPG(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=0.05)) +X, out = slpg(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=0.05)) ``` diff --git a/docs/index.md b/docs/index.md index c52d553..b31d154 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,7 @@ runtime dependency. ```python import numpy as np -from smopt import SLPG_smooth, Stiefel +from smopt import slpg_smooth, Stiefel M = Stiefel(1000, 10) A = np.diag(np.arange(1000, dtype=float)) @@ -39,17 +39,17 @@ def obj_fun(X): return float(np.sum(X * AX)), 2.0 * AX -X, out = SLPG_smooth(obj_fun, M) +X, out = slpg_smooth(obj_fun, M) ``` ## Solvers | Name | Use when | | --- | --- | -| `SLPG_smooth` | the objective is smooth | -| `SLPG` | there is a nonsmooth term with a known proximal operator | -| `SLPG_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | -| `PenCF` | a constraint dissolving penalty method is preferred | +| `slpg_smooth` | the objective is smooth | +| `slpg` | there is a nonsmooth term with a known proximal operator | +| `slpg_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | +| `pencf` | a constraint dissolving penalty method is preferred | ## Documentation diff --git a/docs/quickstart.md b/docs/quickstart.md index 985c4d5..f23f162 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,7 +26,7 @@ import numpy as np from scipy.sparse import diags from scipy.sparse.linalg import spsolve -from smopt import SLPG_smooth, Stiefel +from smopt import slpg_smooth, Stiefel n, p, alpha = 1000, 10, 1.0 M = Stiefel(n, p) @@ -43,7 +43,7 @@ def obj_fun(X): return fval, grad -X, out = SLPG_smooth(obj_fun, M) +X, out = slpg_smooth(obj_fun, M) ``` !!! note @@ -82,7 +82,7 @@ shape. ### 3. Run a solver ```python -X, out = SLPG_smooth(obj_fun, M) +X, out = slpg_smooth(obj_fun, M) ``` `X` is the solution and `out` is a dictionary of log information: @@ -91,7 +91,7 @@ X, out = SLPG_smooth(obj_fun, M) | --- | --- | | `fvals`, `kkts`, `feas` | per-iteration histories | | `fval`, `kkt`, `fea` | the final objective, stationarity and feasibility | -| `beta` | the penalty `PenCF` actually used | +| `beta` | the penalty `pencf` actually used | ## Nonsmooth problems @@ -102,10 +102,10 @@ $\|Y - X\|_F^2 / (2\eta) + r(Y)$. Here is $\ell_1$ regularization built from the operator shipped with the package: ```python -from smopt import SLPG, prox_l1 +from smopt import prox_l1, slpg gamma = 0.05 -X, out = SLPG(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=gamma)) +X, out = slpg(obj_fun, M, prox=lambda X, eta: prox_l1(X, eta, gamma=gamma)) ``` ### Row sparsity @@ -114,9 +114,9 @@ For $r(X) = \gamma\|X\|_{2,1}$ use the dedicated driver, which knows the prox and the constraint multiplier in closed form: ```python -from smopt import SLPG_l21 +from smopt import slpg_l21 -X, out = SLPG_l21(obj_fun, M, gamma=1.0) +X, out = slpg_l21(obj_fun, M, gamma=1.0) ``` Whole rows of `X` are driven to zero, which selects variables: @@ -132,20 +132,20 @@ print(f"{live.sum()} of {len(live)} rows survive") | Solver | Use when | | --- | --- | -| `SLPG_smooth` | the objective is smooth | -| `SLPG` | there is a nonsmooth term with a known prox | -| `SLPG_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | -| `PenCF` | a constraint dissolving penalty method is preferred | +| `slpg_smooth` | the objective is smooth | +| `slpg` | there is a nonsmooth term with a known prox | +| `slpg_l21` | the nonsmooth term is $\gamma\|X\|_{2,1}$ | +| `pencf` | a constraint dissolving penalty method is preferred | ## Common options Every solver accepts: ```python -X, out = SLPG_smooth( +X, out = slpg_smooth( obj_fun, M, - Xinit=None, # starting point; random feasible point if omitted + xinit=None, # starting point; random feasible point if omitted maxit=100, # iteration budget gtol=1e-5, # stationarity tolerance post_process=True, # round the answer onto the manifold @@ -153,10 +153,10 @@ X, out = SLPG_smooth( ) ``` -`PenCF` takes the starting point first and adds `beta`: +`pencf` takes the starting point first and adds `beta`: ```python -from smopt import PenCF +from smopt import pencf -X, out = PenCF(Xinit, obj_fun, M, beta=None) +X, out = pencf(xinit, obj_fun, M, beta=None) ``` diff --git a/docs/theory.md b/docs/theory.md index 6885ee3..5941b06 100644 --- a/docs/theory.md +++ b/docs/theory.md @@ -81,11 +81,11 @@ instead, where $L$ is estimated from the gradient at the starting point. Three drivers are provided: -- `SLPG_smooth` for $r \equiv 0$. -- `SLPG` for a general $r$, whose constraint multiplier $\Lambda$ is +- `slpg_smooth` for $r \equiv 0$. +- `slpg` for a general $r$, whose constraint multiplier $\Lambda$ is tracked by an inner **Arrow-Hurwicz** iteration so that no penalty parameter has to be tuned. -- `SLPG_l21` for $r(X) = \gamma\|X\|_{2,1}$, where both the prox and the +- `slpg_l21` for $r(X) = \gamma\|X\|_{2,1}$, where both the prox and the multiplier $$ @@ -97,9 +97,9 @@ Three drivers are provided: therefore drives whole rows to zero, which is how sparse principal component analysis and related models select variables. -### PenCF +### pencf -`PenCF` adds an explicit penalty to the search direction, +`pencf` adds an explicit penalty to the search direction, $$ \mathcal{G}(X) = \mathcal{J}_A(X)[\nabla f(X)] + \beta\, \mathcal{J}_C(X)[C(X)], diff --git a/src/smopt/__init__.py b/src/smopt/__init__.py index aa3467b..53fc8f3 100644 --- a/src/smopt/__init__.py +++ b/src/smopt/__init__.py @@ -19,22 +19,22 @@ Examples: >>> import numpy as np - >>> from smopt import SLPG_smooth, Stiefel - >>> M = Stiefel(6, 2) - >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) - >>> def obj(X): - ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) - >>> X, out = SLPG_smooth( - ... obj, M, Xinit=np.arange(12.0).reshape(6, 2), verbosity=0 + >>> from smopt import Stiefel, slpg_smooth + >>> manifold = Stiefel(6, 2) + >>> a = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(x): + ... return float(np.sum(x * (a @ x))), 2.0 * (a @ x) + >>> x, out = slpg_smooth( + ... obj, manifold, xinit=np.arange(12.0).reshape(6, 2), verbosity=0 ... ) - >>> bool(M.Feas_eval(X) < 1e-8) + >>> bool(manifold.feas_eval(x) < 1e-8) True """ from importlib.metadata import PackageNotFoundError, version from .manifold import Stiefel -from .solver import SLPG, PenCF, SLPG_l21, SLPG_smooth +from .solver import pencf, slpg, slpg_l21, slpg_smooth from .utility import prox_l1, prox_l21 @@ -44,12 +44,12 @@ __version__ = "0.0.0" __all__: list[str] = [ - "SLPG", - "PenCF", - "SLPG_l21", - "SLPG_smooth", "Stiefel", "__version__", + "pencf", "prox_l1", "prox_l21", + "slpg", + "slpg_l21", + "slpg_smooth", ] diff --git a/src/smopt/manifold/stiefel.py b/src/smopt/manifold/stiefel.py index 6aa25e5..c1b5976 100644 --- a/src/smopt/manifold/stiefel.py +++ b/src/smopt/manifold/stiefel.py @@ -10,7 +10,8 @@ class Stiefel: r"""The Stiefel manifold :math:`\{X \in R^{n \times p} : X^T X = I_p\}`. The object carries the dimensions and exposes the maps the solvers - need. Every one of them is evaluated by the Fortran 77 core. + need. Every one of them is evaluated by the Fortran 77 core, and each + is named after the symbol it carries in the theory documentation. Args: n: Number of rows of the iterate. @@ -23,9 +24,9 @@ class Stiefel: Examples: >>> import numpy as np >>> from smopt import Stiefel - >>> M = Stiefel(4, 2) - >>> X = M.Init_point(np.eye(4, 2)) - >>> bool(M.Feas_eval(X) < 1e-12) + >>> manifold = Stiefel(4, 2) + >>> x = manifold.init_point(np.eye(4, 2)) + >>> bool(manifold.feas_eval(x) < 1e-12) True """ @@ -41,100 +42,100 @@ def __init__(self, n: int, p: int) -> None: self._p = p self.dim = n * p - def _mat(self, x: Matrix, name: str = "X") -> Matrix: + def _mat(self, x: Matrix, name: str = "x") -> Matrix: return as_matrix(x, self._n, self._p, name) def _sq(self, m: Matrix, name: str) -> Matrix: return as_matrix(m, self._p, self._p, name) - def Phi(self, M: Matrix) -> Matrix: # noqa: N802, N803 + def phi(self, m: Matrix) -> Matrix: """Symmetrize a square matrix. Args: - M: A ``(p, p)`` matrix. + m: A ``(p, p)`` matrix. Returns: - ``(M + M.T) / 2``. + ``(m + m.T) / 2``. """ - return _smopt.smsymm(self._sq(M, "M")) + return _smopt.smsymm(self._sq(m, "m")) - def A(self, X: Matrix) -> Matrix: # noqa: N802, N803 + def a(self, x: Matrix) -> Matrix: """Pull a point back towards the manifold. Close to the manifold a second order expansion is used, and the - exact map ``X (X^T X + I)^{-1} 2`` otherwise. + exact map ``x ((x^T x + I) / 2)^-1`` otherwise. Args: - X: An ``(n, p)`` matrix. + x: An ``(n, p)`` matrix. Returns: The restored point. """ - return _smopt.smamap(self._mat(X)) + return _smopt.smamap(self._mat(x)) - def JA(self, X: Matrix, G: Matrix) -> Matrix: # noqa: N802, N803 + def ja(self, x: Matrix, g: Matrix) -> Matrix: """Project a Euclidean gradient onto the search direction. Args: - X: An ``(n, p)`` matrix. - G: The Euclidean gradient at ``X``. + x: An ``(n, p)`` matrix. + g: The Euclidean gradient at ``x``. Returns: - ``G - X Phi(X^T G)``. + ``g - x phi(x^T g)``. """ - return _smopt.smja(self._mat(X), self._mat(G, "G")) + return _smopt.smja(self._mat(x), self._mat(g, "g")) - def JC(self, X: Matrix, Lambda: Matrix) -> Matrix: # noqa: N802, N803 + def jc(self, x: Matrix, lam: Matrix) -> Matrix: """Apply the constraint Jacobian to a multiplier. Args: - X: An ``(n, p)`` matrix. - Lambda: A ``(p, p)`` multiplier. + x: An ``(n, p)`` matrix. + lam: A ``(p, p)`` multiplier. Returns: - ``X Phi(Lambda)``. + ``x phi(lam)``. """ - return _smopt.smjc(self._mat(X), self._sq(Lambda, "Lambda")) + return _smopt.smjc(self._mat(x), self._sq(lam, "lam")) - def JC_transpose(self, X: Matrix, D: Matrix) -> Matrix: # noqa: N802, N803 + def jc_transpose(self, x: Matrix, d: Matrix) -> Matrix: """Apply the adjoint of the constraint Jacobian to a direction. Args: - X: An ``(n, p)`` matrix. - D: An ``(n, p)`` direction. + x: An ``(n, p)`` matrix. + d: An ``(n, p)`` direction. Returns: - ``Phi(X^T D)``. + ``phi(x^T d)``. """ - return _smopt.smjct(self._mat(X), self._mat(D, "D")) + return _smopt.smjct(self._mat(x), self._mat(d, "d")) - def C(self, X: Matrix) -> Matrix: # noqa: N802, N803 + def c(self, x: Matrix) -> Matrix: """Evaluate the constraint violation. Args: - X: An ``(n, p)`` matrix. + x: An ``(n, p)`` matrix. Returns: - ``X^T X - I``. + ``x^T x - I``. """ - return _smopt.smcmap(self._mat(X)) + return _smopt.smcmap(self._mat(x)) - def Feas_eval(self, X: Matrix) -> float: # noqa: N802, N803 + def feas_eval(self, x: Matrix) -> float: """Measure how far a point sits from the manifold. Args: - X: An ``(n, p)`` matrix. + x: An ``(n, p)`` matrix. Returns: - The Frobenius norm of ``X^T X - I``. + The Frobenius norm of ``x^T x - I``. """ - return float(_smopt.smfeas(self._mat(X))) + return float(_smopt.smfeas(self._mat(x))) - def Init_point(self, Xinit: Matrix | None = None) -> Matrix: # noqa: N802, N803 + def init_point(self, xinit: Matrix | None = None) -> Matrix: """Produce a feasible starting point. Args: - Xinit: Optional starting matrix. A standard normal matrix is + xinit: Optional starting matrix. A standard normal matrix is drawn when it is omitted. Either way the result is orthonormalized unless it is already feasible. @@ -143,22 +144,22 @@ def Init_point(self, Xinit: Matrix | None = None) -> Matrix: # noqa: N802, N803 """ start = ( np.random.randn(self._n, self._p) - if Xinit is None - else self._mat(Xinit, "Xinit") + if xinit is None + else self._mat(xinit, "xinit") ) return _smopt.sminit(start) - def Post_process(self, X: Matrix) -> Matrix: # noqa: N802, N803 + def post_process(self, x: Matrix) -> Matrix: """Round a point onto the manifold. Args: - X: An ``(n, p)`` matrix. + x: An ``(n, p)`` matrix. Returns: - The orthogonal polar factor ``U V^T`` of ``X``, where - ``X = U S V^T`` is a thin singular value decomposition. + The orthogonal polar factor ``u v^T`` of ``x``, where + ``x = u s v^T`` is a thin singular value decomposition. """ - return _smopt.smpost(self._mat(X)) + return _smopt.smpost(self._mat(x)) __all__: list[str] = ["Stiefel"] diff --git a/src/smopt/solver/__init__.py b/src/smopt/solver/__init__.py index fb4867c..2df925e 100644 --- a/src/smopt/solver/__init__.py +++ b/src/smopt/solver/__init__.py @@ -1,7 +1,7 @@ """Solvers for optimization over the Stiefel manifold.""" -from .pencf import PenCF -from .slpg import SLPG, SLPG_l21, SLPG_smooth +from .pencf import pencf +from .slpg import slpg, slpg_l21, slpg_smooth -__all__: list[str] = ["SLPG", "PenCF", "SLPG_l21", "SLPG_smooth"] +__all__: list[str] = ["pencf", "slpg", "slpg_l21", "slpg_smooth"] diff --git a/src/smopt/solver/pencf.py b/src/smopt/solver/pencf.py index 79adfb3..d64f85a 100644 --- a/src/smopt/solver/pencf.py +++ b/src/smopt/solver/pencf.py @@ -22,8 +22,8 @@ _AUTO_BETA = -1.0 -def PenCF( # noqa: N802 - Xinit: Matrix, # noqa: N803 +def pencf( + xinit: Matrix, obj_fun: ObjFun, manifold: Stiefel, beta: float | None = None, @@ -35,14 +35,14 @@ def PenCF( # noqa: N802 ) -> tuple[Matrix, dict[str, object]]: r"""Minimize a smooth objective with a constraint dissolving penalty. - The search direction adds ``beta JC(X, C(X))`` to the projected + The search direction adds ``beta jc(x, c(x))`` to the projected gradient, and feasibility is restored only once the iterate has drifted appreciably off the manifold. Args: - Xinit: Starting point. A random feasible point is drawn when it + xinit: Starting point. A random feasible point is drawn when it is ``None``. - obj_fun: Callable mapping ``X`` to ``(fval, grad)``, where + obj_fun: Callable mapping ``x`` to ``(fval, grad)``, where ``grad`` is the Euclidean gradient. manifold: The :class:`~smopt.manifold.Stiefel` instance fixing the dimensions. @@ -63,28 +63,28 @@ def PenCF( # noqa: N802 Examples: >>> import numpy as np - >>> from smopt import PenCF, Stiefel - >>> M = Stiefel(6, 2) - >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) - >>> def obj(X): - ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) - >>> X0 = np.arange(12.0).reshape(6, 2) - >>> X, out = PenCF(X0, obj, M, verbosity=0) - >>> bool(M.Feas_eval(X) < 1e-8) + >>> from smopt import Stiefel, pencf + >>> manifold = Stiefel(6, 2) + >>> a = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(x): + ... return float(np.sum(x * (a @ x))), 2.0 * (a @ x) + >>> x0 = np.arange(12.0).reshape(6, 2) + >>> x, out = pencf(x0, obj, manifold, verbosity=0) + >>> bool(manifold.feas_eval(x) < 1e-8) True """ maxit = check_maxit(maxit) n, p = manifold._n, manifold._p - # A caller supplied starting point is used as given; only the - # default one is drawn and orthonormalized. - x0 = ( - manifold.Init_point() - if Xinit is None - else as_matrix(Xinit, n, p, "Xinit") + # A caller supplied starting point is used exactly as given; only + # the default one is drawn and orthonormalized. + start = ( + manifold.init_point() + if xinit is None + else as_matrix(xinit, n, p, "xinit") ) x, nit, fvals, kkts, feasv, fval, kkt, fea, betout = _smopt.smpcf( - x0, + start, _AUTO_BETA if beta is None else float(beta), maxit, gtol, @@ -97,4 +97,4 @@ def PenCF( # noqa: N802 return x, out -__all__: list[str] = ["PenCF"] +__all__: list[str] = ["pencf"] diff --git a/src/smopt/solver/slpg.py b/src/smopt/solver/slpg.py index 7f12863..87d4055 100644 --- a/src/smopt/solver/slpg.py +++ b/src/smopt/solver/slpg.py @@ -27,10 +27,28 @@ _PROX_PERIOD = 50 -def SLPG_smooth( # noqa: N802 +def _start(manifold: Stiefel, xinit: Matrix | None) -> Matrix: + """Resolve the starting point. + + A caller supplied one is used exactly as given; only the default is + drawn and orthonormalized. + + Args: + manifold: The manifold fixing the dimensions. + xinit: The caller's starting point, or ``None``. + + Returns: + An ``(n, p)`` matrix to start from. + """ + if xinit is None: + return manifold.init_point() + return as_matrix(xinit, manifold._n, manifold._p, "xinit") + + +def slpg_smooth( obj_fun: ObjFun, manifold: Stiefel, - Xinit: Matrix | None = None, # noqa: N803 + xinit: Matrix | None = None, maxit: int = 100, gtol: float = 1e-5, post_process: bool = True, # noqa: FBT001, FBT002 @@ -40,12 +58,12 @@ def SLPG_smooth( # noqa: N802 r"""Minimize a smooth objective over the Stiefel manifold. Args: - obj_fun: Callable mapping ``X`` to ``(fval, grad)``, where + obj_fun: Callable mapping ``x`` to ``(fval, grad)``, where ``grad`` is the Euclidean gradient. Returning both at once is usually much cheaper than computing them separately. manifold: The :class:`~smopt.manifold.Stiefel` instance fixing the dimensions. - Xinit: Starting point. A random feasible point is drawn when it + xinit: Starting point. A random feasible point is drawn when it is omitted. maxit: Maximum number of iterations. gtol: Stationarity tolerance that stops the iteration. @@ -62,28 +80,21 @@ def SLPG_smooth( # noqa: N802 Examples: >>> import numpy as np - >>> from smopt import SLPG_smooth, Stiefel - >>> M = Stiefel(6, 2) - >>> A = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) - >>> def obj(X): - ... return float(np.sum(X * (A @ X))), 2.0 * (A @ X) - >>> X0 = np.arange(12.0).reshape(6, 2) - >>> X, out = SLPG_smooth(obj, M, Xinit=X0, verbosity=0) - >>> bool(M.Feas_eval(X) < 1e-8) + >>> from smopt import Stiefel, slpg_smooth + >>> manifold = Stiefel(6, 2) + >>> a = np.diag([5.0, 4.0, 3.0, 2.0, 1.0, 0.0]) + >>> def obj(x): + ... return float(np.sum(x * (a @ x))), 2.0 * (a @ x) + >>> x0 = np.arange(12.0).reshape(6, 2) + >>> x, out = slpg_smooth(obj, manifold, xinit=x0, verbosity=0) + >>> bool(manifold.feas_eval(x) < 1e-8) True """ maxit = check_maxit(maxit) n, p = manifold._n, manifold._p - # A caller supplied starting point is used as given; only the - # default one is drawn and orthonormalized. - x0 = ( - manifold.Init_point() - if Xinit is None - else as_matrix(Xinit, n, p, "Xinit") - ) x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smslps( - x0, + _start(manifold, xinit), maxit, gtol, int(post_process), @@ -93,10 +104,10 @@ def SLPG_smooth( # noqa: N802 return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) -def SLPG( # noqa: N802 +def slpg( obj_fun: ObjFun, manifold: Stiefel, - Xinit: Matrix | None = None, # noqa: N803 + xinit: Matrix | None = None, maxit: int = 100, prox: Prox | None = None, gtol: float = 1e-5, @@ -104,7 +115,7 @@ def SLPG( # noqa: N802 verbosity: int = 2, **kwargs: Any, # noqa: ANN401 ) -> tuple[Matrix, dict[str, object]]: - r"""Minimize ``f(X) + r(X)`` over the Stiefel manifold. + r"""Minimize ``f(x) + r(x)`` over the Stiefel manifold. The regularizer ``r`` is reached only through its proximal operator. The multiplier of the orthogonality constraint is tracked @@ -112,15 +123,15 @@ def SLPG( # noqa: N802 be tuned. Args: - obj_fun: Callable mapping ``X`` to ``(fval, grad)`` for the + obj_fun: Callable mapping ``x`` to ``(fval, grad)`` for the smooth part ``f``. manifold: The :class:`~smopt.manifold.Stiefel` instance fixing the dimensions. - Xinit: Starting point. A random feasible point is drawn when it + xinit: Starting point. A random feasible point is drawn when it is omitted. maxit: Maximum number of iterations. - prox: Callable mapping ``(X, eta)`` to the minimizer of - ``||Y - X||_F^2 / (2 eta) + r(Y)``. Defaults to the identity, + prox: Callable mapping ``(x, eta)`` to the minimizer of + ``||y - x||_F^2 / (2 eta) + r(y)``. Defaults to the identity, which recovers the smooth case. gtol: Stationarity tolerance that stops the iteration. post_process: Whether to round the final iterate onto the @@ -136,13 +147,6 @@ def SLPG( # noqa: N802 """ maxit = check_maxit(maxit) n, p = manifold._n, manifold._p - # A caller supplied starting point is used as given; only the - # default one is drawn and orthonormalized. - x0 = ( - manifold.Init_point() - if Xinit is None - else as_matrix(Xinit, n, p, "Xinit") - ) if prox is None: @@ -150,7 +154,7 @@ def prox(x: Matrix, eta: float) -> Matrix: return x x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smslpg( - x0, + _start(manifold, xinit), maxit, gtol, int(post_process), @@ -161,10 +165,10 @@ def prox(x: Matrix, eta: float) -> Matrix: return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) -def SLPG_l21( # noqa: N802 +def slpg_l21( obj_fun: ObjFun, manifold: Stiefel, - Xinit: Matrix | None = None, # noqa: N803 + xinit: Matrix | None = None, maxit: int = 100, gamma: float = 0, gtol: float = 1e-5, @@ -172,18 +176,18 @@ def SLPG_l21( # noqa: N802 verbosity: int = 2, **kwargs: Any, # noqa: ANN401 ) -> tuple[Matrix, dict[str, object]]: - r"""Minimize ``f(X) + gamma ||X||_{2,1}`` over the Stiefel manifold. + r"""Minimize ``f(x) + gamma ||x||_{2,1}`` over the Stiefel manifold. The row-sparsity inducing :math:`\ell_{2,1}` norm has a proximal operator and a constraint multiplier available in closed form, so this driver needs no inner iteration. Args: - obj_fun: Callable mapping ``X`` to ``(fval, grad)`` for the + obj_fun: Callable mapping ``x`` to ``(fval, grad)`` for the smooth part ``f``. manifold: The :class:`~smopt.manifold.Stiefel` instance fixing the dimensions. - Xinit: Starting point. A random feasible point is drawn when it + xinit: Starting point. A random feasible point is drawn when it is omitted. maxit: Maximum number of iterations. gamma: Weight of the regularization term. @@ -201,16 +205,9 @@ def SLPG_l21( # noqa: N802 """ maxit = check_maxit(maxit) n, p = manifold._n, manifold._p - # A caller supplied starting point is used as given; only the - # default one is drawn and orthonormalized. - x0 = ( - manifold.Init_point() - if Xinit is None - else as_matrix(Xinit, n, p, "Xinit") - ) x, nit, fvals, kkts, feasv, fval, kkt, fea = _smopt.smsl21( - x0, + _start(manifold, xinit), maxit, gamma, gtol, @@ -221,4 +218,4 @@ def SLPG_l21( # noqa: N802 return x, output_dict(nit, fvals, kkts, feasv, fval, kkt, fea) -__all__: list[str] = ["SLPG", "SLPG_l21", "SLPG_smooth"] +__all__: list[str] = ["slpg", "slpg_l21", "slpg_smooth"] diff --git a/src/smopt/utility/utility.py b/src/smopt/utility/utility.py index 418467c..2088875 100644 --- a/src/smopt/utility/utility.py +++ b/src/smopt/utility/utility.py @@ -1,12 +1,12 @@ -"""Proximal operators of the regularizers SMOPT supports. +r"""Proximal operators of the regularizers SMOPT supports. -The proximal operator of a function :math:`r` at :math:`X` with step -:math:`\\eta` is the minimizer of +The proximal operator of a function :math:`r` at :math:`x` with step +:math:`\eta` is the minimizer of -.. math:: \\frac{1}{2\\eta} \\|Y - X\\|_F^2 + r(Y). +.. math:: \frac{1}{2\eta} \|y - x\|_F^2 + r(y). Both operators below are evaluated by the Fortran 77 core, and both are -shaped so they can be handed straight to :func:`~smopt.solver.SLPG`. +shaped so they can be handed straight to :func:`~smopt.solver.slpg`. """ from .. import _smopt @@ -17,11 +17,11 @@ _EPS = 1e-14 -def prox_l1(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: N803 - r"""Proximal operator of :math:`\gamma \|X\|_1`. +def prox_l1(x: Matrix, eta: float, gamma: float = 0) -> Matrix: + r"""Proximal operator of :math:`\gamma \|x\|_1`. Args: - X_input: The point at which to evaluate the operator. + x: The point at which to evaluate the operator. eta: The proximal step size. gamma: Weight of the regularization term. @@ -31,22 +31,22 @@ def prox_l1(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: N Examples: >>> import numpy as np >>> from smopt import prox_l1 - >>> Y = prox_l1(np.array([[-3.0, 0.5, 2.0]]), 1.0, gamma=1.0) - >>> bool(np.allclose(Y, [[-2.0, 0.0, 1.0]])) + >>> y = prox_l1(np.array([[-3.0, 0.5, 2.0]]), 1.0, gamma=1.0) + >>> bool(np.allclose(y, [[-2.0, 0.0, 1.0]])) True """ - return _smopt.smpl1(X_input, eta, gamma) + return _smopt.smpl1(x, eta, gamma) -def prox_l21(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: N803 - r"""Proximal operator of :math:`\gamma \|X\|_{2,1}`. +def prox_l21(x: Matrix, eta: float, gamma: float = 0) -> Matrix: + r"""Proximal operator of :math:`\gamma \|x\|_{2,1}`. The :math:`\ell_{2,1}` norm sums the Euclidean norms of the rows of - ``X``, so the operator shrinks whole rows towards the origin and + ``x``, so the operator shrinks whole rows towards the origin and induces row sparsity. Args: - X_input: The point at which to evaluate the operator. + x: The point at which to evaluate the operator. eta: The proximal step size. gamma: Weight of the regularization term. @@ -56,15 +56,15 @@ def prox_l21(X_input: Matrix, eta: float, gamma: float = 0) -> Matrix: # noqa: Examples: >>> import numpy as np >>> from smopt import prox_l21 - >>> X = np.array([[3.0, 4.0], [0.3, 0.4]]) + >>> x = np.array([[3.0, 4.0], [0.3, 0.4]]) >>> bool( ... np.allclose( - ... prox_l21(X, 1.0, gamma=1.0), [[2.4, 3.2], [0.0, 0.0]] + ... prox_l21(x, 1.0, gamma=1.0), [[2.4, 3.2], [0.0, 0.0]] ... ) ... ) True """ - return _smopt.smpl21(X_input, eta, gamma, _EPS) + return _smopt.smpl21(x, eta, gamma, _EPS) __all__: list[str] = ["prox_l1", "prox_l21"] diff --git a/tests/reference.py b/tests/reference.py index ed1dd56..76ef608 100644 --- a/tests/reference.py +++ b/tests/reference.py @@ -5,9 +5,12 @@ checked against it, and it is deliberately kept free of the validation, packaging and reporting concerns that the shipped package handles. -The single intentional departure is :meth:`Stiefel.Init_point`, whose -``Xinit == None`` test raises on array input; it is spelled ``is None`` -here so the reference can be driven from the tests. +Names follow the conventions of this project rather than those of the +original, so the oracle and the package under test can be driven through +the same attribute lookups. The one behavioural departure is +:meth:`Stiefel.init_point`, whose ``xinit == None`` test raises on array +input; it is spelled ``is None`` here so the reference can be driven from +the tests. """ import numpy as np @@ -22,47 +25,47 @@ def __init__(self, n, p): self._p = p self.dim = n * p - def Phi(self, M): - return (M + M.T) / 2 + def phi(self, m): + return (m + m.T) / 2 - def A(self, X): - XX = X.T @ X - feas_tmp = norm(XX - np.eye(self._p), "fro") + def a(self, x): + xx = x.T @ x + feas_tmp = norm(xx - np.eye(self._p), "fro") if feas_tmp < 0.5: - return 1.5 * X - X @ (XX / 2) - return np.linalg.solve((XX + np.eye(self._p)) / 2, X.T).T + return 1.5 * x - x @ (xx / 2) + return np.linalg.solve((xx + np.eye(self._p)) / 2, x.T).T - def JA(self, X, G): - return G - X @ self.Phi(X.T @ G) + def ja(self, x, g): + return g - x @ self.phi(x.T @ g) - def JC(self, X, Lambda): - return X @ self.Phi(Lambda) + def jc(self, x, lam): + return x @ self.phi(lam) - def JC_transpose(self, X, D): - return self.Phi(X.T @ D) + def jc_transpose(self, x, d): + return self.phi(x.T @ d) - def C(self, X): - return X.T @ X - np.eye(self._p) + def c(self, x): + return x.T @ x - np.eye(self._p) - def Feas_eval(self, X): - return norm(self.C(X), "fro") + def feas_eval(self, x): + return norm(self.c(x), "fro") - def Init_point(self, Xinit=None): - if Xinit is None: - Xinit = np.random.randn(self._n, self._p) - if norm(Xinit.T @ Xinit - np.eye(self._p), "fro") > 1e-6: - Xinit, _ = np.linalg.qr(Xinit) - return Xinit + def init_point(self, xinit=None): + if xinit is None: + xinit = np.random.randn(self._n, self._p) + if norm(xinit.T @ xinit - np.eye(self._p), "fro") > 1e-6: + xinit, _ = np.linalg.qr(xinit) + return xinit - def Post_process(self, X): - UX, _, VX = svd(X, full_matrices=False) - return UX @ VX + def post_process(self, x): + ux, _, vx = svd(x, full_matrices=False) + return ux @ vx -def SLPG_smooth( +def slpg_smooth( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, gtol=1e-5, post_process=True, @@ -71,34 +74,34 @@ def SLPG_smooth( """Reference SLPG for a smooth objective.""" kkts, feas, fvals = [], [], [] - if Xinit is None: - Xinit = manifold.Init_point() + if xinit is None: + xinit = manifold.init_point() - X = Xinit - fval, gradf = obj_fun(X) - gradr = manifold.JA(X, gradf) - L = norm(gradf, "fro") + norm(gradr, "fro") + x = xinit + fval, gradf = obj_fun(x) + gradr = manifold.ja(x, gradf) + lip = norm(gradf, "fro") + norm(gradr, "fro") - S = Y = None + s = y = None for jj in range(maxit): if jj < 3: - stepsize = 0.01 / L + stepsize = 0.01 / lip else: - stepsize = np.abs(np.sum(S * Y) / np.sum(Y * Y)) + stepsize = np.abs(np.sum(s * y) / np.sum(y * y)) stepsize = np.min((stepsize, 1e10)) - X_p = X - X = X - stepsize * gradr - X = manifold.A(X) - S = X - X_p + x_p = x + x = x - stepsize * gradr + x = manifold.a(x) + s = x - x_p - fval, gradf = obj_fun(X) + fval, gradf = obj_fun(x) gradr_p = gradr - gradr = manifold.JA(X, gradf) - Y = gradr - gradr_p + gradr = manifold.ja(x, gradf) + y = gradr - gradr_p substationarity = norm(gradr, "fro") - feasibility = manifold.Feas_eval(X) + feasibility = manifold.feas_eval(x) kkts.append(substationarity) feas.append(feasibility) @@ -108,16 +111,16 @@ def SLPG_smooth( break if post_process: - X = manifold.Post_process(X) - fval, gradf = obj_fun(X) - gradr = manifold.JA(X, gradf) + x = manifold.post_process(x) + fval, gradf = obj_fun(x) + gradr = manifold.ja(x, gradf) substationarity = norm(gradr, "fro") - feasibility = manifold.Feas_eval(X) + feasibility = manifold.feas_eval(x) kkts[-1] = substationarity feas[-1] = feasibility fvals[-1] = fval - return X, { + return x, { "kkts": kkts, "fvals": fvals, "fea": feasibility, @@ -127,29 +130,29 @@ def SLPG_smooth( } -def Arrow_Hurwicz_SLPG(X, G, eta, prox, Lambda, manifold, tol=0): +def arrow_hurwicz_slpg(x, g, eta, prox, lam, manifold, tol=0): """Reference Arrow-Hurwicz multiplier update.""" - Lambda_temp = Lambda + lam_temp = lam try_stepsize = eta - Z_tmp = X - try_stepsize * G + z_tmp = x - try_stepsize * g for _ in range(5): - X_try = prox( - Z_tmp - try_stepsize * manifold.JC(X, Lambda_temp), try_stepsize + x_try = prox( + z_tmp - try_stepsize * manifold.jc(x, lam_temp), try_stepsize ) - D_X = 1 / try_stepsize * (X_try - X) - Lambda_inc = manifold.JC_transpose(X, D_X) - Lambda_temp = Lambda_temp + Lambda_inc - if norm(Lambda_inc, "fro") < tol: + d_x = 1 / try_stepsize * (x_try - x) + lam_inc = manifold.jc_transpose(x, d_x) + lam_temp = lam_temp + lam_inc + if norm(lam_inc, "fro") < tol: break - return Lambda_temp + return lam_temp -def SLPG( +def slpg( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, - prox=lambda X, eta: X, + prox=lambda x, eta: x, gtol=1e-5, post_process=True, verbosity=0, @@ -157,50 +160,52 @@ def SLPG( """Reference SLPG for a proximable regularizer.""" kkts, feas, fvals, steps = [], [], [], [] - if Xinit is None: - Xinit = manifold.Init_point() + if xinit is None: + xinit = manifold.init_point() p = manifold._p - X = Xinit - fval, gradf = obj_fun(X) - gradr = manifold.JA(X, gradf) - L = norm(gradf, "fro") + norm(gradr, "fro") + x = xinit + fval, gradf = obj_fun(x) + gradr = manifold.ja(x, gradf) + lip = norm(gradf, "fro") + norm(gradr, "fro") - Lambda_r = np.zeros([p, p]) - Lambda_r = Arrow_Hurwicz_SLPG(X, gradr, 0.01 / L, prox, Lambda_r, manifold) - Grad = gradr + manifold.JC(X, Lambda_r) + lam = np.zeros([p, p]) + lam = arrow_hurwicz_slpg(x, gradr, 0.01 / lip, prox, lam, manifold) + grad = gradr + manifold.jc(x, lam) - S = Y = None + s = y = None for jj in range(maxit): if jj < 5: - stepsize = 0.01 / L + stepsize = 0.01 / lip else: - stepsize = np.abs(np.sum(S * S) / np.sum(S * Y)) + stepsize = np.abs(np.sum(s * s) / np.sum(s * y)) stepsize = np.min((stepsize, 1e10)) - X_p = X + x_p = x steps.append(stepsize) - X = prox(X - stepsize * (gradr + manifold.JC(X, Lambda_r)), stepsize) - X = manifold.A(X) - S = X - X_p + x = prox(x - stepsize * (gradr + manifold.jc(x, lam)), stepsize) + x = manifold.a(x) + s = x - x_p - fval, gradf = obj_fun(X) - Grad_p = Grad - gradr = manifold.JA(X, gradf) + fval, gradf = obj_fun(x) + grad_p = grad + gradr = manifold.ja(x, gradf) stepsize_try = np.average(steps[np.maximum(0, jj - 10) :]) - stepsize_try = np.minimum(np.maximum(stepsize_try, 1e-5 / L), 1e10 / L) + stepsize_try = np.minimum( + np.maximum(stepsize_try, 1e-5 / lip), 1e10 / lip + ) - tol_AW = 1000 * manifold.Feas_eval(X) - Lambda_r = Arrow_Hurwicz_SLPG( - X, gradr, stepsize_try, prox, Lambda_r, manifold, tol=tol_AW + tol_aw = 1000 * manifold.feas_eval(x) + lam = arrow_hurwicz_slpg( + x, gradr, stepsize_try, prox, lam, manifold, tol=tol_aw ) - Grad = gradr + manifold.JC(X, Lambda_r) - Y = Grad - Grad_p + grad = gradr + manifold.jc(x, lam) + y = grad - grad_p - substationarity = norm(S / stepsize, "fro") - feasibility = manifold.Feas_eval(X) + substationarity = norm(s / stepsize, "fro") + feasibility = manifold.feas_eval(x) kkts.append(substationarity) feas.append(feasibility) @@ -210,14 +215,14 @@ def SLPG( break if post_process: - X = manifold.Post_process(X) - fval, gradf = obj_fun(X) - feasibility = manifold.Feas_eval(X) + x = manifold.post_process(x) + fval, gradf = obj_fun(x) + feasibility = manifold.feas_eval(x) kkts[-1] = substationarity feas[-1] = feasibility fvals[-1] = fval - return X, { + return x, { "kkts": kkts, "fvals": fvals, "fea": feasibility, @@ -227,10 +232,10 @@ def SLPG( } -def SLPG_l21( +def slpg_l21( obj_fun, manifold, - Xinit=None, + xinit=None, maxit=100, gamma=0, gtol=1e-5, @@ -239,51 +244,51 @@ def SLPG_l21( ): """Reference SLPG for the l_{2,1} regularized objective.""" - def prox(X_input, eta): - X_ref = np.sqrt(np.sum(X_input**2, axis=1, keepdims=True)) - X_ref_reduce = np.maximum(X_ref - gamma * eta, 0) - return (X_ref_reduce / (X_ref + 1e-16)) * X_input + def prox(x_input, eta): + x_ref = np.sqrt(np.sum(x_input**2, axis=1, keepdims=True)) + x_ref_reduce = np.maximum(x_ref - gamma * eta, 0) + return (x_ref_reduce / (x_ref + 1e-16)) * x_input - def generate_Lambda_r(X_input): - X_ref = 1 / (1e-14 + np.sqrt(np.sum(X_input**2, axis=1, keepdims=True))) - return -X_input.T @ (X_ref * X_input) + def generate_lam(x_input): + x_ref = 1 / (1e-14 + np.sqrt(np.sum(x_input**2, axis=1, keepdims=True))) + return -x_input.T @ (x_ref * x_input) kkts, feas, fvals = [], [], [] - if Xinit is None: - Xinit = manifold.Init_point() + if xinit is None: + xinit = manifold.init_point() - X = Xinit - fval, gradf = obj_fun(X) - gradr = manifold.JA(X, gradf) - L = norm(gradf, "fro") + norm(gradr, "fro") + x = xinit + fval, gradf = obj_fun(x) + gradr = manifold.ja(x, gradf) + lip = norm(gradf, "fro") + norm(gradr, "fro") - Lambda_r = gamma * generate_Lambda_r(X) - Grad = gradr + manifold.JC(X, Lambda_r) + lam = gamma * generate_lam(x) + grad = gradr + manifold.jc(x, lam) - S = Y = None + s = y = None for jj in range(maxit): if jj < 5: - stepsize = 0.001 / L + stepsize = 0.001 / lip else: - stepsize = np.abs(np.sum(S * S) / np.sum(S * Y)) + stepsize = np.abs(np.sum(s * s) / np.sum(s * y)) stepsize = np.min((stepsize, 1e5)) - X_p = X - X = prox(X - stepsize * Grad, stepsize) - X = manifold.A(X) - S = X - X_p + x_p = x + x = prox(x - stepsize * grad, stepsize) + x = manifold.a(x) + s = x - x_p - fval, gradf = obj_fun(X) - Grad_p = Grad - gradr = manifold.JA(X, gradf) + fval, gradf = obj_fun(x) + grad_p = grad + gradr = manifold.ja(x, gradf) - Lambda_r = gamma * generate_Lambda_r(X) - Grad = gradr + manifold.JC(X, Lambda_r) - Y = Grad - Grad_p + lam = gamma * generate_lam(x) + grad = gradr + manifold.jc(x, lam) + y = grad - grad_p - substationarity = norm(S / stepsize, "fro") - feasibility = manifold.Feas_eval(X) + substationarity = norm(s / stepsize, "fro") + feasibility = manifold.feas_eval(x) kkts.append(substationarity) feas.append(feasibility) @@ -293,14 +298,14 @@ def generate_Lambda_r(X_input): break if post_process: - X = manifold.Post_process(X) - fval, gradf = obj_fun(X) - feasibility = manifold.Feas_eval(X) + x = manifold.post_process(x) + fval, gradf = obj_fun(x) + feasibility = manifold.feas_eval(x) kkts[-1] = substationarity feas[-1] = feasibility fvals[-1] = fval - return X, { + return x, { "kkts": kkts, "fvals": fvals, "fea": feasibility, @@ -310,8 +315,8 @@ def generate_Lambda_r(X_input): } -def PenCF( - Xinit, +def pencf( + xinit, obj_fun, manifold, beta=None, @@ -324,46 +329,46 @@ def PenCF( kkts, feas, fvals = [], [], [] p = manifold._p - X = Xinit - fval, gradf = obj_fun(X) + x = xinit + fval, gradf = obj_fun(x) if beta is None: beta = 0.1 * norm(gradf, "fro") - gradr = manifold.JA(X, gradf) + beta * manifold.JC(X, manifold.C(X)) - L = norm(gradf, "fro") + norm(gradr, "fro") + gradr = manifold.ja(x, gradf) + beta * manifold.jc(x, manifold.c(x)) + lip = norm(gradf, "fro") + norm(gradr, "fro") - S = Y = None + s = y = None for jj in range(maxit): if jj < 3: - stepsize = 0.01 / L + stepsize = 0.01 / lip else: - stepsize = np.abs(np.sum(S * Y) / np.sum(Y * Y)) + stepsize = np.abs(np.sum(s * y) / np.sum(y * y)) stepsize = np.min((stepsize, 1e10)) - X_p = X - X = X - stepsize * gradr + x_p = x + x = x - stepsize * gradr - XX = X.T @ X - feas_tmp = manifold.Feas_eval(X) + xx = x.T @ x + feas_tmp = manifold.feas_eval(x) if feas_tmp > 1e-1: if feas_tmp < 0.5: - X = 1.5 * X - X @ (XX / 2) + x = 1.5 * x - x @ (xx / 2) else: - X = np.linalg.solve((XX + np.eye(p)) / 2, X.T).T + x = np.linalg.solve((xx + np.eye(p)) / 2, x.T).T - if norm(X, "fro") > 1.001 * np.sqrt(p): - X = X * (1.001 * np.sqrt(p) / norm(X, "fro")) + if norm(x, "fro") > 1.001 * np.sqrt(p): + x = x * (1.001 * np.sqrt(p) / norm(x, "fro")) - S = X - X_p + s = x - x_p - fval, gradf = obj_fun(X) + fval, gradf = obj_fun(x) gradr_p = gradr - gradr = manifold.JA(X, gradf) + beta * manifold.JC(X, manifold.C(X)) - Y = gradr - gradr_p + gradr = manifold.ja(x, gradf) + beta * manifold.jc(x, manifold.c(x)) + y = gradr - gradr_p substationarity = norm(gradr, "fro") - feasibility = manifold.Feas_eval(X) + feasibility = manifold.feas_eval(x) kkts.append(substationarity) feas.append(feasibility) @@ -373,16 +378,16 @@ def PenCF( break if post_process: - X = manifold.Post_process(X) - fval, gradf = obj_fun(X) - gradr = manifold.JA(X, gradf) + x = manifold.post_process(x) + fval, gradf = obj_fun(x) + gradr = manifold.ja(x, gradf) substationarity = norm(gradr, "fro") - feasibility = manifold.Feas_eval(X) + feasibility = manifold.feas_eval(x) kkts[-1] = substationarity feas[-1] = feasibility fvals[-1] = fval - return X, { + return x, { "kkts": kkts, "fvals": fvals, "fea": feasibility, @@ -392,15 +397,15 @@ def PenCF( } -def prox_l1(X_input, eta, gamma=0): +def prox_l1(x_input, eta, gamma=0): """Reference proximal operator of the l_1 norm.""" - return np.maximum(X_input - gamma * eta, 0) + np.minimum( - X_input + gamma * eta, 0 + return np.maximum(x_input - gamma * eta, 0) + np.minimum( + x_input + gamma * eta, 0 ) -def prox_l21(X_input, eta, gamma=0): +def prox_l21(x_input, eta, gamma=0): """Reference proximal operator of the l_{2,1} norm.""" - X_ref = np.sqrt(np.sum(X_input**2, axis=1, keepdims=True)) - X_ref_reduce = np.maximum(X_ref - gamma * eta, 0) - return X_ref_reduce / (X_ref + 1e-14) * X_input + x_ref = np.sqrt(np.sum(x_input**2, axis=1, keepdims=True)) + x_ref_reduce = np.maximum(x_ref - gamma * eta, 0) + return x_ref_reduce / (x_ref + 1e-14) * x_input diff --git a/tests/test_manifold.py b/tests/test_manifold.py index 30795e2..49f9143 100644 --- a/tests/test_manifold.py +++ b/tests/test_manifold.py @@ -15,8 +15,8 @@ def test_phi_symmetrizes(rng: np.random.Generator, n: int, p: int) -> None: m = smopt.Stiefel(n, p) a = rng.standard_normal((p, p)) - got = m.Phi(a) - assert np.allclose(got, reference.Stiefel(n, p).Phi(a)) + got = m.phi(a) + assert np.allclose(got, reference.Stiefel(n, p).phi(a)) assert np.allclose(got, got.T) @@ -27,7 +27,7 @@ def test_phi_does_not_mutate_input( m = smopt.Stiefel(n, p) a = rng.standard_normal((p, p)) before = a.copy() - m.Phi(a) + m.phi(a) assert np.array_equal(a, before) @@ -37,8 +37,8 @@ def test_constraint_and_feasibility( ) -> None: m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) x = rng.standard_normal((n, p)) - assert np.allclose(m.C(x), ref.C(x)) - assert m.Feas_eval(x) == pytest.approx(ref.Feas_eval(x)) + assert np.allclose(m.c(x), ref.c(x)) + assert m.feas_eval(x) == pytest.approx(ref.feas_eval(x)) @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -46,7 +46,7 @@ def test_feasibility_vanishes_on_the_manifold( rng: np.random.Generator, n: int, p: int ) -> None: m = smopt.Stiefel(n, p) - assert m.Feas_eval(orthonormal(rng, n, p)) < 1e-12 + assert m.feas_eval(orthonormal(rng, n, p)) < 1e-12 @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -57,9 +57,9 @@ def test_jacobians(rng: np.random.Generator, n: int, p: int) -> None: d = rng.standard_normal((n, p)) lam = rng.standard_normal((p, p)) - assert np.allclose(m.JA(x, g), ref.JA(x, g)) - assert np.allclose(m.JC(x, lam), ref.JC(x, lam)) - assert np.allclose(m.JC_transpose(x, d), ref.JC_transpose(x, d)) + assert np.allclose(m.ja(x, g), ref.ja(x, g)) + assert np.allclose(m.jc(x, lam), ref.jc(x, lam)) + assert np.allclose(m.jc_transpose(x, d), ref.jc_transpose(x, d)) @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -68,7 +68,7 @@ def test_ja_output_is_tangent(rng: np.random.Generator, n: int, p: int) -> None: m = smopt.Stiefel(n, p) x = orthonormal(rng, n, p) g = rng.standard_normal((n, p)) - r = m.JA(x, g) + r = m.ja(x, g) xtr = x.T @ r assert np.allclose(xtr, -xtr.T, atol=1e-10) @@ -81,7 +81,7 @@ def test_a_map_matches_reference( """Both the near-manifold expansion and the exact solve branch.""" m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) x = orthonormal(rng, n, p) + scale * rng.standard_normal((n, p)) - assert np.allclose(m.A(x), ref.A(x), atol=1e-10) + assert np.allclose(m.a(x), ref.a(x), atol=1e-10) @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -90,7 +90,7 @@ def test_a_map_improves_feasibility( ) -> None: m = smopt.Stiefel(n, p) x = orthonormal(rng, n, p) + 0.05 * rng.standard_normal((n, p)) - assert m.Feas_eval(m.A(x)) < m.Feas_eval(x) + assert m.feas_eval(m.a(x)) < m.feas_eval(x) @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -99,9 +99,9 @@ def test_post_process_is_the_polar_factor( ) -> None: m, ref = smopt.Stiefel(n, p), reference.Stiefel(n, p) x = rng.standard_normal((n, p)) - got = m.Post_process(x) - assert np.allclose(got, ref.Post_process(x), atol=1e-9) - assert m.Feas_eval(got) < 1e-12 + got = m.post_process(x) + assert np.allclose(got, ref.post_process(x), atol=1e-9) + assert m.feas_eval(got) < 1e-12 @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -109,8 +109,8 @@ def test_init_point_lands_on_the_manifold( rng: np.random.Generator, n: int, p: int ) -> None: m = smopt.Stiefel(n, p) - assert m.Feas_eval(m.Init_point(rng.standard_normal((n, p)))) < 1e-12 - assert m.Feas_eval(m.Init_point()) < 1e-12 + assert m.feas_eval(m.init_point(rng.standard_normal((n, p)))) < 1e-12 + assert m.feas_eval(m.init_point()) < 1e-12 @pytest.mark.parametrize(("n", "p"), SHAPES) @@ -119,7 +119,7 @@ def test_init_point_keeps_a_feasible_argument( ) -> None: m = smopt.Stiefel(n, p) x = orthonormal(rng, n, p) - assert np.allclose(m.Init_point(x), x) + assert np.allclose(m.init_point(x), x) def test_rejects_bad_dimensions() -> None: @@ -132,9 +132,9 @@ def test_rejects_bad_dimensions() -> None: def test_rejects_mismatched_shapes(rng: np.random.Generator) -> None: m = smopt.Stiefel(6, 2) with pytest.raises(ValueError, match="must have shape"): - m.C(rng.standard_normal((6, 3))) + m.c(rng.standard_normal((6, 3))) with pytest.raises(ValueError, match="must have shape"): - m.JC(orthonormal(rng, 6, 2), rng.standard_normal((3, 3))) + m.jc(orthonormal(rng, 6, 2), rng.standard_normal((3, 3))) def test_dim_attribute() -> None: @@ -156,10 +156,10 @@ def test_post_process_completes_a_rank_deficient_point( if rank: x[:, :rank] = orthonormal(rng, n, rank) - got = m.Post_process(x) + got = m.post_process(x) assert np.all(np.isfinite(got)) - assert m.Feas_eval(got) < 1e-12 + assert m.feas_eval(got) < 1e-12 # Whatever the completion picks, it must not disturb the directions # the input did pin down. assert np.allclose(got @ got.T @ x, x, atol=1e-10) @@ -167,6 +167,6 @@ def test_post_process_completes_a_rank_deficient_point( def test_post_process_of_a_zero_matrix_is_still_feasible() -> None: m = smopt.Stiefel(5, 2) - got = m.Post_process(np.zeros((5, 2))) + got = m.post_process(np.zeros((5, 2))) assert np.all(np.isfinite(got)) - assert m.Feas_eval(got) < 1e-12 + assert m.feas_eval(got) < 1e-12 diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 75b755a..bfbcd90 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -55,18 +55,18 @@ def test_slpg_smooth_matches_reference( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - got_x, got = smopt.SLPG_smooth( + got_x, got = smopt.slpg_smooth( obj, m, - Xinit=x0.copy(), + xinit=x0.copy(), maxit=TRACE_MAXIT, post_process=post_process, verbosity=0, ) - want_x, want = reference.SLPG_smooth( + want_x, want = reference.slpg_smooth( obj, ref_m, - Xinit=x0.copy(), + xinit=x0.copy(), maxit=TRACE_MAXIT, post_process=post_process, ) @@ -90,11 +90,11 @@ def prox(x: np.ndarray, eta: float) -> np.ndarray: def ref_prox(x: np.ndarray, eta: float) -> np.ndarray: return reference.prox_l1(x, eta, gamma=gamma) - got_x, got = smopt.SLPG( - obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, prox=prox, verbosity=0 + got_x, got = smopt.slpg( + obj, m, xinit=x0.copy(), maxit=TRACE_MAXIT, prox=prox, verbosity=0 ) - want_x, want = reference.SLPG( - obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT, prox=ref_prox + want_x, want = reference.slpg( + obj, ref_m, xinit=x0.copy(), maxit=TRACE_MAXIT, prox=ref_prox ) assert_same_trace(got, want) @@ -110,11 +110,11 @@ def test_slpg_without_a_prox_matches_reference( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - got_x, got = smopt.SLPG( - obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, verbosity=0 + got_x, got = smopt.slpg( + obj, m, xinit=x0.copy(), maxit=TRACE_MAXIT, verbosity=0 ) - want_x, want = reference.SLPG( - obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT + want_x, want = reference.slpg( + obj, ref_m, xinit=x0.copy(), maxit=TRACE_MAXIT ) assert_same_trace(got, want) @@ -130,11 +130,11 @@ def test_slpg_l21_matches_reference( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - got_x, got = smopt.SLPG_l21( - obj, m, Xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma, verbosity=0 + got_x, got = smopt.slpg_l21( + obj, m, xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma, verbosity=0 ) - want_x, want = reference.SLPG_l21( - obj, ref_m, Xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma + want_x, want = reference.slpg_l21( + obj, ref_m, xinit=x0.copy(), maxit=TRACE_MAXIT, gamma=gamma ) assert_same_trace(got, want) @@ -150,10 +150,10 @@ def test_pencf_matches_reference( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - got_x, got = smopt.PenCF( + got_x, got = smopt.pencf( x0.copy(), obj, m, beta=beta, maxit=TRACE_MAXIT, verbosity=0 ) - want_x, want = reference.PenCF( + want_x, want = reference.pencf( x0.copy(), obj, ref_m, beta=beta, maxit=TRACE_MAXIT ) @@ -167,15 +167,15 @@ def test_pencf_reports_the_beta_it_used(rng: np.random.Generator) -> None: obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - _, out = smopt.PenCF(x0.copy(), obj, m, maxit=5, verbosity=0) + _, out = smopt.pencf(x0.copy(), obj, m, maxit=5, verbosity=0) _, grad = obj(x0) assert out["beta"] == pytest.approx(0.1 * np.linalg.norm(grad, "fro")) - _, out = smopt.PenCF(x0.copy(), obj, m, beta=0.0, maxit=5, verbosity=0) + _, out = smopt.pencf(x0.copy(), obj, m, beta=0.0, maxit=5, verbosity=0) assert out["beta"] == pytest.approx(0.0) -SOLVERS = ["SLPG_smooth", "SLPG", "SLPG_l21"] +SOLVERS = ["slpg_smooth", "slpg", "slpg_l21"] @pytest.mark.parametrize("name", SOLVERS) @@ -189,35 +189,35 @@ def test_solvers_reach_the_known_minimum( x0 = orthonormal(rng, n, p) solver = getattr(smopt, name) - x, out = solver(obj, m, Xinit=x0, maxit=3000, gtol=1e-9, verbosity=0) + x, out = solver(obj, m, xinit=x0, maxit=3000, gtol=1e-9, verbosity=0) - assert m.Feas_eval(x) < 1e-10 + assert m.feas_eval(x) < 1e-10 assert out["fval"] == pytest.approx(best, rel=1e-5) -#: PenCF lands near the optimum rather than on it. Holding the problem +#: pencf lands near the optimum rather than on it. Holding the problem #: below fixed and varying only the starting point over 60 draws, the #: relative error has median 7.6e-07 and worst case 1.2e-04; a 150-seed #: sweep over problems too tops out at 1.0e-04, and the NumPy reference #: in tests/reference.py is worse still at 1.7e-04. That is the penalty -#: method, not this port. The SLPG solvers above reach machine precision +#: method, not this port. The slpg solvers above reach machine precision #: on the same instance, hence their much tighter tolerance. PENALTY_RTOL = 1e-3 def test_pencf_reaches_the_known_minimum(rng: np.random.Generator) -> None: - """PenCF converges to the optimum to within a penalty method's reach.""" + """pencf converges to the optimum to within a penalty method's reach.""" n, p = 50, 4 m = smopt.Stiefel(n, p) obj, best = eig_problem(rng, n, p) - x, out = smopt.PenCF( + x, out = smopt.pencf( orthonormal(rng, n, p), obj, m, maxit=3000, gtol=1e-9, verbosity=0 ) # Feasibility is the part the method does guarantee: it holds to # ~3e-15 across every instance measured, so it is asserted tightly. - assert m.Feas_eval(x) < 1e-10 + assert m.feas_eval(x) < 1e-10 assert out["fval"] == pytest.approx(best, rel=PENALTY_RTOL) # A feasible point can never beat the true minimum; catching that # would mean the objective and the constraint had come apart. @@ -232,11 +232,11 @@ def test_l21_regularization_induces_row_sparsity( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - dense, _ = smopt.SLPG_l21( - obj, m, Xinit=x0.copy(), maxit=500, gamma=0.0, verbosity=0 + dense, _ = smopt.slpg_l21( + obj, m, xinit=x0.copy(), maxit=500, gamma=0.0, verbosity=0 ) - sparse, _ = smopt.SLPG_l21( - obj, m, Xinit=x0.copy(), maxit=500, gamma=1.0, verbosity=0 + sparse, _ = smopt.slpg_l21( + obj, m, xinit=x0.copy(), maxit=500, gamma=1.0, verbosity=0 ) def live_rows(x: np.ndarray) -> int: @@ -255,7 +255,7 @@ def test_histories_have_one_entry_per_iteration( solver = getattr(smopt, name) _, out = solver( - obj, m, Xinit=orthonormal(rng, n, p), maxit=7, gtol=0.0, verbosity=0 + obj, m, xinit=orthonormal(rng, n, p), maxit=7, gtol=0.0, verbosity=0 ) assert len(out["fvals"]) == 7 @@ -273,7 +273,7 @@ def test_a_random_start_is_drawn_when_none_is_given( x, _ = getattr(smopt, name)(obj, m, maxit=20, verbosity=0) assert x.shape == (n, p) - assert m.Feas_eval(x) < 1e-10 + assert m.feas_eval(x) < 1e-10 @pytest.mark.parametrize("name", SOLVERS) @@ -294,10 +294,10 @@ def test_verbosity_controls_printing( obj, _ = eig_problem(rng, n, p) x0 = orthonormal(rng, n, p) - smopt.SLPG_smooth(obj, m, Xinit=x0.copy(), maxit=25, verbosity=0) + smopt.slpg_smooth(obj, m, xinit=x0.copy(), maxit=25, verbosity=0) assert capsys.readouterr().out == "" - smopt.SLPG_smooth(obj, m, Xinit=x0.copy(), maxit=25, verbosity=2) + smopt.slpg_smooth(obj, m, xinit=x0.copy(), maxit=25, verbosity=2) out = capsys.readouterr().out assert "Iter:0" in out assert "Iter:20" in out @@ -316,8 +316,8 @@ def obj(x: np.ndarray) -> tuple[float, np.ndarray]: seen.append(x.shape) return float(np.sum(x * x)), 2.0 * x - smopt.SLPG_smooth( - obj, m, Xinit=orthonormal(rng, n, p), maxit=3, verbosity=0 + smopt.slpg_smooth( + obj, m, xinit=orthonormal(rng, n, p), maxit=3, verbosity=0 ) assert seen assert set(seen) == {(n, p)} @@ -335,10 +335,10 @@ def test_l21_stays_feasible_when_it_collapses_the_rank( m = smopt.Stiefel(n, p) obj, _ = eig_problem(rng, n, p) - x, out = smopt.SLPG_l21( - obj, m, Xinit=orthonormal(rng, n, p), maxit=40, gamma=0.1, verbosity=0 + x, out = smopt.slpg_l21( + obj, m, xinit=orthonormal(rng, n, p), maxit=40, gamma=0.1, verbosity=0 ) assert np.all(np.isfinite(x)) - assert m.Feas_eval(x) < 1e-10 + assert m.feas_eval(x) < 1e-10 assert np.isfinite(out["fval"])