Skip to content

Creating Your First Tensor

Tensors are the fundamental objects in Nicole. Unlike standard numerical arrays, Nicole tensors are symmetry-aware: they automatically organize data into blocks according to quantum numbers (charges) and enforce conservation laws during operations.

This page introduces the basic concepts:

  • Indices: Each tensor index is an Index object that defines its symmetry sectors, dimensions, and direction
  • Sectors: Subspaces labeled by quantum numbers (e.g., particle number, spin)
  • Blocks: Dense arrays corresponding to specific charge combinations
  • Charge conservation: Only blocks with compatible charges are non-zero

By leveraging symmetry, Nicole tensors can be orders of magnitude more efficient than dense arrays for quantum many-body problems.

Basic Tensor Creation

# 1. Create a U(1) symmetry group (particle number conservation)
group = U1Group()

# 2. Define an outgoing index with three sectors
index_out = Index(
    Direction.OUT,
    group,
    sectors=(
        Sector(charge=0, dim=2),   # neutral sector with 2 states
        Sector(charge=1, dim=1),   # charge +1 with 1 state
        Sector(charge=-1, dim=1),  # charge -1 with 1 state
    )
)

# 3. Create the conjugate index (flipped direction)
index_in = index_out.flip()

# 4. Create a random tensor
tensor = Tensor.random([index_out, index_in], itags=["i", "j"], seed=42)

print(tensor)
  info:  2x { 3 x 1 }  having 'A',   Tensor,  { i*, j }
  data:  2-D float64 (48 B)    4 x 4 => 4 x 4  @ norm = 1.23835

     1.  1x1     |  1x1     [ -1 ; -1 ]  -0.1863     
     2.  2x2     |  1x1     [  0 ;  0 ]    32 B      
     3.  1x1     |  1x1     [  1 ;  1 ]   -1.123     

Understanding the Output

  • 2x: 2 indices (tensor order)
  • { 3 x 1 }: 3 blocks, 1 charge component each (single symmetry)
  • 'A': Abelian symmetry group (U(1) in this case)
  • { i*, j }: Index tags (* marks OUT direction, i is OUT, j is IN)
  • 4 x 4 => 4 x 4: Total multiplets and states for each index
  • Each line shows: block dimensions | multiplet info | charges | value or memory

Creating Different Tensors

Zero Tensor

# Create with zeros instead of random values
T_zero = Tensor.zeros([index_out, index_in], itags=["i", "j"])

print(T_zero)
  info:  2x { 3 x 1 }  having 'A',   Tensor,  { i*, j }
  data:  2-D float64 (48 B)    4 x 4 => 4 x 4  @ norm = 0

     1.  1x1     |  1x1     [ -1 ; -1 ]       0.     
     2.  2x2     |  1x1     [  0 ;  0 ]    32 B      
     3.  1x1     |  1x1     [  1 ;  1 ]       0.     

Multi-Index Tensor

# Create a 3-index tensor
idx = Index(Direction.OUT, group, sectors=(Sector(0, 2), Sector(1, 1)))

T_3d = Tensor.random(
    [idx, idx.flip(), idx],
    itags=["i", "j", "k"],
    seed=99
)

print(T_3d)
  info:  3x { 3 x 1 }  having 'A',   Tensor,  { i*, j, k* }
  data:  3-D float64 (96 B)    3 x 3 x 3 => 3 x 3 x 3  @ norm = 3.80133

     1.  2x2x2   |  1x1x1   [ 0 ; 0 ; 0 ]    64 B      
     2.  2x1x1   |  1x1x1   [ 0 ; 1 ; 1 ]    16 B      
     3.  1x1x2   |  1x1x1   [ 1 ; 1 ; 0 ]    16 B      

Symmetric Sectors

# Create an index with sectors symmetric around zero
sectors_symmetric = tuple(
    Sector(charge=q, dim=2)
    for q in range(-2, 3)  # -2, -1, 0, 1, 2
)

idx_sym = Index(Direction.OUT, group, sectors=sectors_symmetric)
T_sym = Tensor.random([idx_sym, idx_sym.flip()], itags=["a", "b"], seed=7)

print(T_sym)
  info:  2x { 5 x 1 }  having 'A',   Tensor,  { a*, b }
  data:  2-D float64 (160 B)    10 x 10 => 10 x 10  @ norm = 5.5662

     1.  2x2     |  1x1     [ -1 ; -1 ]    32 B      
     2.  2x2     |  1x1     [ -2 ; -2 ]    32 B      
     3.  2x2     |  1x1     [  0 ;  0 ]    32 B      
     4.  2x2     |  1x1     [  1 ;  1 ]    32 B      
     5.  2x2     |  1x1     [  2 ;  2 ]    32 B      

Accessing Tensor Information

# Get tensor properties
print(f"Data type: {tensor.dtype}")
print(f"Index tags: {tensor.itags}")
print(f"Number of indices: {len(tensor.indices)}\n")

# Get specific blocks (1-indexed to match display)
block_1 = tensor.block(1)
print(f"First block shape: {block_1.shape}")

# Get block key (charges)
key_1 = tensor.key(1)
print(f"First block charges: {key_1}\n")

# Iterate over all blocks
for i in range(1, len(tensor.data) + 1):
    key = tensor.key(i)
    block = tensor.block(i)
    print(f"Block {i}: charges {key}, shape {block.shape}")
Data type: torch.float64
Index tags: ('i', 'j')
Number of indices: 2

First block shape: torch.Size([1, 1])
First block charges: (-1, -1)

Block 1: charges (-1, -1), shape torch.Size([1, 1])
Block 2: charges (0, 0), shape torch.Size([2, 2])
Block 3: charges (1, 1), shape torch.Size([1, 1])

Charge Conservation in Action

# Only charge-conserving blocks exist
# For (OUT, IN) tensor: OUT_charge - IN_charge = 0

# Valid blocks:
# (0, 0): 0 - 0 = 0 ✓
# (1, 1): 1 - 1 = 0 ✓
# (-1, -1): -1 - (-1) = 0 ✓

# Invalid blocks (not created):
# (1, 0): 1 - 0 = 1 ≠ 0 ✗
# (0, 1): 0 - 1 = -1 ≠ 0 ✗

# Verify: all blocks satisfy conservation
from nicole.blocks import BlockSchema

for key in tensor.data.keys():
    conserved = BlockSchema.charges_conserved(tensor.indices, key)
    print(f"Block {key}: conserved = {conserved}")  # All True
Block (0, 0): conserved = True
Block (1, 1): conserved = True
Block (-1, -1): conserved = True

Copying Tensors

# Deep copy (independent data)
T_clone = tensor.clone()
T_clone.data[tensor.key(1)][0, 0] = 999.0

# Original unchanged
print(f"Original unchanged: {tensor.data[tensor.key(1)][0, 0] != 999.0}")
Original unchanged: True

See Also