Tensors in pyAgrum

Posted on Fri 14 March 2025 in notebooks

Major Change in aGrUM/pyAgrum 2.0.0: Renaming Potential to Tensor

One of the significant changes in aGrUM/pyAgrum 2.0.0 is the renaming of the class Potential to Tensor. This change reflects a more accurate and mathematically precise naming convention. Here's why this change is important:

Why the Change?

  • Potential: In pyAgrum, a Potential was traditionally used to represent a factor that contributes to a joint probability distribution. While this term was functional, it was specific to probabilistic graphical models and did not fully capture the mathematical nature of the object.

  • Tensor: A tensor is a well-defined mathematical object that generalizes scalars, vectors, and matrices to higher dimensions. By renaming the class to Tensor, the library now aligns with the standard mathematical terminology, making it more intuitive for users familiar with linear algebra and tensor operations.

This change emphasizes that the object is not limited to probabilistic applications but is a general-purpose mathematical tool.

Efficiency of Tensors in aGrUM/pyAgrum

Tensors in aGrUM/pyAgrum are highly efficient because they avoid ambiguity in their dimensions. Unlike generic tensor libraries (e.g., NumPy), where dimensions are often implicit and can lead to confusion, aGrUM/pyAgrum enforces clear and explicit dimension handling.

Example: Tensor Operations in NumPy

Consider the tensorial addition $$h(y,x,t,z)=f(x,y,z)+g(z,t,x)$$

In NumPy, performing tensor operations requires manual handling of dimensions, such as expanding and rearranging axes to ensure compatibility. This process can be error-prone and requires careful attention to the order of dimensions.

In [1]:
import numpy as np

# initialization
# Define the dimensions
x_dim, y_dim, z_dim, t_dim = 3, 2, 3, 4

# Create tensors f(x, y, z) and g(z, t, x) and add random values 
npf = np.random.uniform(0,1, size=(x_dim, y_dim, z_dim))  # Shape: (3, 3, 3)
npg = np.random.uniform(0,1, size=(z_dim, t_dim, x_dim))  # Shape: (3, 3, 3)
In [2]:
# the operation itself

# we have to explicitely fix the dimension and the order of variables in the tensor

# To compute h(y, x, t, z) = f(x, y, z) + g(z, t, x), we need to align the dimensions:
# 1. Expand f to include the t dimension (since f doesn't have t)
# 2. Expand g to include the y dimension (since g doesn't have y)
# 3. Rearrange the axes to match the desired output shape (y, x, t, z)

# Step 1: Expand f along the t axis
npf_expanded = npf[:, :, :, np.newaxis]  # Add a new axis for t, shape: (3, 3, 3, 1)
npf_expanded = np.broadcast_to(npf_expanded, (x_dim, y_dim, z_dim, t_dim))  # Shape: (3, 3, 3, 3)

# Step 2: Expand g along the y axis
npg_expanded = npg[:, :, :, np.newaxis]  # Add a new axis for y, shape: (3, 3, 3, 1)
npg_expanded = np.broadcast_to(npg_expanded, (z_dim, t_dim, x_dim, y_dim))  # Shape: (3, 3, 3, 3)

# Step 3: Rearrange the axes of f and g to match the desired output shape (y, x, t, z)
# For f: (x, y, z, t) -> (y, x, t, z)
npf_rearranged = np.transpose(npf_expanded, (1, 0, 3, 2))  # Shape: (3, 3, 3, 3)

# For g: (z, t, x, y) -> (y, x, t, z)
npg_rearranged = np.transpose(npg_expanded, (3, 2, 1, 0))  # Shape: (3, 3, 3, 3)

# Step 4: Add the two tensors
nph = npf_rearranged + npg_rearranged  # Shape: (3, 3, 3, 3)

which makes it difficult to write a generic library that can manipulate any type of tensors and implies copies, wasted memory, etc.

Why aGrUM/pyAgrum Tensors are specific

In aGrUM/pyAgrum, tensors are designed to avoid such ambiguity:

  • Explicit Dimensions: Each tensor has clearly defined dimensions, eliminating the need for manual reshaping or broadcasting.
  • Efficient Operations: Tensor operations can then be optimized (in space and in time), ensuring both clarity and performance.
In [3]:
import pyagrum as gum

# initialization
# Define the variables
x,y,z,t=[gum.RangeVariable(name,"",0,dim-1) for name,dim in [("x",3),("y",2),("z",3),("t",4)]]


# Create tensors f(x, y, z) and g(z, t, x)
gumf=gum.Tensor().add(x).add(y).add(z)
gumg=gum.Tensor().add(z).add(t).add(x)

# random values
gumf.random()
gumg.random();
In [4]:
# the operation itself (to compare with cell 2 !)

# everything is implicitely known. So it is just a non-ambiguous addition, 
# optimized in space and time
gumh=gumf+gumg
In [5]:
# Note that we do not know the order of variables in h, which is not important for gum.Tensor.
print(f"order of variable (decided by pyAgrum) : {gumh.names}")
# But if you need to specify this order
gumh=gumh.reorganize("yxtz")
print(f"order of variable (after reorganization) : {gumh.names}")
order of variable (decided by pyAgrum) : ('x', 'z', 't', 'y')
order of variable (after reorganization) : ('y', 'x', 't', 'z')

Inspecting the results

The difference is obvious when inspecting the objets

In [6]:
print("==== numpy version")
print(nph)
==== numpy version
[[[[1.28511595 1.29744531 0.50109429]
   [1.50959552 0.96179598 1.19684383]
   [1.55229068 0.77688263 0.64511023]
   [1.61736785 0.97723461 0.67648557]]

  [[0.31091276 0.78492996 0.84452714]
   [1.05297005 0.50942907 0.84632348]
   [0.20321242 1.21404916 0.9027708 ]
   [0.66716349 1.01792541 0.98128768]]

  [[1.02802566 0.88761335 0.64714886]
   [1.37415188 1.20006492 1.05313763]
   [1.39917005 0.88333037 0.70150209]
   [1.37807501 1.61788232 0.62848293]]]


 [[[0.75374822 1.28876712 0.72892937]
   [0.97822778 0.9531178  1.4246789 ]
   [1.02092295 0.76820445 0.87294531]
   [1.08600012 0.96855642 0.90432065]]

  [[0.58647728 0.76711397 0.65362244]
   [1.32853457 0.49161308 0.65541878]
   [0.47877695 1.19623317 0.7118661 ]
   [0.94272801 1.00010941 0.79038298]]

  [[0.71255414 0.77815581 0.07302721]
   [1.05868036 1.09060738 0.47901599]
   [1.08369853 0.77387283 0.12738044]
   [1.0626035  1.50842478 0.05436128]]]]
In [7]:
print("=== pyAgrum version")
print(gumh)
=== pyAgrum version

                    ||  y                |
x     |t     |z     ||0        |1        |
------|------|------||---------|---------|
0     |0     |0     || 1.0348  | 0.6527  |
1     |0     |0     || 1.2084  | 0.9906  |
2     |0     |0     || 0.6475  | 0.8102  |
0     |1     |0     || 1.3933  | 1.0112  |
1     |1     |0     || 1.0345  | 0.8167  |
2     |1     |0     || 0.5796  | 0.7424  |
[...24 more line(s) ...]
0     |2     |2     || 1.1721  | 1.1810  |
1     |2     |2     || 0.5280  | 1.2861  |
2     |2     |2     || 0.6128  | 1.2641  |
0     |3     |2     || 0.8006  | 0.8094  |
1     |3     |2     || 1.0017  | 1.7598  |
2     |3     |2     || 0.3041  | 0.9555  |

From pyAgrum to numpy to pyAgrum

In [8]:
npf
Out[8]:
array([[[0.86045526, 0.54159734, 0.41863503],
        [0.32908752, 0.53291915, 0.64647011]],

       [[0.06656659, 0.38764099, 0.70547123],
        [0.34213111, 0.369825  , 0.51456653]],

       [[0.91917569, 0.84094667, 0.58473984],
        [0.60370417, 0.73148913, 0.01061819]]])
In [9]:
# fill a pyagrum.Tensor with a numpy array
gumf[:]=npf
gumf
Out[9]:
(pyagrum.Tensor@0000015EE26D5D80) 
             ||  x                          |
y     |z     ||0        |1        |2        |
------|------||---------|---------|---------|
0     |0     || 0.8605  | 0.5416  | 0.4186  |
1     |0     || 0.3291  | 0.5329  | 0.6465  |
0     |1     || 0.0666  | 0.3876  | 0.7055  |
1     |1     || 0.3421  | 0.3698  | 0.5146  |
0     |2     || 0.9192  | 0.8409  | 0.5847  |
1     |2     || 0.6037  | 0.7315  | 0.0106  |
In [10]:
# from pyagrum.Tensor to numpy array
gumf[:]
Out[10]:
array([[[0.86045526, 0.54159734, 0.41863503],
        [0.32908752, 0.53291915, 0.64647011]],

       [[0.06656659, 0.38764099, 0.70547123],
        [0.34213111, 0.369825  , 0.51456653]],

       [[0.91917569, 0.84094667, 0.58473984],
        [0.60370417, 0.73148913, 0.01061819]]])
In [ ]: