Skip to content

Commit 85e517a

Browse files
Alex-Hachelezcano
andauthored
Adding parameterizations of Hurwitz matrices (#47)
* Adding Hurwitz parameterization with a lower bound on the decay rate of matrix eigenvalues, modifying skew class to implement a right inverse and using _register_manifold_ instead of register_parameterization. Now skew constructor needs a size argument. * Fixing tolerances and removing batch support * remove prints * Improved docstrings for constraints.alpha_stable and Hurwitz class. Hurwitz.sample now uses PSD.sample() and Skew.sample() internally. * Update geotorch/hurwitz.py Co-authored-by: Mario Lezcano Casado <3291265+lezcano@users.noreply.github.com> * Added batched version support, indeed vmap will do the trick for korenecker product. * Runing Linter and some fixes for the batched version of submersion inv * Running flake8 and black linters * update submersion for perf --------- Co-authored-by: Mario Lezcano Casado <3291265+lezcano@users.noreply.github.com>
1 parent ba38d40 commit 85e517a

9 files changed

Lines changed: 385 additions & 10 deletions

File tree

‎examples/eigenvalue.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
of a symmetric matrix via the Rayleigh quotient, restricting the optimisation
44
problem to the Sphere
55
"""
6+
67
import torch
78

89
try:

‎geotorch/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
positive_semidefinite,
1414
positive_semidefinite_low_rank,
1515
positive_semidefinite_fixed_rank,
16+
alpha_stable,
1617
)
1718
from .product import ProductManifold
1819
from .reals import Rn
@@ -31,6 +32,7 @@
3132
from .pssd import PSSD
3233
from .pssdfixedrank import PSSDFixedRank
3334
from .pssdlowrank import PSSDLowRank
35+
from .hurwitz import Hurwitz
3436
from .utils import update_base
3537

3638

@@ -56,6 +58,7 @@
5658
"PSSD",
5759
"PSSDLowRank",
5860
"PSSDFixedRank",
61+
"Hurwitz",
5962
"skew",
6063
"symmetric",
6164
"sphere",
@@ -70,5 +73,6 @@
7073
"positive_semidefinite",
7174
"positive_semidefinite_low_rank",
7275
"positive_semidefinite_fixed_rank",
76+
"alpha_stable",
7377
"update_base",
7478
]

‎geotorch/constraints.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .pssd import PSSD
1616
from .pssdlowrank import PSSDLowRank
1717
from .pssdfixedrank import PSSDFixedRank
18+
from .hurwitz import Hurwitz
1819

1920

2021
def _register_manifold(module, tensor_name, cls, *args):
@@ -84,8 +85,7 @@ def skew(module, tensor_name="weight", lower=True):
8485
lower (bool): Optional. Uses the lower triangular part of the matrix to
8586
parametrize the matrix. Default: ``True``
8687
"""
87-
P.register_parametrization(module, tensor_name, Skew(lower))
88-
return module
88+
return _register_manifold(module, tensor_name, Skew, lower)
8989

9090

9191
def sphere(module, tensor_name="weight", radius=1.0, embedded=False):
@@ -550,3 +550,29 @@ def positive_semidefinite_fixed_rank(
550550
a custom callable. Default: ``"expm"``
551551
"""
552552
return _register_manifold(module, tensor_name, PSSDFixedRank, rank, f, triv)
553+
554+
555+
def alpha_stable(module: torch.nn.Module, tensor_name="weight", alpha=1e-3):
556+
r"""Adds an :math:`alpha`-stability constraint to the matrix ``module.tensor_name``.
557+
558+
When accessing ``module.tensor_name``, the module will return the parametrized
559+
version :math:`X` so that all of its eigenvalues have a negative real part lower
560+
than :math:`-\alpha`, with :math:`\alpha \geq 0`.
561+
562+
If the tensor has more than two dimensions, the parametrization will be
563+
applied to the last two dimensions.
564+
565+
Examples::
566+
567+
>>> layer = nn.Linear(30, 30)
568+
>>> alpha_stable(layer, "weight", alpha=2)
569+
>>> torch.all(torch.real(torch.linalg.eigvals(layer.weight))<=-layer.alpha)
570+
True
571+
572+
Args:
573+
module (nn.Module): module on which to register the parametrization
574+
tensor_name (string): name of the parameter, buffer, or parametrization
575+
on which the parametrization will be applied. Default: ``"weight"``
576+
alpha (float): Absolute value of the upper bound on the real part of the eigevenalues of :math: `X`
577+
"""
578+
return _register_manifold(module, tensor_name, Hurwitz, alpha)

‎geotorch/hurwitz.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import torch
2+
import math
3+
from .psd import PSD
4+
from .skew import Skew
5+
from .product import ProductManifold
6+
from .utils import _extra_repr
7+
from .exceptions import (
8+
VectorError,
9+
NonSquareError,
10+
InManifoldError,
11+
)
12+
13+
14+
def get_lyap_exp(A):
15+
return -torch.max(torch.real(torch.linalg.eigvals(A)))
16+
17+
18+
class Hurwitz(ProductManifold):
19+
20+
def __init__(self, size, alpha: float = 0.0, triv="expm"):
21+
r"""
22+
Manifold of matrices with eigenvalues with negative real parts,
23+
also called Hurwitz matrices.
24+
25+
:math`A` is Hurwitz with prescribed decay rate :math:`\alpha`
26+
if and only if it can be written as
27+
..math::
28+
A = P^{-1}(-\frac{Q}{2} +S) - \alpha \mathrm{I}_n
29+
with :math:`P \in \operatorname{PSD}(n), Q \in \operatorname{PSD}(n)` and :math: `S \in \operatorname{Skew}(n)`
30+
31+
Args:
32+
size (torch.size): Size of the tensor to be parametrized
33+
alpha (float): the upper bound on the matrix's eigenvalues real part
34+
triv (str or callable): Optional.
35+
A map that maps skew-symmetric matrices onto the orthogonal matrices
36+
surjectively. This is used to optimize the :math:`Q` in the eigenvalue
37+
decomposition. It can be one of ``["expm", "cayley"]`` or a custom
38+
callable. Default: ``"expm"``
39+
"""
40+
41+
assert alpha >= 0, ValueError(f"alpha must be positive found {alpha}")
42+
43+
n, tensorial_size = Hurwitz.parse_size(size)
44+
super().__init__(Hurwitz.manifolds(n, tensorial_size, triv))
45+
self.n = n
46+
self.tensorial_size = tensorial_size
47+
self.alpha = alpha
48+
self.register_buffer("In", torch.eye(n).expand(*self.tensorial_size, n, n))
49+
self.register_buffer(
50+
"alphaIn", self.alpha * torch.eye(n).expand(*self.tensorial_size, n, n)
51+
)
52+
53+
@classmethod
54+
def parse_size(cls, size):
55+
if len(size) < 2:
56+
raise VectorError(cls.__name__, size)
57+
n, k = size[-2:]
58+
tensorial_size = size[:-2]
59+
if n != k:
60+
raise NonSquareError(cls.__name__, size)
61+
return n, tensorial_size
62+
63+
@staticmethod
64+
def manifolds(n, tensorial_size, triv):
65+
size = tensorial_size + (n, n)
66+
return PSD(size, triv=triv), PSD(size, triv=triv), Skew(size)
67+
68+
def submersion(self, Q, P, S):
69+
return P @ torch.add(S, Q, alpha=-0.5) - self.alphaIn
70+
71+
def forward(self, X1, X2, X3):
72+
Q, P, S = super().forward([X1, X2, X3])
73+
return self.submersion(Q, P, S)
74+
75+
def submersion_inv(self, A: torch.Tensor, check_in_manifold=True, rho=1, tol=1e-4):
76+
r"""
77+
Args:
78+
A (torch.Tensor): a square and Hurwitz matrix with eigenvalues lower than -alpha
79+
check_in_manifold (bool): if set to True we raise an error if the matrix A is not in
80+
the manifold
81+
82+
rho (float): a scaling parameter for the matrix Q = -rho I
83+
84+
tol (float): a small margin to ensure P is not close to singularity, can be increased if needed
85+
86+
"""
87+
if check_in_manifold and not self.in_manifold_eigen(A):
88+
print(f"Enforced alpha = {self.alpha} and given alpha = {get_lyap_exp(A)}")
89+
raise InManifoldError(get_lyap_exp(A), self.alpha)
90+
91+
with torch.no_grad():
92+
A_shifted = A + (self.alpha - tol) * self.In
93+
A_shifted_T = A_shifted.mT.contiguous()
94+
95+
# Batched Kronecker product using vmap for cleaner implementation
96+
def _single_kron_sum(A_single):
97+
I_single = torch.eye(self.n, device=A.device, dtype=A.dtype)
98+
return torch.kron(I_single, A_single) + torch.kron(A_single, I_single)
99+
100+
flat_batch_size = math.prod(self.tensorial_size)
101+
A_flat = A_shifted_T.reshape(flat_batch_size, self.n, self.n)
102+
103+
M_flat = torch.vmap(_single_kron_sum)(A_flat)
104+
M = M_flat.reshape(*self.tensorial_size, self.n * self.n, self.n * self.n)
105+
106+
Q = rho * self.In
107+
flat_Q = Q.flatten(-2, -1)
108+
109+
vec_P = torch.linalg.solve(M, -flat_Q.unsqueeze(-1)).squeeze(-1)
110+
P = vec_P.view(*self.tensorial_size, self.n, self.n)
111+
112+
residual = torch.norm(A.mT @ P + P @ A + 2 * (self.alpha - tol) * P + Q)
113+
if residual >= tol:
114+
raise ValueError(
115+
f"Lyapunov equation ill-conditioned solve failed. \n Residual norm: {torch.norm(residual):.2e}"
116+
)
117+
118+
S = P @ (A + (self.alpha - tol) * self.In) + 0.5 * Q
119+
120+
P_inv = torch.inverse(P)
121+
122+
return Q, P_inv, S
123+
124+
def right_inverse(self, A, check_in_manifold=True):
125+
Q, P, S = self.submersion_inv(A, check_in_manifold)
126+
X1, X2, X3 = super().right_inverse([Q, P, S])
127+
return X1, X2, X3
128+
129+
def in_manifold_eigen(self, A, eps=1e-6):
130+
r"""
131+
Check that all eigenvalues have real part lower than -alpha
132+
"""
133+
eig = torch.linalg.eigvals(A)
134+
reig = torch.real(eig)
135+
return (reig <= -self.alpha + eps).all().item()
136+
137+
def sample(self, init_=torch.nn.init.xavier_normal_):
138+
r"""
139+
Returns a randomly sampled Hurwitz (:math:`\alpha`-stable) matrix on the manifold as:
140+
141+
.. math::
142+
A = P^{-1}(-Q/2 + S) - \alpha I
143+
with :math:`P^{-1}_{i,j}, Q_{i,j}, S_{i,j} \sim \texttt{init_}`
144+
145+
The output of this method can be used to initialize a parametrized tensor as::
146+
147+
>>> layer = nn.Linear(20, 20)
148+
>>> M = Hurwitz(layer.weight.size(), alpha=0.5)
149+
>>> geotorch.register_parametrization(layer, "weight", M)
150+
>>> layer.weight = M.sample()
151+
152+
Args:
153+
init_ (callable): Initialization method for random matrices.
154+
Default: ``torch.nn.init.xavier_normal_``
155+
"""
156+
with torch.no_grad():
157+
158+
mani_psd = PSD((self.n, self.n))
159+
mani_skew = Skew((self.n, self.n))
160+
161+
P = mani_psd.sample(init_)
162+
Q = mani_psd.sample(init_)
163+
S = mani_skew.sample(init_)
164+
165+
A = self.submersion(Q, P, S)
166+
return A
167+
168+
def extra_repr(self) -> str:
169+
print(self.alpha)
170+
return _extra_repr(n=self.n, alpha=self.alpha)

‎geotorch/parametrize.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ class ParametrizationList(ModuleList):
8787
Warning: the parametrization is not checked for consistency upon registration.
8888
Enable this flag at your own risk.
8989
"""
90+
9091
original: Tensor
9192
unsafe: bool
9293

‎geotorch/skew.py‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import torch
22
from torch import nn
3-
from .exceptions import VectorError, NonSquareError
3+
from .exceptions import VectorError, NonSquareError, InManifoldError
44

55

66
class Skew(nn.Module):
7-
def __init__(self, lower=True):
7+
def __init__(self, size, lower=True):
88
r"""
99
Vector space of skew-symmetric matrices, parametrized in terms of
1010
the upper or lower triangular part of a matrix.
@@ -15,8 +15,21 @@ def __init__(self, lower=True):
1515
to parametrize the matrix. Default: ``True``
1616
"""
1717
super().__init__()
18+
n, tensorial_size = Skew.parse_size(size)
19+
self.n = n
20+
self.tensorial_size = tensorial_size
1821
self.lower = lower
1922

23+
@classmethod
24+
def parse_size(cls, size):
25+
if len(size) < 2:
26+
raise VectorError(cls.__name__, size)
27+
n, k = size[-2:]
28+
tensorial_size = size[:-2]
29+
if n != k:
30+
raise NonSquareError(cls.__name__, size)
31+
return n, tensorial_size
32+
2033
@staticmethod
2134
def frame(X, lower):
2235
if lower:
@@ -32,10 +45,55 @@ def forward(self, X):
3245
raise NonSquareError(type(self).__name__, X.size())
3346
return self.frame(X, self.lower)
3447

48+
def right_inverse(self, X, check_in_manifold=True, tol=1e-4):
49+
if check_in_manifold and not torch.allclose(X, -X.mT, atol=tol):
50+
raise InManifoldError(X, self)
51+
# We assume that X is skew_symmetric
52+
if self.lower:
53+
return X.tril(-1)
54+
else:
55+
return X.triu(1)
56+
3557
@staticmethod
3658
def in_manifold(X):
3759
return (
3860
X.dim() >= 2
3961
and X.size(-2) == X.size(-1)
4062
and torch.allclose(X, -X.transpose(-2, -1))
4163
)
64+
65+
def sample(self, init_=nn.init.xavier_normal_, lower=True):
66+
r"""
67+
Returns a randomly sampled matrix on the manifold as
68+
69+
.. math::
70+
71+
tril(W) \qquad W_{i,j} \sim \texttt{init_} if lower is set to True
72+
tiu(W) \qquad W_{i,j} \sim \texttt{init_} otherwise
73+
74+
By default ``init\_`` is a (xavier) normal distribution, so that the
75+
returned matrix follows a Wishart distribution.
76+
77+
The output of this method can be used to initialize a parametrized tensor
78+
that has been parametrized with this or any other manifold as::
79+
80+
>>> layer = nn.Linear(20, 20)
81+
>>> M = Skew(layer.weight.size())
82+
>>> geotorch.register_parametrization(layer, "weight", M)
83+
>>> layer.weight = M.sample()
84+
85+
Args:
86+
init\_ (callable): Optional.
87+
A function that takes a tensor and fills it in place according
88+
to some distribution. See
89+
`torch.init <https://pytorch.org/docs/stable/nn.init.html>`_.
90+
Default: ``torch.nn.init.xavier_normal_``
91+
"""
92+
with torch.no_grad():
93+
X = torch.empty(*(self.tensorial_size + (self.n, self.n)))
94+
init_(X)
95+
if lower:
96+
X.tril_(-1)
97+
else:
98+
X.triu_(1)
99+
return X - X.mT

‎geotorch/utils.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,7 @@ def _extra_repr(**kwargs): # noqa: C901
4848
if "transposed" in kwargs:
4949
if kwargs["transposed"]:
5050
ret += ", transposed"
51+
if "alpha" in kwargs:
52+
ret += ", alpha={}".format(kwargs["alpha"])
53+
5154
return ret

0 commit comments

Comments
 (0)