sparseqr 1.4.1__tar.gz → 1.5.1__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.
- sparseqr-1.4.1/README.md → sparseqr-1.5.1/PKG-INFO +59 -12
- sparseqr-1.4.1/PKG-INFO → sparseqr-1.5.1/README.md +40 -28
- {sparseqr-1.4.1 → sparseqr-1.5.1}/pyproject.toml +15 -4
- sparseqr-1.5.1/setup.cfg +4 -0
- {sparseqr-1.4.1 → sparseqr-1.5.1}/sparseqr/__init__.py +1 -1
- {sparseqr-1.4.1 → sparseqr-1.5.1}/sparseqr/sparseqr.py +52 -10
- {sparseqr-1.4.1 → sparseqr-1.5.1}/sparseqr/sparseqr_gen.py +7 -3
- sparseqr-1.5.1/sparseqr.egg-info/PKG-INFO +195 -0
- sparseqr-1.5.1/sparseqr.egg-info/SOURCES.txt +15 -0
- sparseqr-1.5.1/sparseqr.egg-info/dependency_links.txt +1 -0
- sparseqr-1.5.1/sparseqr.egg-info/requires.txt +6 -0
- sparseqr-1.5.1/sparseqr.egg-info/top_level.txt +1 -0
- sparseqr-1.5.1/test/test_complex.py +105 -0
- sparseqr-1.5.1/test/test_sparseqr.py +313 -0
- sparseqr-1.4.1/.gitignore +0 -3
- sparseqr-1.4.1/CHANGELOG.md +0 -57
- sparseqr-1.4.1/test/test.py +0 -79
- {sparseqr-1.4.1 → sparseqr-1.5.1}/LICENSE.md +0 -0
- {sparseqr-1.4.1 → sparseqr-1.5.1}/setup.py +0 -0
- {sparseqr-1.4.1 → sparseqr-1.5.1}/sparseqr/cffi_asarray.py +0 -0
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparseqr
|
|
3
|
+
Version: 1.5.1
|
|
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
|
+
|
|
1
20
|
# Python wrapper for SuiteSparseQR
|
|
2
21
|
|
|
3
22
|
This module wraps the [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html)
|
|
@@ -103,19 +122,29 @@ or leave them in their directory and call it as a module.
|
|
|
103
122
|
|
|
104
123
|
# Deploy
|
|
105
124
|
|
|
106
|
-
1. Change the version in
|
|
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`):
|
|
107
132
|
|
|
108
133
|
```
|
|
109
|
-
|
|
110
|
-
pyproject.toml
|
|
134
|
+
flit publish --format sdist
|
|
111
135
|
```
|
|
112
136
|
|
|
113
|
-
|
|
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
|
+
```
|
|
114
142
|
|
|
115
|
-
|
|
143
|
+
or
|
|
116
144
|
|
|
117
145
|
```
|
|
118
|
-
|
|
146
|
+
uv build --sdist
|
|
147
|
+
UV_PUBLISH_TOKEN=<token> uv publish
|
|
119
148
|
```
|
|
120
149
|
|
|
121
150
|
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?
|
|
@@ -124,17 +153,35 @@ We don't publish binary wheels, because it must be compiled against suite-sparse
|
|
|
124
153
|
|
|
125
154
|
`pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
|
|
126
155
|
|
|
127
|
-
# Tested
|
|
156
|
+
# Tested
|
|
157
|
+
|
|
158
|
+
GitHub Continuous Integration (CI) tests:
|
|
128
159
|
|
|
129
|
-
- Python 3.9, 3.13.
|
|
130
|
-
-
|
|
131
|
-
-
|
|
160
|
+
- Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
|
|
161
|
+
- macOS, Ubuntu Linux, and Windows.
|
|
162
|
+
- conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
|
|
132
163
|
|
|
133
|
-
|
|
164
|
+
Test manually with:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
python -m pytest
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
or
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
uv run --extra test pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
or
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
uv run --with sparseqr,pytest path/to/test_sparseqr.py
|
|
180
|
+
```
|
|
134
181
|
|
|
135
182
|
# Dependencies
|
|
136
183
|
|
|
137
|
-
These are installed
|
|
184
|
+
These are listed as dependencies and will be installed automatically:
|
|
138
185
|
|
|
139
186
|
* [SciPy/NumPy](http://www.scipy.org)
|
|
140
187
|
* [cffi](http://cffi.readthedocs.io/)
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: sparseqr
|
|
3
|
-
Version: 1.4.1
|
|
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
|
-
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
|
|
13
|
-
Project-URL: homepage, https://github.com/yig/PySPQR
|
|
14
|
-
Project-URL: source, https://github.com/yig/PySPQR
|
|
15
|
-
|
|
16
1
|
# Python wrapper for SuiteSparseQR
|
|
17
2
|
|
|
18
3
|
This module wraps the [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html)
|
|
@@ -118,19 +103,29 @@ or leave them in their directory and call it as a module.
|
|
|
118
103
|
|
|
119
104
|
# Deploy
|
|
120
105
|
|
|
121
|
-
1. Change the version in
|
|
106
|
+
1. Change the version in `sparseqr/__init__.py`
|
|
107
|
+
|
|
108
|
+
2. Update `CHANGELOG.md`
|
|
109
|
+
|
|
110
|
+
3. Commit to git. Push to GitHub.
|
|
111
|
+
|
|
112
|
+
4. Run (in a clean repos, e.g., `git clone . clean; cd clean`):
|
|
122
113
|
|
|
123
114
|
```
|
|
124
|
-
|
|
125
|
-
pyproject.toml
|
|
115
|
+
flit publish --format sdist
|
|
126
116
|
```
|
|
127
117
|
|
|
128
|
-
|
|
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
|
+
```
|
|
129
123
|
|
|
130
|
-
|
|
124
|
+
or
|
|
131
125
|
|
|
132
126
|
```
|
|
133
|
-
|
|
127
|
+
uv build --sdist
|
|
128
|
+
UV_PUBLISH_TOKEN=<token> uv publish
|
|
134
129
|
```
|
|
135
130
|
|
|
136
131
|
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?
|
|
@@ -139,17 +134,35 @@ We don't publish binary wheels, because it must be compiled against suite-sparse
|
|
|
139
134
|
|
|
140
135
|
`pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
|
|
141
136
|
|
|
142
|
-
# Tested
|
|
137
|
+
# Tested
|
|
138
|
+
|
|
139
|
+
GitHub Continuous Integration (CI) tests:
|
|
143
140
|
|
|
144
|
-
- Python 3.9, 3.13.
|
|
145
|
-
-
|
|
146
|
-
-
|
|
141
|
+
- Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
|
|
142
|
+
- macOS, Ubuntu Linux, and Windows.
|
|
143
|
+
- conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
|
|
147
144
|
|
|
148
|
-
|
|
145
|
+
Test manually with:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
python -m pytest
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
or
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
uv run --extra test pytest
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
or
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
uv run --with sparseqr,pytest path/to/test_sparseqr.py
|
|
161
|
+
```
|
|
149
162
|
|
|
150
163
|
# Dependencies
|
|
151
164
|
|
|
152
|
-
These are installed
|
|
165
|
+
These are listed as dependencies and will be installed automatically:
|
|
153
166
|
|
|
154
167
|
* [SciPy/NumPy](http://www.scipy.org)
|
|
155
168
|
* [cffi](http://cffi.readthedocs.io/)
|
|
@@ -161,4 +174,3 @@ These must be installed manually:
|
|
|
161
174
|
# License
|
|
162
175
|
|
|
163
176
|
Public Domain [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
|
|
164
|
-
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sparseqr"
|
|
3
|
-
|
|
3
|
+
dynamic = ["version"]
|
|
4
4
|
description = "Python wrapper for SuiteSparseQR"
|
|
5
5
|
authors = [{name = "Yotam Gingold", email = "yotam@yotamgingold.com"}]
|
|
6
|
-
license =
|
|
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.1/setup.cfg
ADDED
|
@@ -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.
|
|
20
|
+
__version__ = '1.5.1'
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 ) ),
|
|
@@ -150,19 +170,41 @@ When no longer needed, the returned CHOLMOD dense matrix must be deallocated usi
|
|
|
150
170
|
nrow = numpy_A.shape[0]
|
|
151
171
|
ncol = numpy_A.shape[1]
|
|
152
172
|
lda = nrow # cholmod_dense is column-oriented
|
|
153
|
-
|
|
173
|
+
|
|
174
|
+
# Check if the array has complex entries and adapt cholmod type accordingly
|
|
175
|
+
is_complex = numpy.issubdtype(numpy_A.dtype, numpy.complexfloating)
|
|
176
|
+
cholmod_dtype = lib.CHOLMOD_COMPLEX if is_complex else lib.CHOLMOD_REAL
|
|
177
|
+
|
|
178
|
+
chol_A = lib.cholmod_l_allocate_dense( nrow, ncol, lda, cholmod_dtype, cc )
|
|
154
179
|
if chol_A == ffi.NULL:
|
|
155
180
|
raise RuntimeError("Failed to allocate chol_A")
|
|
156
181
|
Adata = ffi.cast( "double*", chol_A.x )
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
|
|
183
|
+
if is_complex:
|
|
184
|
+
# chol_A.x has size 2*nrow*ncol and real and imag parts are interleaved [real, imag, real, imag, ...]
|
|
185
|
+
array_element_size = ffi.sizeof(ffi.typeof(Adata).item)
|
|
186
|
+
Adata_view = numpy.frombuffer(ffi.buffer(Adata, 2*nrow*ncol * array_element_size), ctype2dtype["double"])
|
|
187
|
+
for j in range(ncol):
|
|
188
|
+
col_data = numpy_A[:,j]
|
|
189
|
+
Adata_view[(j*lda*2):((j+1)*lda*2):2] = col_data.real
|
|
190
|
+
Adata_view[(j*lda*2)+1:((j+1)*lda*2):2] = col_data.imag
|
|
191
|
+
else:
|
|
192
|
+
for j in range(ncol): # FIXME inefficient?
|
|
193
|
+
Adata[(j*lda):((j+1)*lda)] = numpy_A[:,j]
|
|
159
194
|
return chol_A
|
|
160
195
|
|
|
161
196
|
def cholmoddense2numpy( chol_A ):
|
|
162
197
|
'''Convert a CHOLMOD dense matrix to a NumPy array.'''
|
|
163
198
|
Adata = ffi.cast( "double*", chol_A.x )
|
|
164
199
|
|
|
165
|
-
|
|
200
|
+
if chol_A.xtype == lib.CHOLMOD_COMPLEX:
|
|
201
|
+
# read real and imag part from the buffer and create view as complex datatype
|
|
202
|
+
result = asarray(ffi, Adata, 2*chol_A.nrow*chol_A.ncol).copy()
|
|
203
|
+
complex_dtype = numpy.dtype(f"c{result.itemsize * 2}")
|
|
204
|
+
result = result.view(complex_dtype)
|
|
205
|
+
else:
|
|
206
|
+
result = asarray( ffi, Adata, chol_A.nrow*chol_A.ncol ).copy()
|
|
207
|
+
|
|
166
208
|
result = result.reshape( (chol_A.nrow, chol_A.ncol), order='F' )
|
|
167
209
|
return result
|
|
168
210
|
|
|
@@ -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
|
-
|
|
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,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparseqr
|
|
3
|
+
Version: 1.5.1
|
|
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
|
+
or
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
uv build --sdist
|
|
147
|
+
UV_PUBLISH_TOKEN=<token> uv publish
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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?
|
|
151
|
+
|
|
152
|
+
# Known issues
|
|
153
|
+
|
|
154
|
+
`pip uninstall sparseqr` won't remove the generated libraries. It will list them with a warning.
|
|
155
|
+
|
|
156
|
+
# Tested
|
|
157
|
+
|
|
158
|
+
GitHub Continuous Integration (CI) tests:
|
|
159
|
+
|
|
160
|
+
- Python 3.9, 3.10, 3.11, 3.12, 3.13, 3.14.
|
|
161
|
+
- macOS, Ubuntu Linux, and Windows.
|
|
162
|
+
- conda (Windows) and not conda (Linux/macOS). I tested conda on macOS manually at one point.
|
|
163
|
+
|
|
164
|
+
Test manually with:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
python -m pytest
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
or
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
uv run --extra test pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
or
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
uv run --with sparseqr,pytest path/to/test_sparseqr.py
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
# Dependencies
|
|
183
|
+
|
|
184
|
+
These are listed as dependencies and will be installed automatically:
|
|
185
|
+
|
|
186
|
+
* [SciPy/NumPy](http://www.scipy.org)
|
|
187
|
+
* [cffi](http://cffi.readthedocs.io/)
|
|
188
|
+
|
|
189
|
+
These must be installed manually:
|
|
190
|
+
|
|
191
|
+
* [SuiteSparseQR](http://faculty.cse.tamu.edu/davis/suitesparse.html) (macOS: `brew install suitesparse`; debian/ubuntu linux: `apt-get install libsuitesparse-dev`)
|
|
192
|
+
|
|
193
|
+
# License
|
|
194
|
+
|
|
195
|
+
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sparseqr
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for complex matrix support in sparseqr."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
import scipy.sparse
|
|
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 = scipy.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 = scipy.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 = scipy.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 = scipy.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
|
+
class TestComplexSolve:
|
|
58
|
+
"""Tests for solve function with complex matrices."""
|
|
59
|
+
|
|
60
|
+
ATOL = 1e-10
|
|
61
|
+
|
|
62
|
+
def test_complex_solve_exact_system(self):
|
|
63
|
+
"""Test solving an exact complex system where Ax=b has exact solution."""
|
|
64
|
+
A = scipy.sparse.random(m=50, n=50, density=0.2, dtype=np.complex128, random_state=42)
|
|
65
|
+
# Add diagonal to make it well-conditioned
|
|
66
|
+
A = A + 5 * scipy.sparse.eye(50, dtype=np.complex128)
|
|
67
|
+
x_true = np.random.RandomState(42).random(50) + 1j * np.random.RandomState(43).random(50)
|
|
68
|
+
b = A @ x_true
|
|
69
|
+
|
|
70
|
+
x = sparseqr.solve(A, b, tolerance=0)
|
|
71
|
+
|
|
72
|
+
assert x is not None, "solve() returned None"
|
|
73
|
+
np.testing.assert_allclose(x, x_true, atol=self.ATOL)
|
|
74
|
+
|
|
75
|
+
def test_complex_solve_overdetermined_least_squares(self):
|
|
76
|
+
"""Test least-squares solution for overdetermined complex system."""
|
|
77
|
+
# 100 equations, 50 unknowns
|
|
78
|
+
A = scipy.sparse.random(m=100, n=50, density=0.2, dtype=np.complex128, random_state=42)
|
|
79
|
+
x_true = np.random.RandomState(42).random(50) + 1j * np.random.RandomState(43).random(50)
|
|
80
|
+
b = A @ x_true
|
|
81
|
+
|
|
82
|
+
x = sparseqr.solve(A, b, tolerance=0)
|
|
83
|
+
|
|
84
|
+
assert x is not None, "solve() returned None"
|
|
85
|
+
assert x.shape == (50,), f"Solution shape wrong: {x.shape}"
|
|
86
|
+
# For an exact system (b in range of A), solution should match
|
|
87
|
+
np.testing.assert_allclose(x, x_true, atol=self.ATOL)
|
|
88
|
+
|
|
89
|
+
def test_complex_solve_multiple_rhs(self):
|
|
90
|
+
"""Test solving AX=B with multiple complex RHS vectors."""
|
|
91
|
+
A = scipy.sparse.random(m=50, n=50, density=0.2, dtype=np.complex128, random_state=42)
|
|
92
|
+
A = A + 5 * scipy.sparse.eye(50, dtype=np.complex128)
|
|
93
|
+
X_true = (np.random.RandomState(42).random((50, 3)) +
|
|
94
|
+
1j * np.random.RandomState(43).random((50, 3)))
|
|
95
|
+
B = A @ X_true
|
|
96
|
+
|
|
97
|
+
X = sparseqr.solve(A, B, tolerance=0)
|
|
98
|
+
|
|
99
|
+
assert X is not None, "solve() returned None"
|
|
100
|
+
assert X.shape == (50, 3), f"Solution shape wrong: {X.shape}"
|
|
101
|
+
np.testing.assert_allclose(X, X_true, atol=self.ATOL)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == '__main__':
|
|
105
|
+
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
sparseqr-1.4.1/CHANGELOG.md
DELETED
|
@@ -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`
|
sparseqr-1.4.1/test/test.py
DELETED
|
@@ -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
|
|
File without changes
|