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

ENH: sparse.linalg: Implement matrix_power() #18544

Merged
merged 13 commits into from Aug 12, 2023

Conversation

ljwolf
Copy link
Contributor

@ljwolf ljwolf commented May 26, 2023

Reference issue

No specific reference issue

What does this implement/fix?

This adds a scipy.sparse.linalg.matrix_power() analogue to numpy.linalg.matrix_power(). It uses the old _spmatrix.__pow__() recursive implementation, since MatrixPowerOperator() was quite a bit slower.

@j-bowhay j-bowhay added enhancement A new feature or improvement scipy.sparse.linalg labels May 26, 2023
@j-bowhay j-bowhay changed the title implement scipy.sparse.linalg.matrix_power() ENH: sparse.linalg: Implement matrix_power() May 26, 2023
@ljwolf
Copy link
Contributor Author

ljwolf commented May 26, 2023

Whitespace issues fixed. 37 tests fail expecting _spmatrix.__pow__ to raise an exception for a sparse matrix raised to a negative power.

@ljwolf ljwolf marked this pull request as ready for review May 26, 2023 17:27
@ljwolf
Copy link
Contributor Author

ljwolf commented May 26, 2023

We decided to keep the default behaviour in both MatrixPowerOperator() and _spmatrix.__pow__, which raises an error when the user raises a matrix to a negative power.

If we allowed the user to pass a negative power, we would have to invert the input array A. That would usually create a dense matrix unless there is special structure in the input matrix. It's easy enough for an interested user to invert the matrix themselves and use abs(pow), so we'll keep the current behaviour consistent by raising an error with a negative power.

@rossbar
Copy link
Contributor

rossbar commented May 26, 2023

Just to note - it looks like #513 introduced a recursive implementation that saves on the number of matmuls for larger exponents. It would be worthwhile to ensure there is a benchmark in place for sparse matrix power to ensure that there are no performance regressions with the updated implementation. @perimosocordiae is working on a benchmark in a separate PR!

@ljwolf
Copy link
Contributor Author

ljwolf commented May 26, 2023

Happy to pull in the old implementation, too. I figured that the MatrixPowerOperator() implementation would be faster than the recursive call, especially if structure is known.

@perimosocordiae
Copy link
Member

I just opened gh-18553 with a benchmark covering this case. Once that merges, it'll be really easy to evaluate the impact of this PR's changes using dev.py bench --compare main.

@perimosocordiae
Copy link
Member

My benchmark PR is now merged.

@ljwolf
Copy link
Contributor Author

ljwolf commented May 31, 2023

Yeah, this is not gonna fly. The LinearOperator() version is an order of magnitude slower than the recursive solution on my computer:

on main
              === ============== ==============

              --           N / density
              --- -----------------------------
               x   1000 / 1e-06   1000 / 0.001
              === ============== ==============
               0     49.7±3μs       52.0±6μs
               1    66.7±20μs       58.9±7μs
               2     209±20μs       268±10μs
               3     338±20μs       451±10μs
               8     516±50μs       662±50μs
               9     697±50μs       856±20μs
              === ============== ==============
in PR: 
              === ============== ==============
              --           N / density
              --- -----------------------------
               x   1000 / 1e-06   1000 / 0.001
              === ============== ==============
               0     424±10μs       428±30μs
               1     826±30μs       845±40μs
               2     932±20μs     1.05±0.06ms
               3   1.13±0.06ms     1.52±0.3ms
               8   1.76±0.05ms     2.13±0.1ms
               9    2.11±0.2ms     2.35±0.2ms
              === ============== ==============

I'll swap back to the _spmatrix.__pow__ implementation, rather than scipy.sparse.linalg.MatrixPowerOperator().

@ljwolf
Copy link
Contributor Author

ljwolf commented Jun 1, 2023

OK, this now uses the older recursive __pow__() implementation for scipy.sparse.linalg.matrix_power(), and transitions _spmatrix.__pow__ to use scipy.sparse.linalg.matrix_power(), so the benchmark is about the same as on main:

=== ============== ==============
--           N / density
--- -----------------------------
 x   1000 / 1e-06   1000 / 0.001
=== ============== ==============
 0     56.2±5μs       52.1±4μs
 1     60.2±8μs      70.8±20μs
 2     197±20μs       254±30μs
 3     351±20μs       442±30μs
 8     533±50μs      769±200μs
 9     627±80μs      905±100μs
=== ============== ==============

@perimosocordiae
Copy link
Member

Commenting here to say that I haven't forgotten about this PR! I still need to do one more read through the changes, but in the meantime, could you update the PR description to reflect the current state of the change?

@ljwolf
Copy link
Contributor Author

ljwolf commented Jun 22, 2023

Great, the description of the PR and docstring is now fixed.

Copy link
Contributor

@jjerphan jjerphan left a comment

Choose a reason for hiding this comment

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

Thank you, @ljwolf!

Here are a few comments.

scipy/sparse/_matrix.py Show resolved Hide resolved
scipy/sparse/linalg/_matfuncs.py Show resolved Hide resolved
scipy/sparse/linalg/_matfuncs.py Show resolved Hide resolved
Comment on lines +902 to +906
tmp = matrix_power(A, power // 2)
if power % 2:
return A @ tmp @ tmp
else:
return tmp @ tmp
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the benefit of proceeding as follow, here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is part of the existing matrix_power implementation, which (as I understand it) was used to reduce the number of required multiplications. This performance improvement was significant then, and seems so again.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes, that's just a recursive implementation.

We could further reduce the cost of this function using inline computations with more or less np.log2(power) iterations not to have the checks just above be rerun several times.

What do you think?

scipy/sparse/linalg/_matfuncs.py Show resolved Hide resolved
scipy/sparse/linalg/_matfuncs.py Show resolved Hide resolved
scipy/sparse/linalg/tests/test_matfuncs.py Show resolved Hide resolved
scipy/sparse/_matrix.py Outdated Show resolved Hide resolved
scipy/sparse/_matrix.py Outdated Show resolved Hide resolved
ljwolf and others added 2 commits July 28, 2023 16:30
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
ljwolf and others added 3 commits July 28, 2023 16:30
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
@ljwolf
Copy link
Contributor Author

ljwolf commented Jul 28, 2023

OK, I took all of @jjerphan's suggestions, and clarified the comments! let me know where to take this further!

Copy link
Contributor

@jjerphan jjerphan left a comment

Choose a reason for hiding this comment

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

Here is a second pass.

I just have another comment regarding the implementation of matrix_power which I think can be improved.

Comment on lines +902 to +906
tmp = matrix_power(A, power // 2)
if power % 2:
return A @ tmp @ tmp
else:
return tmp @ tmp
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes, that's just a recursive implementation.

We could further reduce the cost of this function using inline computations with more or less np.log2(power) iterations not to have the checks just above be rerun several times.

What do you think?

scipy/sparse/linalg/_matfuncs.py Show resolved Hide resolved
scipy/sparse/linalg/_matfuncs.py Outdated Show resolved Hide resolved
@@ -861,3 +862,47 @@ def _ell(A, m):
log2_alpha_div_u = np.log2(alpha/u)
value = int(np.ceil(log2_alpha_div_u / (2 * m)))
return max(value, 0)

def matrix_power(A, power, structure=None):
"""
Copy link
Member

Choose a reason for hiding this comment

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

A couple docstring issues:

  • As @jjerphan already noted, structure must be described in the "Parameters" section. If there is nothing implemented yet, then remove the parameter.
  • Add an "Examples" section (see DOC: Add "Examples" to docstrings #7168).
  • Add a "Notes" section with the appropriate versionadded markup.

For comparison, see the expm docstring in this file. A "Notes" section with just the versionadded markup is fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks! Have done. "Structure" was a holdover from using the MatrixPowerOperator implementation, and it has been removed. I also refer now to the idea of your stackoverflow comment in the notes... I think it's pretty important to disclose this to the user.

@WarrenWeckesser
Copy link
Member

The recursive/logarithmic implementation is probably a good default, and I don't suggest we change it, but folks involved in this PR might be interested to know that it is not always the best approach. Its performance can be substantially worse than simple iteration, depending on how the number of nonzero values increases as powers are computed. See my answer to a question on stackoverflow for an example where the recursive calculation of A**16 is much slower than computing A*A*A*A*A*A*A*A*A*A*A*A*A*A*A*A.

@ljwolf
Copy link
Contributor Author

ljwolf commented Aug 4, 2023

thanks for the comment @WarrenWeckesser! I think I've addressed the concerns mentioned. The point on your stackoverflow post is useful to disclose (imho) so I've added the gist of the point to the notes.

@ljwolf
Copy link
Contributor Author

ljwolf commented Aug 4, 2023

I should say, I'm happy to move forward with an inline-based computation, but my goal was only to bring forward the existing implementation. This recursive strategy was adopted in #513, and we probably should develop a more stringent benchmark to characterise the relative performance of a recursive vs. inline strategy over different sizes and sparsity levels. For now, we should probably proceed with the current _sp_matrix.__pow__ implementation, and optimise its implementation once it's brought forward to the sparse array.

Copy link
Contributor

@jjerphan jjerphan left a comment

Choose a reason for hiding this comment

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

LGTM.

I am also in favor of studying both possible implementations in a dedicated PR.

I just have one comment regarding the docstring.

scipy/sparse/linalg/_matfuncs.py Outdated Show resolved Hide resolved
Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
@perimosocordiae
Copy link
Member

As discussed, there are a few ways that this implementation could be made more efficient, but as it stands this is already a very nice improvement to the library: without a standalone matrix_power function, users of sparse arrays would be out of luck if they were migrating old sparse matrix code using the ** operator.

Thanks for the PR, @ljwolf !

@perimosocordiae perimosocordiae merged commit 1ef84bc into scipy:main Aug 12, 2023
20 of 22 checks passed
@j-bowhay j-bowhay added this to the 1.12.0 milestone Aug 12, 2023
lucascolley pushed a commit to lucascolley/scipy that referenced this pull request Aug 12, 2023
* add matrix_power() using MatrixPowerOperator()

* remove unused imports in _matrix.py

* move back to recursive matrix_power implementation

* fix lint issues

* add matrix_power to __init__ autosummary

* fix docstring explaining negative powers

* Update scipy/sparse/linalg/_matfuncs.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

* Update scipy/sparse/_matrix.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

* Update scipy/sparse/linalg/_matfuncs.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

* Update scipy/sparse/_matrix.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

* Update scipy/sparse/linalg/_matfuncs.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

* update docstring and remove structure param

* Update scipy/sparse/linalg/_matfuncs.py

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>

---------

Co-authored-by: Julien Jerphanion <git@jjerphan.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement A new feature or improvement scipy.sparse.linalg
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants