Capítulo 13 Introducción a la Teoría de Gráficas.

13.1 ¿Qué es una gráfica?

Una gráfica es un objeto matemático que representa un conjunto de objetos y las relaciones entre ellos. Estos se usan para modelar fenómenos complejos, como la interacción en redes sociales, redes de comunicaciones, las relaciones entre citas de documentos, la configuración de compuestos químicos y demás.

import torch
import torch.nn as nn
from torch.nn import Linear
import torch.nn.functional as F
import torch_geometric
from torch_geometric.datasets import Planetoid
from torch_geometric.utils import (
  to_dense_adj, to_networkx, degree
)

import numpy as np
import networkx as nx
import matplotlib.pyplot as plt
import pandas as pd
import random

# Load Zachary's Karate Club graph
G = nx.karate_club_graph()

print(f"Number of nodes: {G.number_of_nodes()}")
## Number of nodes: 34
print(f"Number of edges: {G.number_of_edges()}")
## Number of edges: 78
# Get the club membership attribute for coloring
club_labels = [G.nodes[i]['club'] for i in G.nodes]

# Color based on club membership ('Mr. Hi' or 'Officer')
color_map = [
  '#FF5733' if club == 'Mr. Hi' else '#3371FF' for club in club_labels
  ]

# Draw the graph
plt.figure(figsize=(8, 6))
pos = nx.spring_layout(G) # positions for all nodes
nx.draw_networkx_nodes(G, pos, node_color=color_map)
nx.draw_networkx_edges(G, pos, alpha=0.6)
nx.draw_networkx_labels(G, pos, font_color='white')
## {0: Text(-0.04719248013020107, -0.24839125943933962, '0'), 1: Text(0.2527061933150169, -0.49853455876626895, '1'), 2: Text(0.18566695314916642, -0.21266682268175097, '2'), 3: Text(-0.008383036646563192, -0.6439993123881046, '3'), 4: Text(-0.5360248191245703, 0.024524280025362565, '4'), 5: Text(-0.5352479067701617, 0.3478180477965669, '5'), 6: Text(-0.38539660372494483, 0.4091755909484533, '6'), 7: Text(0.1861921144042323, -0.642247317123454, '7'), 8: Text(0.2904656263409677, -0.0182088686069621, '8'), 9: Text(0.6962549141862674, 0.38503225480269104, '9'), 10: Text(-0.7509492691755976, 0.06447817148422515, '10'), 11: Text(0.6280781559604274, -0.3883000117951177, '11'), 12: Text(-0.25653071196175875, -1.0, '12'), 13: Text(0.12514863455767072, -0.37006969238228743, '13'), 14: Text(-0.20682621275600446, -0.18837809006539058, '14'), 15: Text(0.38293168470972005, 0.4021947491463121, '15'), 16: Text(-0.505814793320668, 0.8885756854662669, '16'), 17: Text(0.048219906960434315, -0.9587541712942022, '17'), 18: Text(0.7305662673930557, 0.22666495046220123, '18'), 19: Text(0.48593445647478734, -0.47766709292332676, '19'), 20: Text(-0.38515409253901134, -0.21405119036828252, '20'), 21: Text(0.3918472870541829, -0.8416382729148583, '21'), 22: Text(0.3548745515028669, 0.6147570976490807, '22'), 23: Text(-0.09362049308351174, 0.4743109168716645, '23'), 24: Text(-0.1802597339264463, 0.8759848229910039, '24'), 25: Text(-0.3555479449878414, 0.6603804298322751, '25'), 26: Text(-0.5882110543793023, -0.052217857704892665, '26'), 27: Text(0.09624343417563727, 0.5092114605111109, '27'), 28: Text(-0.15261197898794066, 0.008646757903172543, '28'), 29: Text(-0.314306892645962, 0.18978119101271848, '29'), 30: Text(0.4484037616416584, -0.027980555679159046, '30'), 31: Text(-0.1773476911929115, 0.3696333299793844, '31'), 32: Text(0.03578559216621987, 0.15851367128534505, '32'), 33: Text(0.14010618136108588, 0.1734216659655616, '33')}
plt.title("Zachary's Karate Club Network")
plt.axis('off')
## (np.float64(-0.9065084005153062), np.float64(0.8861253987327644), np.float64(-1.198300446973958), np.float64(1.086876132440225))
plt.show()

Matematicamente se representan como conjuntos de vértices (o nodos) \(V = \{v_1, \ldots, v_n \}\) y del conjunto de las relaciones entre ellas \(E = \{e_{i_1 i_j}, \ldots, e_{i_m i_m} \}\).

Si bien, el conjunto de aristas define la estructura de la gráfica es comun generar representaciones alternativas. Entre ellas se encuentran:

  • Matriz de adjyacencias: \(A\in \mathbb{R}^n\), donde \(n\) es el número de vertices de la gráfica y las entradas de la matriz estan dadas como sigue

\[A_{ij} = \begin{cases} 1,& \text{ existe arista entre } v_i \text{ y }v_j\\ 0,& \text{ en otro caso } \end{cases}\]

Es de notar que en la práctica, dicha matriz suele ser de tipo sparse, dado que no todos los nodos están conectados.

  • Sparse Coodinate Format (COO): Es una representacion tensorial de tipo sparse de las estructura de la gráfica. La idea es solo almacenar los indices de los nodos que denotan las adyacencias en la gráfica omitiendo aquellos lugares donde no hay relaciones ente nodos. Véase el siguiente link.

  • Por otro lado, se puede considerar de forma opcional incluir en el análisis a características numéricas y categorícas (features) de los vértices de la gráfica.

  • Grado de sus vertices: el grado de un nodo \(deg(v_i)\) como a la cantidad de aristas donde dicho nodo está presente. Este se puede calcular como:

\[deg(v_i) = \sum_{i=1}^N A_{ij}\]

G = nx.Graph()
G.add_edges_from([('A', 'B'), ('A', 'C'), ('B', 'D'),
('B', 'E'), ('C', 'F'), ('C', 'G')])

# Draw the graph
plt.figure(figsize=(8, 6))
pos = nx.spring_layout(G) # positions for all nodes
nx.draw_networkx_nodes(G, pos)
nx.draw_networkx_edges(G, pos, alpha=0.6)
nx.draw_networkx_labels(G, pos, font_color='white')
## {'A': Text(0.0014884134129522653, 0.00038927366611223594, 'A'), 'B': Text(0.27649108229581354, 0.5351300817099227, 'B'), 'C': Text(-0.27656878516697603, -0.5363259769209958, 'C'), 'D': Text(0.16389404009945505, 1.0, 'D'), 'E': Text(0.7198087698415417, 0.7144950449075653, 'E'), 'F': Text(-0.7180668173023278, -0.7138747005754214, 'F'), 'G': Text(-0.1670467031804588, -0.9998137227871826, 'G')}
plt.title("Samaple Graph")
plt.axis('off')
## (np.float64(-0.869043753952434), np.float64(0.870785706491648), np.float64(-1.2097941636798368), np.float64(1.2099804408926542))
plt.show()

print(f"deg(A) = {G.degree['A']}")
## deg(A) = 2
print(f"deg(B) = {G.degree['B']}")
## deg(B) = 3

13.2 Problemas clásicos de teoría de gráficas (selección)

13.2.1 Caminos y conectividad

Problema: determinar si dos nodos están conectados y cómo se propaga la información a través del grafo.
Aplicaciones: navegación y mapas, análisis de redes sociales, propagación de epidemias, redes de comunicación.

13.2.2 Detección de comunidades

Problema: identificar grupos de nodos densamente conectados entre sí.
Aplicaciones: segmentación de usuarios, análisis de redes sociales, biología de sistemas, recomendación de contenidos.

13.2.3 Centralidad e influencia

Problema: medir la importancia relativa de los nodos dentro de un grafo.
Aplicaciones: ranking de páginas web, detección de líderes de opinión, identificación de nodos críticos en infraestructura, epidemiología.

13.2.4 Emparejamiento y asignación

Problema: asignar nodos entre dos conjuntos respetando restricciones estructurales.
Aplicaciones: asignación de tareas, sistemas de recomendación, mercados laborales, publicidad y subastas.

13.3 Ecosistema de Herramientas para el Trabajo con Grafos

  1. Software para Visualización y Análisis de Grafos
  • Gephi: Herramienta open source la visualización interactiva y el análisis exploratorio de redes complejas y grandes grafos. https://gephi.org. La siguiente imagen fue generada en dicha herramienta

  • Bases de datos de Grafos:
    • Neo4j: Sistema de gestión de bases de datos orientada a grafos que incluye el Graph Data Science (GDS) Library para ejecutar algoritmos directamente sobre los datos almacenados.
    • Memgraph: Base de datos de grafos con capacidades de Streaming https://memgraph.com
    • AWS Neptune: Base de datos serveless, funcioina en la nube de AWS https://aws.amazon.com/es/neptune/
  • Graphviz: Herramienta basada en scripts (lenguaje DOT) que permite la generación automática de diagramas de grafos de manera estructural y jerárquica.
  1. Librerías de Python para Manipulación y Algoritmos
  1. Librerías de Deep Learning para Grafos (GNN)
  • PyTorch Geometric (PyG): Construida sobre PyTorch. Ofrece implementaciones de última generación para operaciones de convolución y pooling en grafos. https://pytorch-geometric.readthedocs.io/en/latest/

  • Deep Graph Library (DGL): Una librería optimizada y agnóstica al framework (compatible con PyTorch, TensorFlow y JAX), diseñada para facilitar la implementación de modelos GNN a escala industrial. https://www.dgl.ai

13.4 ¿Por qué combinar Graficas y Deep Learning?

Cuando los datos de un problema, ademas de features numéricas tambien incorporan estructura, puede resultar util tomarla en consideracion para tareas de ML.

Para ejemplificarlo, vamos a resolver el problema de clasificación de los datos del Cora de la libreria torch_geometric.datasets.

Este es un conjunto de datos de una red de citas bibliográficas que relaciona artículos científicos y citas bibliográficas de otros artículos en su texto. La tabla siguiente hace un resumen

# Atributo Valor
1 Nodos 2,708 (Artículos científicos)
2 Aristas 10,556 (Citas)
3 Características (Features) 1,433 (Vector binario de palabras clave, Bag-of-Words)
4 Clases 7 categorías de investigación

Las categorias de los artìculos son las siguientes:

Categoria Nombre Descripción
0 Theory Aspectos teóricos, algoritmos fundamentales y pruebas matemáticas.
1 Reinforcement Learning Agentes que aprenden mediante prueba y error para maximizar recompensas.
2 Genetic Algorithms Algoritmos de optimización inspirados en la evolución biológica.
3 Neural Networks Modelos basados en capas de neuronas artificiales y Deep Learning.
4 Probabilistic Methods Modelos que gestionan la incertidumbre (Redes Bayesianas, etc.).
5 Case Based Sistemas de razonamiento basados en casos y analogías previas.
dataset = Planetoid(root='./tmp/cora', name='cora')
data = dataset[0]
class GraphUtils:
    @staticmethod
    def create_adjacency_matrix(data):
        # Crea matriz de adyacencia
        adjacency = to_dense_adj(data.edge_index)[0]
        # Agrega una diagonal de unos (auto-referencia a nodos)
        adjacency = adjacency + torch.eye(len(adjacency))
        return adjacency

    @staticmethod
    def convert_to_networkx(graph, n_sample=None):
        g = to_networkx(graph, node_attrs=["x"])
        y = graph.y.numpy()
    
        if n_sample is not None:
            sampled_nodes  = random.sample(list(g.nodes), n_sample)
            g = g.subgraph(sampled_nodes)
            y = y[sampled_nodes]
    
        return g, y
    
    @staticmethod
    def plot_graph(g, y):
        plt.figure(figsize=(9, 7))
        nx.draw_spring(g, node_size=30, arrows=False, node_color=y)
        plt.show() 

Esta es una representación de una muestra de 1,000 artículos en el conjunto Cora.

g, y = GraphUtils.convert_to_networkx(data, n_sample=1000)
GraphUtils.plot_graph(g, y)  

En complemento, ahora veremos el arreglo tabular de datos donde cada reglón es un artículo, los features son el Bag of Words del corpus del artículo con las palabras de toda la colección y la categoría a la que pertenece dicho artículo.

cora_features = pd.DataFrame(dataset.x.numpy(),columns=["word"+str(i) for i in range(1433)])
cora_features["category"] = data.y

cora_features[cora_features.columns[-5:]].head(20)
##     word1429  word1430  word1431  word1432  category
## 0        0.0       0.0       0.0       0.0         3
## 1        0.0       0.0       0.0       0.0         4
## 2        0.0       0.0       0.0       0.0         4
## 3        0.0       0.0       0.0       0.0         0
## 4        0.0       0.0       0.0       0.0         3
## 5        0.0       0.0       0.0       0.0         2
## 6        0.0       0.0       0.0       0.0         0
## 7        0.0       0.0       0.0       0.0         3
## 8        0.0       0.0       0.0       0.0         3
## 9        0.0       0.0       0.0       0.0         2
## 10       0.0       0.0       0.0       0.0         0
## 11       0.0       0.0       0.0       0.0         0
## 12       0.0       0.0       0.0       0.0         4
## 13       0.0       0.0       0.0       0.0         3
## 14       0.0       0.0       0.0       0.0         3
## 15       0.0       0.0       0.0       0.0         3
## 16       0.0       0.0       0.0       0.0         2
## 17       0.0       0.0       0.0       1.0         3
## 18       0.0       0.0       0.0       0.0         1
## 19       0.0       0.0       0.0       0.0         3

Para la comparativa consistirá en los siguientes:

  1. Un modelo de tipo Multilayer Percepton con dos capas que usa las features numéricas para predecir la categoría a la que corresponde cada artículos

  2. Un modelo que combina capaz, que conectan los datos las features numèricas a una capa lineal y despúes multiplican la salida por la matriz de adyacencias para involucrar la estrucura de la red.

13.4.1 A. Multilayer Percepton en las features tabulares de Cora

# Define MLP model
class MLP(torch.nn.Module):
    def __init__(self, dim_in, dim_h, dim_out):
        super().__init__()
        self.linear1 = Linear(dim_in, dim_h)
        self.linear2 = Linear(dim_h, dim_out)

    def forward(self, x):
        x = self.linear1(x)
        x = torch.relu(x)
        x = self.linear2(x)
        return F.log_softmax(x, dim=1)

    def fit(self, data, epochs, learning_rate=0.01, weight_decay=5e-4):
        criterion = torch.nn.CrossEntropyLoss()
        optimizer = torch.optim.Adam(self.parameters(), lr=learning_rate, weight_decay=weight_decay)

        self.train()
        for epoch in range(epochs+1):
            optimizer.zero_grad()
            out = self(data.x)
            loss = criterion(out[data.train_mask], data.y[data.train_mask])
            acc = self.accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
            loss.backward()
            optimizer.step()

            if epoch % 20 == 0:
                val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                val_acc = self.accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc:'
                      f' {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | '
                      f'Val Acc: {val_acc*100:.2f}%')

    @torch.no_grad()
    def test(self, data):
        self.eval()
        out = self(data.x)
        acc = self.accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
        return acc

    @staticmethod
    def accuracy(y_pred, y_true):
        return torch.sum(y_pred == y_true).item() / len(y_true)

13.4.2 B. Modelo basado en una capa lineal que se multiplica por la matriz de adyacencia.

class GNNLayer(torch.nn.Module):
    def __init__(self, dim_in, dim_out):
        super().__init__()
        self.linear = Linear(dim_in, dim_out, bias=False)

    def forward(self, x, adjacency):
        x = self.linear(x)
        x = torch.sparse.mm(adjacency, x)
        return x
class GNN(torch.nn.Module):
    def __init__(self, dim_in, dim_h, dim_out):
        super().__init__()
        self.gnn1 = GNNLayer(dim_in, dim_h)
        self.gnn2 = GNNLayer(dim_h, dim_out)

    def forward(self, x, adjacency):
        h = self.gnn1(x, adjacency)
        h = torch.relu(h)
        h = self.gnn2(h, adjacency)
        return F.log_softmax(h, dim=1)

    def fit(self, data, adjacency, epochs, learning_rate=0.01, weight_decay=5e-4):
        criterion = torch.nn.CrossEntropyLoss()
        optimizer = torch.optim.Adam(self.parameters(), lr=learning_rate, weight_decay=weight_decay)

        self.train()
        for epoch in range(epochs+1):
            optimizer.zero_grad()
            out = self(data.x, adjacency)
            loss = criterion(out[data.train_mask], data.y[data.train_mask])
            acc = MLP.accuracy(out[data.train_mask].argmax(dim=1), data.y[data.train_mask])
            loss.backward()
            optimizer.step()

            if epoch % 20 == 0:
                val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                val_acc = MLP.accuracy(out[data.val_mask].argmax(dim=1), data.y[data.val_mask])
                print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc:'
                      f' {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | '
                      f'Val Acc: {val_acc*100:.2f}%')

    @torch.no_grad()
    def test(self, data, adjacency):
        self.eval()
        out = self(data.x, adjacency)
        acc = MLP.accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
        return acc

Ahora entrenamos ambos modelos:

mlp = MLP(dataset.num_features, 16, dataset.num_classes)
print(mlp)
## MLP(
##   (linear1): Linear(in_features=1433, out_features=16, bias=True)
##   (linear2): Linear(in_features=16, out_features=7, bias=True)
## )
mlp.fit(data, epochs=100)
## Epoch   0 | Train Loss: 1.959 | Train Acc: 10.71% | Val Loss: 2.03 | Val Acc: 9.60%
## Epoch  20 | Train Loss: 0.098 | Train Acc: 100.00% | Val Loss: 1.34 | Val Acc: 54.40%
## Epoch  40 | Train Loss: 0.013 | Train Acc: 100.00% | Val Loss: 1.42 | Val Acc: 54.20%
## Epoch  60 | Train Loss: 0.007 | Train Acc: 100.00% | Val Loss: 1.38 | Val Acc: 53.40%
## Epoch  80 | Train Loss: 0.008 | Train Acc: 100.00% | Val Loss: 1.32 | Val Acc: 54.60%
## Epoch 100 | Train Loss: 0.009 | Train Acc: 100.00% | Val Loss: 1.30 | Val Acc: 55.20%
acc_mlp = mlp.test(data)
print(f'\nMLP test accuracy: {acc_mlp*100:.2f}%')
## 
## MLP test accuracy: 55.60%
adjacency = GraphUtils.create_adjacency_matrix(data)
gnn = GNN(dataset.num_features, 16, dataset.num_classes)
print(gnn)
## GNN(
##   (gnn1): GNNLayer(
##     (linear): Linear(in_features=1433, out_features=16, bias=False)
##   )
##   (gnn2): GNNLayer(
##     (linear): Linear(in_features=16, out_features=7, bias=False)
##   )
## )
gnn.fit(data, adjacency, epochs=100)
## Epoch   0 | Train Loss: 1.940 | Train Acc: 18.57% | Val Loss: 1.95 | Val Acc: 16.40%
## Epoch  20 | Train Loss: 0.074 | Train Acc: 100.00% | Val Loss: 1.55 | Val Acc: 75.20%
## Epoch  40 | Train Loss: 0.007 | Train Acc: 100.00% | Val Loss: 2.26 | Val Acc: 73.80%
## Epoch  60 | Train Loss: 0.002 | Train Acc: 100.00% | Val Loss: 2.39 | Val Acc: 74.40%
## Epoch  80 | Train Loss: 0.001 | Train Acc: 100.00% | Val Loss: 2.35 | Val Acc: 74.60%
## Epoch 100 | Train Loss: 0.001 | Train Acc: 100.00% | Val Loss: 2.30 | Val Acc: 74.60%
acc_gnn = gnn.test(data, adjacency)

print(f'\nGNN test accuracy: {acc_gnn*100:.2f}%')
## 
## GNN test accuracy: 76.60%
pd.DataFrame(
  {
    "modelo": ["MLP", "GNN"],
    "accuracy": [acc_mlp*100, acc_gnn*100]
  }
)
##   modelo  accuracy
## 0    MLP      55.6
## 1    GNN      76.6

En este ejemplo, el factor que marco la diferencia de la accion de la matriz de transaciones sobre los pesos de la red, pues intutivamente sirve para comunicar la estructura de relaciones de los nodos en el proceso de aprendizaje.

Dicho modelo es una implementación dummy de una familia conocida como Graph Convolutional Networks que abordaremos más adelante.

13.4.3 ¿Qué esta haciendo la red?

En este caso usaremos los datos del Karate Club presentes en PyTorch Geoemtric.

Entrenaremos nuevamente una red con la misma idea previa, que las features numéricas sean multiplicadas por la matriz de adyancencia

from torch_geometric.datasets import KarateClub

# Import dataset from PyTorch Geometric
dataset = KarateClub()
data = dataset[0]

Podemos acceder a la información de la gráfica:

# Print information
print(dataset)
## KarateClub()
print('------------')
## ------------
print(f'Number of graphs: {len(dataset)}')
## Number of graphs: 1
print(f'Number of features: {dataset.num_features}')
## Number of features: 34
print(f'Number of classes: {dataset.num_classes}')
## Number of classes: 4

En este caso las features numéricas son solo encoding de los nodos con el indice que se han numerado:

print(f'x = {data.x.shape}')
## x = torch.Size([34, 34])
print(data.x)
## tensor([[1., 0., 0.,  ..., 0., 0., 0.],
##         [0., 1., 0.,  ..., 0., 0., 0.],
##         [0., 0., 1.,  ..., 0., 0., 0.],
##         ...,
##         [0., 0., 0.,  ..., 1., 0., 0.],
##         [0., 0., 0.,  ..., 0., 1., 0.],
##         [0., 0., 0.,  ..., 0., 0., 1.]])
print(f'edge_index = {data.edge_index.shape}')
## edge_index = torch.Size([2, 156])
pd.DataFrame(data.x, columns=["x"+str(i+1) for i in range(data.x.shape[0])]).head()
##     x1   x2   x3   x4   x5   x6   x7   x8   x9  x10  x11  x12  x13  ...  x22  x23  x24  x25  x26  x27  x28  x29  x30  x31  x32  x33  x34
## 0  1.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  ...  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
## 1  0.0  1.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  ...  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
## 2  0.0  0.0  1.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  ...  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
## 3  0.0  0.0  0.0  1.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  ...  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
## 4  0.0  0.0  0.0  0.0  1.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  ...  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
## 
## [5 rows x 34 columns]
from torch_geometric.utils import to_networkx

G = to_networkx(data, to_undirected=True)
plt.figure(figsize=(12,12))
plt.axis('off')
## (np.float64(0.0), np.float64(1.0), np.float64(0.0), np.float64(1.0))
nx.draw_networkx(G,
                pos=nx.spring_layout(G, seed=0),
                with_labels=True,
                node_size=400,
                node_color=data.y,
                cmap="hsv",
                vmin=-2,
                vmax=3,
                width=0.8,
                edge_color="grey",
                font_size=14
                )
plt.show()

from torch.nn import Linear
from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.gcn = GCNConv(dataset.num_features, 3)
        self.out = Linear(3, dataset.num_classes)

    def forward(self, x, edge_index):
        h = self.gcn(x, edge_index).relu()
        z = self.out(h)
        return h, z

model = GCN()
print(model)
## GCN(
##   (gcn): GCNConv(34, 3)
##   (out): Linear(in_features=3, out_features=4, bias=True)
## )
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)

# Calculate accuracy
def accuracy(pred_y, y):
    return (pred_y == y).sum() / len(y)

# Data for animations
embeddings = []
losses = []
accuracies = []
outputs = []

# Training loop
for epoch in range(201):
    # Clear gradients
    optimizer.zero_grad()

    # Forward pass
    h, z = model(data.x, data.edge_index)

    # Calculate loss function
    loss = criterion(z, data.y)

    # Calculate accuracy
    acc = accuracy(z.argmax(dim=1), data.y)

    # Compute gradients
    loss.backward()

    # Tune parameters
    optimizer.step()

    # Store data for animations
    embeddings.append(h)
    losses.append(loss)
    accuracies.append(acc)
    outputs.append(z.argmax(dim=1))

    # Print metrics every 10 epochs
    if epoch % 10 == 0:
        print(f'Epoch {epoch:>3} | Loss: {loss:.2f} | Acc: {acc*100:.2f}%')
## Epoch   0 | Loss: 1.32 | Acc: 35.29%
## Epoch  10 | Loss: 1.17 | Acc: 70.59%
## Epoch  20 | Loss: 0.99 | Acc: 70.59%
## Epoch  30 | Loss: 0.78 | Acc: 85.29%
## Epoch  40 | Loss: 0.55 | Acc: 91.18%
## Epoch  50 | Loss: 0.34 | Acc: 100.00%
## Epoch  60 | Loss: 0.19 | Acc: 100.00%
## Epoch  70 | Loss: 0.11 | Acc: 100.00%
## Epoch  80 | Loss: 0.07 | Acc: 100.00%
## Epoch  90 | Loss: 0.04 | Acc: 100.00%
## Epoch 100 | Loss: 0.03 | Acc: 100.00%
## Epoch 110 | Loss: 0.02 | Acc: 100.00%
## Epoch 120 | Loss: 0.02 | Acc: 100.00%
## Epoch 130 | Loss: 0.02 | Acc: 100.00%
## Epoch 140 | Loss: 0.01 | Acc: 100.00%
## Epoch 150 | Loss: 0.01 | Acc: 100.00%
## Epoch 160 | Loss: 0.01 | Acc: 100.00%
## Epoch 170 | Loss: 0.01 | Acc: 100.00%
## Epoch 180 | Loss: 0.01 | Acc: 100.00%
## Epoch 190 | Loss: 0.01 | Acc: 100.00%
## Epoch 200 | Loss: 0.01 | Acc: 100.00%
plt.rcParams["animation.bitrate"] = 3000

def draw_predicted_graph(i):
    G = to_networkx(data, to_undirected=True)
    fig = plt.figure(figsize=(12, 12))
    plt.axis('off')
    nx.draw_networkx(G,
                    pos=nx.spring_layout(G, seed=0),
                    with_labels=True,
                    node_size=300,
                    node_color=outputs[i].numpy(),
                    cmap="hsv",
                    vmin=0,
                    vmax=5,
                    width=0.8,
                    edge_color="grey",
                    font_size=14
                    )
    plt.title(
      f'Epoch {i} | Loss: {losses[i]:.2f} | Acc: {accuracies[i]*100:.2f}%',
      fontsize=18, pad=20)
    plt.show()
draw_predicted_graph(1)

draw_predicted_graph(5)

draw_predicted_graph(9)

draw_predicted_graph(100)

En las imágenes se aprecia como poco a poco, los pesos se van ajustando, a través de la información de la matriz de adyancias, para predecir las etiquetas de los nodos.

13.5 Tipos de problemas de GNN

El aprendizaje automático en grafos se presenta en diversas modalidades:

  • Aprendizaje Supervisado/Semi-supervisado:

    • Clasificación de Nodos (o Aristas): Nodos etiquetados → etiquetar otros nodos.
      • Ejemplos: Marketing (orientado/segmentaciones), predicción de interfaces proteicas.
    • Clasificación de Grafos: Grafos etiquetados → etiquetar nuevo grafo.
      • Ejemplos: Clasificación de moléculas, predicción de la eficacia de fármacos.

  • Aprendizaje No Supervisado (y Semi-supervisado)
    • Ejemplos:
      • Detección de Comunidades: Un grafo → agrupar nodos
      • Análisis de redes sociales.

13.6 ¿Existe un Teorema de Aproximación Univeral (TAU) para GNN’s?

  • Muchas pruebas del existentes del TAU para GNN son extensiones de del Teorema de Stone-Weierstrass,

  • Un tema a resolver en problema a resolver es si dos gráficas son isomórficas.

    • Weisfeiler y Leman idearon una heurística que trata de determinar si dos gráficas son isomórficas entre si.
    • Los resultados algoritmos son invariantes ante permutaciones de etiquetado de las gráficas
    • En la práctica, es muy dificil encontrar gráficas isomórficas entre si.
  • Algunos resultados prueban la universalidad bajo ciertas condiciones:

    • Maron et al. On the Universality of Invariant Networks (2019) “G-invariant networks are universal if high-order tensors are allowed (..)”
    • Universal Invariant and Equivariant Graph Neural Network (2019) “GNNs are universal approximators in probability for node classification & regression tasks, as they can approximate any measurable function that satisfies the 1–WL equivalence on nodes”

13.7 Enfoques de aprendizaje transductivo e inductivo

Aprendizaje Transductivo: En este paradigma, el modelo se entrena considerando todo el grafo disponible, es decir, con todos los nodos y sus relaciones (aristas) presentes durante la fase de entrenamiento, incluso aquellos cuya informacion no se utiliza directamente para el cálculo de la función de pérdida.

Nota: Bajo dicho enfoque, un modelo aprende representaciones (embeddings) dependientes de la estructura completa del grafo, por lo que no puede generalizar a nodos o subgrafos no observados durante el entrenamiento.

Aprendizaje Inductivo: reservan conjuntos de datos de entrenamiento y prueba separados. El proceso de aprendizaje ingesta los datos de entrenamiento y, a continuación, el modelo aprendido se prueba utilizando los datos de prueba, que no ha observado antes en ninguna capacidad.