The Rayleigh Quotient

Ronny Bergmann 2024-03-09

Introduction

This example reproduces a few conceptual ideas of Optimization on Manifolds that are used throughout [Bou23] using the Rayleigh quotient and explores several different ways to use the algorithms from Manopt.jl.

For a symmetric matrix $A \in \mathbb R^{n\times n}$ we consider the 📖 Rayleigh Quotient

\[\operatorname*{arg\,min}_{x \in \mathbb R^n \backslash \{0\}} \frac{x^{\mathrm{T}}Ax}{\lVert x \rVert^2}.\]

On the sphere we can omit the denominator and obtain

\[f(p) = p^{\mathrm{T}}Ap,\qquad p ∈ 𝕊^{n-1},\]

which by itself we can again continue in the embedding as

\[\tilde f(x) = x^{\mathrm{T}}Ax,\qquad x \in \mathbb R^n.\]

This cost has the nice feature that at the minimizer $p^*\in\mathbb S^{n-1}$ the function falue $f(p^*)$ is the smalles eigenvalue of $A$.

For the embedded function $\tilde f$ the gradient and Hessian can be computed with classical methods as

\[\begin{align*} ∇\tilde f(x) &= 2Ax, \qquad x ∈ ℝ^n, \\ ∇^2\tilde f(x)[V] &= 2AV, \qquad x, V ∈ ℝ^n. \end{align*}\]

Similarly, cf. Examples 3.62 and 5.27 of [Bou23], the Riemannian gradient and Hessian on the manifold $\mathcal M = \mathbb S^{n-1}$ are given by

\[\begin{align*} \operatorname{grad} f(p) &= 2Ap - 2(p^{\mathrm{T}}Ap)*p,\qquad p ∈ 𝕊^{n-1}, \\ \operatorname{Hess} f(p)[X] &= 2AX - 2(p^{\mathrm{T}}AX)p - 2(p^{\mathrm{T}}Ap)X,\qquad p ∈ 𝕊^{n-1}, X \in T_p𝕊^{n-1} \end{align*}\]

Let’s first generate an example martrx $A$.

using Pkg;
cd(@__DIR__)
Pkg.activate("."); # use the example environment,
using LRUCache, BenchmarkTools, LinearAlgebra, Manifolds, ManoptExamples, Manopt, Random
Random.seed!(42)
n = 500
A = Symmetric(randn(n, n) / n)

And the manifolds

M = Sphere(n-1)
Sphere(499, ℝ)
E = get_embedding(M)
Euclidean(500; field=ℝ)

Setup the corresponding functions

Since RayleighQuotientCost, RayleighQuotientGrad!!, and RayleighQuotientHess!! are themselves manifold agnostic we only need to initialize them once. Agnostic here means that they would compute $f$ is called with M as their first argument and $\tilde f$ if called with E.

We instantiate

f = ManoptExamples.RayleighQuotientCost(A)
grad_f = ManoptExamples.RayleighQuotientGrad!!(A)
Hess_f = ManoptExamples.RayleighQuotientHess!!(A)

the suffix !! also indicates that these functions both work as allocating and in-place variants. Given a starting point and some memory

p0 = [1.0, zeros(n-1)...]
X = zero_vector(M, p0)

we can both call

Y = grad_f(M, p0)  # Allocates memory
grad_f(M, X, p0)    # Computes in place of X and returns the result in X.
norm(M, p0, X-Y)
0.0

Now we can use a few different variants of solvers to approaach this and this tutorial will walk you through a few of them.

First of all let’s construct the actual result – since Rayleigh quotient minimization is not necessarily the best way to compute the smallest Eigenvalue. We can also compute

λ = min(eigvals(A)...)
-0.08924035897103727

A Solver based on gradient information

Let’s first just use first-order information and since we are just starting, maybe we only derived the Euclidean gradient $\nabla \tilde f$. We can “tell” the solver, that the provided function and the gradient are defined as the Euclidean variants in the embedding. internally, Manopt.jl then issues the conversion for Euclidean gradients to the corresponding Riemannian one, cf. e.g. this tutorial section or Section 3.8 or more precisely Example 3.62 in [Bou23].

But instead of diving into all the tecnical details, we can just specify objective_type=:Euclidean to trigger the conversion. We start with a simple gradient descent

s = gradient_descent(M, f, grad_f, p0; objective_type=:Euclidean,
    debug = [:Iteration, :Cost, :GradientNorm, 50, "\n"],
    return_state=true,
)
q1 = get_solver_result(s)
s
Initial f(x): -0.000727
# 50    f(x): -0.088242|grad f(p)|:0.003870474326981599
# 100   f(x): -0.088680|grad f(p)|:0.0034956568288634616
# 150   f(x): -0.089026|grad f(p)|:0.0026514781676923237
# 200   f(x): -0.089178|grad f(p)|:0.001531160335922979

# Solver state for `Manopt.jl`s Gradient Descent
After 200 iterations

## Parameters
* retraction method: ExponentialRetraction()

## Stepsize
ArmijoLinesearch(;
    initial_stepsize=1.0
    retraction_method=ExponentialRetraction()
    contraction_factor=0.95
    sufficient_decrease=0.1
)

## Stopping criterion

Stop When _one_ of the following are fulfilled:
  * Max Iteration 200:  reached
  * |grad f| < 1.0e-8: not reached
Overall: reached
This indicates convergence: No

## Debug
    :Iteration = [(:Iteration, "# %-6d"), (:Cost, "f(x): %f"), (:GradientNorm, "|grad f(p)|:%s"), "\n", 50]

From the final cost we can already see that q1 is an eigenvector to the smallest eigenvalue we obtaines above.

And we can compare this to running with the Riemannian gradient, since the RayleighQuotientGrad!! returns this one as well, when just called with the sphere as first Argument, we just have to remove the objective_type.

q2 = gradient_descent(M, f, grad_f, p0;
    debug = [:Iteration, :Cost, :GradientNorm, 50, "\n"],
)
#Test that both are the same
isapprox(M, q1,q2)
Initial f(x): -0.000727
# 50    f(x): -0.088242|grad f(p)|:0.0038704743269815734
# 100   f(x): -0.088680|grad f(p)|:0.0034956568288633714
# 150   f(x): -0.089026|grad f(p)|:0.002651478167692353
# 200   f(x): -0.089178|grad f(p)|:0.0015311603359229782

true

We can also benchmark both

@benchmark gradient_descent($M, $f, $grad_f, $p0; objective_type=:Euclidean)
BenchmarkTools.Trial: 15 samples with 1 evaluation per sample.
 Range (min … max):  313.438 ms … 365.884 ms  ┊ GC (min … max): 10.02% … 4.56%
 Time  (median):     341.265 ms               ┊ GC (median):    10.18%
 Time  (mean ± σ):   338.854 ms ±  15.925 ms  ┊ GC (mean ± σ):   9.95% ± 1.56%

  ▁    ▁  ▁ ▁      ▁      █       ▁▁   ▁    ▁▁  ▁        ▁    ▁  
  █▁▁▁▁█▁▁█▁█▁▁▁▁▁▁█▁▁▁▁▁▁█▁▁▁▁▁▁▁██▁▁▁█▁▁▁▁██▁▁█▁▁▁▁▁▁▁▁█▁▁▁▁█ ▁
  313 ms           Histogram: frequency by time          366 ms <

 Memory estimate: 1.13 GiB, allocs estimate: 7964.
@benchmark gradient_descent($M, $f, $grad_f, $p0)
BenchmarkTools.Trial: 161 samples with 1 evaluation per sample.
 Range (min … max):  28.766 ms … 40.709 ms  ┊ GC (min … max): 0.00% … 19.53%
 Time  (median):     30.280 ms              ┊ GC (median):    2.78%
 Time  (mean ± σ):   31.098 ms ±  2.404 ms  ┊ GC (mean ± σ):  2.83% ±  3.53%

    ▇   ▃▄█▂                                                   
  ▃▅██▃▄████▆▅▇▇▃▃▃▃▁▁▁▁▃▁▄▁▁▃▁▁▁▁▃▁▃▆▆▁▃▁▁▁▁▁▃▁▁▃▃▁▁▁▃▁▁▁▃▁▃ ▃
  28.8 ms         Histogram: frequency by time        39.4 ms <

 Memory estimate: 11.20 MiB, allocs estimate: 8750.

From these results we see, that the conversion from the Euclidean to the Riemannian gradient does require a small amount of effort and hence reduces the performance slighly. Still, if the Euclidean Gradient is easier to compute or already available, this is in terms of coding the faster way. Finally this is a tradeoff between derivation and implementation efforts for the Riemannian gradient and a slight performance reduction when using the Euclidean one.

A Solver based (also) on (approximate) Hessian information

To also involve the Hessian, we consider the trust regions solver with three cases:

  1. Euclidean, approximating the Hessian
  2. Euclidean, providing the Hessian
  3. Riemannian, providing the Hessian but also using in-place evaluations.
q3 = trust_regions(M, f, grad_f, p0; objective_type=:Euclidean,
    debug = [:Iteration, :Cost, :GradientNorm, 10, "\n"],
);
Initial f(x): -0.000727
# 10    f(x): -0.088412|grad f(p)|:0.020989167846023046
# 20    f(x): -0.089079|grad f(p)|:0.007420373217153763
# 30    f(x): -0.089095|grad f(p)|:0.0022962557538450884
# 40    f(x): -0.089095|grad f(p)|:0.0022962557538450884
# 50    f(x): -0.089095|grad f(p)|:0.002296255752372694
# 60    f(x): -0.089095|grad f(p)|:0.002296255750900326
# 70    f(x): -0.089095|grad f(p)|:0.002296255749427973
# 80    f(x): -0.089095|grad f(p)|:0.0022962557479555895
# 90    f(x): -0.089095|grad f(p)|:0.002296255746483256
# 100   f(x): -0.089095|grad f(p)|:0.002296255745010824
# 110   f(x): -0.089095|grad f(p)|:0.0022962557435384336
# 120   f(x): -0.089095|grad f(p)|:0.0022962557420660814
# 130   f(x): -0.089095|grad f(p)|:0.0022962557405937175
# 140   f(x): -0.089095|grad f(p)|:0.0022962557391213406
# 150   f(x): -0.089095|grad f(p)|:0.0022962557376489472
# 160   f(x): -0.089095|grad f(p)|:0.0022962557361765603
# 170   f(x): -0.089095|grad f(p)|:0.0022962557347042086
# 180   f(x): -0.089095|grad f(p)|:0.0022962557332318594
# 190   f(x): -0.089095|grad f(p)|:0.002296255731759444
# 200   f(x): -0.089095|grad f(p)|:0.0022962557302870605
# 210   f(x): -0.089095|grad f(p)|:0.002296255728814736
# 220   f(x): -0.089095|grad f(p)|:0.0022962557273423162
# 230   f(x): -0.089095|grad f(p)|:0.002296255725869944
# 240   f(x): -0.089095|grad f(p)|:0.002296255724397584
# 250   f(x): -0.089095|grad f(p)|:0.0022962557229252085
# 260   f(x): -0.089095|grad f(p)|:0.0022962557214528346
# 270   f(x): -0.089095|grad f(p)|:0.0022962557199804855
# 280   f(x): -0.089095|grad f(p)|:0.0022962557185080726
# 290   f(x): -0.089095|grad f(p)|:0.002296255717035685
# 300   f(x): -0.089095|grad f(p)|:0.0022962557155633144
# 310   f(x): -0.089095|grad f(p)|:0.0022962557140909605
# 320   f(x): -0.089095|grad f(p)|:0.002296255712618571
# 330   f(x): -0.089095|grad f(p)|:0.0022962557111461876
# 340   f(x): -0.089095|grad f(p)|:0.002296255709673808
# 350   f(x): -0.089095|grad f(p)|:0.0022962557082014416
# 360   f(x): -0.089095|grad f(p)|:0.00229625570672907
# 370   f(x): -0.089095|grad f(p)|:0.002296255705256684
# 380   f(x): -0.089095|grad f(p)|:0.0022962557037843165
# 390   f(x): -0.089095|grad f(p)|:0.002296255702311948
# 400   f(x): -0.089095|grad f(p)|:0.0022962557008395722
# 410   f(x): -0.089095|grad f(p)|:0.002296255699367198
# 420   f(x): -0.089095|grad f(p)|:0.0022962556978947854
# 430   f(x): -0.089095|grad f(p)|:0.002296255696422434
# 440   f(x): -0.089095|grad f(p)|:0.0022962556949500715
# 450   f(x): -0.089095|grad f(p)|:0.002296255693477701
# 460   f(x): -0.089095|grad f(p)|:0.0022962556920053264
# 470   f(x): -0.089095|grad f(p)|:0.002296255690532953
# 480   f(x): -0.089095|grad f(p)|:0.0022962556890605674
# 490   f(x): -0.089095|grad f(p)|:0.002296255687588196
# 500   f(x): -0.089095|grad f(p)|:0.002296255686115812
# 510   f(x): -0.089095|grad f(p)|:0.0022962556846434557
# 520   f(x): -0.089095|grad f(p)|:0.00229625568317109
# 530   f(x): -0.089095|grad f(p)|:0.0022962556816987227
# 540   f(x): -0.089095|grad f(p)|:0.002296255680226299
# 550   f(x): -0.089095|grad f(p)|:0.0022962556787539364
# 560   f(x): -0.089095|grad f(p)|:0.0022962556772815833
# 570   f(x): -0.089095|grad f(p)|:0.0022962556758091917
# 580   f(x): -0.089095|grad f(p)|:0.0022962556743368473
# 590   f(x): -0.089095|grad f(p)|:0.0022962556728644322
# 600   f(x): -0.089095|grad f(p)|:0.002296255671392082
# 610   f(x): -0.089095|grad f(p)|:0.0022962556699197066
# 620   f(x): -0.089095|grad f(p)|:0.002296255668447314
# 630   f(x): -0.089095|grad f(p)|:0.002296255666974941
# 640   f(x): -0.089095|grad f(p)|:0.002296255665502539
# 650   f(x): -0.089095|grad f(p)|:0.002296255664030171
# 660   f(x): -0.089095|grad f(p)|:0.002296255662557798
# 670   f(x): -0.089095|grad f(p)|:0.0022962556610854248
# 680   f(x): -0.089095|grad f(p)|:0.002296255659613032
# 690   f(x): -0.089095|grad f(p)|:0.0022962556581407082
# 700   f(x): -0.089095|grad f(p)|:0.002296255656668318
# 710   f(x): -0.089095|grad f(p)|:0.0022962556551958985
# 720   f(x): -0.089095|grad f(p)|:0.002296255653723546
# 730   f(x): -0.089095|grad f(p)|:0.002296255652251183
# 740   f(x): -0.089095|grad f(p)|:0.0022962556507788168
# 750   f(x): -0.089095|grad f(p)|:0.0022962556493064516
# 760   f(x): -0.089095|grad f(p)|:0.002296255647834051
# 770   f(x): -0.089095|grad f(p)|:0.002296255646361663
# 780   f(x): -0.089095|grad f(p)|:0.0022962556448893287
# 790   f(x): -0.089095|grad f(p)|:0.0022962556434169014
# 800   f(x): -0.089095|grad f(p)|:0.0022962556419445497
# 810   f(x): -0.089095|grad f(p)|:0.0022962556404721797
# 820   f(x): -0.089095|grad f(p)|:0.0022962556389998293
# 830   f(x): -0.089095|grad f(p)|:0.002296255637527417
# 840   f(x): -0.089095|grad f(p)|:0.0022962556360550538
# 850   f(x): -0.089095|grad f(p)|:0.00229625563458268
# 860   f(x): -0.089095|grad f(p)|:0.002296255633110347
# 870   f(x): -0.089095|grad f(p)|:0.0022962556316379126
# 880   f(x): -0.089095|grad f(p)|:0.0022962556301655314
# 890   f(x): -0.089095|grad f(p)|:0.0022962556286931957
# 900   f(x): -0.089095|grad f(p)|:0.002296255627220805
# 910   f(x): -0.089095|grad f(p)|:0.0022962556257484328
# 920   f(x): -0.089095|grad f(p)|:0.0022962556242760554
# 930   f(x): -0.089095|grad f(p)|:0.0022962556228036954
# 940   f(x): -0.089095|grad f(p)|:0.002296255621331309
# 950   f(x): -0.089095|grad f(p)|:0.0022962556198589204
# 960   f(x): -0.089095|grad f(p)|:0.0022962556183865473
# 970   f(x): -0.089095|grad f(p)|:0.002296255616914149
# 980   f(x): -0.089095|grad f(p)|:0.0022962556154418005
# 990   f(x): -0.089095|grad f(p)|:0.0022962556139694153
# 1000  f(x): -0.089095|grad f(p)|:0.0022962556124970193

To provide the Hessian in the high-level interface we need to prodive it as an anonymous function, since any struct is considered to (eventually) be the also optional starting point. For space reasons, let’s also shorten the debug print to only iterations 7 and 14.

q4 = trust_regions(M, f, grad_f, (E, p, X) -> Hess_f(E, p, X), p0; objective_type=:Euclidean,
    debug = [:Iteration, :Cost, :GradientNorm, 10, "\n"],
);
Initial f(x): -0.000727
# 10    f(x): -0.089234|grad f(p)|:0.0013561755210368023
q5 = trust_regions(M, f, grad_f, (M, Y, p, X) -> Hess_f(M, Y, p, X), p0;
    evaluation=InplaceEvaluation(),
    debug = [:Iteration, :Cost, :GradientNorm, 10, "\n"],
);
Initial f(x): -0.000727
# 10    f(x): -0.089234|grad f(p)|:0.0013561755210368012

Let’s also here compare them in benchmarks. Let’s here compare all variants in their (more performant) in-place versions.

@benchmark trust_regions($M, $f, $grad_f, $p0;
  objective_type=:Euclidean,
  evaluation=InplaceEvaluation(),
)
BenchmarkTools.Trial: 7 samples with 1 evaluation per sample.
 Range (min … max):  750.045 ms … 806.410 ms  ┊ GC (min … max):  7.09% … 10.98%
 Time  (median):     782.691 ms               ┊ GC (median):    10.68%
 Time  (mean ± σ):   781.670 ms ±  19.942 ms  ┊ GC (mean ± σ):  10.21% ±  1.39%

  █           █                   █  █          █    █        █  
  █▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁█▁▁▁▁▁▁▁▁▁▁█▁▁▁▁█▁▁▁▁▁▁▁▁█ ▁
  750 ms           Histogram: frequency by time          806 ms <

 Memory estimate: 1.96 GiB, allocs estimate: 75417.
@benchmark trust_regions($M, $f, $grad_f, $((E, Y, p, X) -> Hess_f(E, Y, p, X)), $p0;
  evaluation=InplaceEvaluation(),
  objective_type=:Euclidean
)
BenchmarkTools.Trial: 217 samples with 1 evaluation per sample.
 Range (min … max):  18.994 ms … 42.563 ms  ┊ GC (min … max):  0.00% … 8.23%
 Time  (median):     22.067 ms              ┊ GC (median):    10.55%
 Time  (mean ± σ):   23.041 ms ±  3.516 ms  ┊ GC (mean ± σ):   9.60% ± 3.83%

  ▁   ▂▂▄██▅▂▁▃  ▂                                             
  █▇▅▆██████████▇█▇▆▁▁▆▁▁▁▆▅▅▁▁▁▁▆▁▁▁▁▁▁▁▅▁▁▅▁▅▅▁▁▁▁▆▁▁▁▁▁▁▁▅ ▆
  19 ms        Histogram: log(frequency) by time      40.6 ms <

 Memory estimate: 44.92 MiB, allocs estimate: 12651.
@benchmark trust_regions($M, $f, $grad_f, $((M, Y, p, X) -> Hess_f(M, Y, p, X)), $p0;
    evaluation=InplaceEvaluation(),
)
BenchmarkTools.Trial: 336 samples with 1 evaluation per sample.
 Range (min … max):  12.458 ms … 46.158 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     14.305 ms              ┊ GC (median):    9.22%
 Time  (mean ± σ):   14.887 ms ±  3.059 ms  ┊ GC (mean ± σ):  7.51% ± 6.54%

  ▂▄▃   ██▂▆▁                                                  
  ███▆▂▄█████▅▆▅▃▃▃▁▁▂▂▁▁▁▁▁▁▁▃▅▃▂▃▁▁▁▁▂▁▁▁▁▁▁▁▁▂▁▁▁▁▂▁▃▁▂▁▁▂ ▃
  12.5 ms         Histogram: frequency by time        27.3 ms <

 Memory estimate: 16.35 MiB, allocs estimate: 12633.

We see that Hessian approximation is quite costly, and Gradient and Hessian conversion somewhat costly; still, they also might serve as a good starting point, before deciding to delve into computing Riemannian gradients and Hessians.

Of course all 5 runs obtained solutions close by; one might consider the gradient based runs to not have fully converged.

[distance(M, q1, q) for q ∈ [q2,q3] ]
2-element Vector{Float64}:
 8.835276992173579e-16
 0.8953986994946139
[distance(M, q3, q) for q ∈ [q4,q5] ]
2-element Vector{Float64}:
 1.1159334410358788
 1.1159334410358788

Which we can also see in the final cost, comparing it to the Eigenvalue

[f(M, q) - λ for q ∈ [q1, q2, q3, q4, q5] ]
5-element Vector{Float64}:
  6.211293387550776e-5
  6.211293387559103e-5
  0.0001451688142172225
 -5.551115123125783e-17
 -5.551115123125783e-17

Summary

We illustrated several possibilities to call solvers, with both Euclidean gradient and Hessian and Riemannian gradient and Hessian, allocating and in-place function. While the performance is better for the Riemannian case, the Euclidean one is a worthy alternative, when those are easier to compute.

Literature