Extendiendo autograd para algunas funciones adicionales

En el notebook anterior se implemento autograd con la operación de suma pero en un grafo computacional la suma es una de muchas otras operaciones que se pueden realizar con tensores. Por tanto es necesario definir estas operaciones con sus respectivos gradientes o derivadas.

Para la suma definimos el siguiente metodo:

def __add__(self, other):
    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)

Y para la retropropagacion definimos la siguiente condicion dentro del método backward():

if (self.creation_op == 'add'):
    self.creators[0].backward(self.grad, grad_origin=self)
    self.creators[1].backward(self.grad, grad_origin=self)

Aparte de estas dos secciones de código, la retropropagación no se maneja en ninguna otra parte de la clase Tensor. Toda la lógica de la retropropagación se abstrae para que todo lo necesario para la suma sea definido solamente en esas dos partes.

Hay que notar también de que la retropropagación o backprop (a partir de ahora) no se realiza en el caso de que el tensor tenga el atributo autograd = False, por eso la primera linea de __add__ contiene una condicion que verifica que los dos tensores participando de la suma tengan self.autograd == True para que al instanciar un nuevo tensor, este también tenga self.autograd == True

Añadamos la soporte para la negacion

La funcion de negacion se logra agregando las siguientes secciones de código a la clase: Para realizar la negacion:

def __neg__(self):
    if(self.autograd):
        return Tensor(self.data * -1,
                     autograd=True,
                     creators=[self],
                     creation_op='neg')
    return Tensor(self.data * -1)

Y dentro de la funcion backward()

if (self.creation_op == 'neg'):
    self.creators[0].backward(self.grad.__neg__())

Como la negacion involucra solo a un tensor, se hace backward solo para un solo creator. Al retropropagar el gradiente de un tensor "negado", el signo de este gradiente también se invierte

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 == '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 == 'neg'):
                self.creators[0].backward(self.grad.__neg__())

    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 __neg__(self):
        if(self.autograd):
            return Tensor(self.data * -1,
                         autograd=True,
                         creators=[self],
                         creation_op='neg')
        return Tensor(self.data * -1)

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

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

Ejemplo:

x = Tensor([1,1,1,1], autograd=True)
y = (-x) + (-x)
y.backward(Tensor([1,1,1,1]))
print(x.grad.data == np.array([-2,-2,-2,-2]))
[ True  True  True  True]

Agregando soporte a más funciones

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__())

Como funciona sum

x = Tensor([[1,2,3],[4,5,6]])
print(x)
[[1 2 3]
 [4 5 6]]
x.sum(0)
array([5, 7, 9])
x.sum(1)
array([ 6, 15])

Como funciona expand

x.expand(0, 4)
array([[[1, 2, 3],
        [4, 5, 6]],

       [[1, 2, 3],
        [4, 5, 6]],

       [[1, 2, 3],
        [4, 5, 6]],

       [[1, 2, 3],
        [4, 5, 6]]])
x.expand(1, 4)
array([[[1, 2, 3],
        [1, 2, 3],
        [1, 2, 3],
        [1, 2, 3]],

       [[4, 5, 6],
        [4, 5, 6],
        [4, 5, 6],
        [4, 5, 6]]])
x.expand(2, 4)
array([[[1, 1, 1, 1],
        [2, 2, 2, 2],
        [3, 3, 3, 3]],

       [[4, 4, 4, 4],
        [5, 5, 5, 5],
        [6, 6, 6, 6]]])

Intuición detras de mm o matrix multiplication

Una sola iteracion de forward y backward pass para entender detrás de mm durante la ejecución de backward()

forward pass

x = Tensor([[1,1]], autograd=True)
y = Tensor([1], autograd=True)
weight_0_1 = Tensor([[1,1,1,1],[1,1,1,1]], autograd=True)
weight_1_2 = Tensor([[1],[1],[1],[1]], autograd=True)
print(x.data.shape, weight_0_1.data.shape, weight_1_2.data.shape)
(1, 2) (2, 4) (4, 1)
layer_1 = x.mm(weight_0_1)
layer_2 = layer_1.mm(weight_1_2)
print(layer_1.data.shape, layer_2.data.shape)
(1, 4) (1, 1)

backward pass

layer_2_delta = layer_2 - y
print(layer_2, y, layer_2_delta)
grad = layer_2_delta
[[8]] [1] [[7]]

Calculo de delta o gradiente de la capa actual tomando el grad de la capa anterior:

grad * weight.T

grad_1 = grad.mm(weight_1_2.transpose())
print(grad_1)
[[7 7 7 7]]
grad_1 = Tensor.mm(grad, weight_1_2.transpose())
print(grad_1)
[[7 7 7 7]]

Dos maneras de calcular la actualizacion de los pesos de una red neuronal, es decir layer.T x grad

weight_1_2_update = grad.transpose().mm(layer_1).transpose()
print(weight_1_2_update, weight_1_2_update.data.shape ,weight_1_2_update.autograd)
[[14]
 [14]
 [14]
 [14]] (4, 1) True
weight_1_2_update = Tensor.mm(layer_1.transpose(), grad)
print(weight_1_2_update, weight_1_2_update.data.shape ,weight_1_2_update.autograd)
[[14]
 [14]
 [14]
 [14]] (4, 1) True