Skip to content

Commit 187d6b6

Browse files
mdhaberj-bowhay
andauthored
ENH: linalg: enable N-D batch support in special matrix functions (scipy#21446)
--------- Co-authored-by: Jake Bowhay <[email protected]>
1 parent 1946f3e commit 187d6b6

File tree

3 files changed

+103
-43
lines changed

3 files changed

+103
-43
lines changed

scipy/linalg/_special_matrices.py

Lines changed: 75 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
from numpy.lib.stride_tricks import as_strided
66

7+
78
__all__ = ['toeplitz', 'circulant', 'hankel',
89
'hadamard', 'leslie', 'kron', 'block_diag', 'companion',
910
'helmert', 'hilbert', 'invhilbert', 'pascal', 'invpascal', 'dft',
@@ -16,7 +17,7 @@
1617

1718

1819
def toeplitz(c, r=None):
19-
"""
20+
r"""
2021
Construct a Toeplitz matrix.
2122
2223
The Toeplitz matrix has constant diagonals, with c as its first column
@@ -35,6 +36,12 @@ def toeplitz(c, r=None):
3536
``[c[0], r[1:]]``. Whatever the actual shape of `r`, it will be
3637
converted to a 1-D array.
3738
39+
.. warning::
40+
41+
Beginning in SciPy 1.17, multidimensional input will be treated as a batch,
42+
not ``ravel``\ ed. To preserve the existing behavior, ``ravel`` aruments
43+
before passing them to `toeplitz`.
44+
3845
Returns
3946
-------
4047
A : (len(c), len(r)) ndarray
@@ -65,11 +72,19 @@ def toeplitz(c, r=None):
6572
[ 4.-1.j, 2.+3.j, 1.+0.j]])
6673
6774
"""
68-
c = np.asarray(c).ravel()
75+
c = np.asarray(c)
6976
if r is None:
7077
r = c.conjugate()
7178
else:
72-
r = np.asarray(r).ravel()
79+
r = np.asarray(r)
80+
81+
if c.ndim > 1 or r.ndim > 1:
82+
msg = ("Beginning in SciPy 1.17, multidimensional input will be treated as a "
83+
"batch, not `ravel`ed. To preserve the existing behavior and silence "
84+
"this warning, `ravel` aruments before passing them to `toeplitz`.")
85+
warnings.warn(msg, FutureWarning, stacklevel=2)
86+
87+
c, r = c.ravel(), r.ravel()
7388
# Form a 1-D array containing a reversed c followed by r[1:] that could be
7489
# strided to give us toeplitz matrix.
7590
vals = np.concatenate((c[::-1], r[1:]))
@@ -246,18 +261,19 @@ def leslie(f, s):
246261
247262
Parameters
248263
----------
249-
f : (N,) array_like
264+
f : (..., N,) array_like
250265
The "fecundity" coefficients.
251-
s : (N-1,) array_like
252-
The "survival" coefficients, has to be 1-D. The length of `s`
253-
must be one less than the length of `f`, and it must be at least 1.
266+
s : (..., N-1,) array_like
267+
The "survival" coefficients. The length of each slice of `s` (along the last
268+
axis) must be one less than the length of `f`, and it must be at least 1.
254269
255270
Returns
256271
-------
257-
L : (N, N) ndarray
272+
L : (..., N, N) ndarray
258273
The array is zero except for the first row,
259274
which is `f`, and the first sub-diagonal, which is `s`.
260-
The data-type of the array will be the data-type of ``f[0]+s[0]``.
275+
For 1-D input, the data-type of the array will be the data-type of
276+
``f[0]+s[0]``.
261277
262278
Notes
263279
-----
@@ -270,6 +286,11 @@ def leslie(f, s):
270286
class, and the `n` - 1 "survival coefficients", which give the
271287
per-capita survival rate of each age class.
272288
289+
N-dimensional input are treated as a batches of coefficient arrays: each
290+
slice along the last axis of the input arrays is a 1-D coefficient array,
291+
and each slice along the last two dimensions of the output is the
292+
corresponding Leslie matrix.
293+
273294
References
274295
----------
275296
.. [1] P. H. Leslie, On the use of matrices in certain population
@@ -290,18 +311,21 @@ def leslie(f, s):
290311
"""
291312
f = np.atleast_1d(f)
292313
s = np.atleast_1d(s)
293-
if f.ndim != 1:
294-
raise ValueError("Incorrect shape for f. f must be 1D")
295-
if s.ndim != 1:
296-
raise ValueError("Incorrect shape for s. s must be 1D")
297-
if f.size != s.size + 1:
298-
raise ValueError("Incorrect lengths for f and s. The length"
299-
" of s must be one less than the length of f.")
300-
if s.size == 0:
314+
315+
if f.shape[-1] != s.shape[-1] + 1:
316+
raise ValueError("Incorrect lengths for f and s. The length of s along "
317+
"the last axis must be one less than the length of f.")
318+
if s.shape[-1] == 0:
301319
raise ValueError("The length of s must be at least 1.")
302320

321+
n = f.shape[-1]
322+
323+
if f.ndim > 1 or s.ndim > 1:
324+
from scipy.stats._resampling import _vectorize_statistic
325+
_leslie_nd = _vectorize_statistic(leslie)
326+
return np.moveaxis(_leslie_nd(f, s, axis=-1), [0, 1], [-2, -1])
327+
303328
tmp = f[0] + s[0]
304-
n = f.size
305329
a = np.zeros((n, n), dtype=tmp.dtype)
306330
a[0] = f
307331
a[list(range(1, n)), list(range(0, n - 1))] = s
@@ -948,12 +972,16 @@ def fiedler(a):
948972
949973
Parameters
950974
----------
951-
a : (n,) array_like
952-
coefficient array
975+
a : (..., n,) array_like
976+
Coefficient array. N-dimensional arrays are treated as a batch:
977+
each slice along the last axis is a 1-D coefficient array.
953978
954979
Returns
955980
-------
956-
F : (n, n) ndarray
981+
F : (..., n, n) ndarray
982+
Fiedler matrix. For batch input, each slice of shape ``(n, n)``
983+
along the last two dimensions of the output corresponds with a
984+
slice of shape ``(n,)`` along the last dimension of the input.
957985
958986
See Also
959987
--------
@@ -1003,8 +1031,8 @@ def fiedler(a):
10031031
"""
10041032
a = np.atleast_1d(a)
10051033

1006-
if a.ndim != 1:
1007-
raise ValueError("Input 'a' must be a 1D array.")
1034+
if a.ndim > 1:
1035+
return np.apply_along_axis(fiedler, -1, a)
10081036

10091037
if a.size == 0:
10101038
return np.array([], dtype=float)
@@ -1023,23 +1051,28 @@ def fiedler_companion(a):
10231051
10241052
Parameters
10251053
----------
1026-
a : (N,) array_like
1054+
a : (..., N) array_like
10271055
1-D array of polynomial coefficients in descending order with a nonzero
10281056
leading coefficient. For ``N < 2``, an empty array is returned.
1057+
N-dimensional arrays are treated as a batch: each slice along the last
1058+
axis is a 1-D array of polynomial coefficients.
10291059
10301060
Returns
10311061
-------
1032-
c : (N-1, N-1) ndarray
1033-
Resulting companion matrix
1062+
c : (..., N-1, N-1) ndarray
1063+
Resulting companion matrix. For batch input, each slice of shape
1064+
``(N-1, N-1)`` along the last two dimensions of the output corresponds
1065+
with a slice of shape ``(N,)`` along the last dimension of the input.
10341066
10351067
See Also
10361068
--------
10371069
companion
10381070
10391071
Notes
10401072
-----
1041-
Similar to `companion` the leading coefficient should be nonzero. In the case
1042-
the leading coefficient is not 1, other coefficients are rescaled before
1073+
Similar to `companion`, each leading coefficient along the last axis of the
1074+
input should be nonzero.
1075+
If the leading coefficient is not 1, other coefficients are rescaled before
10431076
the array generation. To avoid numerical issues, it is best to provide a
10441077
monic polynomial.
10451078
@@ -1067,8 +1100,8 @@ def fiedler_companion(a):
10671100
"""
10681101
a = np.atleast_1d(a)
10691102

1070-
if a.ndim != 1:
1071-
raise ValueError("Input 'a' must be a 1-D array.")
1103+
if a.ndim > 1:
1104+
return np.apply_along_axis(fiedler_companion, -1, a)
10721105

10731106
if a.size <= 2:
10741107
if a.size == 2:
@@ -1101,8 +1134,9 @@ def convolution_matrix(a, n, mode='full'):
11011134
11021135
Parameters
11031136
----------
1104-
a : (m,) array_like
1105-
The 1-D array to convolve.
1137+
a : (..., m) array_like
1138+
The 1-D array to convolve. N-dimensional arrays are treated as a
1139+
batch: each slice along the last axis is a 1-D array to convolve.
11061140
n : int
11071141
The number of columns in the resulting matrix. It gives the length
11081142
of the input to be convolved with `a`. This is analogous to the
@@ -1114,7 +1148,7 @@ def convolution_matrix(a, n, mode='full'):
11141148
11151149
Returns
11161150
-------
1117-
A : (k, n) ndarray
1151+
A : (..., k, n) ndarray
11181152
The convolution matrix whose row count `k` depends on `mode`::
11191153
11201154
======= =========================
@@ -1125,6 +1159,10 @@ def convolution_matrix(a, n, mode='full'):
11251159
'valid' max(m, n) - min(m, n) + 1
11261160
======= =========================
11271161
1162+
For batch input, each slice of shape ``(k, n)`` along the last two
1163+
dimensions of the output corresponds with a slice of shape ``(m,)``
1164+
along the last dimension of the input.
1165+
11281166
See Also
11291167
--------
11301168
toeplitz : Toeplitz matrix
@@ -1244,16 +1282,17 @@ def convolution_matrix(a, n, mode='full'):
12441282
raise ValueError('n must be a positive integer.')
12451283

12461284
a = np.asarray(a)
1247-
if a.ndim != 1:
1248-
raise ValueError('convolution_matrix expects a one-dimensional '
1249-
'array as input')
1285+
12501286
if a.size == 0:
12511287
raise ValueError('len(a) must be at least 1.')
12521288

12531289
if mode not in ('full', 'valid', 'same'):
12541290
raise ValueError(
12551291
"'mode' argument must be one of ('full', 'valid', 'same')")
12561292

1293+
if a.ndim > 1:
1294+
return np.apply_along_axis(lambda a: convolution_matrix(a, n, mode), -1, a)
1295+
12571296
# create zero padded versions of the array
12581297
az = np.pad(a, (0, n-1), 'constant')
12591298
raz = np.pad(a[::-1], (0, n-1), 'constant')

scipy/linalg/tests/test_matmul_toeplitz.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ def do(self, x, c, r=None, check_finite=False, workers=None):
128128
if r is None:
129129
actual = matmul_toeplitz(c, x, check_finite, workers)
130130
else:
131+
r = np.ravel(r)
131132
actual = matmul_toeplitz((c, r), x, check_finite)
132-
desired = toeplitz(c, r) @ x
133+
desired = toeplitz(np.ravel(c), r) @ x
133134
assert_allclose(actual, desired,
134135
rtol=self.tolerance, atol=self.tolerance)

scipy/linalg/tests/test_special_matrices.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ class TestLeslie:
100100

101101
def test_bad_shapes(self):
102102
assert_raises(ValueError, leslie, [[1, 1], [2, 2]], [3, 4, 5])
103-
assert_raises(ValueError, leslie, [3, 4, 5], [[1, 1], [2, 2]])
104103
assert_raises(ValueError, leslie, [1, 2], [1, 2])
105104
assert_raises(ValueError, leslie, [1], [])
106105

@@ -585,11 +584,6 @@ def test_bad_n(self):
585584
with pytest.raises(ValueError, match='n must be a positive integer'):
586585
convolution_matrix([1, 2, 3], 0)
587586

588-
def test_bad_first_arg(self):
589-
# first arg must be a 1d array, otherwise ValueError
590-
with pytest.raises(ValueError, match='one-dimensional'):
591-
convolution_matrix(1, 4)
592-
593587
def test_empty_first_arg(self):
594588
# first arg must have at least one value
595589
with pytest.raises(ValueError, match=r'len\(a\)'):
@@ -615,3 +609,29 @@ def test_against_numpy_convolve(self, cpx, na, nv, mode):
615609
A = convolution_matrix(a, nv, mode)
616610
y2 = A @ v
617611
assert_array_almost_equal(y1, y2)
612+
613+
614+
@pytest.mark.fail_slow(5) # `leslie` has an import in the function
615+
@pytest.mark.parametrize('f, args', [(companion, ()),
616+
(convolution_matrix, (5, 'same')),
617+
(fiedler, ()),
618+
(fiedler_companion, ()),
619+
(leslie, (np.arange(9),)),
620+
(toeplitz, (np.arange(9),)),
621+
])
622+
def test_batch(f, args):
623+
rng = np.random.default_rng(283592436523456)
624+
batch_shape = (2, 3)
625+
m = 10
626+
A = rng.random(batch_shape + (m,))
627+
628+
if f in {toeplitz}:
629+
message = "Beginning in SciPy 1.17, multidimensional input will be..."
630+
with pytest.warns(FutureWarning, match=message):
631+
f(A, *args)
632+
return
633+
634+
res = f(A, *args)
635+
ref = np.asarray([f(a, *args) for a in A.reshape(-1, m)])
636+
ref = ref.reshape(A.shape[:-1] + ref.shape[-2:])
637+
assert_allclose(res, ref)

0 commit comments

Comments
 (0)