Tensor Node Notation

# Nested boxtimes

## Determining the order of contractions

Once we have any mode name appearing more than twice in a -product, we may not simply resolve brackets. For example for as follows,
.
The result may be rather unintended.
n = assign_mode_size({'alpha','beta','gamma'},[4,3,2]);
N1 = init_node({'alpha','beta'},n);
N2 = init_node({'beta','gamma'},n);
N3 = N1;
N4 = N2;
N1 = randomize_node(N1)
N1 = struct with fields:
mode_names: {'alpha' 'beta'}
pos: [1×1 struct]
data: [4×3 double]
N2 = randomize_node(N2)
N2 = struct with fields:
mode_names: {'beta' 'gamma'}
pos: [1×1 struct]
data: [3×2 double]
N3 = randomize_node(N3)
N3 = struct with fields:
mode_names: {'alpha' 'beta'}
pos: [1×1 struct]
data: [4×3 double]
N4 = randomize_node(N4)
N4 = struct with fields:
mode_names: {'beta' 'gamma'}
pos: [1×1 struct]
data: [3×2 double]
w = boxtimes(node_transpose(N2),node_transpose(N1),N3,N4);
w.data
ans = 0.1703
net_view(node_transpose(N2),node_transpose(N1),N3,N4)
But instead of performing three single -products, we can transfer the bracket structure as before to one single boxtimes in form of nested cells. boxtimes will never call itself!
r1 = boxtimes(node_transpose(boxtimes(N1,N2)),boxtimes(N3,N4));
r1.data
ans = 0.0183
r2 = boxtimes(net_transpose({N1,N2}),{N3,N4});
r2.data
ans = 0.0183
net_view(net_transpose({N1,N2}),{N3,N4})
In the following call we can see that, interestingly, the optimal order of contractions (in terms of complexity) is not the one prescribed by the original brackets . So boxtimes indeed does not call itself, but uses the nested input solely to determine contraction rules.
a = n.alpha; b = n.beta; c = n.gamma;
cost_as_by_brackets = (a*b*c) + (a*b*c) + (a*c)
cost_as_by_brackets = 56
boxtimes(net_transpose({N1,N2}),{N3,N4},'mode','optimal_show')
Accum cost: 54, Iterations: 11
Contractions:
[ 1 <- 2] - 24 -> 24
[ 1 <- 3] - 24 -> 48
[ 1 <- 4] - 6 -> 54
Indices of nodes remaining: 1
ans = struct with fields:
mode_names: {1×0 cell}
pos: [1×1 struct]
data: 0.0183
The difference is tiny for now, but points at a central idea behind tensor networks.

## Scalar products between different tensor formats

This mechanic allows us to be more flexible when for example taking the scalar product between two different tensor formats in which we used β as mode names in both cases.
boxtimes(G)
ans = struct with fields:
mode_names: {'alpha_1' 'alpha_2' 'alpha_3' 'alpha_4'}
pos: [1×1 struct]
data: [10×10×10×10 double]
boxtimes(G,{C,U})
ans = struct with fields:
mode_names: {1×0 cell}
pos: [1×1 struct]
data: 1.3561e-04
G{:}
ans = struct with fields:
mode_names: {'alpha_1' 'beta_1'}
pos: [1×1 struct]
data: [10×2 double]
ans = struct with fields:
mode_names: {'beta_1' 'alpha_2' 'beta_2'}
pos: [1×1 struct]
data: [2×10×2 double]
ans = struct with fields:
mode_names: {'beta_2' 'alpha_3' 'beta_3'}
pos: [1×1 struct]
data: [2×10×2 double]
ans = struct with fields:
mode_names: {'beta_3' 'alpha_4'}
pos: [1×1 struct]
data: [2×10 double]
net_view(G,{C,U}) % node numbers for G: 1-4, C: 5, U: 6-9
Here, the order of contractions is crucial. If we would first evaluate , then let alone the resulting tensor has size . However, the optimal order of multiplication just requires an order of complexity of (calculated classically) and might not be the order of contractions one expects at a first glance.
node_size(G)
ans = struct with fields:
alpha_1: 10
alpha_2: 10
alpha_3: 10
alpha_4: 10
beta_1: 2
beta_2: 2
beta_3: 2
node_size(C)
ans = struct with fields:
beta_1: 2
beta_2: 2
beta_3: 2
beta_4: 2
boxtimes(G,{C,U},'mode','optimal_show')
Accum cost: 308, Iterations: 215
Contractions:
[ 3 <- 8] - 80 -> 80
[ 2 <- 7] - 80 -> 160
[ 4 <- 9] - 40 -> 200
[ 1 <- 6] - 40 -> 240
[ 3 <- 4] - 16 -> 256
[ 3 <- 5] - 32 -> 288
[ 2 <- 3] - 16 -> 304
[ 1 <- 2] - 4 -> 308
Indices of nodes remaining: 1
ans = struct with fields:
mode_names: {1×0 cell}
pos: [1×1 struct]
data: 1.3561e-04
boxtimes(boxtimes(G),boxtimes(C,U),'mode','optimal_show')
Accum cost: 10000, Iterations: 2
Contractions:
[ 1 <- 2] - 10000 -> 10000
Indices of nodes remaining: 1
ans = struct with fields:
mode_names: {1×0 cell}
pos: [1×1 struct]
data: 1.3561e-04

## Boxtimes memory

Once this order of computations is found, we may of course reuse this insight for the next analogous multiplication.
activate_boxtimes_mem()
clear_boxtimes_mem()
tic
boxtimes(G,{C,U},'mode','optimal');
toc
Elapsed time is 0.308307 seconds.
Once the boxtimes memory is activated, the required cpu time may reduce considerably:
tic
boxtimes(G,{C,U},'mode','optimal');
toc
Elapsed time is 0.033543 seconds.
The memory of boxtimes is smart, but does not overthink, since its main purpose is to avoid repeated calculations in loops:
So while it realizes that changing the order of mode names in the single node Cdoes not change it (since it has no dublicate mode names)...
CT = node_transpose(C)
CT = struct with fields:
mode_names: {'beta_4' 'beta_3' 'beta_2' 'beta_1'}
pos: [1×1 struct]
data: [2×2×2×2 double]
tic
boxtimes(G,{CT,U},'mode','optimal');
toc
Elapsed time is 0.008410 seconds.
...it does not realize that transposing makes no difference either.
{C,U}
ans = 1×2 cell array
{1×1 struct} {1×4 cell}
CUT = net_transpose({C,U})
CUT = 1×2 cell array
{1×4 cell} {1×1 struct}
tic
boxtimes(CUT,G,'mode','optimal');
toc
Elapsed time is 0.216938 seconds.
deactivate_boxtimes_mem()