MPS Basics#
Matrix Product States (MPS) are the fundamental tensor network representation used throughout ATLAS-Q. This tutorial provides a comprehensive introduction to MPS structure, operations, and practical usage.
Prerequisites#
Completed Beginner’s Tutorial tutorial
Understanding of quantum states and tensor products
Familiarity with singular value decomposition (SVD)
Basic linear algebra knowledge
Learning Objectives#
After completing this tutorial, you will understand:
MPS tensor structure and representation
How bond dimensions control accuracy and memory
Canonical forms and their importance
How gates are applied to MPS
Truncation mechanisms and error control
Entanglement measures in MPS
Practical MPS usage patterns
Part 1: Understanding MPS Structure#
What is an MPS?#
An MPS represents a quantum state as a chain of rank-3 tensors. Instead of storing all 2ⁿ amplitudes, we decompose the state into a product of smaller tensors.
Mathematical Representation#
A quantum state can be written as:
where each tensor \(A^{[i]}\) has shape \([\chi_{i-1}, 2, \chi_i]\):
Visual Representation#
Tensor Network Diagram:
|s₁⟩ |s₂⟩ |s₃⟩ |sₙ⟩
| | | |
[A¹]--[A²]--[A³]-- ... --[Aⁿ]
χ₁ χ₂ χ₃ χₙ
Each A^[i] is a rank-3 tensor with indices:
- Physical index (up): connects to qubit basis
- Left/right indices (horizontal): connect to neighboring tensors
Creating Your First MPS#
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
# Create 10-qubit MPS with bond dimension 8
mps = AdaptiveMPS(num_qubits=10, bond_dim=8, device='cuda')
# Inspect the MPS structure
print(f"Number of tensors: {len(mps.tensors)}")
print(f"First tensor shape: {mps.tensors[0].shape}")
print(f"Middle tensor shape: {mps.tensors[5].shape}")
print(f"Last tensor shape: {mps.tensors[-1].shape}")
# Output:
# Number of tensors: 10
# First tensor shape: torch.Size([1, 2, 8]) # χ_L=1 (boundary)
# Middle tensor shape: torch.Size([8, 2, 8]) # χ_L=8, χ_R=8
# Last tensor shape: torch.Size([8, 2, 1]) # χ_R=1 (boundary)
Memory Scaling Analysis#
The power of MPS comes from exponential compression:
import torch
# Full statevector memory
def statevector_memory(n_qubits):
# 2^n complex numbers, each 8 bytes (complex64)
return 2**n_qubits * 8
# MPS memory (approximate)
def mps_memory(n_qubits, bond_dim):
# n tensors, each roughly bond_dim^2 * 2 * 8 bytes
return n_qubits * bond_dim**2 * 2 * 8
# Compare for different system sizes
for n in [10, 20, 30, 40]:
chi = 64
sv_mem = statevector_memory(n) / (1024**3) # GB
mps_mem = mps_memory(n, chi) / (1024**3) # GB
compression = statevector_memory(n) / mps_memory(n, chi)
print(f"{n} qubits (χ={chi}):")
print(f" Statevector: {sv_mem:.3f} GB")
print(f" MPS: {mps_mem:.6f} GB")
print(f" Compression: {compression:,.0f}×\n")
Expected output shows MPS uses orders of magnitude less memory:
10 qubits (χ=64):
Statevector: 0.000 GB
MPS: 0.000005 GB
Compression: 160×
20 qubits (χ=64):
Statevector: 0.008 GB
MPS: 0.000010 GB
Compression: 819×
30 qubits (χ=64):
Statevector: 8.590 GB
MPS: 0.000015 GB
Compression: 559,241×
40 qubits (χ=64):
Statevector: 8796.093 GB
MPS: 0.000020 GB
Compression: 439,804,651×
Part 2: Bond Dimensions#
What is Bond Dimension?#
Bond dimension (χ) controls how much information flows between tensors. Higher χ allows more entanglement but requires more memory.
Entanglement Capacity#
For a bipartition of the system, maximum entanglement entropy is:
where \(n_L\) and \(n_R\) are qubits on left and right.
This means: - χ=1: No entanglement (product state) - χ=2: 1 ebit of entanglement - χ=4: 2 ebits of entanglement - χ=2^k: k ebits of entanglement
Choosing Bond Dimension#
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
import numpy as np
def test_bond_dimension(n_qubits, chi_values):
"""Test different bond dimensions on entangled circuit."""
H = torch.tensor([[1, 1], [1, -1]], dtype=torch.complex64) / np.sqrt(2)
H = H.to('cuda')
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
results = []
for chi in chi_values:
mps = AdaptiveMPS(num_qubits=n_qubits, bond_dim=chi, device='cuda')
# Create entanglement
for q in range(n_qubits):
mps.apply_single_qubit_gate(q, H)
for q in range(n_qubits - 1):
mps.apply_two_site_gate(q, CNOT)
stats = mps.stats_summary()
error = mps.global_error_bound()
memory = mps.memory_usage() / (1024**2) # MB
results.append({
'chi': chi,
'max_chi': stats['max_chi'],
'error': error,
'memory_mb': memory
})
print(f"χ={chi:3d}: max_chi={stats['max_chi']:3d}, "
f"error={error:.2e}, memory={memory:.2f} MB")
return results
# Test
results = test_bond_dimension(n_qubits=15, chi_values=[4, 8, 16, 32, 64])
Guidelines for χ Selection#
Product states (no entanglement): χ=1
Weakly entangled (shallow circuits): χ=8-16
Moderately entangled (medium depth): χ=32-64
Highly entangled (deep circuits): χ=128-512
Maximally entangled (random circuits): χ ≈ 2^(n/2)
Start small and increase if truncation error is too large.
Part 3: Canonical Forms#
Why Canonical Forms?#
Canonical forms provide numerical stability and enable efficient algorithms. An MPS in canonical form has orthogonal tensors.
Left Canonical Form#
Left canonical form means tensors satisfy:
Visually:
Left canonical: [A¹]--[A²]--[A³]-- ... --[Λ]
Each A^[i] is left-orthogonal (L†L = I)
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
mps = AdaptiveMPS(num_qubits=10, bond_dim=8, device='cuda')
# Convert to left canonical form
mps.to_left_canonical()
# Check orthogonality of first tensor
A = mps.tensors[0] # Shape: [1, 2, 8]
A_reshaped = A.reshape(2, 8) # Combine left index with physical
# Verify A†A = I
identity = torch.matmul(A_reshaped.conj().T, A_reshaped)
error = torch.norm(identity - torch.eye(8, device='cuda'))
print(f"Orthogonality error: {error:.2e}") # Should be ~1e-7
Right Canonical Form#
Right canonical form means tensors satisfy:
# Convert to right canonical form
mps.to_right_canonical()
# Check orthogonality of last tensor
A = mps.tensors[-1] # Shape: [8, 2, 1]
A_reshaped = A.reshape(8, 2) # Combine physical with right index
# Verify AA† = I
identity = torch.matmul(A_reshaped, A_reshaped.conj().T)
error = torch.norm(identity - torch.eye(8, device='cuda'))
print(f"Orthogonality error: {error:.2e}")
Mixed Canonical Form#
Most efficient for gate application: left-canonical up to site i, right-canonical after:
[A¹]--[A²]--[Λ]--[A⁴]--[A⁵]
←LC→ ←→ ←RC→
Center
The center tensor contains all the state’s norm.
Part 4: Gate Application#
Single-Qubit Gates#
Applying a single-qubit gate to MPS:
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
import numpy as np
mps = AdaptiveMPS(num_qubits=10, bond_dim=16, device='cuda')
# Define rotation gate R_y(θ)
theta = np.pi / 4
Ry = torch.tensor([
[np.cos(theta/2), -np.sin(theta/2)],
[np.sin(theta/2), np.cos(theta/2)]
], dtype=torch.complex64).to('cuda')
# Apply to qubit 5
mps.apply_single_qubit_gate(5, Ry)
# Bond dimensions unchanged
stats = mps.stats_summary()
print(f"Max bond dimension: {stats['max_chi']}") # Still 16
Single-qubit gates don’t increase entanglement or bond dimensions.
Two-Qubit Gates#
Two-qubit gates can increase entanglement:
# Start with product state
mps = AdaptiveMPS(num_qubits=10, bond_dim=2, device='cuda')
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
print(f"Before CNOT: max_chi = {mps.stats_summary()['max_chi']}")
# Apply CNOT - creates entanglement
mps.apply_two_site_gate(4, CNOT)
print(f"After CNOT: max_chi = {mps.stats_summary()['max_chi']}")
print(f"Truncation error: {mps.global_error_bound():.2e}")
Gate Application Algorithm#
For a two-site gate on qubits i and i+1:
Contract tensors A^[i] and A^[i+1] into a 4-index tensor
Apply the gate to the physical indices
Reshape into matrix form
Perform SVD to split back into two tensors
Truncate small singular values based on χ_max and ε
def explain_two_site_gate_application():
"""Conceptual explanation of algorithm."""
# Step 1: Contract two tensors
# Θ[α, s_i, s_{i+1}, γ] = A^[i][α, s_i, β] * A^[i+1][β, s_{i+1}, γ]
# Step 2: Apply gate
# Θ'[α, s'_i, s'_{i+1}, γ] = U[s'_i, s'_{i+1}, s_i, s_{i+1}] * Θ[α, s_i, s_{i+1}, γ]
# Step 3: Reshape to matrix
# M[(α, s'_i), (s'_{i+1}, γ)]
# Step 4: SVD
# M = U * S * V†
# Step 5: Truncate
# Keep largest χ singular values
# A^[i]_new = U[:, :χ]
# A^[i+1]_new = (S[:χ] * V†[:χ, :])
print("See adaptive_mps.py for full implementation")
explain_two_site_gate_application()
Part 5: Truncation Mechanics#
Why Truncate?#
Without truncation, bond dimensions grow exponentially with gates:
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
# Track bond dimension growth
mps = AdaptiveMPS(num_qubits=10, bond_dim=1024, device='cuda')
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
chi_history = []
for layer in range(5):
for q in range(9):
mps.apply_two_site_gate(q, CNOT)
stats = mps.stats_summary()
chi_history.append(stats['max_chi'])
print(f"Layer {layer+1}: max_chi = {stats['max_chi']}")
# Without truncation, χ would double each layer!
Truncation Criterion#
ATLAS-Q uses energy-based truncation. Keep smallest k such that:
where σ_i are singular values and ε is the truncation tolerance.
from atlas_q.adaptive_mps import AdaptiveMPS
# Configure truncation
mps = AdaptiveMPS(
num_qubits=20,
bond_dim=16, # Initial χ
eps_bond=1e-6, # Local truncation tolerance
chi_max_per_bond=128, # Maximum χ allowed
device='cuda'
)
# Apply some gates...
# Check truncation results
global_error = mps.global_error_bound()
print(f"Global truncation error: {global_error:.2e}")
# Per-operation errors
for i, eps_local in enumerate(mps.statistics.logs['eps_local'][:10]):
print(f"Operation {i}: ε_local = {eps_local:.2e}")
Error Accumulation#
Global error bound:
import math
def compute_expected_error(n_operations, eps_bond):
"""Estimate global error after n operations."""
# Assuming each operation introduces eps_bond error
return math.sqrt(n_operations * eps_bond**2)
# Example: 1000 operations with ε=1e-6
n_ops = 1000
eps = 1e-6
expected_error = compute_expected_error(n_ops, eps)
print(f"Expected global error: {expected_error:.2e}") # ~3e-5
Part 6: Entanglement Measures#
Entanglement Entropy#
The entanglement entropy at a bond quantifies how entangled the two halves are:
from atlas_q.diagnostics import bond_entropy_from_S
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
import numpy as np
mps = AdaptiveMPS(num_qubits=10, bond_dim=64, device='cuda')
# Create entangled state
H = torch.tensor([[1, 1], [1, -1]], dtype=torch.complex64).to('cuda') / np.sqrt(2)
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
for q in range(10):
mps.apply_single_qubit_gate(q, H)
for q in range(9):
mps.apply_two_site_gate(q, CNOT)
# Compute entanglement across center bond
# Would need to extract singular values from center bond
# (This is tracked internally in mps.statistics)
entropy_logs = mps.statistics.logs.get('entropy', [])
if entropy_logs:
avg_entropy = sum(entropy_logs) / len(entropy_logs)
print(f"Average bond entropy: {avg_entropy:.2f} bits")
Interpreting Entropy#
S = 0: No entanglement (product state)
S = 1: 1 ebit of entanglement (Bell pair)
S = k: k ebits of entanglement
S = log₂(χ): Bond is saturated (need higher χ)
Part 7: Practical Patterns#
Circuit Construction Pattern#
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
import numpy as np
def build_layered_circuit(n_qubits, n_layers, bond_dim=32):
"""Build circuit with repeated layers."""
mps = AdaptiveMPS(num_qubits=n_qubits, bond_dim=bond_dim, device='cuda')
# Define gates
H = torch.tensor([[1, 1], [1, -1]], dtype=torch.complex64).to('cuda') / np.sqrt(2)
Rz = lambda theta: torch.diag(torch.tensor([1, np.exp(1j*theta)], dtype=torch.complex64)).to('cuda')
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
for layer in range(n_layers):
# Single-qubit layer
for q in range(n_qubits):
mps.apply_single_qubit_gate(q, H)
mps.apply_single_qubit_gate(q, Rz(layer * 0.1))
# Entangling layer
for q in range(0, n_qubits-1, 2): # Even pairs
mps.apply_two_site_gate(q, CNOT)
for q in range(1, n_qubits-1, 2): # Odd pairs
mps.apply_two_site_gate(q, CNOT)
# Monitor progress
if (layer + 1) % 10 == 0:
stats = mps.stats_summary()
error = mps.global_error_bound()
print(f"Layer {layer+1}: χ_max={stats['max_chi']}, ε={error:.2e}")
return mps
# Build 50-layer circuit
mps = build_layered_circuit(n_qubits=20, n_layers=50, bond_dim=64)
State Preparation Pattern#
def prepare_ghz_state(n_qubits):
"""Prepare GHZ state: (|00...0⟩ + |11...1⟩)/√2"""
mps = AdaptiveMPS(num_qubits=n_qubits, bond_dim=4, device='cuda')
H = torch.tensor([[1, 1], [1, -1]], dtype=torch.complex64).to('cuda') / torch.sqrt(torch.tensor(2.0))
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to('cuda')
# Hadamard on first qubit
mps.apply_single_qubit_gate(0, H)
# CNOT chain
for q in range(n_qubits - 1):
mps.apply_two_site_gate(q, CNOT)
return mps
def prepare_w_state(n_qubits):
"""Prepare W state: (|100...0⟩ + |010...0⟩ + ... + |00...1⟩)/√n"""
# More complex - requires controlled rotations
# Left as exercise
pass
# Test
ghz = prepare_ghz_state(10)
samples = ghz.sample(num_shots=100)
from collections import Counter
counts = Counter(samples)
print("GHZ state measurements:")
for state, count in sorted(counts.items()):
print(f"|{bin(state)[2:].zfill(10)}⟩: {count}")
Measurement Pattern#
def measure_observable(mps, observable_mpo):
"""Compute expectation value ⟨ψ|O|ψ⟩"""
# This would use MPO-MPS contraction
# See mpo_ops tutorial for details
pass
def measure_correlations(mps, site_i, site_j):
"""Measure ⟨Z_i Z_j⟩ correlation"""
# Would require partial contractions
# See diagnostics for entropy calculations
pass
Part 8: Troubleshooting#
High Truncation Errors#
Problem: Global error exceeds tolerance
Solutions:
Increase bond dimension:
mps = AdaptiveMPS(num_qubits=20, bond_dim=128, device='cuda') # Higher χ
Tighten truncation tolerance:
mps = AdaptiveMPS(num_qubits=20, bond_dim=64, eps_bond=1e-8, device='cuda')
Use higher precision:
mps = AdaptiveMPS(num_qubits=20, bond_dim=64, dtype=torch.complex128, device='cuda')
Memory Exhaustion#
Problem: Out of GPU memory
Solutions:
Reduce bond dimension
Enable memory budgets:
mps = AdaptiveMPS( num_qubits=50, bond_dim=32, budget_global_mb=4096, # 4GB limit device='cuda' )
Use CPU for larger systems:
mps = AdaptiveMPS(num_qubits=50, bond_dim=32, device='cpu')
Slow Performance#
Problem: Gate application is slow
Solutions:
Ensure GPU is being used
Install Triton for GPU kernels:
pip install tritonUse cuQuantum:
pip install cuquantum-pythonBatch operations when possible
Numerical Instability#
Problem: NaN or Inf values appearing
Solutions:
Use canonical forms regularly:
if step % 100 == 0: mps.to_left_canonical()
Use higher precision (complex128)
Check condition numbers in diagnostics
Avoid very small truncation tolerances (< 1e-12)
Summary#
You have learned:
✅ MPS structure and tensor representation
✅ Bond dimensions and entanglement capacity
✅ Canonical forms for numerical stability
✅ How gates are applied to MPS
✅ Truncation mechanisms and error control
✅ Entanglement entropy measures
✅ Practical usage patterns
✅ Common troubleshooting techniques
Next Steps#
VQE Tutorial - Use MPS for variational algorithms
TDVP Tutorial - Time evolution with MPS
Tensor Networks - Deeper theory
Adaptive Truncation - Truncation mathematics
How to Optimize Performance - Performance tuning
Practice Exercises#
Create an MPS with linearly increasing entanglement along the chain
Measure bond entropies at each bond and plot them
Compare truncation errors for different ε values on the same circuit
Implement a custom state preparation (e.g., W state)
Build a parameterized circuit and track how χ grows with depth
See Also#
atlas_q.adaptive_mps - AdaptiveMPS API reference
atlas_q.diagnostics - Entropy and diagnostics API
atlas_q.truncation - Truncation functions
Tensor Networks - Tensor network theory