Calculo de gradiente para otras funciones básicas entre tensores

En el notebook de autograd definimos diferentes operaciones comunes junto con el calculo de sus gradientes, en este notebook vemos de donde salen esos calculos de gradientes.

Obs: La notacion matemática para representar operaciones entre tensores puede no estar correcta. Otra gran ayuda para saber los signos en latex es Detexify

import numpy as np


class Tensor(object):

    def __init__(self, data,
                 autograd=False,
                 creators=None,
                 creation_op=None,
                 id=None):
        '''
        Inicializa un tensor utilizando numpy

        @data: una lista de numeros
        @creators: lista de tensores que participarion en la creacion de un nuevo tensor
        @creators_op: la operacion utilizada para combinar los tensores en el nuevo tensor
        '''
        self.data = np.array(data)
        self.creation_op = creation_op
        self.creators = creators
        self.grad = None
        self.autograd = autograd
        self.children = {}
        # se asigna un id al tensor
        if (id is None):
            id = np.random.randint(0, 100000)
        self.id = id

        # se hace un seguimiento de cuantos hijos tiene un tensor
        # si los creadores no es none
        if (creators is not None):
            # para cada tensor padre
            for c in creators:
                # se verifica si el tensor padre posee el id del tensor hijo
                # en caso de no estar, agrega el id del tensor hijo al tensor padre
                if (self.id not in c.children):
                    c.children[self.id] = 1
                # si el tensor ya se encuentra entre los hijos del padre
                # y vuelve a aparece, se incrementa en uno
                # la cantidad de apariciones del tensor hijo
                else:
                    c.children[self.id] += 1

    def all_children_grads_accounted_for(self):
        '''
        Verifica si un tensor ha recibido la cantidad
        correcta de gradientes por cada uno de sus hijos
        '''
        # print('tensor id:', self.id)
        for id, cnt in self.children.items():
            if (cnt != 0):
                return False
        return True

    def backward(self, grad, grad_origin=None):
        '''
        Funcion que propaga recursivamente el gradiente a los creators o padres del tensor

        @grad: gradiente
        @grad_orign
        '''
        #         tab=tab
        if (self.autograd):
            if (grad_origin is not None):
                # Verifica para asegurar si se puede hacer retropropagacion
                if (self.children[grad_origin.id] == 0):
                    raise Exception("No se puede retropropagar mas de una vez")
                # o si se está esperando un gradiente, en dicho caso se decrementa
                else:
                    # el contador para ese hijo
                    self.children[grad_origin.id] -= 1

        # acumula el gradiente de multiples hijos
        if (self.grad is None):
            self.grad = grad
        else:
            self.grad += grad

        if (self.creators is not None and
                (self.all_children_grads_accounted_for() or grad_origin is None)):

            if (self.creation_op == 'neg'):
                self.creators[0].backward(self.grad.__neg__())
                
            if (self.creation_op == 'add'):
                # al recibir self.grad, empieza a realizar backprop
                self.creators[0].backward(self.grad, grad_origin=self)
                self.creators[1].backward(self.grad, grad_origin=self)
                
            if(self.creation_op == "sub"):
                self.creators[0].backward(Tensor(self.grad.data), self)
                self.creators[1].backward(Tensor(self.grad.__neg__().data), self)

            if(self.creation_op == "mul"):
                new = self.grad * self.creators[1]
                self.creators[0].backward(new , self)
                new = self.grad * self.creators[0]
                self.creators[1].backward(new, self)                    

            if(self.creation_op == "mm"):
                layer = self.creators[0]                    # activaciones => layer
                weights = self.creators[1]                  # pesos = weights
                # c0 = self.creators[0]                       # activaciones => layer
                # c1 = self.creators[1]                       # pesos = weights
                # new = self.grad.mm(c1.transpose())  # grad = delta => delta x weights.T
                new = Tensor.mm(self.grad, weights.transpose())  # grad = delta => delta x weights.T
                layer.backward(new)
                # c0.backward(new)                            
                # new = self.grad.transpose().mm(c0).transpose() # (delta.T x layer).T = layer.T x delta
                new = Tensor.mm(layer.transpose(), self.grad)  # layer.T x delta
                weights.backward(new)
                # c1.backward(new)

            if(self.creation_op == "transpose"):
                self.creators[0].backward(self.grad.transpose())

            if("sum" in self.creation_op):
                dim = int(self.creation_op.split("_")[1])
                self.creators[0].backward(self.grad.expand(dim, self.creators[0].data.shape[dim]))

            if("expand" in self.creation_op):
                dim = int(self.creation_op.split("_")[1])
                self.creators[0].backward(self.grad.sum(dim))
                
                
    def __neg__(self):
        if(self.autograd):
            return Tensor(self.data * -1,
                         autograd=True,
                         creators=[self],
                         creation_op='neg')
        return Tensor(self.data * -1)
    
    def __add__(self, other):
        '''
        @other: un Tensor
        '''
        if (self.autograd and other.autograd):
            return Tensor(self.data + other.data,
                          autograd=True,
                          creators=[self, other],
                          creation_op='add')
        return Tensor(self.data + other.data)
    
    def __sub__(self, other):
        '''
        @other: un Tensor
        '''
        if (self.autograd and other.autograd):
            return Tensor(self.data - other.data,
                          autograd=True,
                          creators=[self, other],
                          creation_op='sub')
        return Tensor(self.data - other.data)
    
    def __mul__(self, other):
        '''
        @other: un Tensor
        '''
        if(self.autograd and other.autograd):
            return Tensor(self.data * other.data,
                          autograd=True,
                          creators=[self,other],
                          creation_op="mul")
        return Tensor(self.data * other.data)
    
    def sum(self, dim):
        '''
        Suma atravez de dimensiones, si tenemos una matriz 2x3 y 
        aplicamos sum(0) sumara todos los valores de las filas 
        dando como resultado un vector 1x3, en cambio si se aplica
        sum(1) el resultado es un vector 2x1
        
        @dim: dimension para la suma
        '''
        if(self.autograd):
            return Tensor(self.data.sum(dim),
                          autograd=True,
                          creators=[self],
                          creation_op="sum_"+str(dim))
        return Tensor(self.data.sum(dim))
    
    def expand(self, dim, copies):
        '''
        Se utiliza para retropropagar a traves de una suma sum().
        Copia datos a lo largo de una dimension
        '''

        trans_cmd = list(range(0,len(self.data.shape)))
        trans_cmd.insert(dim,len(self.data.shape))
        new_data = self.data.repeat(copies).reshape(list(self.data.shape) + [copies]).transpose(trans_cmd)
        
        if(self.autograd):
            return Tensor(new_data,
                          autograd=True,
                          creators=[self],
                          creation_op="expand_"+str(dim))
        return Tensor(new_data)
    
    def transpose(self):
        if(self.autograd):
            return Tensor(self.data.transpose(),
                          autograd=True,
                          creators=[self],
                          creation_op="transpose")
        
        return Tensor(self.data.transpose())
    
    def mm(self, x):
        if(self.autograd):
            return Tensor(self.data.dot(x.data),
                          autograd=True,
                          creators=[self,x],
                          creation_op="mm")
        return Tensor(self.data.dot(x.data))
    

    def __repr__(self):
        return str(self.data.__repr__())

    def __str__(self):
        return str(self.data.__str__())
def __sub__(self, other):
'''
@other: un Tensor
'''
if (self.autograd and other.autograd):
    return Tensor(self.data - other.data,
                  autograd=True,
                  creators=[self, other],
                  creation_op='sub')
return Tensor(self.data - other.data)
def zero_grads(tensor: Tensor):
    for c in tensor.creators:
        c.grad.data *= 0
x1 = Tensor([3,3,3], autograd=True)
x2 = Tensor([2,2,2], autograd=True)

Resta o sub

Calculamos la resta entre los tensores x1 y x2

y = x1-x2
y
array([1, 1, 1])

Ahora queremos calcular el gradiente que deberá ser pasado tanto a x1 como a x2 a partir de esta operacion. Sabemos que:

  • x1 es el minuendo
  • x2 es el sustraendo

Esto se puede verificar facilmente mirando quienes fueron los creadores de y

print(y.creators[0].id == x1.id)
print(y.creators[1].id == x2.id)
print(y.grad)
True
True
None

En el caso de una resta, el gradiente que debemos pasar a los creadores del tensor y debe ser:

  • Para el minuendo, el gradiente del hijo
  • Para el sustraendo, el la negacion ( todo por -1) del gradiente del hijo
y_grad = Tensor(np.ones_like(y.data))
x1.grad = y_grad
x2.grad = y_grad.__neg__()
print('x1:',x1.id,'\n', x1.grad)
print('x2: ',x2.id,'\n', x2.grad)
zero_grads(y)
x1: 42962 
 [1 1 1]
x2:  98957 
 [-1 -1 -1]

Implementando esto dentro de la funcion backward()

y.backward(Tensor(np.ones_like(y.data)))

Verificamos que esto se aplique correctamente

print('y.creators[0]: ',y.creators[0].id,'\n', y.creators[0].grad)
print('y.creators[1]: ',y.creators[1].id,'\n', y.creators[1].grad)
y.creators[0]:  42962 
 [1 1 1]
y.creators[1]:  98957 
 [-1 -1 -1]

Multiplicacion elemento por elemento o mul

y = x1*x2
y
array([6, 6, 6])

Ahora queremos calcular el gradiente que deberá ser pasado tanto a $x_1$ como a $x_2$ a partir de esta operacion.

Como los gradientes se optienen a partir de derivadas parciales debemos calcular la derivada parcial de $x_1*x_2$ con respecto a $x_1$ y con respecto a $x_2$ por el gradiente que viene del tensor hijo ($y$) y pasarle este nuevo valor a los tensores padres como gradientes A modo de ejemplo consideremos solo $x_1$: $$f(x_1,x_2) = x_1 * x_2$$ Al calcular la derivada parcial con respecto a $x_1$, $x_2$ queda como constante, por tanto: $${d f(x_1,x_2)\over dx_1} = x_2 $$ A todo esto debemos multiplicarle el gradiente que viene del tensor hijo por lo que queda de la siguiente manera:

$$grad_x = grad_y * x_2$$

Como el gradiente debe ser pasado a ambos tensores, para el caso de $x_2$ quedaría de la siguiente manera: $$f(x_1,x_2) = x_1 * x_2$$ $${d f(x_1,x_2)\over dx_2} = x_1 $$ A todo esto debemos multiplicarle el gradiente que viene del tensor hijo por lo que queda de la siguiente manera: $$grad_x = grad_y * x_1$$

Por tanto, para realizar retropropagación de una multiplicacion elemento a elemento, debemos crear un metodo que pase estos gradientes a los tensores padres

y_grad = Tensor(np.ones_like(y.data))
x1.grad = y_grad * x2
x2.grad = y_grad * x1
print('x1:',x1.id,'\n', x1.grad)
print('x2: ',x2.id,'\n', x2.grad)
zero_grads(y)
x1: 42962 
 [2 2 2]
x2:  98957 
 [3 3 3]

Implementando esto dentro de la funcion backward()

y.backward(Tensor(np.ones_like(y.data)))
print('y.creators[0]: ',y.creators[0].id,'\n', y.creators[0].grad)
print('y.creators[1]: ',y.creators[1].id,'\n', y.creators[1].grad)
y.creators[0]:  42962 
 [2 2 2]
y.creators[1]:  98957 
 [3 3 3]

Multiplicacion de Matrices o mm

Esta operacion es clave a la hora de multiplicar entradas o ìnputs con los pesos de una capa de una red neuronal y permite obtener la salida de una capa. Se trata de realizar filas por columnas.

Consideremos como ejemplo un tensor $x_1$ de dimensiones $(2,2)$ y un tensor $x_2$ de dimensiones $(2,1)$.

x1 = Tensor([[2,2,2],
             [2,2,2]], autograd=True)

x2 = Tensor([[3],
             [3],
             [3]], autograd=True)

La regla para multiplicar matrices entre sí es que el numero de filas de la primera matriz debe ser igual a la segunda y como resultado se obtiene una matriz con el mismo numero de filas que la primera columna y el mismo numero de columnas que la segunda matriz

y = Tensor.mm(x1,x2)
print(y.data.shape)
print(y.data)
(2, 1)
[[18]
 [18]]
y_grad = Tensor(np.ones_like(y.data))

Para entender de una forma más intuitiva la multiplicacion de matrices podemos observar un ejemplo en el siguiente link (reemplazar los valores de la matriz por los mismos de este notebook). Ahora es necesario pasar el gradiente desde $Y$ hasta sus tensores padres $X_1$ y $X_2$. Tenemos los siguientes tensores:

$$X_1 = \begin{pmatrix} 2& 2& 2\\ 2& 2& 2\\ \end{pmatrix},$$$$X_2 = \begin{pmatrix} 3\\ 3\\ 3\\ \end{pmatrix},$$$$grad_Y = \begin{pmatrix} 1\\ 1\\ \end{pmatrix},$$

Como el gradiente debe ser pasado a los dos tensores que generaron $Y$, es necesario generar, utilizando el gradiente, tensores con las mismas dimensiones para cada tensor "padre", es decir:

  • Si $X_1$ es un tensor $(2,3)$, debemos pasar como gradiente un tensor con dimension $(2,3)$
  • Si $X_2$ es un tensor $(3,1)$, debemos pasar como gradiente un tensor con dimension $(3,1)$
  • Sabemos tambien que tanto $y$ como $y_{grad}$ son de dimensiones $(2,1)$

En el caso de $X_1$ podemos regenerar un tensor $(2,3)$ utilizando ${grad_Y}$ y la transpuesta de $X_2$

$$grad_{X_1} = grad_Y * {X_2^T} = \begin{pmatrix} 1\\ 1\\ \end{pmatrix} \times \begin{pmatrix} 3& 3& 3\\ \end{pmatrix} = \begin{pmatrix} 3& 3& 3\\ 3& 3& 3\\ \end{pmatrix} $$

En el caso de $X_2$ podemos regenerar un tensor $(3,1)$ utilizando la transpuesta de $X_1$ y ${grad_Y}$

$$grad_{X_1} = grad_Y * {X_2^T} = \begin{pmatrix} 2& 2\\ 2& 2\\ 2& 2\\ \end{pmatrix} \times \begin{pmatrix} 1\\ 1\\ \end{pmatrix} = \begin{pmatrix} 4\\ 4\\ 4\\ \end{pmatrix} $$

Podemos decir que básicamente el gradiente de una multiplicacion de matrices es el producto de la multiplicacion de matrices por la transpuesta

x1.grad = y_grad.mm(x2.transpose())
x2.grad = x1.transpose().mm(y_grad)
print('x1:',x1.id,'\n', x1.grad)
print('x2: ',x2.id,'\n', x2.grad)
zero_grads(y)
x1: 41967 
 [[3 3 3]
 [3 3 3]]
x2:  26556 
 [[4]
 [4]
 [4]]
y.backward(Tensor(np.ones_like(y.data)))
print('y.creators[0]: ',y.creators[0].id,'\n', y.creators[0].grad)
print('y.creators[1]: ',y.creators[1].id,'\n', y.creators[1].grad)
y.creators[0]:  41967 
 [[3 3 3]
 [3 3 3]]
y.creators[1]:  26556 
 [[4]
 [4]
 [4]]

Transpuesta de Matrices o transpose

Hacer backward de la funcion transpuesta es sencillo

  • Si tenemos una matriz $X$ de dimensiones $(a,b)$ la transpuesta $Y=X'$ tendrá las dimensiones $(b,a)$
  • Como tenemos un grafo computacional, debemos pasar el gradiente de $Y$ al tensor padre, en este caso $X$
  • Como el gradiente $grad_Y$ en $Y$ es de dimension $(b,a)$ al pasar este gradiente a su tensor padre, debemos pasarlo de manera que tenga la misma forma que el tensor padre $X$, por tanto aplicando nuevamente la transpuesta a $grad_Y$, tenemos un tensor con las mismas dimensiones que $X$
x = Tensor([[2,2,2],
             [2,2,2]], autograd=True)
x.data.shape
(2, 3)
y = Tensor.transpose(x)
y.data.shape
(3, 2)
y_grad = Tensor(np.ones_like(y.data))
y_grad.data.shape
x.grad = y_grad.transpose()
print('x: ',x.id,'\n', x.grad.data.shape, '\n', x.grad.data)
zero_grads(y)
x:  44681 
 (2, 3) 
 [[1 1 1]
 [1 1 1]]
y.backward(np.ones_like(y.data))
y.grad
array([[1, 1],
       [1, 1],
       [1, 1]])
x.grad
array([[1, 1, 1],
       [1, 1, 1]])

Suma y Expancion

Estas operaciones siguen una lógica similar entre sí. Se utilizan para sumar las filas o columnas de una matriz, o para expandir n veces (copiar) las columnas de un vector columna o expandir n veces las filas de un vector fila

Partamos de un tensor $X$ de dimension $(2,3)$

x = Tensor([[2,2,2],
             [2,2,2]], autograd=True)

En numpy y por consiguiente en lightdlf se guardan las dimensiones en atributo llamado shape el cual es una tupla

x.data.shape
(2, 3)

Por lo que si queremos saber cuantas filas o cuantas columnas tiene el tensor debemos seguir el orden en el que aparecen en la tupla shape, el valor en el indice 0 se correspondera a la cantidad de filas y el valor en el indice 1 al numero de columnas. Por ejemplo:

print('filas:', x.data.shape[0])
print('columntas:', x.data.shape[1])
filas: 2
columntas: 3

Si queremos sumar todas las filas entre sí, es decir tomar todos los elementos de una columna y sumarlos para obtener un solo valor por columna, obtendremos un vector fila. Para referenciar a las filas debemos usar el indice 0 de la tupla shape para que la funcion sepa que queremos sumar las filasA

x_0 = x.sum(0)
x_0
array([4, 4, 4])

Lo mismo si queremos sumar las columnas, con la diferencia de que ahora queremos sumar las columnas, es decir, sumar todos los valores de una fila en un solo valor. En este caso el indice de la tupla shape que determina las columnas es el indice 1

x_1 = x.sum(1)
x_1
array([6, 6])

Ahora digamos que queremos pasar gradientes a $X$ desde $X_0$ o $X_1$, para esto usamos la operacion de expanción

y_0 = x_0.expand(dim=0,copies=x.data.shape[0])
y_0
array([[4, 4, 4],
       [4, 4, 4]])
y_1 = x_1.expand(dim=1, copies=x.data.shape[1])
y_1
array([[6, 6, 6],
       [6, 6, 6]])

En el caso inverso, en el que partamos de un vector columna o un vector fila, y lo expandimos a una matriz, para pasar el gradiente al vector padre debemos realizar una suma, usando el eje 0 en caso de ser un vector fila y el eje 1 en caso de ser un vector columna