Creación de una gráfica computacional¶

Las gráficas computacionales son gráficas dirigidas que organizan el flujo de los procesos en el cómputo de una función. De forma dinámica, los nodos de la gráfica representan los valores resultado de la realización de las unciones que cada nodo define. Así, los nodos de entrada contendrán los valores de entrada de la función.

Para la creación de los nodos, definimos objetos por medio de las clases de pyhton, asignándole los atributos necesarios: 1) en primer lugar, deben contar con el valor numérico que el nodo está guardando; 2) asignamos un atributo de gradiente grad que nos permitirá almacenar el valor del gradiente en ese nodo; y 3) agregamos otros parámetros necesarios para la función. En primer lugar, definimos una super clase Node que definira propiedades básicas de los nodos de entrada, la forma de llamarlos y de imprimirlos. Posteriormente, definimos la clase para definir nodos de entrada, que sólo contendrá los valores.

In [1]:
import numpy as np

class Node:
    # Super clase para nodos
    def __init__(self, require_grad=False, grad=None):
        self.require_grad = require_grad
        if require_grad:
            self.grad = grad

    def __call__(self, *kwargs):
        return self.forward(*kwargs)

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

class Variable(Node):
    # Nodos de entrada
    def __init__(self, value, array=False):
        super().__init__(require_grad=True)
        if array:
                self.value = np.array(value)
        else:
                self.value = value

Este tipos de nodos sólo guardará los valores que asignemos de entrada como una clase especial Node. Este nodo puede ser un arreglo de numpy o un número. Estos valores serán la entrada de nuestras gráficas computacionales.

In [2]:
x = Variable(3)
y = Variable([2,4], array=True)

print(x, y)
3 [2 4]

Nodos de funciones¶

Para definir nodos de la gráfica computacional que definan funciones, la clase debe tener la asignación del valor, así como la del gradiente. Sin embargo, el valor se asigna al nodo hasta que la función es aplicada a un valor específico. Estos nodos tienen un valor numérico que se asigna cuando se corre con una entrada fija. Asimismo, cada función tiene un padre, que es el nodo previo. De esta forma, se puede definir la estructura de gráfica.

La primera función que definimos es la de coseno.

In [3]:
class Cos(Node):
    # Función coseno
    def __init__(self, n=2):
        super().__init__(require_grad=True)
        self.n = n

    def forward(self, x):
        self.value = np.cos(self.n*x.value)
        self.parent = x

        return self

También definimos una función de potencia cuyo parámetro $p$ indica la potencia a la que se va a elevar el valor.

In [4]:
class Pow(Node):
    # Función potencia
    def __init__(self, p=2):
        super().__init__(require_grad=True)
        self.pow = p

    def forward(self, x):
        self.value = x.value**self.pow
        self.parent = x

        return self

Finalmente, definimos la función de suma, esta es una función binaria que tomará dos entradas, dos nodos previos que se sumarán entre sí.

In [5]:
class Sum(Node):
    # Funció suma
    def __init__(self):
        super().__init__(require_grad=True)

    def forward(self, x, y):
        self.value = x.value + y.value
        self.parentA = x
        self.parentB = y

        return self

Uso de los nodos¶

Para generar los nodos de la gráfica computacional asignamos las clases a variables que funcionarán como las funciones. En este sentido, sólo definimos los nodos, pero no las conexiones entre estos.

In [6]:
cos = Cos(n=1)
pow = Pow(p=2)
cos2 = Cos(n=2)
pow4 = Pow(p=4)
sum = Sum()

Para generar las conexiones de la gráfica computacional, necesitamos aplicar los nodos a valores específicos. Dado que ningún nodo de función tiene un valor asignado hasta que se aplica a un valor numérico específico, el primer nodo debe ser siempre uno de entrada. El nodo hijo será la función que se aplique, el valor resultante de aplicar la función al nodo previo. Entonces la aplicación de las funciones en los nodos creará las relaciones de parentesco en la gráfica computacional.

Por ejemplo, en el siguiente ejemplo usamos los nodos de funciones anteriormente definidos para crear una gráfica computacional que responda a la función de la forma:

$$f(x) = cos^4\big(2cos^2(x)\big) + cos^2(x)$$

La estructura de la gráfica se define en términos de la aplicación de las funciones.

In [7]:
n1 = cos(x)
print(n1)

n2 = pow(n1)
print(n2)

n3 = cos2(n2)
print(n3)

n4 = pow4(n3)
print(n4)

n5 = sum(n4,n2)
print(n5)
-0.9899924966004454
0.9800851433251829
-0.37960931045656354
0.020765740488709425
1.0008508838138923

Podemos visualizar la estructura de la gráfica al definir una función recursiva que que tome el nodo de resultado (el último nodo en aplicarse) y recorra la gráfica creada.

In [18]:
def print_graph(n, sep='>'):
    if type(n) == Variable:
        print(sep, n)
    elif type(n) == Sum:
        print(sep, n)
        print_graph(n.parentA, sep='\t'+sep)
        print_graph(n.parentB, sep='\t'+sep)
    else:
        print(sep, n)
        print_graph(n.parent, sep='\t'+sep)
In [19]:
print_graph(n5)
> 1.0008508838138923
	> 0.020765740488709425
		> -0.37960931045656354
			> 0.9800851433251829
				> -0.9899924966004454
					> 3
	> 0.9800851433251829
		> -0.9899924966004454
			> 3

También podemos simplificar la creación de la gráfica si esta es secuencial; es decir, que se aplica una función detrás de otra. Para esto definimos una nueva clase Sequential que toma un conjunto de nodos asumiendo que cada función en el nodo se aplica de forma previa al nodo siguiente. De esta forma se puede obtener una gráfica simple.

In [10]:
class Sequential(Node):
    def __init__(self, *argv):
        super().__init__(require_grad=True)
        self.nodes = argv

    def forward(self, x):
        self.value = x
        for f in self.nodes:
            self.value = f(self.value)
        return self
        
    def print_graph(self):
        print_graph(self.nodes[-1])

Podemos usar esta clase para construir una gráfica que, en este ejemplo, compute la función:

$$f(x) = cos^5\big(5cos^2(2x)\big)$$

También podemos visualizar esta gráfica, que como se puede ver, es secuencial.

In [11]:
f = Sequential(Cos(n=2), Pow(p=2), Cos(n=5), Pow(p=5))

result = f(y)
print(result)
result.print_graph()
[-0.04415795  0.97232642]
> [-0.04415795  0.97232642]
	> [-0.53579886  0.99440298]
		> [0.42724998 0.02117026]
			> [-0.65364362 -0.14550003]
				> [2 4]