Skip to content

Commit 171aeb0

Browse files
authored
Hosvd (#67)
* HOSVD: Preliminary outline of core functionality * HOSVD: Fix numeric bug * Was slicing incorrectly * Update test to check convergence * HOSVD: Finish output and test coverage * TENSOR: Prune numbers real * Real and mypy don't play nice python/mypy#3186 * This allows partial typing support of HOSVD
1 parent ec36a27 commit 171aeb0

File tree

4 files changed

+216
-11
lines changed

4 files changed

+216
-11
lines changed

pyttb/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pyttb.cp_als import cp_als
1010
from pyttb.cp_apr import *
1111
from pyttb.export_data import export_data
12+
from pyttb.hosvd import hosvd
1213
from pyttb.import_data import import_data
1314
from pyttb.khatrirao import khatrirao
1415
from pyttb.ktensor import ktensor

pyttb/hosvd.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Higher Order SVD Implementation"""
2+
import warnings
3+
from typing import List, Optional
4+
5+
import numpy as np
6+
import scipy
7+
8+
import pyttb as ttb
9+
10+
11+
def hosvd(
12+
tensor,
13+
tol: float,
14+
verbosity: float = 1,
15+
dimorder: Optional[List[int]] = None,
16+
sequential: bool = True,
17+
ranks: Optional[List[int]] = None,
18+
):
19+
"""Compute sequentially-truncated higher-order SVD (Tucker).
20+
21+
Computes a Tucker decomposition with relative error
22+
specified by tol, i.e., it computes a ttensor T such that
23+
||X-T||/||X|| <= tol.
24+
25+
Parameters
26+
----------
27+
tensor: Tensor to factor
28+
tol: Relative error to stop at
29+
verbosity: Print level
30+
dimorder: Order to loop through dimensions
31+
sequential: Use sequentially-truncated version
32+
ranks: Specify ranks to consider rather than computing
33+
34+
Example
35+
-------
36+
>>> data = np.array([[29, 39.], [63., 85.]])
37+
>>> tol = 1e-4
38+
>>> disable_printing = -1
39+
>>> tensorInstance = ttb.tensor().from_data(data)
40+
>>> result = hosvd(tensorInstance, tol, verbosity=disable_printing)
41+
>>> ((result.full() - tensorInstance).norm() / tensorInstance.norm()) < tol
42+
True
43+
"""
44+
# In tucker als this is N
45+
d = tensor.ndims
46+
47+
if ranks is not None:
48+
if len(ranks) != d:
49+
raise ValueError(
50+
f"Ranks must be a list of length tensor ndims. Ndims: {d} but got "
51+
f"ranks: {ranks}."
52+
)
53+
else:
54+
ranks = [0] * d
55+
56+
# Set up dimorder if not specified (this is copy past from tucker_als
57+
if not dimorder:
58+
dimorder = list(range(d))
59+
else:
60+
if not isinstance(dimorder, list):
61+
raise ValueError("Dimorder must be a list")
62+
elif tuple(range(d)) != tuple(sorted(dimorder)):
63+
raise ValueError(
64+
"Dimorder must be a list or permutation of range(tensor.ndims)"
65+
)
66+
67+
# TODO should unify printing throughout. Probably easier to use python logging levels
68+
if verbosity > 0:
69+
print("Computing HOSVD...\n")
70+
71+
normxsqr = (tensor**2).collapse()
72+
eigsumthresh = ((tol**2) * normxsqr) / d
73+
74+
if verbosity > 2:
75+
print(
76+
f"||X||^2 = {normxsqr: g}\n"
77+
f"tol = {tol: g}\n"
78+
f"eigenvalue sum threshold = tol^2 ||X||^2 / d = {eigsumthresh: g}"
79+
)
80+
81+
# Main Loop
82+
factor_matrices = [np.empty(1)] * d
83+
# Copy input tensor, shrinks every step for sequential
84+
Y = ttb.tensor.from_tensor_type(tensor)
85+
86+
for k in dimorder:
87+
# Compute Gram matrix
88+
Yk = ttb.tenmat.from_tensor_type(Y, np.array([k])).double()
89+
Z = np.dot(Yk, Yk.transpose())
90+
91+
# Compute eigenvalue decomposition
92+
D, V = scipy.linalg.eigh(Z)
93+
pi = np.argsort(-D, kind="quicksort")
94+
eigvec = D[pi]
95+
96+
# If rank not provided compute it.
97+
if ranks[k] == 0:
98+
eigsum = np.cumsum(eigvec[::-1])
99+
eigsum = eigsum[::-1]
100+
ranks[k] = np.where(eigsum > eigsumthresh)[0][-1]
101+
102+
if verbosity > 5:
103+
print(f"Reverse cummulative sum of evals of Gram matrix:")
104+
for i in range(len(eigsum)):
105+
print_msg = f"{i: d}: {eigsum[i]: 6.4f}"
106+
if i == ranks[k]:
107+
print_msg += " <-- Cutoff"
108+
print(print_msg)
109+
110+
# Extract factor matrix b picking leading eigenvectors of V
111+
# NOTE: Plus 1 in pi slice for inclusive range to match MATLAB
112+
factor_matrices[k] = V[:, pi[0 : ranks[k] + 1]]
113+
114+
# Shrink!
115+
if sequential:
116+
Y = Y.ttm(factor_matrices[k].transpose(), k)
117+
# Extract final core
118+
if sequential:
119+
G = Y
120+
else:
121+
G = Y.ttm(factor_matrices, transpose=True)
122+
123+
result = ttb.ttensor.from_data(G, factor_matrices)
124+
125+
if verbosity > 0:
126+
diffnormsqr = ((tensor - result.full()) ** 2).collapse()
127+
relnorm = np.sqrt(diffnormsqr / normxsqr)
128+
print(f" Size of core: {G.shape}")
129+
if relnorm <= tol:
130+
print(f"||X-T||/||X|| = {relnorm: g} <=" f"{tol: f} (tol)")
131+
else:
132+
print(
133+
"Tolerance not satisfied!! "
134+
f"||X-T||/||X|| = {relnorm: g} >="
135+
f"{tol: f} (tol)"
136+
)
137+
warnings.warn("Specified tolerance was not achieved")
138+
return result

pyttb/tensor.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import warnings
88
from itertools import permutations
99
from math import factorial
10-
from numbers import Real
1110
from typing import Any, Callable, List, Literal, Optional, Tuple, Union
1211

1312
import numpy as np
@@ -181,9 +180,9 @@ def collapse(
181180
self,
182181
dims: Optional[np.ndarray] = None,
183182
fun: Union[
184-
Literal["sum"], Callable[[np.ndarray], Union[Real, np.ndarray]]
183+
Literal["sum"], Callable[[np.ndarray], Union[float, np.ndarray]]
185184
] = "sum",
186-
) -> Union[Real, np.ndarray, tensor]:
185+
) -> Union[float, np.ndarray, tensor]:
187186
"""
188187
Collapse tensor along specified dimensions.
189188
@@ -389,7 +388,7 @@ def full(self) -> tensor:
389388
"""
390389
return ttb.tensor.from_data(self.data)
391390

392-
def innerprod(self, other: Union[tensor, ttb.sptensor, ttb.ktensor]) -> Real:
391+
def innerprod(self, other: Union[tensor, ttb.sptensor, ttb.ktensor]) -> float:
393392
"""
394393
Efficient inner product with a tensor
395394
@@ -542,7 +541,7 @@ def issymmetric(
542541
return bool((all_diffs == 0).all())
543542
return bool((all_diffs == 0).all()), all_diffs, all_perms
544543

545-
def logical_and(self, B: Union[Real, tensor]) -> tensor:
544+
def logical_and(self, B: Union[float, tensor]) -> tensor:
546545
"""
547546
Logical and for tensors
548547
@@ -578,7 +577,7 @@ def logical_not(self) -> tensor:
578577
"""
579578
return ttb.tensor.from_data(np.logical_not(self.data))
580579

581-
def logical_or(self, other: Union[Real, tensor]) -> tensor:
580+
def logical_or(self, other: Union[float, tensor]) -> tensor:
582581
"""
583582
Logical or for tensors
584583
@@ -598,7 +597,7 @@ def tensor_or(x, y):
598597

599598
return ttb.tt_tenfun(tensor_or, self, other)
600599

601-
def logical_xor(self, other: Union[Real, tensor]) -> tensor:
600+
def logical_xor(self, other: Union[float, tensor]) -> tensor:
602601
"""
603602
Logical xor for tensors
604603
@@ -867,7 +866,7 @@ def reshape(self, shape: Tuple[int, ...]) -> tensor:
867866

868867
return ttb.tensor.from_data(np.reshape(self.data, shape, order="F"), shape)
869868

870-
def squeeze(self) -> Union[tensor, np.ndarray, Real]:
869+
def squeeze(self) -> Union[tensor, np.ndarray, float]:
871870
"""
872871
Removes singleton dimensions from a tensor
873872
@@ -1029,7 +1028,7 @@ def symmetrize(
10291028
def ttm(
10301029
self,
10311030
matrix: Union[np.ndarray, List[np.ndarray]],
1032-
dims: Optional[Union[Real, np.ndarray]] = None,
1031+
dims: Optional[Union[float, np.ndarray]] = None,
10331032
transpose: bool = False,
10341033
) -> tensor:
10351034
"""
@@ -1046,7 +1045,7 @@ def ttm(
10461045
dims = np.arange(self.ndims)
10471046
elif isinstance(dims, list):
10481047
dims = np.array(dims)
1049-
elif isinstance(dims, Real):
1048+
elif isinstance(dims, (float, int, np.generic)):
10501049
dims = np.array([dims])
10511050

10521051
if isinstance(matrix, list):
@@ -1060,7 +1059,7 @@ def ttm(
10601059
return Y
10611060

10621061
if not isinstance(matrix, np.ndarray):
1063-
assert False, "matrix must be of type numpy.ndarray"
1062+
assert False, f"matrix must be of type numpy.ndarray but got:\n{matrix}"
10641063

10651064
if not (dims.size == 1 and np.isin(dims, np.arange(self.ndims))):
10661065
assert False, "dims must contain values in [0,self.dims)"

tests/test_hosvd.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import numpy as np
2+
import pytest
3+
4+
import pyttb as ttb
5+
6+
7+
@pytest.fixture()
8+
def sample_tensor():
9+
data = np.array([[29, 39.0], [63.0, 85.0]])
10+
shape = (2, 2)
11+
params = {"data": data, "shape": shape}
12+
tensorInstance = ttb.tensor().from_data(data, shape)
13+
return params, tensorInstance
14+
15+
16+
@pytest.mark.indevelopment
17+
def test_hosvd_simple_convergence(capsys, sample_tensor):
18+
(data, T) = sample_tensor
19+
tol = 1e-4
20+
result = ttb.hosvd(T, tol)
21+
assert (result.full() - T).norm() / T.norm() < tol, f"Failed to converge"
22+
23+
tol = 1e-4
24+
result = ttb.hosvd(T, tol, sequential=False)
25+
assert (
26+
result.full() - T
27+
).norm() / T.norm() < tol, f"Failed to converge for non-sequential option"
28+
29+
impossible_tol = 1e-20
30+
with pytest.warns(UserWarning):
31+
result = ttb.hosvd(T, impossible_tol)
32+
assert (
33+
result.full() - T
34+
).norm() / T.norm() > impossible_tol, f"Converged beyond provided precision"
35+
36+
37+
@pytest.mark.indevelopment
38+
def test_hosvd_default_init(capsys, sample_tensor):
39+
(data, T) = sample_tensor
40+
_ = ttb.hosvd(T, 1)
41+
42+
43+
@pytest.mark.indevelopment
44+
def test_hosvd_smoke_test_verbosity(capsys, sample_tensor):
45+
"""For now just make sure verbosity calcs don't crash"""
46+
(data, T) = sample_tensor
47+
ttb.hosvd(T, 1, verbosity=10)
48+
49+
50+
@pytest.mark.indevelopment
51+
def test_hosvd_incorrect_ranks(capsys, sample_tensor):
52+
(data, T) = sample_tensor
53+
ranks = list(range(T.ndims - 1))
54+
with pytest.raises(ValueError):
55+
_ = ttb.hosvd(T, 1, ranks=ranks)
56+
57+
58+
@pytest.mark.indevelopment
59+
def test_hosvd_incorrect_dimorder(capsys, sample_tensor):
60+
(data, T) = sample_tensor
61+
dimorder = list(range(T.ndims - 1))
62+
with pytest.raises(ValueError):
63+
_ = ttb.hosvd(T, 1, dimorder=dimorder)
64+
65+
dimorder = 1
66+
with pytest.raises(ValueError):
67+
_ = ttb.hosvd(T, 1, dimorder=dimorder)

0 commit comments

Comments
 (0)