"""
Tensor-Train operations — evaluation, reconstruction, arithmetic, diagnostics.
Provides:
- tt_eval: Evaluate a single element from TT cores
- tt_eval_batch: Vectorized multi-point evaluation
- tt_to_full: Reconstruct full tensor from TT cores (validation only)
- tt_add: Add two TT tensors (rank-additive)
- tt_scale: Multiply a TT tensor by a scalar
- tt_hadamard: Element-wise product of two TT tensors
- tt_dot: Inner product of two TT tensors (no reconstruction)
- tt_ranks: Get TT-ranks
- tt_memory: Compute memory usage of TT format
- tt_error: Compute relative reconstruction error
"""
from __future__ import annotations
import numpy as np
[docs]
def tt_eval(cores: list[np.ndarray], indices: tuple[int, ...]) -> float:
"""Evaluate a single element of a tensor in TT format.
Computes A[i1, i2, ..., id] by contracting core slices:
result = G1[:, i1, :] @ G2[:, i2, :] @ ... @ Gd[:, id, :]
Complexity: O(d * r^2) where r is the max rank.
Args:
cores: List of TT-cores. cores[k].shape = (r_{k-1}, n_k, r_k).
indices: Tuple of indices (i1, i2, ..., id).
Returns:
Scalar value at the given index.
"""
assert len(indices) == len(cores), f"Expected {len(cores)} indices, got {len(indices)}"
# Start with the first core slice: shape (1, r_0) → flatten to (r_0,)
result = cores[0][:, indices[0], :] # shape (r_0_left, r_0_right) = (1, r_0)
for k in range(1, len(cores)):
# cores[k][:, indices[k], :] has shape (r_{k-1}, r_k)
slice_k = cores[k][:, indices[k], :]
result = result @ slice_k # matrix multiply: (1, r_{k-1}) @ (r_{k-1}, r_k) = (1, r_k)
# Final result should be shape (1, 1) → scalar
return float(result.item())
[docs]
def tt_eval_batch(
cores: list[np.ndarray],
indices_array: np.ndarray,
) -> np.ndarray:
"""Evaluate multiple elements of a tensor in TT format.
Vectorized version of tt_eval for batch evaluation.
Args:
cores: List of TT-cores.
indices_array: Array of shape (n_points, d) where each row is an index tuple.
Returns:
Array of shape (n_points,) with values at the given indices.
"""
indices_array.shape[0]
d = len(cores)
assert indices_array.shape[1] == d
# Initialize with first core slices for all points
# cores[0] shape: (1, n_0, r_0)
# For each point p, extract cores[0][0, indices_array[p, 0], :] → shape (r_0,)
idx_0 = indices_array[:, 0] # shape (n_points,)
result = cores[0][0, idx_0, :] # shape (n_points, r_0)
for k in range(1, d):
idx_k = indices_array[:, k] # shape (n_points,)
# cores[k] shape: (r_{k-1}, n_k, r_k)
# For each point p, we need cores[k][:, idx_k[p], :] → (r_{k-1}, r_k)
# Then result[p] = result[p] @ cores[k][:, idx_k[p], :]
slices = cores[k][:, idx_k, :] # shape (r_{k-1}, n_points, r_k)
slices = slices.transpose(1, 0, 2) # shape (n_points, r_{k-1}, r_k)
# Batch matrix multiply: (n_points, 1, r_{k-1}) @ (n_points, r_{k-1}, r_k)
result = np.einsum("ni,nij->nj", result, slices)
# result shape: (n_points, 1) → squeeze
return result.squeeze(-1)
[docs]
def tt_to_full(cores: list[np.ndarray]) -> np.ndarray:
"""Reconstruct the full tensor from TT cores.
WARNING: Only use for validation on small tensors.
Memory usage is O(n1 * n2 * ... * nd).
Args:
cores: List of TT-cores.
Returns:
Full tensor of shape (n1, n2, ..., nd).
"""
d = len(cores)
shape = tuple(c.shape[1] for c in cores)
# Build by sequential contraction
# Start with first core: (1, n_0, r_0) → squeeze to (n_0, r_0)
result = cores[0].squeeze(0) # shape (n_0, r_0)
for k in range(1, d):
# result shape: (n_0 * n_1 * ... * n_{k-1}, r_{k-1})
# cores[k] shape: (r_{k-1}, n_k, r_k)
r_prev = result.shape[-1]
n_k = cores[k].shape[1]
r_k = cores[k].shape[2]
# Contract: result @ cores[k] reshaped
# Reshape cores[k]: (r_{k-1}, n_k * r_k)
core_mat = cores[k].reshape(r_prev, n_k * r_k)
# result @ core_mat: (prod_prev, n_k * r_k)
result = result @ core_mat
# Reshape to separate n_k: (prod_prev * n_k, r_k)
result = result.reshape(-1, r_k)
# Final: (prod_all, 1) → reshape to original shape
return result.reshape(shape)
[docs]
def tt_ranks(cores: list[np.ndarray]) -> list[int]:
"""Get the TT-ranks (bond dimensions between cores).
Args:
cores: List of TT-cores.
Returns:
List of ranks [r_0, r_1, ..., r_d] where r_0 = r_d = 1.
"""
ranks = [cores[0].shape[0]] # r_0 = 1
for c in cores:
ranks.append(c.shape[2])
return ranks
[docs]
def tt_memory(cores: list[np.ndarray]) -> int:
"""Compute total memory usage of TT cores in bytes.
Args:
cores: List of TT-cores.
Returns:
Total bytes (assuming float64).
"""
return sum(c.nbytes for c in cores)
[docs]
def tt_error(
cores: list[np.ndarray],
original: np.ndarray,
) -> float:
"""Compute relative Frobenius reconstruction error.
error = ||A - A_TT||_F / ||A||_F
WARNING: Requires full tensor reconstruction — only for small tensors.
Args:
cores: List of TT-cores.
original: Original tensor (same shape).
Returns:
Relative error (scalar).
"""
reconstructed = tt_to_full(cores)
norm_orig = np.linalg.norm(original)
if norm_orig < 1e-15:
return 0.0
return float(np.linalg.norm(original - reconstructed) / norm_orig)
[docs]
def tt_compression_ratio(
cores: list[np.ndarray],
original_shape: tuple[int, ...],
) -> float:
"""Compute compression ratio: full_size / tt_size.
Args:
cores: List of TT-cores.
original_shape: Shape of the original tensor.
Returns:
Compression ratio (> 1 means TT is smaller).
"""
full_bytes = int(np.prod(original_shape)) * 8 # float64
tt_bytes = tt_memory(cores)
if tt_bytes == 0:
return float("inf")
return full_bytes / tt_bytes
# ====================================================================== #
# TT Arithmetic
# ====================================================================== #
[docs]
def tt_add(
cores_a: list[np.ndarray],
cores_b: list[np.ndarray],
) -> list[np.ndarray]:
"""Add two TT tensors: C = A + B.
The result has TT-ranks that are the *sum* of the input ranks.
Use tt_round() afterwards to compress back down.
Both tensors must have the same mode sizes (n_k).
Args:
cores_a: TT-cores of tensor A.
cores_b: TT-cores of tensor B.
Returns:
TT-cores of A + B.
"""
d = len(cores_a)
if len(cores_b) != d:
raise ValueError(f"Dimension mismatch: A has {d} cores, B has {len(cores_b)}")
result = []
for k in range(d):
ra_l, na, ra_r = cores_a[k].shape
rb_l, nb, rb_r = cores_b[k].shape
if na != nb:
raise ValueError(f"Mode size mismatch at core {k}: A has {na}, B has {nb}")
if k == 0:
# First core: concatenate along right rank → (1, n, ra_r + rb_r)
new_core = np.concatenate([cores_a[k], cores_b[k]], axis=2)
elif k == d - 1:
# Last core: concatenate along left rank → (ra_l + rb_l, n, 1)
new_core = np.concatenate([cores_a[k], cores_b[k]], axis=0)
else:
# Interior: block-diagonal → (ra_l + rb_l, n, ra_r + rb_r)
new_core = np.zeros((ra_l + rb_l, na, ra_r + rb_r))
new_core[:ra_l, :, :ra_r] = cores_a[k]
new_core[ra_l:, :, ra_r:] = cores_b[k]
result.append(new_core)
return result
[docs]
def tt_scale(
cores: list[np.ndarray],
alpha: float,
) -> list[np.ndarray]:
"""Multiply a TT tensor by a scalar: B = alpha * A.
Only modifies the first core (ranks unchanged).
Args:
cores: TT-cores of tensor A.
alpha: Scalar multiplier.
Returns:
TT-cores of alpha * A.
"""
result = [c.copy() for c in cores]
result[0] = alpha * result[0]
return result
[docs]
def tt_hadamard(
cores_a: list[np.ndarray],
cores_b: list[np.ndarray],
) -> list[np.ndarray]:
"""Element-wise (Hadamard) product of two TT tensors: C = A ⊙ B.
The result has TT-ranks that are the *product* of the input ranks.
Use tt_round() afterwards to compress.
Args:
cores_a: TT-cores of tensor A.
cores_b: TT-cores of tensor B.
Returns:
TT-cores of A ⊙ B.
"""
d = len(cores_a)
if len(cores_b) != d:
raise ValueError(f"Dimension mismatch: A has {d} cores, B has {len(cores_b)}")
result = []
for k in range(d):
ra_l, na, ra_r = cores_a[k].shape
rb_l, nb, rb_r = cores_b[k].shape
if na != nb:
raise ValueError(f"Mode size mismatch at core {k}: A has {na}, B has {nb}")
# Kronecker product along rank dimensions for each mode index
# Result core shape: (ra_l * rb_l, n, ra_r * rb_r)
new_core = np.zeros((ra_l * rb_l, na, ra_r * rb_r))
for i in range(na):
# cores_a[k][:, i, :] is (ra_l, ra_r)
# cores_b[k][:, i, :] is (rb_l, rb_r)
# Kronecker product → (ra_l * rb_l, ra_r * rb_r)
new_core[:, i, :] = np.kron(cores_a[k][:, i, :], cores_b[k][:, i, :])
result.append(new_core)
return result
[docs]
def tt_dot(
cores_a: list[np.ndarray],
cores_b: list[np.ndarray],
) -> float:
"""Inner product of two TT tensors: <A, B> = sum(A ⊙ B).
Computed efficiently without full reconstruction via
sequential transfer-matrix contraction. O(d * r_a^2 * r_b^2 * n).
Args:
cores_a: TT-cores of tensor A.
cores_b: TT-cores of tensor B.
Returns:
Scalar inner product.
"""
d = len(cores_a)
if len(cores_b) != d:
raise ValueError(f"Dimension mismatch: A has {d} cores, B has {len(cores_b)}")
# Transfer matrix: shape (ra_l * rb_l,) initially (1,1) → scalar 1
ra_l0 = cores_a[0].shape[0]
rb_l0 = cores_b[0].shape[0]
Z = np.ones((ra_l0, rb_l0)) # (1, 1)
for k in range(d):
_ra_l, n_k, ra_r = cores_a[k].shape
_rb_l, nb, rb_r = cores_b[k].shape
if n_k != nb:
raise ValueError(f"Mode size mismatch at core {k}: A has {n_k}, B has {nb}")
# Contract: Z_new[ia, ib] = sum_j sum_{ia', ib'} Z[ia', ib'] * Ga[ia',j,ia] * Gb[ib',j,ib]
# Efficient: loop over mode index j, accumulate
Z_new = np.zeros((ra_r, rb_r))
for j in range(n_k):
# Ga_j = cores_a[k][:, j, :] shape (ra_l, ra_r)
# Gb_j = cores_b[k][:, j, :] shape (rb_l, rb_r)
# contribution = Ga_j^T @ Z @ Gb_j
Z_new += cores_a[k][:, j, :].T @ Z @ cores_b[k][:, j, :]
Z = Z_new
return float(Z.item())
[docs]
def tt_frobenius_norm(cores: list[np.ndarray]) -> float:
"""Frobenius norm of a TT tensor: ||A||_F = sqrt(<A, A>).
Computed without reconstruction.
Args:
cores: TT-cores.
Returns:
Frobenius norm (scalar).
"""
return float(np.sqrt(max(0.0, tt_dot(cores, cores))))