Skip to content

Commit 3ac89ea

Browse files
author
nickj
committed
TTENSOR: Improve test coverage and corresponding bug fixes discovered.
1 parent 9f48eb2 commit 3ac89ea

File tree

2 files changed

+186
-23
lines changed

2 files changed

+186
-23
lines changed

pyttb/ttensor.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import textwrap
1616
import warnings
1717

18+
ALT_CORE_ERROR = "TTensor doesn't support non-tensor cores yet"
19+
1820
class ttensor(object):
1921
"""
2022
TTENSOR Class for Tucker tensors (decomposed).
@@ -55,7 +57,7 @@ def from_data(cls, core, factors):
5557
>>> import numpy as np
5658
5759
Set up input data
58-
# Create sptensor with explicit data description
60+
# Create ttensor with explicit data description
5961
6062
>>> core_values = np.ones((2,2,2))
6163
>>> core = ttb.tensor.from_data(core_values)
@@ -98,6 +100,8 @@ def _validate_ttensor(self):
98100
"""
99101
# Confirm all factors are matrices
100102
for factor_idx, factor in enumerate(self.u):
103+
if not isinstance(factor, np.ndarray):
104+
raise ValueError(f"Factor matrices must be numpy arrays but factor {factor_idx} was {type(factor)}")
101105
if len(factor.shape) != 2:
102106
raise ValueError(f"Factor matrix {factor_idx} has shape {factor.shape} and is not a matrix!")
103107

@@ -156,7 +160,7 @@ def full(self):
156160

157161
# There is a small chance tensor could be sparse so ensure we cast that to dense.
158162
if not isinstance(recomposed_tensor, tensor):
159-
recomposed_tensor = tensor(recomposed_tensor)
163+
raise ValueError(ALT_CORE_ERROR)
160164
return recomposed_tensor
161165

162166
def double(self):
@@ -331,7 +335,7 @@ def ttv(self, vector, dims=None):
331335
# Check that each multiplicand is the right size.
332336
for i in range(dims.size):
333337
if vector[vidx[i]].shape != (self.shape[dims[i]],):
334-
assert False, "Multiplicand is wrong size"
338+
raise ValueError("Multiplicand is wrong size")
335339

336340
# Get remaining dimensions when we're done
337341
remdims = np.setdiff1d(np.arange(0, self.ndims), dims)
@@ -364,11 +368,7 @@ def mttkrp(self, U, n):
364368
-------
365369
:class:`numpy.ndarray`
366370
"""
367-
368-
if n == 0:
369-
R = U[1].shape[-1]
370-
else:
371-
R = U[0].shape[-1]
371+
# NOTE: MATLAB version calculates an unused R here
372372

373373
W = [None] * self.ndims
374374
for i in range(0, self.ndims):
@@ -444,10 +444,6 @@ def ttm(self, matrix, dims=None, transpose=False):
444444

445445
# Check that each multiplicand is the right size.
446446
for i in range(len(dims)):
447-
import logging
448-
logging.warning(
449-
f"Matrix shape: \n\t{matrix[vidx[i]].shape}"
450-
)
451447
if matrix[vidx[i]].shape[size_idx] != self.shape[dims[i]]:
452448
raise ValueError(f"Multiplicand {i} is wrong size")
453449

@@ -482,7 +478,7 @@ def reconstruct(self, samples=None, modes=None):
482478
if modes is None:
483479
modes = np.arange(self.ndims)
484480
elif isinstance(modes, list):
485-
modes = np.array([modes])
481+
modes = np.array(modes)
486482
elif np.isscalar(modes):
487483
modes = np.array([modes])
488484

@@ -500,7 +496,10 @@ def reconstruct(self, samples=None, modes=None):
500496

501497
full_samples = [np.array([])] * self.ndims
502498
for sample, mode in zip(samples, modes):
503-
full_samples[mode] = sample
499+
if np.isscalar(sample):
500+
full_samples[mode] = np.array([sample])
501+
else:
502+
full_samples[mode] = sample
504503

505504
shape = self.shape
506505
new_u = []
@@ -510,7 +509,7 @@ def reconstruct(self, samples=None, modes=None):
510509
new_u.append(self.u[k])
511510
continue
512511
elif len(full_samples[k].shape) == 2 and full_samples[k].shape[-1] == shape[k]:
513-
new_u.append(full_samples[k] * self.u[k])
512+
new_u.append(full_samples[k].dot(self.u[k]))
514513
else:
515514
new_u.append(self.u[k][full_samples[k], :])
516515

@@ -540,14 +539,14 @@ def nvecs(self, n, r, flipsign = True):
540539
H = self.core.ttm(V)
541540

542541
if isinstance(H, sptensor):
543-
raise NotImplementedError("TTensor doesn't support sparse core yet")
542+
raise NotImplementedError(ALT_CORE_ERROR)
544543
else:
545544
HnT = tenmat.from_tensor_type(H.full(), cdims=np.array([n])).double()
546545

547546
G = self.core
548547

549548
if isinstance(G, sptensor):
550-
raise NotImplementedError("TTensor doesn't support sparse core yet")
549+
raise NotImplementedError(ALT_CORE_ERROR)
551550
else:
552551
GnT = tenmat.from_tensor_type(G.full(), cdims=np.array([n])).double()
553552

tests/test_ttensor.py

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import numpy as np
66
import pyttb as ttb
77
import pytest
8+
import scipy.sparse as sparse
89

910
@pytest.fixture()
1011
def sample_ttensor():
@@ -41,6 +42,30 @@ def test_ttensor_initialization_from_data(sample_ttensor):
4142
assert isinstance(ttensorInstance.core, ttb.tensor)
4243
assert all([isinstance(a_factor, np.ndarray) for a_factor in ttensorInstance.u])
4344

45+
# Negative Tests
46+
non_array_factor = ttensorInstance.u + [1]
47+
with pytest.raises(ValueError):
48+
ttb.ttensor.from_data(ttensorInstance.core, non_array_factor[1:])
49+
50+
non_matrix_factor = ttensorInstance.u + [np.array([1])]
51+
with pytest.raises(ValueError):
52+
ttb.ttensor.from_data(ttensorInstance.core, non_matrix_factor[1:])
53+
54+
too_few_factors = ttensorInstance.u.copy()
55+
too_few_factors.pop()
56+
with pytest.raises(ValueError):
57+
ttb.ttensor.from_data(ttensorInstance.core, too_few_factors)
58+
59+
wrong_shape_factor = ttensorInstance.u.copy()
60+
row, col = wrong_shape_factor[0].shape
61+
wrong_shape_factor[0] = np.random.random((row+1, col+1))
62+
with pytest.raises(ValueError):
63+
ttb.ttensor.from_data(ttensorInstance.core, wrong_shape_factor)
64+
65+
# Enforce error until sptensor core/other cores supported
66+
with pytest.raises(ValueError):
67+
ttb.ttensor.from_data(ttb.sptensor.from_tensor_type(ttensorInstance.core), ttensorInstance.u)
68+
4469
@pytest.mark.indevelopment
4570
def test_ttensor_initialization_from_tensor_type(sample_ttensor):
4671

@@ -58,6 +83,17 @@ def test_ttensor_full(sample_ttensor):
5883
# This sanity check only works for all 1's
5984
assert tensor.double() == np.prod(ttensorInstance.core.shape)
6085

86+
# Negative tests
87+
sparse_core = ttb.sptensor()
88+
sparse_core.shape = ttensorInstance.core.shape
89+
sparse_u = [sparse.coo_matrix(np.zeros(factor.shape)) for factor in ttensorInstance.u]
90+
# We could probably make these properties to avoid this edge case but expect to eventually cover these alternate
91+
# cores
92+
ttensorInstance.core = sparse_core
93+
ttensorInstance.u = sparse_u
94+
with pytest.raises(ValueError):
95+
ttensorInstance.full()
96+
6197
@pytest.mark.indevelopment
6298
def test_ttensor_double(sample_ttensor):
6399
ttensorInstance = sample_ttensor
@@ -87,17 +123,45 @@ def test_sptensor__neg__(sample_ttensor):
87123
assert ttensorInstance.isequal(ttensorInstance3)
88124

89125
@pytest.mark.indevelopment
90-
def test_ttensor_innerproduct(sample_ttensor):
126+
def test_ttensor_innerproduct(sample_ttensor, random_ttensor):
91127
ttensorInstance = sample_ttensor
92128

93129
# TODO these are an overly simplistic edge case for ttensors that are a single float
94130

95131
# ttensor innerprod ttensor
96132
assert ttensorInstance.innerprod(ttensorInstance) == ttensorInstance.double()**2
133+
core_dim = ttensorInstance.core.shape[0] + 1
134+
ndim = ttensorInstance.ndims
135+
large_core_ttensor = ttb.ttensor.from_data(
136+
ttb.tensor.from_data(np.ones((core_dim,)*ndim)),
137+
[np.ones((1, core_dim))] * ndim
138+
)
139+
assert large_core_ttensor.innerprod(ttensorInstance) == ttensorInstance.full().innerprod(large_core_ttensor.full())
97140

98141
# ttensor innerprod tensor
99142
assert ttensorInstance.innerprod(ttensorInstance.full()) == ttensorInstance.double() ** 2
100143

144+
# ttensr innerprod ktensor
145+
ktensorInstance = ttb.ktensor.from_data(np.array([8.]), [np.array([[1.]])]*3)
146+
assert ttensorInstance.innerprod(ktensorInstance) == ttensorInstance.double() ** 2
147+
148+
# ttensor innerprod tensor (shape larger than core)
149+
random_ttensor.innerprod(random_ttensor.full())
150+
151+
# Negative Tests
152+
ttensor_extra_factors = ttb.ttensor.from_tensor_type(ttensorInstance)
153+
ttensor_extra_factors.u.extend(ttensorInstance.u)
154+
with pytest.raises(ValueError):
155+
ttensorInstance.innerprod(ttensor_extra_factors)
156+
157+
tensor_extra_dim = ttb.tensor.from_data(np.ones(ttensorInstance.shape + (1,)))
158+
with pytest.raises(ValueError):
159+
ttensorInstance.innerprod(tensor_extra_dim)
160+
161+
invalid_option = []
162+
with pytest.raises(ValueError):
163+
ttensorInstance.innerprod(invalid_option)
164+
101165
@pytest.mark.indevelopment
102166
def test_ttensor__mul__(sample_ttensor):
103167
ttensorInstance = sample_ttensor
@@ -107,6 +171,10 @@ def test_ttensor__mul__(sample_ttensor):
107171
assert (ttensorInstance * mul_factor).double() == np.prod(ttensorInstance.core.shape) * mul_factor
108172
assert (ttensorInstance * float(2)).double() == np.prod(ttensorInstance.core.shape) * float(mul_factor)
109173

174+
# Negative tests
175+
with pytest.raises(ValueError):
176+
_ = ttensorInstance * 'some_string'
177+
110178
@pytest.mark.indevelopment
111179
def test_ttensor__rmul__(sample_ttensor):
112180
ttensorInstance = sample_ttensor
@@ -116,6 +184,10 @@ def test_ttensor__rmul__(sample_ttensor):
116184
assert (mul_factor * ttensorInstance).double() == np.prod(ttensorInstance.core.shape) * mul_factor
117185
assert (float(2) * ttensorInstance).double() == np.prod(ttensorInstance.core.shape) * float(mul_factor)
118186

187+
# Negative tests
188+
with pytest.raises(ValueError):
189+
_ = 'some_string' * ttensorInstance
190+
119191
@pytest.mark.indevelopment
120192
def test_ttensor_ttv(sample_ttensor):
121193
ttensorInstance = sample_ttensor
@@ -124,6 +196,17 @@ def test_ttensor_ttv(sample_ttensor):
124196
final_value = sample_ttensor.ttv(trivial_vectors)
125197
assert final_value == np.prod(ttensorInstance.core.shape)
126198

199+
assert np.allclose(
200+
ttensorInstance.ttv(trivial_vectors[0], 0).double(),
201+
ttensorInstance.full().ttv(trivial_vectors[0], 0).double()
202+
)
203+
204+
# Negative tests
205+
wrong_shape_vector = trivial_vectors.copy()
206+
wrong_shape_vector[0] = np.array([mul_factor, mul_factor])
207+
with pytest.raises(ValueError):
208+
sample_ttensor.ttv(wrong_shape_vector)
209+
127210
@pytest.mark.indevelopment
128211
def test_ttensor_mttkrp(random_ttensor):
129212
ttensorInstance = random_ttensor
@@ -133,23 +216,32 @@ def test_ttensor_mttkrp(random_ttensor):
133216
]
134217
final_value = ttensorInstance.mttkrp(vectors, 2)
135218
full_value = ttensorInstance.full().mttkrp(vectors, 2)
136-
assert np.all(np.isclose(final_value, full_value)), (
219+
assert np.allclose(final_value, full_value), (
137220
f"TTensor value is: \n{final_value}\n\n"
138221
f"Full value is: \n{full_value}"
139222
)
140223

141224
@pytest.mark.indevelopment
142-
def test_ttensor_norm(random_ttensor):
225+
def test_ttensor_norm(sample_ttensor, random_ttensor):
143226
ttensorInstance = random_ttensor
144227
assert np.isclose(ttensorInstance.norm(), ttensorInstance.full().norm())
145228

229+
# Core larger than full tensor
230+
ttensorInstance = sample_ttensor
231+
assert np.isclose(ttensorInstance.norm(), ttensorInstance.full().norm())
232+
146233
@pytest.mark.indevelopment
147234
def test_ttensor_permute(random_ttensor):
148235
ttensorInstance = random_ttensor
149236
original_order = np.arange(0, len(ttensorInstance.core.shape))
150237
permuted_tensor = ttensorInstance.permute(original_order)
151238
assert ttensorInstance.isequal(permuted_tensor)
152239

240+
# Negative Tests
241+
with pytest.raises(ValueError):
242+
bad_permutation_order = np.arange(0, len(ttensorInstance.core.shape) + 1)
243+
ttensorInstance.permute(bad_permutation_order)
244+
153245
@pytest.mark.indevelopment
154246
def test_ttensor_ttm(random_ttensor):
155247
ttensorInstance = random_ttensor
@@ -163,19 +255,91 @@ def test_ttensor_ttm(random_ttensor):
163255
f"TTensor value is: \n{final_value}\n\n"
164256
f"Full value is: \n{reverse_value}"
165257
)
258+
final_value = ttensorInstance.ttm(matrices) # No dims
259+
assert final_value.isequal(reverse_value)
260+
final_value = ttensorInstance.ttm(matrices, list(range(len(matrices)))) # Dims as list
261+
assert final_value.isequal(reverse_value)
262+
263+
264+
single_tensor_result = ttensorInstance.ttm(matrices[0], 0)
265+
single_tensor_full_result = ttensorInstance.full().ttm(matrices[0], 0)
266+
assert np.allclose(single_tensor_result.double(), single_tensor_full_result.double()), (
267+
f"TTensor value is: \n{single_tensor_result.full()}\n\n"
268+
f"Full value is: \n{single_tensor_full_result}"
269+
)
270+
271+
transposed_matrices = [matrix.transpose() for matrix in matrices]
272+
transpose_value = ttensorInstance.ttm(transposed_matrices, np.arange(len(matrices)), transpose=True)
273+
assert final_value.isequal(transpose_value)
274+
275+
# Negative Tests
276+
big_wrong_size = 123
277+
matrices[0] = np.random.random((big_wrong_size, big_wrong_size))
278+
with pytest.raises(ValueError):
279+
_ = ttensorInstance.ttm(matrices, np.arange(len(matrices)))
280+
166281

167282
@pytest.mark.indevelopment
168283
def test_ttensor_reconstruct(random_ttensor):
169284
ttensorInstance = random_ttensor
170285
# TODO: This slice drops the singleton dimension, should it? If so should ttensor squeeze during reconstruct?
171286
full_slice = ttensorInstance.full()[:, 1, :]
172287
ttensor_slice = ttensorInstance.reconstruct(1, 1)
173-
assert np.all(np.isclose(full_slice.double(), ttensor_slice.squeeze().double()))
288+
assert np.allclose(full_slice.double(), ttensor_slice.squeeze().double())
174289
assert ttensorInstance.reconstruct().isequal(ttensorInstance.full())
290+
sample_all_modes = [np.array([0])] * len(ttensorInstance.shape)
291+
sample_all_modes[-1] = 0 # Make raw scalar
292+
reconstruct_scalar = ttensorInstance.reconstruct(sample_all_modes).full().double()
293+
full_scalar = ttensorInstance.full()[tuple(sample_all_modes)]
294+
assert np.isclose(reconstruct_scalar, full_scalar)
295+
296+
scale = np.random.random(ttensorInstance.u[1].shape).transpose()
297+
_ = ttensorInstance.reconstruct(scale, 1)
298+
# FIXME from the MATLAB docs wasn't totally clear how to validate this
299+
300+
# Negative Tests
301+
with pytest.raises(ValueError):
302+
_ = ttensorInstance.reconstruct(1, [0, 1])
175303

176304
@pytest.mark.indevelopment
177305
def test_ttensor_nvecs(random_ttensor):
178306
ttensorInstance = random_ttensor
179-
ttensor_eigvals = ttensorInstance.nvecs(0, 2)
180-
full_eigvals = ttensorInstance.full().nvecs(0, 2)
307+
n = 0
308+
r = 2
309+
ttensor_eigvals = ttensorInstance.nvecs(n, r)
310+
full_eigvals = ttensorInstance.full().nvecs(n, r)
311+
assert np.allclose(ttensor_eigvals, full_eigvals)
312+
313+
# Test for eig vals larger than shape-1
314+
n = 1
315+
r = 2
316+
full_eigvals = ttensorInstance.full().nvecs(n, r)
317+
with pytest.warns(Warning) as record:
318+
ttensor_eigvals = ttensorInstance.nvecs(n, r)
319+
assert 'Greater than or equal to tensor.shape[n] - 1 eigenvectors requires cast to dense to solve' \
320+
in str(record[0].message)
181321
assert np.allclose(ttensor_eigvals, full_eigvals)
322+
323+
# Negative Tests
324+
sparse_core = ttb.sptensor()
325+
sparse_core.shape = ttensorInstance.core.shape
326+
ttensorInstance.core = sparse_core
327+
328+
# Sparse core
329+
with pytest.raises(NotImplementedError):
330+
ttensorInstance.nvecs(0, 1)
331+
332+
# Sparse factors
333+
sparse_u = [sparse.coo_matrix(np.zeros(factor.shape)) for factor in ttensorInstance.u]
334+
ttensorInstance.u = sparse_u
335+
with pytest.raises(NotImplementedError):
336+
ttensorInstance.nvecs(0, 1)
337+
338+
@pytest.mark.indevelopment
339+
def test_sptensor_isequal(sample_ttensor):
340+
ttensorInstance = sample_ttensor
341+
# Negative Tests
342+
assert not ttensorInstance.isequal(ttensorInstance.full())
343+
ttensor_extra_factors = ttb.ttensor.from_tensor_type(ttensorInstance)
344+
ttensor_extra_factors.u.extend(ttensorInstance.u)
345+
assert not ttensorInstance.isequal(ttensor_extra_factors)

0 commit comments

Comments
 (0)