Skip to content

Commit c5670f4

Browse files
nickjntjohnson1
authored andcommitted
TUCKER_ALS: Add tucker_als to validate ttucker implementation.
1 parent 393abe6 commit c5670f4

File tree

4 files changed

+239
-2
lines changed

4 files changed

+239
-2
lines changed

pyttb/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from pyttb.khatrirao import khatrirao
2020
from pyttb.cp_apr import *
2121
from pyttb.cp_als import cp_als
22+
from pyttb.tucker_als import tucker_als
2223

2324
from pyttb.import_data import import_data
2425
from pyttb.export_data import export_data

pyttb/cp_als.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ def cp_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None,
125125
for n in dimorder:
126126
if init.factor_matrices[n].shape != (tensor.shape[n], rank):
127127
assert False, "Mode {} of the initial guess is the wrong size".format(n)
128-
elif init.lower() == 'random':
128+
elif isinstance(init, str) and init.lower() == 'random':
129129
factor_matrices = []
130130
for n in range(N):
131131
factor_matrices.append(np.random.uniform(0, 1, (tensor.shape[n], rank)))
132132
init = ttb.ktensor.from_factor_matrices(factor_matrices)
133-
elif init.lower() == 'nvecs':
133+
elif isinstance(init, str) and init.lower() == 'nvecs':
134134
factor_matrices = []
135135
for n in range(N):
136136
factor_matrices.append(tensor.nvecs(n, rank))

pyttb/tucker_als.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
2+
from numbers import Real
3+
import numpy as np
4+
from pyttb.ttensor import ttensor
5+
6+
def tucker_als(tensor, rank, stoptol=1e-4, maxiters=1000, dimorder=None,
7+
init='random', printitn=1):
8+
"""
9+
Compute Tucker decomposition with alternating least squares
10+
11+
Parameters
12+
----------
13+
tensor: :class:`pyttb.tensor`
14+
rank: int, list[int]
15+
Rank of the decomposition(s)
16+
stoptol: float
17+
Tolerance used for termination - when the change in the fitness function in successive iterations drops
18+
below this value, the iterations terminate (default: 1e-4)
19+
dimorder: list
20+
Order to loop through dimensions (default: [range(tensor.ndims)])
21+
maxiters: int
22+
Maximum number of iterations (default: 1000)
23+
init: str or list[np.ndarray]
24+
Initial guess (default: "random")
25+
26+
* "random": initialize using a :class:`pyttb.ttensor` with values chosen from a Normal distribution with mean 1 and standard deviation 0
27+
* "nvecs": initialize factor matrices of a :class:`pyttb.ttensor` using the eigenvectors of the outer product of the matricized input tensor
28+
* :class:`pyttb.ttensor`: initialize using a specific :class:`pyttb.ttensor` as input - must be the same shape as the input tensor and have the same rank as the input rank
29+
30+
printitn: int
31+
Number of iterations to perform before printing iteration status - 0 for no status printing (default: 1)
32+
33+
Returns
34+
-------
35+
M: :class:`pyttb.ttensor`
36+
Resulting ttensor from Tucker-ALS factorization
37+
Minit: :class:`pyttb.ttensor`
38+
Initial guess
39+
output: dict
40+
Information about the computation. Dictionary keys:
41+
42+
* `params` : tuple of (stoptol, maxiters, printitn, dimorder)
43+
* `iters`: number of iterations performed
44+
* `normresidual`: norm of the difference between the input tensor and ktensor factorization
45+
* `fit`: value of the fitness function (fraction of tensor data explained by the model)
46+
47+
"""
48+
N = tensor.ndims
49+
normX = tensor.norm()
50+
51+
# TODO: These argument checks look common with CP-ALS factor out
52+
if not isinstance(stoptol, Real):
53+
raise ValueError(f"stoptol must be a real valued scalar but received: {stoptol}")
54+
if not isinstance(maxiters, Real) or maxiters < 0:
55+
raise ValueError(f"maxiters must be a non-negative real valued scalar but received: {maxiters}")
56+
if not isinstance(printitn, Real):
57+
raise ValueError(f"printitn must be a real valued scalar but received: {printitn}")
58+
59+
if isinstance(rank, Real) or len(rank) == 1:
60+
rank = rank * np.ones(N, dtype=int)
61+
62+
# Set up dimorder if not specified
63+
if not dimorder:
64+
dimorder = list(range(N))
65+
else:
66+
if not isinstance(dimorder, list):
67+
raise ValueError("Dimorder must be a list")
68+
elif tuple(range(N)) != tuple(sorted(dimorder)):
69+
raise ValueError("Dimorder must be a list or permutation of range(tensor.ndims)")
70+
71+
if isinstance(init, list):
72+
Uinit = init
73+
if len(init) != N:
74+
raise ValueError(f"Init needs to be of length tensor.ndim (which was {N}) but only got length {len(init)}.")
75+
for n in dimorder[1::]:
76+
correct_shape = (tensor.shape[n], rank[n])
77+
if Uinit[n].shape != correct_shape:
78+
raise ValueError(
79+
f"Init factor {n} had incorrect shape. Expected {correct_shape} but got {Uinit[n].shape}"
80+
)
81+
elif isinstance(init, str) and init.lower() == 'random':
82+
Uinit = [None] * N
83+
# Observe that we don't need to calculate an initial guess for the
84+
# first index in dimorder because that will be solved for in the first
85+
# inner iteration.
86+
for n in range(1, N):
87+
Uinit[n] = np.random.uniform(0, 1, (tensor.shape[n], rank[n]))
88+
elif isinstance(init, str) and init.lower() in ('nvecs', 'eigs'):
89+
# Compute an orthonormal basis for the dominant
90+
# Rn-dimensional left singular subspace of
91+
# X_(n) (0 <= n < N).
92+
Uinit = [None] * N
93+
for n in dimorder[1::]:
94+
print(f" Computing {rank[n]} leading e-vector for factor {n}.\n")
95+
Uinit[n] = tensor.nvecs(n, rank[n])
96+
else:
97+
raise ValueError(f"The selected initialization method is not supported. Provided: {init}")
98+
99+
# Set up for iterations - initializing U and the fit.
100+
U = Uinit.copy()
101+
fit = 0
102+
103+
if printitn > 0:
104+
print("\nTucker Alternating Least-Squares:\n")
105+
106+
# Main loop: Iterate until convergence
107+
for iter in range(maxiters):
108+
fitold = fit
109+
110+
# Iterate over all N modes of the tensor
111+
for n in dimorder:
112+
if n == 0: # TODO proposal to change ttm to include_dims and exclude_dims to resolve -0 ambiguity
113+
dims = np.arange(1, tensor.ndims)
114+
Utilde = tensor.ttm(U, dims, True)
115+
else:
116+
Utilde = tensor.ttm(U, -n, True)
117+
118+
# Maximize norm(Utilde x_n W') wrt W and
119+
# maintain orthonormality of W
120+
U[n] = Utilde.nvecs(n, rank[n])
121+
122+
# Assemble the current approximation
123+
core = Utilde.ttm(U, n, True)
124+
125+
# Compute fit
126+
# TODO this abs is missing from MATLAB, but I get negative numbers for trivial examples
127+
normresidual = np.sqrt(abs(normX**2 - core.norm()**2))
128+
fit = 1 - (normresidual / normX) # fraction explained by model
129+
fitchange = abs(fitold - fit)
130+
131+
if iter % printitn == 0:
132+
print(f" NormX: {normX} Core norm: {core.norm()}")
133+
print(f" Iter {iter}: fit = {fit:e} fitdelta = {fitchange:7.1e}\n")
134+
135+
# Check for convergence
136+
if fitchange < stoptol:
137+
break
138+
139+
solution = ttensor.from_data(core, U)
140+
141+
output = {}
142+
output['params'] = (stoptol, maxiters, printitn, dimorder)
143+
output['iters'] = iter
144+
output['normresidual'] = normresidual
145+
output['fit'] = fit
146+
147+
return solution, Uinit, output

tests/test_tucker_als.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import pyttb as ttb
2+
import numpy as np
3+
import pytest
4+
5+
6+
@pytest.fixture()
7+
def sample_tensor():
8+
data = np.array([[29, 39.], [63., 85.]])
9+
shape = (2, 2)
10+
params = {'data': data, 'shape': shape}
11+
tensorInstance = ttb.tensor().from_data(data, shape)
12+
return params, tensorInstance
13+
14+
@pytest.mark.indevelopment
15+
def test_tucker_als_tensor_default_init(capsys, sample_tensor):
16+
(data, T) = sample_tensor
17+
(Solution, Uinit, output) = ttb.tucker_als(T, 2)
18+
capsys.readouterr()
19+
assert pytest.approx(output['fit'], 1) == 0
20+
21+
(Solution, Uinit, output) = ttb.tucker_als(T, 2, init=Uinit)
22+
capsys.readouterr()
23+
assert pytest.approx(output['fit'], 1) == 0
24+
25+
(Solution, Uinit, output) = ttb.tucker_als(T, 2, init='nvecs')
26+
capsys.readouterr()
27+
assert pytest.approx(output['fit'], 1) == 0
28+
29+
@pytest.mark.indevelopment
30+
def test_tucker_als_tensor_incorrect_init(capsys, sample_tensor):
31+
(data, T) = sample_tensor
32+
33+
non_list = np.array([1]) # TODO: Consider generalizing to iterable
34+
with pytest.raises(ValueError):
35+
_ = ttb.tucker_als(T, 2, init=non_list)
36+
37+
bad_string = "foo_bar"
38+
with pytest.raises(ValueError):
39+
_ = ttb.tucker_als(T, 2, init=bad_string)
40+
41+
wrong_length = [np.ones(T.shape)] * T.ndims
42+
wrong_length.pop()
43+
with pytest.raises(ValueError):
44+
_ = ttb.tucker_als(T, 2, init=wrong_length)
45+
46+
wrong_shape = [np.ones(5)] * T.ndims
47+
with pytest.raises(ValueError):
48+
_ = ttb.tucker_als(T, 2, init=wrong_shape)
49+
50+
@pytest.mark.indevelopment
51+
def test_tucker_als_tensor_incorrect_steptol(capsys, sample_tensor):
52+
(data, T) = sample_tensor
53+
54+
non_scalar = np.array([1])
55+
with pytest.raises(ValueError):
56+
_ = ttb.tucker_als(T, 2, stoptol=non_scalar)
57+
58+
@pytest.mark.indevelopment
59+
def test_tucker_als_tensor_incorrect_maxiters(capsys, sample_tensor):
60+
(data, T) = sample_tensor
61+
62+
negative_value = -1
63+
with pytest.raises(ValueError):
64+
_ = ttb.tucker_als(T, 2, maxiters=negative_value)
65+
66+
non_scalar = np.array([1])
67+
with pytest.raises(ValueError):
68+
_ = ttb.tucker_als(T, 2, maxiters=non_scalar)
69+
70+
@pytest.mark.indevelopment
71+
def test_tucker_als_tensor_incorrect_printitn(capsys, sample_tensor):
72+
(data, T) = sample_tensor
73+
74+
non_scalar = np.array([1])
75+
with pytest.raises(ValueError):
76+
_ = ttb.tucker_als(T, 2, printitn=non_scalar)
77+
78+
@pytest.mark.indevelopment
79+
def test_tucker_als_tensor_incorrect_dimorder(capsys, sample_tensor):
80+
(data, T) = sample_tensor
81+
82+
non_list = np.array([1]) # TODO: Consider generalizing to iterable
83+
with pytest.raises(ValueError):
84+
_ = ttb.tucker_als(T, 2, dimorder=non_list)
85+
86+
too_few_dims = list(range(len(T.shape)))
87+
too_few_dims.pop()
88+
with pytest.raises(ValueError):
89+
_ = ttb.tucker_als(T, 2, dimorder=too_few_dims)

0 commit comments

Comments
 (0)