Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cholesky decompositions of LinearOperators #618

Merged

Conversation

marvinpfoertner
Copy link
Collaborator

@marvinpfoertner marvinpfoertner commented Jan 23, 2022

In a Nutshell

This PR implements Cholesky decompositions for ProbNum's linops library.
To achieve this goal, I also had to implement symmetrization and parts of a matrix property system.

Detailed Description

  • LinearOperator.cholesky(lower: bool) -> LinearOperator
  • LinearOperator.symmetrize() -> LinearOperator implementing "1/2 * (A + A^T)"
  • Matrix properties
    • is_symmetric
    • is_{upper,lower}_triangular
    • is_positive_definite
  • Fixed bugs in Kronecker.{inv,cond}
  • _InverseLinearOperator uses Cholesky factorization for SPD matrices
  • Fixed bug in _InverseLinearOperator of singular matrix
  • Added new tests and test cases

Related Issues

Implements parts of #569 and #571

@marvinpfoertner marvinpfoertner added improvement Improvements of existing functionality linops Issues related to linear operators labels Jan 23, 2022
@marvinpfoertner marvinpfoertner self-assigned this Jan 23, 2022
@codecov
Copy link

codecov bot commented Jan 23, 2022

Codecov Report

Merging #618 (285b304) into main (846f0bc) will increase coverage by 0.11%.
The diff coverage is 90.90%.

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #618      +/-   ##
==========================================
+ Coverage   89.40%   89.52%   +0.11%     
==========================================
  Files         181      181              
  Lines        6684     6875     +191     
  Branches     1026     1064      +38     
==========================================
+ Hits         5976     6155     +179     
- Misses        479      488       +9     
- Partials      229      232       +3     
Impacted Files Coverage Δ
src/probnum/linops/_linear_operator.py 86.15% <87.50%> (+0.67%) ⬆️
src/probnum/linops/_scaling.py 82.55% <89.47%> (+0.85%) ⬆️
src/probnum/linops/_arithmetic_fallbacks.py 94.44% <100.00%> (+1.58%) ⬆️
src/probnum/linops/_kronecker.py 90.94% <100.00%> (+3.32%) ⬆️

@marvinpfoertner marvinpfoertner force-pushed the linop-cholesky branch 2 times, most recently from 6d65351 to a50d087 Compare January 24, 2022 13:03
@marvinpfoertner marvinpfoertner changed the title [Draft] Cholesky decompositions of LinearOperators Cholesky decompositions of LinearOperators Jan 24, 2022
@marvinpfoertner marvinpfoertner marked this pull request as ready for review January 24, 2022 17:37
Copy link
Contributor

@JonathanWenger JonathanWenger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's great to finally have this property system. I have a few comments which should be easily resolved. Thanks for the effort! One question to think about for the future (not this PR) what do we do with semi-definite operators? Add another property _is_positive_semi_definite? Often there are algorithms which also work if the linear operator is at least positive semi-definite.

src/probnum/linops/_linear_operator.py Outdated Show resolved Hide resolved
numpy.linalg.LinAlgError
If the :class:`LinearOperator` is not positive definite.
"""
if not self.is_symmetric:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the is_symmetric and is_positive_definite properties are None, i.e. it is unknown whether the matrix is spd? According to the docstring .cholesky() should still run and if it succeeds, set the properties accordingly. However if self.is_symmetric is None, this conditional should throw an exception, if i'm not mistaken.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the Raises section of the docstring, the method raises an exception if is_symmetric is not True, i.e. the method will fail if it is not known that the operator is indeed symmetric.
Similarly I state that the operator raises an exception if the matrix is not positive definite, while still setting linop.is_positive_definite = False.
This is intentional as it can be used as a test for positive definiteness.
So I don't really see a contradiction to the docstring here. Or am I missing something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My mistake! We might want to be lenient here and only raise an exception if the properties are False. Otherwise this may lead to a lot of boilerplate in particular when prototyping. Your call.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, I think it would be more in line with Python philosophy to attempt the Cholesky decomposition if it is not known whether the linop is spd and then raise an exception should Cholesky fail.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In principle, I agree with the second point. In fact this is what happens if is_positive_definite is None: We attempt the Cholesky and if it fails, we set it to False and raise an exception. However, after giving this some thought, this issue is much more complicated with is_symmetric.
Since matrices are rarely exactly symmetric, an automatic check of symmetry would be much too restrictive, unless we have some sort of "asymmetry tolerance", which is just another one of these arbitrary parameters to make things work. Personally, I came to the conclusion that it is better if the user has to call .symmetrize on the operator before calling .cholesky because it makes this whole symmetry business much more transparent.
Moreover, if symmetrize is too costly, the user can always choose to set linop.symmetric = True manually, even if the property is only fulfilled approximately.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that NumPy and SciPy circumvent this issue by only operating on the lower triangle of the matrix.

>>> a = np.array([
...     [1.0, 2.0],
...     [0.9, 1.0],
... ])
>>> np.linalg.cholesky(a)
array([[1.        , 0.        ],
       [0.9       , 0.43588989]])
>>> scipy.linalg.cholesky(a, lower=True)
array([[1.        , 0.        ],
       [0.9       , 0.43588989]])

IMHO, this is quite dangerous, since it can produce very subtle bugs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me! Thanks for the detailed explanation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we find a good solution, I'm also fine with removing this in the future.
However, I would propose to make this a separate PR, since this would increase complexity by quite a bit.
We can always make interfaces less restrictive

@@ -520,6 +709,59 @@ def inv(self) -> "LinearOperator":

return self.__inverse()

def symmetrize(self) -> LinearOperator:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By introduction of this method the linear operator Symmetrize in _kronecker.py should just be removed, I think. Otherwise there are two different ways of achieving the same thing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symmetrize in _kronecker.py symmetrizes vectorized matrices used in Kronecker algebra, so it operates "on the arguments" of linear operators, while linop.symmetrize symmetrizes the LinearOperator itself, so I'd say they serve distinct purposes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They do, but Symmetrize is really only used for SymmetricKronecker and is arguably confusing in its naming if there is a symmetrize() method. My suggestion would be to fold Symmetrize into the SymmetricKronecker implementation as a (future) refactor. I'm okay with considering this as out of scope for this PR.

Copy link
Collaborator Author

@marvinpfoertner marvinpfoertner Jan 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, let's make a separate PR here 👍

src/probnum/linops/_scaling.py Outdated Show resolved Hide resolved
src/probnum/linops/_scaling.py Outdated Show resolved Hide resolved
@marvinpfoertner
Copy link
Collaborator Author

It's great to finally have this property system. I have a few comments which should be easily resolved. Thanks for the effort! One question to think about for the future (not this PR) what do we do with semi-definite operators? Add another property _is_positive_semi_definite? Often there are algorithms which also work if the linear operator is at least positive semi-definite.

I agree, we should add more matrix properties over time. I also think that we will need is_indefinite, as well as is_diagonal and is_orthogonal (the latter for SVDs and eigendecompositions). I started a list of these properties in #571 and #569. Feel free to add is_positive_semidefinite there.

numpy.linalg.LinAlgError
If the :class:`LinearOperator` is not positive definite.
"""
if not self.is_symmetric:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My mistake! We might want to be lenient here and only raise an exception if the properties are False. Otherwise this may lead to a lot of boilerplate in particular when prototyping. Your call.

@@ -520,6 +709,59 @@ def inv(self) -> "LinearOperator":

return self.__inverse()

def symmetrize(self) -> LinearOperator:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They do, but Symmetrize is really only used for SymmetricKronecker and is arguably confusing in its naming if there is a symmetrize() method. My suggestion would be to fold Symmetrize into the SymmetricKronecker implementation as a (future) refactor. I'm okay with considering this as out of scope for this PR.

@marvinpfoertner marvinpfoertner merged commit 6659bb6 into probabilistic-numerics:main Jan 28, 2022
@marvinpfoertner marvinpfoertner deleted the linop-cholesky branch January 28, 2022 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
improvement Improvements of existing functionality linops Issues related to linear operators
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants