sparseqr 1.4.1__tar.gz → 1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,21 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: sparseqr
3
- Version: 1.4.1
3
+ Version: 1.5
4
4
  Summary: Python wrapper for SuiteSparseQR
5
- Keywords: suitesparse,bindings,wrapper,scipy,numpy,qr-decomposition,qr-factorisation,sparse-matrix,sparse-linear-system,sparse-linear-solver
6
5
  Author-email: Yotam Gingold <yotam@yotamgingold.com>
7
- Requires-Python: >= 3.8
8
- Description-Content-Type: text/markdown
9
- Requires-Dist: numpy >1.2
10
- Requires-Dist: scipy >= 1.0
11
- Requires-Dist: cffi >= 1.0
12
- Requires-Dist: setuptools >35
6
+ License-Expression: CC0-1.0
13
7
  Project-URL: homepage, https://github.com/yig/PySPQR
14
8
  Project-URL: source, https://github.com/yig/PySPQR
9
+ Keywords: suitesparse,bindings,wrapper,scipy,numpy,qr-decomposition,qr-factorisation,sparse-matrix,sparse-linear-system,sparse-linear-solver
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: numpy>1.2
14
+ Requires-Dist: scipy>=1.0
15
+ Requires-Dist: cffi>=1.0
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=7.0; extra == "test"
18
+ Dynamic: license-file
15
19
 
16
20
  # Python wrapper for SuiteSparseQR
17
21
 
@@ -118,38 +122,53 @@ or leave them in their directory and call it as a module.
118
122
 
119
123
  # Deploy
120
124
 
121
- 1. Change the version in:
122
-
123
- ```
124
- sparseqr/__init__.py
125
- pyproject.toml
126
- ```
125
+ 1. Change the version in `sparseqr/__init__.py`
127
126
 
128
127
  2. Update `CHANGELOG.md`
129
128
 
130
- 3. Run:
129
+ 3. Commit to git. Push to GitHub.
130
+
131
+ 4. Run (in a clean repos, e.g., `git clone . clean; cd clean`):
131
132
 
132
133
  ```
133
134
  flit publish --format sdist
134
135
  ```
135
136
 
137
+ Using [uv](https://docs.astral.sh/uv/) and [PyPI API tokens](https://pypi.org/help/#apitoken):
138
+
139
+ ```
140
+ FLIT_USERNAME=__token__ uv tool run --with flit flit publish --format sdist
141
+ ```
142
+
136
143
  We don't publish binary wheels, because it must be compiled against suite-sparse as a system dependency. We could publish a `none-any` wheel, which would cause compilation to happen the first time the module is imported rather than when it is installed. Is there a point to that?
137
144
 
138
145
  # Known issues
139
146
 
140
147
  `pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
141
148
 
142
- # Tested on
149
+ # Tested
150
+
151
+ GitHub Continuous Integration (CI) tests:
152
+
153
+ - Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
154
+ - macOS, Ubuntu Linux, and Windows.
155
+ - conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
156
+
157
+ Test manually with:
158
+
159
+ ```
160
+ python -m pytest
161
+ ```
143
162
 
144
- - Python 3.9, 3.13.
145
- - Conda and not conda.
146
- - macOS, Ubuntu Linux, and Linux Mint.
163
+ or
147
164
 
148
- PYTHONPATH='.:$PYTHONPATH' python3 test/test.py
165
+ ```
166
+ uv run --extra test pytest
167
+ ```
149
168
 
150
169
  # Dependencies
151
170
 
152
- These are installed via pip:
171
+ These are listed as dependencies and will be installed automatically:
153
172
 
154
173
  * [SciPy/NumPy](http://www.scipy.org)
155
174
  * [cffi](http://cffi.readthedocs.io/)
@@ -161,4 +180,3 @@ These must be installed manually:
161
180
  # License
162
181
 
163
182
  Public Domain [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
164
-
@@ -103,38 +103,53 @@ or leave them in their directory and call it as a module.
103
103
 
104
104
  # Deploy
105
105
 
106
- 1. Change the version in:
107
-
108
- ```
109
- sparseqr/__init__.py
110
- pyproject.toml
111
- ```
106
+ 1. Change the version in `sparseqr/__init__.py`
112
107
 
113
108
  2. Update `CHANGELOG.md`
114
109
 
115
- 3. Run:
110
+ 3. Commit to git. Push to GitHub.
111
+
112
+ 4. Run (in a clean repos, e.g., `git clone . clean; cd clean`):
116
113
 
117
114
  ```
118
115
  flit publish --format sdist
119
116
  ```
120
117
 
118
+ Using [uv](https://docs.astral.sh/uv/) and [PyPI API tokens](https://pypi.org/help/#apitoken):
119
+
120
+ ```
121
+ FLIT_USERNAME=__token__ uv tool run --with flit flit publish --format sdist
122
+ ```
123
+
121
124
  We don't publish binary wheels, because it must be compiled against suite-sparse as a system dependency. We could publish a `none-any` wheel, which would cause compilation to happen the first time the module is imported rather than when it is installed. Is there a point to that?
122
125
 
123
126
  # Known issues
124
127
 
125
128
  `pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
126
129
 
127
- # Tested on
130
+ # Tested
128
131
 
129
- - Python 3.9, 3.13.
130
- - Conda and not conda.
131
- - macOS, Ubuntu Linux, and Linux Mint.
132
+ GitHub Continuous Integration (CI) tests:
132
133
 
133
- PYTHONPATH='.:$PYTHONPATH' python3 test/test.py
134
+ - Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
135
+ - macOS, Ubuntu Linux, and Windows.
136
+ - conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
137
+
138
+ Test manually with:
139
+
140
+ ```
141
+ python -m pytest
142
+ ```
143
+
144
+ or
145
+
146
+ ```
147
+ uv run --extra test pytest
148
+ ```
134
149
 
135
150
  # Dependencies
136
151
 
137
- These are installed via pip:
152
+ These are listed as dependencies and will be installed automatically:
138
153
 
139
154
  * [SciPy/NumPy](http://www.scipy.org)
140
155
  * [cffi](http://cffi.readthedocs.io/)
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "sparseqr"
3
- version = "1.4.1"
3
+ dynamic = ["version"]
4
4
  description = "Python wrapper for SuiteSparseQR"
5
5
  authors = [{name = "Yotam Gingold", email = "yotam@yotamgingold.com"}]
6
- license = {text = "Public Domain CC0"}
6
+ license = "CC0-1.0"
7
7
  readme = "README.md"
8
8
  keywords = ["suitesparse", "bindings", "wrapper", "scipy", "numpy", "qr-decomposition", "qr-factorisation", "sparse-matrix", "sparse-linear-system", "sparse-linear-solver"]
9
9
 
@@ -12,18 +12,29 @@ requires-python = ">= 3.8"
12
12
  dependencies = [
13
13
  "numpy >1.2",
14
14
  "scipy >= 1.0",
15
- "cffi >= 1.0",
16
- "setuptools >35",
15
+ "cffi >= 1.0"
17
16
  ]
18
17
 
18
+ [project.optional-dependencies]
19
+ test = ["pytest>=7.0"]
20
+
19
21
  [project.urls]
20
22
  homepage = "https://github.com/yig/PySPQR"
21
23
  source = "https://github.com/yig/PySPQR"
22
24
 
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["test"]
27
+ python_files = ["test_*.py"]
28
+ python_functions = ["test_*"]
29
+ addopts = "-v"
30
+
23
31
  [build-system]
24
32
  requires = ["setuptools>=61"]
25
33
  build-backend = "setuptools.build_meta"
26
34
 
35
+ [tool.setuptools.dynamic]
36
+ version = {attr = "sparseqr.__version__"}
37
+
27
38
  #[tool.setuptools.packages.find]
28
39
  # include = ["test/*.py", "README.md", "LICENSE.md"]
29
40
  #exclude = ["sparseqr/_sparseqr*"]
sparseqr-1.5/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -17,7 +17,7 @@ See the docstrings of the individual functions for details.
17
17
 
18
18
  from __future__ import absolute_import
19
19
 
20
- __version__ = '1.4.1'
20
+ __version__ = '1.5'
21
21
 
22
22
  # import the important things into the package's top-level namespace.
23
23
  from .sparseqr import qr, rz, solve, permutation_vector_to_matrix, qr_factorize,qmult
@@ -35,7 +35,7 @@ import _cffi_backend
35
35
  import scipy.sparse
36
36
  import numpy
37
37
 
38
- from .cffi_asarray import asarray
38
+ from .cffi_asarray import asarray, ctype2dtype
39
39
 
40
40
  '''
41
41
  Helpful links for developers:
@@ -72,17 +72,30 @@ When no longer needed, the returned CHOLMOD sparse matrix must be deallocated us
72
72
 
73
73
  nnz = scipy_A.nnz
74
74
 
75
+ # Check if the sparse matrix has complex entries and adapt cholmod type accordingly
76
+ is_complex = numpy.issubdtype(scipy_A, complex)
77
+ cholmod_dtype = lib.CHOLMOD_COMPLEX if is_complex else lib.CHOLMOD_REAL
78
+
75
79
  ## There is a potential performance win if we know A is symmetric and we
76
80
  ## can get only the upper or lower triangular elements.
77
- chol_A = lib.cholmod_l_allocate_triplet( scipy_A.shape[0], scipy_A.shape[1], nnz, 0, lib.CHOLMOD_REAL, cc )
81
+ chol_A = lib.cholmod_l_allocate_triplet( scipy_A.shape[0], scipy_A.shape[1], nnz, 0, cholmod_dtype, cc )
78
82
 
83
+ # Cast and copy indices
79
84
  Ai = ffi.cast( "SuiteSparse_long*", chol_A.i )
80
85
  Aj = ffi.cast( "SuiteSparse_long*", chol_A.j )
81
- Avals = ffi.cast( "double*", chol_A.x )
82
-
83
86
  Ai[0:nnz] = scipy_A.row
84
87
  Aj[0:nnz] = scipy_A.col
85
- Avals[0:nnz] = scipy_A.data
88
+
89
+ # Handle values based on dtype
90
+ Avals = ffi.cast( "double*", chol_A.x )
91
+ if is_complex:
92
+ # chol_A.x has size 2*nnz and real and imag parts are interleaved [real, imag, real, imag, ...]
93
+ array_element_size = ffi.sizeof(ffi.typeof(Avals).item)
94
+ Avals_view = numpy.frombuffer(ffi.buffer(Avals, 2*nnz * array_element_size), ctype2dtype["double"])
95
+ Avals_view[0::2] = scipy_A.data.real
96
+ Avals_view[1::2] = scipy_A.data.imag
97
+ else:
98
+ Avals[0:nnz] = scipy_A.data
86
99
 
87
100
  chol_A.nnz = nnz
88
101
 
@@ -125,7 +138,14 @@ def cholmodsparse2scipy( chol_A ):
125
138
  ## doesn't and the cholmod memory fill get freed.
126
139
  i = asarray( ffi, Ai, nnz ).copy()
127
140
  j = asarray( ffi, Aj, nnz ).copy()
128
- data = asarray( ffi, Adata, nnz ).copy()
141
+
142
+ if chol_A.xtype == lib.CHOLMOD_COMPLEX:
143
+ # read real and imag part from the buffer and create view as complex datatype
144
+ data = asarray(ffi, Adata, 2*nnz).copy()
145
+ complex_dtype = numpy.dtype(f"c{data.itemsize * 2}")
146
+ data = data.view(complex_dtype)
147
+ else:
148
+ data = asarray(ffi, Adata, nnz).copy()
129
149
 
130
150
  scipy_A = scipy.sparse.coo_matrix(
131
151
  ( data, ( i, j ) ),
@@ -17,6 +17,9 @@ libraries = ['spqr']
17
17
  if 'CONDA_PREFIX' in os.environ:
18
18
  include_dirs.append( os.path.join(os.environ['CONDA_PREFIX'], 'include', 'suitesparse') )
19
19
  library_dirs.append( os.path.join(os.environ['CONDA_PREFIX'], 'lib') )
20
+ ## For Windows:
21
+ include_dirs.append( os.path.join(os.environ['CONDA_PREFIX'], 'Library', 'include', 'suitesparse') )
22
+ library_dirs.append( os.path.join(os.environ['CONDA_PREFIX'], 'Library', 'lib') )
20
23
 
21
24
  ## Otherwise, add common system-wide directories
22
25
  else:
@@ -31,8 +34,9 @@ else:
31
34
 
32
35
  if platform.system() == 'Windows':
33
36
  # https://github.com/yig/PySPQR/issues/6
34
- libraries.extend( ['amd','btf','camd','ccolamd','cholmod','colamd','cxsparse'
35
- 'klu','lapack','ldl','lumfpack','metis','suitesparseconfig','libblas'] )
37
+ ## Update: This list fails for `windows-latest` on GitHub. Some are missing. The only needed library is `cholmod`.
38
+ # libraries.extend( ['amd','btf','camd','ccolamd','cholmod','colamd','cxsparse', 'klu','lapack','ldl','lumfpack','metis','suitesparseconfig','libblas'] )
39
+ libraries.extend( ['cholmod'] )
36
40
 
37
41
  ffibuilder = FFI()
38
42
 
@@ -50,7 +54,7 @@ ffibuilder.set_source( "sparseqr._sparseqr",
50
54
  library_dirs = library_dirs,
51
55
  libraries = libraries )
52
56
 
53
- ffibuilder.cdef("""
57
+ ffibuilder.cdef(r"""
54
58
  // The int... is a magic thing which tells the compiler to figure out what the right
55
59
  // integer type is.
56
60
  typedef int... SuiteSparse_long;
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: sparseqr
3
+ Version: 1.5
4
+ Summary: Python wrapper for SuiteSparseQR
5
+ Author-email: Yotam Gingold <yotam@yotamgingold.com>
6
+ License-Expression: CC0-1.0
7
+ Project-URL: homepage, https://github.com/yig/PySPQR
8
+ Project-URL: source, https://github.com/yig/PySPQR
9
+ Keywords: suitesparse,bindings,wrapper,scipy,numpy,qr-decomposition,qr-factorisation,sparse-matrix,sparse-linear-system,sparse-linear-solver
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: numpy>1.2
14
+ Requires-Dist: scipy>=1.0
15
+ Requires-Dist: cffi>=1.0
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=7.0; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # Python wrapper for SuiteSparseQR
21
+
22
+ This module wraps the [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html)
23
+ decomposition function for use with [SciPy](http://www.scipy.org).
24
+ This is Matlab's sparse `[Q,R,E] = qr()`.
25
+ For some reason, no one ever wrapped that function of SuiteSparseQR for Python.
26
+
27
+ Also wrapped are the SuiteSparseQR solvers for ``A x = b`` for the cases with sparse `A` and dense or sparse `b`.
28
+ This is especially useful for solving sparse overdetermined linear systems in the least-squares sense.
29
+ Here `A` is of size m-by-n and `b` is m-by-k (storing `k` different right-hand side vectors, each considered separately).
30
+
31
+ # Usage
32
+
33
+ ```python
34
+ import numpy
35
+ import scipy.sparse.linalg
36
+ import sparseqr
37
+
38
+ # QR decompose a sparse matrix M such that Q R = M E
39
+ #
40
+ M = scipy.sparse.rand( 10, 10, density = 0.1 )
41
+ Q, R, E, rank = sparseqr.qr( M )
42
+ print( "Should be approximately zero:", abs( Q*R - M*sparseqr.permutation_vector_to_matrix(E) ).sum() )
43
+
44
+ # Solve many linear systems "M x = b for b in columns(B)"
45
+ #
46
+ B = scipy.sparse.rand( 10, 5, density = 0.1 ) # many RHS, sparse (could also have just one RHS with shape (10,))
47
+ x = sparseqr.solve( M, B, tolerance = 0 )
48
+
49
+ # Solve an overdetermined linear system A x = b in the least-squares sense
50
+ #
51
+ # The same routine also works for the usual non-overdetermined case.
52
+ #
53
+ A = scipy.sparse.rand( 20, 10, density = 0.1 ) # 20 equations, 10 unknowns
54
+ b = numpy.random.random(20) # one RHS, dense, but could also have many (in shape (20,k))
55
+ x = sparseqr.solve( A, b, tolerance = 0 )
56
+ ## Call `rz()`:
57
+ sparseqr.rz( A, b, tolerance = 0 )
58
+
59
+ # Solve a linear system M x = B via QR decomposition
60
+ #
61
+ # This approach is slow due to the explicit construction of Q, but may be
62
+ # useful if a large number of systems need to be solved with the same M.
63
+ #
64
+ M = scipy.sparse.rand( 10, 10, density = 0.1 )
65
+ Q, R, E, rank = sparseqr.qr( M )
66
+ r = rank # r could be min(M.shape) if M is full-rank
67
+
68
+ # The system is only solvable if the lower part of Q.T @ B is all zero:
69
+ print( "System is solvable if this is zero (unlikely for a random matrix):", abs( (( Q.tocsc()[:,r:] ).T ).dot( B ) ).sum() )
70
+
71
+ # Systems with large non-square matrices can benefit from "economy" decomposition.
72
+ M = scipy.sparse.rand( 20, 5, density=0.1 )
73
+ B = scipy.sparse.rand( 20, 5, density = 0.1 )
74
+ Q, R, E, rank = sparseqr.qr( M )
75
+ print("Q shape (should be 20x20):", Q.shape)
76
+ print("R shape (should be 20x5):", R.shape)
77
+ Q, R, E, rank = sparseqr.qr( M, economy=True )
78
+ print("Q shape (should be 20x5):", Q.shape)
79
+ print("R shape (should be 5x5):", R.shape)
80
+
81
+
82
+ R = R.tocsr()[:r,:r] #for best performance, spsolve_triangular() wants the Matrix to be in CSR format.
83
+ Q = Q.tocsc()[:,:r] # Use CSC format for fast indexing of columns.
84
+ QB = (Q.T).dot(B).todense() # spsolve_triangular() need the RHS in array format.
85
+ result = scipy.sparse.linalg.spsolve_triangular(R, QB, lower=False)
86
+
87
+ # Recover a solution (as a dense array):
88
+ x = numpy.zeros( ( M.shape[1], B.shape[1] ), dtype = result.dtype )
89
+ x[:r] = result
90
+ x[E] = x.copy()
91
+
92
+ # Recover a solution (as a sparse matrix):
93
+ x = scipy.sparse.vstack( ( result, scipy.sparse.coo_matrix( ( M.shape[1] - rank, B.shape[1] ), dtype = result.dtype ) ) )
94
+ x.row = E[ x.row ]
95
+ ```
96
+
97
+ # Installation
98
+
99
+ Before installing this module, you must first install [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html). You can do that via conda (`conda install suitesparse`) or your system's package manager (macOS: `brew install suitesparse`; debian/ubuntu linux: `apt-get install libsuitesparse-dev`).
100
+
101
+ Now you are ready to install this module.
102
+
103
+ ## Via `pip`
104
+
105
+ From PyPI:
106
+
107
+ ```bash
108
+ pip install sparseqr
109
+ ```
110
+
111
+ From GitHub:
112
+
113
+ ```bash
114
+ pip install git+https://github.com/yig/PySPQR.git
115
+ ```
116
+
117
+ ## Directly
118
+
119
+ Copy the three `sparseqr/*.py` files next to your source code,
120
+ or leave them in their directory and call it as a module.
121
+
122
+
123
+ # Deploy
124
+
125
+ 1. Change the version in `sparseqr/__init__.py`
126
+
127
+ 2. Update `CHANGELOG.md`
128
+
129
+ 3. Commit to git. Push to GitHub.
130
+
131
+ 4. Run (in a clean repos, e.g., `git clone . clean; cd clean`):
132
+
133
+ ```
134
+ flit publish --format sdist
135
+ ```
136
+
137
+ Using [uv](https://docs.astral.sh/uv/) and [PyPI API tokens](https://pypi.org/help/#apitoken):
138
+
139
+ ```
140
+ FLIT_USERNAME=__token__ uv tool run --with flit flit publish --format sdist
141
+ ```
142
+
143
+ We don't publish binary wheels, because it must be compiled against suite-sparse as a system dependency. We could publish a `none-any` wheel, which would cause compilation to happen the first time the module is imported rather than when it is installed. Is there a point to that?
144
+
145
+ # Known issues
146
+
147
+ `pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
148
+
149
+ # Tested
150
+
151
+ GitHub Continuous Integration (CI) tests:
152
+
153
+ - Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
154
+ - macOS, Ubuntu Linux, and Windows.
155
+ - conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
156
+
157
+ Test manually with:
158
+
159
+ ```
160
+ python -m pytest
161
+ ```
162
+
163
+ or
164
+
165
+ ```
166
+ uv run --extra test pytest
167
+ ```
168
+
169
+ # Dependencies
170
+
171
+ These are listed as dependencies and will be installed automatically:
172
+
173
+ * [SciPy/NumPy](http://www.scipy.org)
174
+ * [cffi](http://cffi.readthedocs.io/)
175
+
176
+ These must be installed manually:
177
+
178
+ * [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html) (macOS: `brew install suitesparse`; debian/ubuntu linux: `apt-get install libsuitesparse-dev`)
179
+
180
+ # License
181
+
182
+ Public Domain [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
@@ -0,0 +1,15 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ sparseqr/__init__.py
6
+ sparseqr/cffi_asarray.py
7
+ sparseqr/sparseqr.py
8
+ sparseqr/sparseqr_gen.py
9
+ sparseqr.egg-info/PKG-INFO
10
+ sparseqr.egg-info/SOURCES.txt
11
+ sparseqr.egg-info/dependency_links.txt
12
+ sparseqr.egg-info/requires.txt
13
+ sparseqr.egg-info/top_level.txt
14
+ test/test_complex.py
15
+ test/test_sparseqr.py
@@ -0,0 +1,6 @@
1
+ numpy>1.2
2
+ scipy>=1.0
3
+ cffi>=1.0
4
+
5
+ [test]
6
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ sparseqr
@@ -0,0 +1,58 @@
1
+ """Tests for complex matrix support in sparseqr."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+ from scipy.sparse import random as sparse_random
6
+
7
+ import sparseqr
8
+
9
+
10
+ def adj(A):
11
+ """Return the conjugate transpose (adjoint) of a matrix."""
12
+ return A.conj().T
13
+
14
+
15
+ class TestComplexQR:
16
+ """Tests for QR decomposition of complex matrices."""
17
+
18
+ ATOL = 1e-14 # close to machine precision
19
+
20
+ def test_complex_qr_unitary_q(self):
21
+ """Test that Q is unitary for complex matrix (Q @ Q^H = I)."""
22
+ A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
23
+ Q, R, P, rank = sparseqr.qr(A)
24
+
25
+ QQH = (Q @ adj(Q)).toarray()
26
+ np.testing.assert_allclose(QQH, np.eye(Q.shape[0]), atol=self.ATOL)
27
+
28
+ def test_complex_qr_upper_triangular_r(self):
29
+ """Test that R is upper triangular for complex matrix."""
30
+ A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
31
+ Q, R, P, rank = sparseqr.qr(A)
32
+
33
+ lower_triangle = np.tril(R.toarray(), k=-1)
34
+ np.testing.assert_allclose(lower_triangle, 0, atol=self.ATOL)
35
+
36
+ def test_complex_qr_reconstruction(self):
37
+ """Test that A can be reconstructed from QR decomposition: A = (QR) @ P^T."""
38
+ A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
39
+ Q, R, P, rank = sparseqr.qr(A)
40
+
41
+ P_matrix = sparseqr.permutation_vector_to_matrix(P)
42
+ A_reconstructed = (Q @ R) @ P_matrix.T
43
+ np.testing.assert_allclose(A_reconstructed.toarray(), A.toarray(), atol=self.ATOL)
44
+
45
+ @pytest.mark.parametrize("shape", [(50, 50), (100, 50), (50, 100)])
46
+ def test_complex_qr_various_shapes(self, shape):
47
+ """Test complex QR on square, tall, and wide matrices."""
48
+ m, n = shape
49
+ A = sparse_random(m=m, n=n, density=0.1, dtype=np.complex128, random_state=42)
50
+ Q, R, P, rank = sparseqr.qr(A)
51
+
52
+ P_matrix = sparseqr.permutation_vector_to_matrix(P)
53
+ A_reconstructed = (Q @ R) @ P_matrix.T
54
+ np.testing.assert_allclose(A_reconstructed.toarray(), A.toarray(), atol=self.ATOL)
55
+
56
+
57
+ if __name__ == '__main__':
58
+ pytest.main([__file__, '-v'])
@@ -0,0 +1,313 @@
1
+ """Tests for sparseqr module.
2
+
3
+ Run with: pytest test/ -v
4
+ Or simply: python -m pytest
5
+ """
6
+
7
+ import numpy as np
8
+ import scipy.sparse
9
+ import pytest
10
+
11
+ import sparseqr
12
+
13
+
14
+ class TestQR:
15
+ """Tests for sparseqr.qr() function."""
16
+
17
+ def test_qr_basic_decomposition(self):
18
+ """Test that Q*R = M*E for a random sparse matrix."""
19
+ M = scipy.sparse.rand(10, 10, density=0.1, random_state=42)
20
+ Q, R, E, rank = sparseqr.qr(M)
21
+
22
+ # Q*R should equal M*E
23
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
24
+ residual = abs(Q @ R - M @ E_matrix).sum()
25
+ assert residual < 1e-10, f"QR decomposition residual too large: {residual}"
26
+
27
+ def test_qr_rectangular_tall(self):
28
+ """Test QR on a tall (overdetermined) matrix."""
29
+ M = scipy.sparse.rand(20, 10, density=0.1, random_state=42)
30
+ Q, R, E, rank = sparseqr.qr(M)
31
+
32
+ assert Q.shape == (20, 20), f"Q shape wrong: {Q.shape}"
33
+ assert R.shape == (20, 10), f"R shape wrong: {R.shape}"
34
+
35
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
36
+ residual = abs(Q @ R - M @ E_matrix).sum()
37
+ assert residual < 1e-10
38
+
39
+ def test_qr_rectangular_wide(self):
40
+ """Test QR on a wide (underdetermined) matrix."""
41
+ M = scipy.sparse.rand(10, 20, density=0.1, random_state=42)
42
+ Q, R, E, rank = sparseqr.qr(M)
43
+
44
+ assert Q.shape == (10, 10), f"Q shape wrong: {Q.shape}"
45
+ assert R.shape == (10, 20), f"R shape wrong: {R.shape}"
46
+
47
+ def test_qr_economy_mode(self):
48
+ """Test economy=True produces smaller Q and R."""
49
+ M = scipy.sparse.rand(20, 5, density=0.1, random_state=42)
50
+
51
+ # Full QR
52
+ Q_full, R_full, E, rank = sparseqr.qr(M, economy=False)
53
+ assert Q_full.shape == (20, 20)
54
+ assert R_full.shape == (20, 5)
55
+
56
+ # Economy QR
57
+ Q_econ, R_econ, E, rank = sparseqr.qr(M, economy=True)
58
+ assert Q_econ.shape == (20, 5), f"Economy Q shape wrong: {Q_econ.shape}"
59
+ assert R_econ.shape == (5, 5), f"Economy R shape wrong: {R_econ.shape}"
60
+
61
+ def test_qr_identity_matrix(self):
62
+ """Test QR decomposition of identity matrix."""
63
+ I = scipy.sparse.eye(5, format='coo')
64
+ Q, R, E, rank = sparseqr.qr(I)
65
+
66
+ assert rank == 5, f"Rank should be 5, got {rank}"
67
+
68
+ # Q and R should both be identity (up to permutation)
69
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
70
+ residual = abs(Q @ R - I @ E_matrix).sum()
71
+ assert residual < 1e-10
72
+
73
+ def test_qr_with_tolerance(self):
74
+ """Test QR with explicit tolerance parameter."""
75
+ M = scipy.sparse.rand(10, 10, density=0.2, random_state=42)
76
+ Q, R, E, rank = sparseqr.qr(M, tolerance=0)
77
+
78
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
79
+ residual = abs(Q @ R - M @ E_matrix).sum()
80
+ assert residual < 1e-10
81
+
82
+
83
+ class TestSolve:
84
+ """Tests for sparseqr.solve() function."""
85
+
86
+ def test_solve_dense_rhs_single(self):
87
+ """Test solving Ax=b with single dense RHS vector."""
88
+ # Create a well-conditioned matrix
89
+ A = scipy.sparse.diags([1, 2, 3, 4, 5], format='coo', dtype=np.float64) + scipy.sparse.rand(5, 5, density=0.1, random_state=42)
90
+ b = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
91
+
92
+ x = sparseqr.solve(A, b, tolerance=0)
93
+
94
+ assert x is not None, "solve() returned None"
95
+ assert x.shape == (5,), f"Solution shape wrong: {x.shape}"
96
+
97
+ def test_solve_dense_rhs_multiple(self):
98
+ """Test solving AX=B with multiple dense RHS vectors."""
99
+ A = scipy.sparse.diags([1, 2, 3, 4, 5], format='coo', dtype=np.float64) + scipy.sparse.rand(5, 5, density=0.1, random_state=42)
100
+ B = np.random.RandomState(42).random((5, 3))
101
+
102
+ X = sparseqr.solve(A, B, tolerance=0)
103
+
104
+ assert X is not None, "solve() returned None"
105
+ assert X.shape == (5, 3), f"Solution shape wrong: {X.shape}"
106
+
107
+ def test_solve_sparse_rhs(self):
108
+ """Test solving AX=B with sparse RHS."""
109
+ A = scipy.sparse.diags([1, 2, 3, 4, 5], format='coo', dtype=np.float64) + scipy.sparse.rand(5, 5, density=0.1, random_state=42)
110
+ B = scipy.sparse.rand(5, 3, density=0.3, random_state=42)
111
+
112
+ X = sparseqr.solve(A, B, tolerance=0)
113
+
114
+ assert X is not None, "solve() returned None"
115
+ assert scipy.sparse.issparse(X), "Solution should be sparse for sparse RHS"
116
+ assert X.shape == (5, 3), f"Solution shape wrong: {X.shape}"
117
+
118
+ def test_solve_overdetermined_least_squares(self):
119
+ """Test least-squares solution for overdetermined system."""
120
+ # 20 equations, 10 unknowns
121
+ A = scipy.sparse.rand(20, 10, density=0.2, random_state=42)
122
+ b = np.random.RandomState(42).random(20)
123
+
124
+ x = sparseqr.solve(A, b, tolerance=0)
125
+
126
+ assert x is not None, "solve() returned None"
127
+ assert x.shape == (10,), f"Solution shape wrong: {x.shape}"
128
+
129
+ def test_solve_exact_system(self):
130
+ """Test solving an exact system where Ax=b has exact solution."""
131
+ # Create a system with known solution
132
+ A = scipy.sparse.eye(5, format='coo')
133
+ x_true = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
134
+ b = A @ x_true
135
+
136
+ x = sparseqr.solve(A, b, tolerance=0)
137
+
138
+ assert x is not None
139
+ np.testing.assert_allclose(x, x_true, rtol=1e-10)
140
+
141
+
142
+ class TestRZ:
143
+ """Tests for sparseqr.rz() function."""
144
+
145
+ def test_rz_basic(self):
146
+ """Test rz() returns expected outputs."""
147
+ A = scipy.sparse.rand(20, 10, density=0.1, random_state=42)
148
+ b = np.random.RandomState(42).random(20)
149
+
150
+ Z, R, E, rank = sparseqr.rz(A, b, tolerance=0)
151
+
152
+ assert Z is not None
153
+ assert R is not None
154
+ assert isinstance(rank, (int, np.integer))
155
+
156
+ def test_rz_output_shapes(self):
157
+ """Test rz() returns correct shapes."""
158
+ m, n = 15, 8
159
+ A = scipy.sparse.rand(m, n, density=0.2, random_state=42)
160
+ b = np.random.RandomState(42).random(m)
161
+
162
+ Z, R, E, rank = sparseqr.rz(A, b, tolerance=0)
163
+
164
+ # Z should have n rows (one per unknown)
165
+ assert Z.shape[0] == n, f"Z rows should be {n}, got {Z.shape[0]}"
166
+
167
+
168
+ class TestQRFactorize:
169
+ """Tests for sparseqr.qr_factorize() and qmult() functions."""
170
+
171
+ def test_qr_factorize_basic(self):
172
+ """Test qr_factorize() returns a factorization object."""
173
+ M = scipy.sparse.rand(100, 100, density=0.05, random_state=42)
174
+ QR = sparseqr.qr_factorize(M)
175
+
176
+ assert QR is not None
177
+
178
+ def test_qmult_basic(self):
179
+ """Test qmult() with Householder form QR."""
180
+ M = scipy.sparse.rand(100, 100, density=0.05, random_state=42)
181
+ QR = sparseqr.qr_factorize(M)
182
+
183
+ X = np.zeros((M.shape[0], 1))
184
+ X[-1, 0] = 1
185
+
186
+ Y = sparseqr.qmult(QR, X)
187
+
188
+ assert Y.shape == (100, 1), f"Y shape wrong: {Y.shape}"
189
+
190
+ def test_qmult_methods(self):
191
+ """Test different qmult methods (Q'X, QX, XQ', XQ)."""
192
+ M = scipy.sparse.rand(20, 20, density=0.2, random_state=42)
193
+ QR = sparseqr.qr_factorize(M)
194
+
195
+ X = np.random.RandomState(42).random((20, 1))
196
+
197
+ # method=0: Q'X
198
+ Y0 = sparseqr.qmult(QR, X, method=0)
199
+ assert Y0.shape == (20, 1)
200
+
201
+ # method=1: QX (default)
202
+ Y1 = sparseqr.qmult(QR, X, method=1)
203
+ assert Y1.shape == (20, 1)
204
+
205
+
206
+ class TestPermutationVectorToMatrix:
207
+ """Tests for sparseqr.permutation_vector_to_matrix() function."""
208
+
209
+ def test_identity_permutation(self):
210
+ """Test identity permutation [0,1,2,3,4]."""
211
+ E = np.array([0, 1, 2, 3, 4])
212
+ P = sparseqr.permutation_vector_to_matrix(E)
213
+
214
+ expected = scipy.sparse.eye(5)
215
+ diff = abs(P - expected).sum()
216
+ assert diff < 1e-10, "Identity permutation should produce identity matrix"
217
+
218
+ def test_swap_permutation(self):
219
+ """Test a permutation that swaps first and last."""
220
+ E = np.array([4, 1, 2, 3, 0])
221
+ P = sparseqr.permutation_vector_to_matrix(E)
222
+
223
+ # P[E[k], k] = 1, so P[4,0]=1, P[1,1]=1, P[2,2]=1, P[3,3]=1, P[0,4]=1
224
+ assert P.shape == (5, 5)
225
+ assert P.nnz == 5 # exactly 5 non-zeros
226
+
227
+ # Convert to dense for easier checking
228
+ P_dense = P.toarray()
229
+ assert P_dense[4, 0] == 1
230
+ assert P_dense[0, 4] == 1
231
+
232
+ def test_permutation_orthogonality(self):
233
+ """Test that permutation matrix is orthogonal (P @ P.T = I)."""
234
+ E = np.array([2, 0, 4, 1, 3])
235
+ P = sparseqr.permutation_vector_to_matrix(E)
236
+
237
+ result = P @ P.T
238
+ expected = scipy.sparse.eye(5)
239
+ diff = abs(result - expected).sum()
240
+ assert diff < 1e-10
241
+
242
+
243
+ class TestEdgeCases:
244
+ """Tests for edge cases and special matrices."""
245
+
246
+ def test_very_sparse_matrix(self):
247
+ """Test with very sparse matrix."""
248
+ M = scipy.sparse.rand(50, 50, density=0.01, random_state=42)
249
+ Q, R, E, rank = sparseqr.qr(M)
250
+
251
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
252
+ residual = abs(Q @ R - M @ E_matrix).sum()
253
+ assert residual < 1e-10
254
+
255
+ def test_diagonal_matrix(self):
256
+ """Test with diagonal matrix."""
257
+ M = scipy.sparse.diags([1, 2, 3, 4, 5], format='coo', dtype=np.float64)
258
+ Q, R, E, rank = sparseqr.qr(M)
259
+
260
+ assert rank == 5
261
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
262
+ residual = abs(Q @ R - M @ E_matrix).sum()
263
+ assert residual < 1e-10
264
+
265
+ def test_single_element_matrix(self):
266
+ """Test with 1x1 matrix."""
267
+ M = scipy.sparse.coo_matrix([[3.0]])
268
+ Q, R, E, rank = sparseqr.qr(M)
269
+
270
+ assert rank == 1
271
+ assert Q.shape == (1, 1)
272
+ assert R.shape == (1, 1)
273
+
274
+ def test_different_sparse_formats(self):
275
+ """Test that different input formats work (coo, csr, csc)."""
276
+ base = scipy.sparse.rand(10, 10, density=0.2, random_state=42)
277
+
278
+ for fmt in ['coo', 'csr', 'csc', 'lil']:
279
+ M = base.asformat(fmt)
280
+ Q, R, E, rank = sparseqr.qr(M)
281
+ E_matrix = sparseqr.permutation_vector_to_matrix(E)
282
+ residual = abs(Q @ R - M @ E_matrix).sum()
283
+ assert residual < 1e-10, f"Failed for format {fmt}"
284
+
285
+
286
+ class TestNumericalAccuracy:
287
+ """Tests for numerical accuracy."""
288
+
289
+ def test_orthogonality_of_q(self):
290
+ """Test that Q is orthogonal (Q.T @ Q ≈ I for the relevant columns)."""
291
+ M = scipy.sparse.rand(10, 10, density=0.3, random_state=42)
292
+ Q, R, E, rank = sparseqr.qr(M)
293
+
294
+ # For a square matrix with full rank, Q should be orthogonal
295
+ if rank == 10:
296
+ QtQ = (Q.T @ Q).toarray()
297
+ I = np.eye(10)
298
+ np.testing.assert_allclose(QtQ, I, atol=1e-10)
299
+
300
+ def test_upper_triangular_r(self):
301
+ """Test that R is upper triangular."""
302
+ M = scipy.sparse.rand(10, 10, density=0.3, random_state=42)
303
+ Q, R, E, rank = sparseqr.qr(M)
304
+
305
+ R_dense = R.toarray()
306
+ # Check lower triangle is zero (below diagonal)
307
+ for i in range(R_dense.shape[0]):
308
+ for j in range(min(i, R_dense.shape[1])):
309
+ assert abs(R_dense[i, j]) < 1e-10, f"R[{i},{j}] = {R_dense[i,j]} should be zero"
310
+
311
+
312
+ if __name__ == '__main__':
313
+ pytest.main([__file__, '-v'])
sparseqr-1.4.1/.gitignore DELETED
@@ -1,3 +0,0 @@
1
- *.pyc
2
- *.pyo
3
- _spqr.*
@@ -1,57 +0,0 @@
1
- # Changelog
2
- All notable changes to this project will be documented in this file.
3
-
4
- The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
- and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
-
7
- ## [Unreleased]
8
-
9
- ## [v1.4.1] - 2025-02-10
10
- ### Fixed
11
- - An import statement got lost in the 1.4 update.
12
-
13
- ## [v1.4] - 2025-01-28
14
- ### Fixed
15
- - Modernized the build system (`pyproject.toml`).
16
- - Changed the way suite-sparse is found to be more robust.
17
-
18
- ## [v1.3] - 2025-01-09
19
- ### Added
20
- - Bindings for `qr_factorize` and `qmult` (thanks to jkrokowski)
21
-
22
- ### Fixed
23
- - Compatibility with more environments (more search paths, newer numpy, setuptools dependency)
24
- - Readme example uses `spsolve_triangular`.
25
-
26
- ## [v1.2.1] - 2023-04-12
27
- ### Fixed
28
- - Fixed a memory leak in `qr()` and `rz()`.
29
- ### Changed
30
- - Bumped minimal Python version to 3.8.
31
- - `rz()` is called by the test script. Its output is ignored.
32
-
33
- ## [v1.2] - 2022-05-27
34
- ### Added
35
- - Added support for partial "economy" decompositions. (Christoph Hansknecht <c.hansknecht@tu-braunschweig.de>): 'The "economy" option can be used in SPQR to compute a QR factorization of a (m x n) matrix with m < n consisting of blocks Q_1, and Q_2, where Q_1 has as shape of (m x n) and Q_2 of (m x k - n). For k = n we get the reduced form, for k = m the full one. For k in between m and n, SPQR yields a block that spans part of the kernel of A. This patch adds this functionality to PySPQR.'
36
- - Added support for macOS on arm64.
37
-
38
- ## [v1.1.2] - 2021-08-09
39
- ### Added
40
- - Added rz recomposition (thanks to Ben Smith <bsmith@apl.washington.edu>)
41
- - Added support for "economy" decomposition. (Jeffrey Bouas <ignirtoq@gmail.com>)
42
- ### Changed
43
- - Supports conda environments (thanks to Ben Smith <bsmith@apl.washington.edu> and Sterling Baird <sterling.baird@icloud.com>)
44
-
45
- ## [v1.0.0] - 2017-08-31
46
- ### Added
47
- - Installation and packaging using `setuptools`
48
- ### Changed
49
- - Rename module `spqr` to `sparseqr`
50
- - Clean up public API: `qr`, `solve`, `permutation_vector_to_matrix`
51
-
52
- ## [v1.0.0] - 2017-08-31
53
- ### Added
54
- - Installation and packaging using `setuptools` (thanks to Juha Jeronen <juha.jeronen@tut.fi>)
55
- ### Changed
56
- - Rename module `spqr` to `sparseqr`
57
- - Clean up public API: `qr`, `solve`, `permutation_vector_to_matrix`
@@ -1,79 +0,0 @@
1
- #!/usr/bin/python
2
- # -*- coding: utf-8 -*-
3
-
4
- from __future__ import division, print_function, absolute_import
5
-
6
- import numpy
7
- import scipy.sparse.linalg
8
- import sparseqr
9
-
10
- # QR decompose a sparse matrix M such that Q R = M E
11
- #
12
- M = scipy.sparse.rand( 10, 10, density = 0.1 )
13
- Q, R, E, rank = sparseqr.qr( M )
14
- print( "Should be approximately zero:", abs( Q*R - M*sparseqr.permutation_vector_to_matrix(E) ).sum() )
15
-
16
- # Solve many linear systems "M x = b for b in columns(B)"
17
- #
18
- B = scipy.sparse.rand( 10, 5, density = 0.1 ) # many RHS, sparse (could also have just one RHS with shape (10,))
19
- x = sparseqr.solve( M, B, tolerance = 0 )
20
-
21
- # Solve an overdetermined linear system A x = b in the least-squares sense
22
- #
23
- # The same routine also works for the usual non-overdetermined case.
24
- #
25
- A = scipy.sparse.rand( 20, 10, density = 0.1 ) # 20 equations, 10 unknowns
26
- b = numpy.random.random(20) # one RHS, dense, but could also have many (in shape (20,k))
27
- x = sparseqr.solve( A, b, tolerance = 0 )
28
- ## Call `rz()`:
29
- sparseqr.rz( A, b, tolerance = 0 )
30
-
31
- # Solve a linear system M x = B via QR decomposition
32
- #
33
- # This approach is slow due to the explicit construction of Q, but may be
34
- # useful if a large number of systems need to be solved with the same M.
35
- #
36
- M = scipy.sparse.rand( 10, 10, density = 0.1 )
37
- Q, R, E, rank = sparseqr.qr( M )
38
- r = rank # r could be min(M.shape) if M is full-rank
39
-
40
- # The system is only solvable if the lower part of Q.T @ B is all zero:
41
- print( "System is solvable if this is zero (unlikely for a random matrix):", abs( (( Q.tocsc()[:,r:] ).T ).dot( B ) ).sum() )
42
-
43
- # Systems with large non-square matrices can benefit from "economy" decomposition.
44
- M = scipy.sparse.rand( 20, 5, density=0.1 )
45
- B = scipy.sparse.rand( 20, 5, density = 0.1 )
46
- Q, R, E, rank = sparseqr.qr( M )
47
- print("Q shape (should be 20x20):", Q.shape)
48
- print("R shape (should be 20x5):", R.shape)
49
- Q, R, E, rank = sparseqr.qr( M, economy=True )
50
- print("Q shape (should be 20x5):", Q.shape)
51
- print("R shape (should be 5x5):", R.shape)
52
-
53
- # Use CSC format for fast indexing of columns.
54
- R = R.tocsc()[:r,:r]
55
- Q = Q.tocsc()[:,:r]
56
- QB = (Q.T).dot(B).tocsc() # for best performance, spsolve() wants the RHS to be in CSC format.
57
- result = scipy.sparse.linalg.spsolve(R, QB)
58
-
59
- # Recover a solution (as a dense array):
60
- x = numpy.zeros( ( M.shape[1], B.shape[1] ), dtype = result.dtype )
61
- x[:r] = result.todense()
62
- x[E] = x.copy()
63
-
64
- # Recover a solution (as a sparse matrix):
65
- x = scipy.sparse.vstack( ( result.tocoo(), scipy.sparse.coo_matrix( ( M.shape[1] - rank, B.shape[1] ), dtype = result.dtype ) ) )
66
- x.row = E[ x.row ]
67
-
68
- #initialize QR Factorization object
69
- M = scipy.sparse.rand( 100, 100, density=0.05 )
70
-
71
- #perform QR factorization, but store in Householder form
72
- QR= sparseqr.qr_factorize( M )
73
- X = numpy.zeros((M.shape[0],1))
74
- #change last entry of the first column to a 1
75
- # this allows us to construct only the first column of Q
76
- X[-1,0]=1
77
-
78
- Y = sparseqr.qmult(QR,X)
79
- print("Y shape (should be 100x1):",Y.shape)
File without changes
File without changes