Source code for holovec.models.vtb

"""VTB (Vector-derived Transformation Binding) VSA model (MBAT-style).

VTB implements non-commutative binding by applying a transformation derived
from one vector to the other:

    c = M(a) @ b

In this implementation, M(a) is constructed implicitly as a weighted sum of
fixed non-commuting basis transformations applied to b (no D×D matrices are
materialized):

    M(a) @ b ≈ Σ_k w_k(a) · R_k(b)

where R_k are fixed basis transformations (circular shifts here) and w_k(a)
are content-dependent weights (softmax of projections of a onto K code vectors).

Properties:
- Binding: weighted sum of transformed b (non-commutative)
- Unbinding: approximate inverse via weighted inverse transforms
- Non-commutative: a ⊗ b ≠ b ⊗ a
- Exact inverse: No (approximate)

References:
- Gallant & Okaywe (2013): Matrix Binding of Additive Terms (MBAT)
- Gayler (2003); Plate (2003); Schlegel et al. (2022)
"""

from __future__ import annotations

from typing import Optional, Sequence, List

import numpy as np

from ..backends import Backend
from ..backends.base import Array
from ..spaces import RealSpace, VectorSpace
from .base import VSAModel


[docs] class VTBModel(VSAModel): """VTB (Vector-derived Transformation Binding) model. Binding (MBAT-style): c = Σ_k w_k(a) · roll(b, s_k) Unbinding (approximate): b̂ = Σ_k w_k(a) · roll(c, -s_k) Bundling: element-wise addition + normalization Permutation: circular shift Uses RealSpace with L2-normalized real-valued vectors. """
[docs] def __init__( self, dimension: int = 10000, space: Optional[VectorSpace] = None, backend: Optional[Backend] = None, seed: Optional[int] = None, n_bases: int = 4, shifts: Optional[List[int]] = None, temperature: float = 100.0, ): """Initialize VTB model. Args: dimension: Dimensionality of hypervectors space: Vector space (defaults to RealSpace) backend: Computational backend seed: Random seed for space """ if space is None: from ..backends import get_backend backend = backend if backend is not None else get_backend() space = RealSpace(dimension, backend=backend, seed=seed) super().__init__(space, backend) # MBAT parameters self.n_bases = int(n_bases) if self.n_bases < 2: raise ValueError("n_bases must be >= 2") self.temperature = float(temperature) if self.temperature <= 0: self.temperature = 1.0 # Basis transformations: use integer circular shifts as R_k if shifts is None: # choose distinct small shifts spread across dimension step = max(1, self.dimension // (self.n_bases + 1)) self.shifts = [((i + 1) * step) % self.dimension for i in range(self.n_bases)] # ensure non-zero and unique self.shifts = [s if s != 0 else 1 for s in self.shifts] self.shifts = list(dict.fromkeys(self.shifts)) while len(self.shifts) < self.n_bases: # fill with incremental shifts self.shifts.append((self.shifts[-1] + 1) % self.dimension) else: if len(shifts) != self.n_bases: raise ValueError("len(shifts) must equal n_bases") self.shifts = [int(s) % self.dimension for s in shifts] # Code vectors U_k to produce weights w_k(a) = softmax(τ · <a, U_k>) # Stack shape (K, D) self._U = self.backend.stack([ self.backend.normalize(self.backend.random_normal(self.dimension, seed=(seed or 0) + k)) for k in range(self.n_bases) ], axis=0)
@property def model_name(self) -> str: return "VTB" @property def is_self_inverse(self) -> bool: return False @property def is_commutative(self) -> bool: return False @property def is_exact_inverse(self) -> bool: return False def _weights(self, a: Array) -> Array: """Compute softmax weights over bases from vector a. w_k(a) = softmax(τ · <a, U_k>) Returns shape (K,) """ # scores: (K,) scores = [] for k in range(self.n_bases): uk = self._U[k] scores.append(self.backend.dot(a, uk)) scores = self.backend.stack(scores, axis=0) # scale by temperature then softmax scaled = self.backend.multiply_scalar(scores, self.temperature) return self.backend.softmax(scaled, axis=0) def _vector_to_circulant(self, vec: Array) -> Array: """Convert vector to circulant matrix. A circulant matrix is a special matrix where each row is a circular shift of the previous row. For vector [a, b, c]: [[a, b, c], [c, a, b], [b, c, a]] This construction enables efficient matrix-vector multiplication via circular convolution in the frequency domain. Args: vec: Vector of shape (D,) Returns: Circulant matrix of shape (D, D) """ vec_np = self.backend.to_numpy(vec) D = len(vec_np) # Build circulant matrix: each row is a circular shift matrix = np.zeros((D, D), dtype=vec_np.dtype) for i in range(D): matrix[i] = np.roll(vec_np, i) return self.backend.from_numpy(matrix)
[docs] def bind(self, a: Array, b: Array) -> Array: """Bind using MBAT-style weighted basis transforms. c = Σ_k w_k(a) · roll(b, s_k) """ # Derive transform from a to act on b w = self._weights(a) # (K,) # accumulate weighted shifts parts = [] for k, shift in enumerate(self.shifts): wk = w[k] rb = self.backend.roll(b, shift=shift) parts.append(self.backend.multiply_scalar(rb, float(self.backend.to_numpy(wk)))) result = self.backend.sum(self.backend.stack(parts, axis=0), axis=0) return self.normalize(result)
[docs] def unbind(self, c: Array, b: Array) -> Array: """Approximate unbinding using weighted inverse transforms. IMPORTANT: Due to non-commutativity, this recovers b from c = bind(a, b). You must pass the FIRST argument of bind (a) as the second argument here. For c = bind(a, b): - unbind(c, a) → recovers b (correct usage) - unbind(c, b) → does NOT recover a b̂ = Σ_k w_k(b) · roll(c, -s_k) """ # Use same transform derived from b w = self._weights(b) parts = [] for k, shift in enumerate(self.shifts): wk = w[k] rc = self.backend.roll(c, shift=-shift) parts.append(self.backend.multiply_scalar(rc, float(self.backend.to_numpy(wk)))) num = self.backend.sum(self.backend.stack(parts, axis=0), axis=0) # Denominator as sum of squared weights (scalar) w_np = self.backend.to_numpy(w) denom = float((w_np ** 2).sum()) + 1e-8 result = self.backend.multiply_scalar(num, 1.0 / denom) return self.normalize(result)
[docs] def bundle(self, vectors: Sequence[Array]) -> Array: """Bundle using element-wise addition. Sum all hypervectors element-wise and normalize. Args: vectors: Sequence of hypervectors to bundle Returns: Bundled hypervector Raises: ValueError: If vectors is empty """ if not vectors: raise ValueError("Cannot bundle empty sequence") vectors = list(vectors) # Sum all vectors result = self.backend.sum(self.backend.stack(vectors, axis=0), axis=0) # Normalize to unit length return self.normalize(result)
[docs] def permute(self, vec: Array, k: int = 1) -> Array: """Permute using circular shift. Shifts vector elements by k positions. Combined with binding, this can encode position in sequences. Args: vec: Hypervector to permute k: Number of positions to shift (default: 1) Returns: Permuted hypervector """ return self.backend.roll(vec, shift=k)
[docs] def test_non_commutativity(self, a: Array, b: Array) -> float: """Test degree of non-commutativity for two hypervectors. Computes: similarity(a ⊗ b, b ⊗ a) A similarity of 1.0 means commutative, close to 0 means non-commutative. Args: a: First hypervector b: Second hypervector Returns: Similarity between a⊗b and b⊗a (should be low for VTB) """ ab = self.bind(a, b) ba = self.bind(b, a) return self.similarity(ab, ba)
[docs] def bind_sequence(self, items: Sequence[Array], use_permute: bool = True) -> Array: """Bind a sequence of items with positional encoding. Two strategies: 1. With permutation: c = a₁ ⊗ ρ⁰(pos) + a₂ ⊗ ρ¹(pos) + ... 2. Without permutation: c = (...((a₁ ⊗ a₂) ⊗ a₃)...) (nested binding) Args: items: Sequence of hypervectors to bind use_permute: If True, use permutation strategy; else nested binding Returns: Sequence hypervector Raises: ValueError: If items is empty """ if not items: raise ValueError("Cannot bind empty sequence") items = list(items) if use_permute: # Strategy 1: Bind each item with permuted position vector pos = self.random(seed=42) # Fixed position vector bound_items = [] for i, item in enumerate(items): permuted_pos = self.permute(pos, k=i) bound_items.append(self.bind(item, permuted_pos)) return self.bundle(bound_items) else: # Strategy 2: Nested binding (naturally non-commutative) result = items[0] for item in items[1:]: result = self.bind(result, item) return result
def __repr__(self) -> str: return (f"VTBModel(dimension={self.dimension}, " f"space={self.space.space_name}, " f"backend={self.backend.name})")
# Helper function to compare VTB vs HRR unbinding quality def compare_vtb_hrr_unbinding(dimension: int = 1000, trials: int = 10) -> dict: """Compare unbinding quality between VTB and HRR. Both models use approximate inverse, but with different mechanisms. This function empirically measures recovery quality. Args: dimension: Dimensionality of hypervectors trials: Number of random trials Returns: Dictionary with comparison statistics """ from ..backends import get_backend from .hrr import HRRModel backend = get_backend('numpy') vtb = VTBModel(dimension=dimension, backend=backend) hrr = HRRModel(dimension=dimension, backend=backend) vtb_similarities = [] hrr_similarities = [] for i in range(trials): # Generate random vectors a_vtb = vtb.random(seed=i*2) b_vtb = vtb.random(seed=i*2+1) a_hrr = hrr.random(seed=i*2) b_hrr = hrr.random(seed=i*2+1) # Bind c_vtb = vtb.bind(a_vtb, b_vtb) c_hrr = hrr.bind(a_hrr, b_hrr) # Unbind (recover the second operand for VTB, first for HRR) b_recovered_vtb = vtb.unbind(c_vtb, a_vtb) a_recovered_hrr = hrr.unbind(c_hrr, b_hrr) # Measure similarity sim_vtb = vtb.similarity(b_vtb, b_recovered_vtb) sim_hrr = hrr.similarity(a_hrr, a_recovered_hrr) vtb_similarities.append(sim_vtb) hrr_similarities.append(sim_hrr) return { 'vtb_mean': float(np.mean(vtb_similarities)), 'vtb_std': float(np.std(vtb_similarities)), 'hrr_mean': float(np.mean(hrr_similarities)), 'hrr_std': float(np.std(hrr_similarities)), 'vtb_better': np.mean(vtb_similarities) > np.mean(hrr_similarities), }