{ "cells": [ { "cell_type": "markdown", "metadata": { "id": "KIttxWZg5D8S" }, "source": [ "# Aprendizaje Automático para Datos en Grafos - Laboratorio 1\n", "## Introducción al procesamiento de grafos, NetworkX y PyTorch Geometric\n", "\n", "En este laboratorio tomaremos un dataset real, generaremos grafos a partir de él y lo analizaremos mediante la biblioteca **[NetworkX](https://networkx.org/)**. En el camino, aprovechamos para presentar la biblioteca **pandas**, excelente para levantar datos y poder procesarlos de manera rápida y cómoda. También empezaremos a familarizarnos con **PyTorch Geometric**, una biblioteca que nos será útil para trabajar con redes neuronales en grafos (GNNs).\n", "\n", "Como excusa para esto, usaremos el dataset de Enron. Los detalles del escándalo se pueden consultar en https://en.wikipedia.org/wiki/Enron_scandal. Durante la investigación se publicaron los mails que recibieron y enviaron varios empleados de Enron entre noviembre de 1998 y junio de 2002. El dataset completo se puede consultar en http://www.cs.cmu.edu/~enron/. Aquí usaremos una versión curada y resumida (por ejemplo, no contiene el cuerpo de los mensajes) que se puede consultar en http://cis.jhu.edu/~parky/Enron/enron.html.\n", "\n", "El objetivo es que aquellos que nunca tuvieron contacto con alguna de estas bibliotecas aprendan mínimamente a usarlas. Para el curso, se pide que entreguen las respuestas a las preguntas planteadas, pero cualquier exploración por fuera del camino trazado es más que bienvenida." ] }, { "cell_type": "markdown", "metadata": { "id": "2v5d66ux0r8x" }, "source": [ "## Generando el grafo" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "ljqF2gmO54y3" }, "outputs": [], "source": [ "# importemos bibliotecas que vamos a usar\n", "import pandas as pd\n", "import networkx as nx\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "uWGrbfmQ4qKe" }, "outputs": [], "source": [ "# bajemos el dataset (ver http://cis.jhu.edu/~parky/Enron/enron.html por los detalles)\n", "!wget http://cis.jhu.edu/~parky/Enron/employees\n", "!wget http://cis.jhu.edu/~parky/Enron/execs.email.linesnum" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "1UjEEZHy50qR" }, "outputs": [], "source": [ "# carguemos los datos\n", "df_mails = pd.read_csv('execs.email.linesnum', names=['time','from','to'], sep=' ')\n", "df_employees = pd.read_csv('employees', sep='\\t', names=['mail', 'name and more'])\n" ] }, { "cell_type": "markdown", "metadata": { "id": "DA0VzjMw7VlS" }, "source": [ "En la variable `df_mails` tenemos un [`DataFrame`](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) de pandas con el id de quien (columna `from`) le envió un mail a quien (`to`) y cuándo (`time`). A su vez, el usuario de mail y otros datos de cada usuario está en el dataframe `df_employees`. Un dataframe se puede pensar como una tabla indexada, pero además pandas da muchas facilidades, algunas de las cuales vamos a aprovechar para procesar los datos y generar el grafo." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "XaR2uNLG-auM" }, "outputs": [], "source": [ "# calculamos las fechas a partir del timestamp (de segundos desde el 1/1/1970 a una fecha)\n", "df_mails['date'] = pd.to_datetime(df_mails.time, unit='s')\n", "\n", "# no sé porque hay fechas del 79. Olimpicamente las borramos.\n", "df_mails = df_mails[df_mails.date.dt.year>1980]\n", "\n", "df_mails.head()" ] }, { "cell_type": "markdown", "metadata": { "id": "PiNIuXYP8umA" }, "source": [ "### Generación del grafo de todo el período\n", "\n", "Primero vamos a crear un grafo que abarque todos los mails.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "PAmW8c7kEIb7" }, "outputs": [], "source": [ "# cuento cuántos mails hubo entre dos usuarios\n", "mails_intercambiados = df_mails.groupby(['from', 'to']).count().reset_index()\n", "mails_intercambiados.head()\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "rpxWsTfAE65P" }, "outputs": [], "source": [ "# las columnas \"time\" y \"date\" tienen todo lo mismo, asi que arbitrariamente cambio una a \"weight\" para usarlo como peso en el grafo\n", "mails_intercambiados.rename(columns={'time':'weight'}, inplace=True)\n", "mails_intercambiados.head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "r2TZkz7OGEEK" }, "outputs": [], "source": [ "# Y acá viene lo interesante: pandas se integra con networkx.\n", "G = nx.from_pandas_edgelist(mails_intercambiados, source='from', target='to', edge_attr='weight', create_using=nx.DiGraph)\n", "\n", "# Saco self loops\n", "G.remove_edges_from(nx.selfloop_edges(G))\n", "\n", "# dibujamos el grafo sin hacer mucho esfuerzo ...\n", "nx.draw_networkx(G)\n", "plt.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "HrLXKPBT7RPx" }, "outputs": [], "source": [ "# ... pero no se aprecia mucho, porque los nodos quedan muy apretados.\n", "\n", "# Así que ahora nos vamos a poner un poco más las pilas\n", "posiciones = nx.circular_layout(G)\n", "edges = G.edges()\n", "weights = np.array([G[u][v]['weight'] for u,v in edges])\n", "\n", "between_dict = nx.betweenness_centrality(G)\n", "between = np.array(list(between_dict.values()))\n", "\n", "plt.figure(figsize=(12,12))\n", "nx.draw_networkx_nodes(G, pos=posiciones, node_color=10*np.log(1+between/(np.min(between)+1e-9)), cmap='Blues')\n", "nx.draw_networkx_edges(G, alpha=0.1, width=np.log10(weights+1), pos=posiciones)\n", "nx.draw_networkx_labels(G, pos=posiciones, font_color='black')\n", "plt.title('Grafo de mails intercambiados en todo el período.\\n Ancho de la arista proporcional a la cantidad de mails (en escala logarítmica).\\n \\\n", " Intensidad del color del nodo proporcional a su betweeness (en escala logarítmica).', fontsize=18)\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": { "id": "JVkzFblZaH2x" }, "source": [ "### Interfaz de NetworkX con NumPy" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "oiT906OPXkER" }, "outputs": [], "source": [ "# Además de tener interfaz hacia pandas, NetworkX puede trabajar con NumPy y matrices\n", "\n", "# por ejemplo, la matriz de adyacencia se obtiene así de sencillo\n", "G_np = nx.to_numpy_array(G,nodelist=range(G.number_of_nodes()))\n", "# y la ploteo usando seaborn\n", "sns.heatmap(np.log10(G_np+1), cmap='Greys')\n", "plt.show()\n", "# o si quisiera mirar solo la conectividad...\n", "sns.heatmap(G_np>0, cmap='Greys')\n", "plt.show()\n" ] }, { "cell_type": "markdown", "metadata": { "id": "2sscNxoyVx4K" }, "source": [ "## Análisis del grafo completo\n", "\n", "Ahora deberá usar la API de Networkx o NumPy para calcular distintos indicadores del grafo `G`:\n", "\n", "\n", "1. Número de aristas dirigidas (arcos).\n", "2. Número de aristas no-dirigidas (es decir, si existe al menos una de las dos direcciones entre un par de nodos (i,j), entonces se cuenta como 1).\n", "3. Número de arcos mutuos (es decir, pares de nodos con \"ida y vuelta\").\n", "4. Número de nodos con in-degree igual a 0 (o sea, empleados que no recibieron mails). ¿A qué personas corresponde?\n", "5. Número de nodos con out-degree igual a 0 (o sea, empleados que no enviaron mails). ¿A qué personas corresponde?\n", "6. El número de empleados que fueron contactados por más de 30 empleados. Re-haga el dibujo del grafo marcando estos nodos en rojo y haciendo aparecer su nombre.\n", "7. El número de empleados que contactaron a más de 30 empleados. Re-haga el dibujo del grafo marcando estos nodos en rojo y haciendo aparecer su nombre.\n", "8. El histograma del grado de los nodos (in y out). Se puede utilizar por ejemplo la herramienta histplot de seaborn.\n", "\n" ] }, { "cell_type": "markdown", "metadata": { "id": "uZn1nm5CagbZ" }, "source": [ "## Análisis temporal del grafo\n", "\n", "Hasta ahora miramos la totalidad de los datos e ignoramos la componente temporal del mismo. Ahora vamos a analizar mínimamente cómo cambia el grafo con el tiempo." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "ADMP8EEVXTZq" }, "outputs": [], "source": [ "# los voy a agrupar por semana, asi que primero me fijo a que semana corresponde cada mail y lo agrego a df_mails\n", "df_mails['week'] = df_mails.date.dt.to_period('W')\n", "print(df_mails.head())\n", "\n", "# Agrupo por semana. Esto genera un objeto GroupBy sobre el que puedo iterar, y contiene\n", "# los datos correspondientes a cada semana\n", "grouped_week = df_mails.groupby('week')\n", "# lista que contendrá los grafos correspondientes a cada semana\n", "grafos = []\n", "# lista que contendrá las semanas propiamente dichas. Se puede usar para identificar tiempo más adelante.\n", "semanas = []\n", "\n", "for semana, grupo_mails in grouped_week:\n", " # básicamente repito lo que hice antes para el grafo grande, pero ahora por semana.\n", " # Voy a ir guardando los grafos en una lista. Quizá no sea lo más eficiente, pero el\n", " # dataset no es muy grande.\n", "\n", " # cuento cuantos mails hubo en esta semana entre dos usuarios\n", " mails_intercambiados = grupo_mails.groupby(['from', 'to']).count().reset_index()\n", " # las columnas tienen todo lo mismo, asi que arbitrariamente cambio una a \"weight\" para usarlo como peso en el grafo\n", " mails_intercambiados.rename(columns={'week':'weight'}, inplace=True)\n", " G = nx.from_pandas_edgelist(mails_intercambiados, source='from', target='to', edge_attr='weight', create_using=nx.DiGraph)\n", "\n", " # Saco self loops\n", " G.remove_edges_from(nx.selfloop_edges(G))\n", "\n", " # agrego el nuevo grafo a la lista\n", " grafos.append(G)\n", " semanas.append(semana)\n", "\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "XgyUT8NnsVoS" }, "outputs": [], "source": [ "# vamos a mirar algunos indicadores\n", "\n", "numero_de_nodos = [grafo.number_of_nodes() for grafo in grafos]\n", "numero_de_arcos = [grafo.number_of_edges() for grafo in grafos]\n", "pd.DataFrame({'n_nodos':numero_de_nodos, 'n_arcos':numero_de_arcos}, index=semanas).plot(figsize=(12,6))\n", "plt.grid()\n", "plt.legend(['número de nodos', 'número de arcos'])\n", "plt.show()\n" ] }, { "cell_type": "markdown", "metadata": { "id": "MuoDL6Vx3vvm" }, "source": [ "### Cambios en el grafo:\n", "9. Elija dos indicadores de nodo e indique cuál fue la persona más importante para cada instante de tiempo según cada indicador. Justifique su elección del indicador. Compárelo con el grafo \"completo\" (i.e. considerando todo el tiempo).\n", "10. Considere algunos indicadores a nivel de grafo y trate de identificar algún evento en el escándalo (la figura 8 de https://arxiv.org/abs/1403.0989 tiene un timeline muy prolijo que puede ayudar). Con toda seguridad el lanzamiento de Enron online y la asunción de Stephen Cooper como CEO son fácilmente detectables." ] }, { "cell_type": "markdown", "metadata": { "id": "plUYGwb-oh0J" }, "source": [ "## Una introducción a Pytorch Geometric (PyG)\n", "**[PyTorch Geometric](https://github.com/rusty1s/pytorch_geometric)** es una bilbioteca de Python para trabajar con aprendizaje profundo en grafos, a través de las redes neuronales de grafos (Graph Neural Networks o GNNs). La biblioteca es una extensión de **[PyTorch](https://pytorch.org/)**, un framework de deep learning muy utilizado." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "vLfWs_xwoh0J" }, "outputs": [], "source": [ "import torch\n", "print(f\"La versión de PyTorch es {torch.__version__}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "S33uGlepoh0K" }, "outputs": [], "source": [ "# Instalo versión de PyG según instalación de PyTorch\n", "!pip install torch-scatter -f https://data.pyg.org/whl/torch-{torch.__version__}.html\n", "!pip install torch-sparse -f https://data.pyg.org/whl/torch-{torch.__version__}.html\n", "!pip install torch-geometric" ] }, { "cell_type": "markdown", "metadata": { "id": "JTHkIICqoh0K" }, "source": [ "PyG tiene varios datasets de grafos en el paquete **[torch_geometric.datasets](https://pytorch-geometric.readthedocs.io/en/latest/modules/datasets.html#torch_geometric.datasets)**. En esta parte trabajaremos con un dataset bastante utilizado para hacer pruebas de concepto en el área: el [**Zachary's karate club network**](https://en.wikipedia.org/wiki/Zachary%27s_karate_club)." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "cxjTi2vioh0K" }, "outputs": [], "source": [ "from torch_geometric.datasets import KarateClub\n", "\n", "dataset = KarateClub()\n", "print(f'Dataset: {dataset}:')\n", "print('======================')\n", "print(f'Cantidad de grafos: {len(dataset)}')\n", "print(f'Cantidad de features: {dataset.num_features}')\n", "print(f'Cantidad de clases: {dataset.num_classes}')" ] }, { "cell_type": "markdown", "metadata": { "id": "aRf8X-czoh0L" }, "source": [ "El dataset está formado por un único grafo, cada nodo del grafo tiene asociado un vector en $\\mathbb{R}^{34}$ (llamado vector de *features*), y hay 4 clases de nodos. Veamos en más detalle algunas estadísticas del grafo:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "cz2j97_foh0M" }, "outputs": [], "source": [ "# Miro el primer grafo\n", "data = dataset[0]\n", "\n", "print(data)\n", "print('==============================================================')\n", "\n", "# Estadísticas del grafo.\n", "print(f'Cantidad de nodos: {data.num_nodes}')\n", "print(f'Cantidad de aristas: {data.num_edges}')\n", "print(f'Grado promedio de nodo: {(2*data.num_edges) / data.num_nodes:.2f}')\n", "print(f'Tiene nodos aislados: {data.has_isolated_nodes()}')\n", "print(f'Tiene self-loops: {data.has_self_loops()}')\n", "print(f'Es no dirigido: {data.is_undirected()}')" ] }, { "cell_type": "markdown", "metadata": { "id": "FB_zV8q-oh0M" }, "source": [ "Un grafo en PyG está representado por un objeto de tipo [`Data`](https://pytorch-geometric.readthedocs.io/en/latest/modules/data.html#torch_geometric.data.Data). Cada uno de estos objetos tiene al menos 5 atributos:\n", "- **`x`**: es la matriz de features del grafo (es decir, la matriz cuyas columnas son los featues de cada nodo del grafo). Es un objeto de tipo [`tensor`](https://pytorch.org/docs/stable/tensors.html), el tipo nativo de torch para guardar matrices (el equivalente a [`ndarray`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) en numpy).\n", "- **`edge_index`**: es la matriz de conectividad del grafo, en formato [COO](https://en.wikipedia.org/wiki/Sparse_matrix#Coordinate_list_(COO)). Este formato es muy útil para guardar matrices *ralas* o *esparsas* (matrices con muchos ceros en sus entradas). Consiste en guardar solamente la lista de nodos que están conectados, en lugar de guardar toda la matriz de adyacencia del grafo.\n", "- **`y`**: es la matriz de etiquetas de cada nodo (en este ejemplo, la matriz que nos dice a cuál de las 4 clases pertenece cada nodo).\n", "- **`train_mask`**: matriz binaria que indica qué nodos de nuestro dataset son parte del conjunto de entrenamiento. Será útil más adelante cuando queramos por ejemplo estimar a qué clase pertenece cada nodo.\n", "- **`edge_attr`**: matriz de features de cada arista del grafo. Como el grafo con el que estamos trabajando es simple, en este caso las aristas no tienen features, pero en el caso que tengamos un grafo dirigido, en `edge_attr` podríamos guardar los pesos de cada arista." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "ebs9sGvkoh0M" }, "outputs": [], "source": [ "print('data.x')\n", "print('========================================')\n", "print(data.x)\n", "print('\\ndata.edge_index')\n", "print('=========================================')\n", "print(data.edge_index.t())\n", "print('\\ndata.y')\n", "print('=========================================')\n", "print(data.y)\n", "print('\\ndata.train_mask')\n", "print('=========================================')\n", "print(data.train_mask)\n", "print('\\ndata.edge_attr')\n", "print('=========================================')\n", "print(data.edge_attr)" ] }, { "cell_type": "markdown", "metadata": { "id": "XLEBY1ojoh0N" }, "source": [ "PyG nos proporciona una interfaz simple para convertir un grafo a formato de Networkx" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "JPIVsaswoh0N" }, "outputs": [], "source": [ "from torch_geometric.utils import to_networkx\n", "G = to_networkx(data, to_undirected=True)\n", "nx.draw_networkx(G,node_color=data.y,pos=nx.spring_layout(G, seed=42))" ] }, { "cell_type": "markdown", "metadata": { "id": "XKK__9ZAoh0N" }, "source": [ "## Verificando empíricamente las propiedades de la matriz laplaciana\n", "La idea de los siguientes ejercicios es verificar empíricamente las propiedades de la matriz laplaciana que se demuestran en el ejercicio opcional de abajo.\n", "\n", "11. Calcular la matriz laplaciana $\\mathbf{L}$ del grafo del karate club. Se sugiere utilizar alguna función del subpaquete [`torch_geometric.utils`](https://pytorch-geometric.readthedocs.io/en/latest/modules/utils.html).\n", "12. Vertificar que $\\mathbf{L}$ tiene valor propio 0, y que el vector de unos $[1,1,\\dots,1]^\\top$ es vector propio con valor propio 0. El subpaquete [`torch.linalg`](https://pytorch.org/docs/stable/linalg.html) puede ser de utilidad.\n", "13. Verificar que $\\mathbf{L}$ es semidefinida positiva.\n", "14. Armar una matriz $\\tilde{\\mathbf{B}}$ como la de la parte 2. del ejercicio opcional y verificar que $\\mathbf{L}=\\tilde{\\mathbf{B}}\\tilde{\\mathbf{B}}^\\top$. Se sugiere utilizar la función [`networkx.incidence_matrix`](https://networkx.org/documentation/stable/reference/generated/networkx.linalg.graphmatrix.incidence_matrix.html)." ] }, { "cell_type": "markdown", "source": [ "# Ejercicio opcional: demostrar algunas propiedades de la matriz laplaciana\n", "\n", "Sea $G(V,E)$ un grafo simple (no dirigido, sin pesos y sin auto-aristas), con cantidad de vértices $N_v = |V|$ y cantidad de aristas $N_e = |E|$, y matriz de adyacencia $\\mathbf{A}$. Sea $\\mathbf{D} = \\text{diag}(d_{1},\\dots, d_{N_v})$ la matriz de grados y $\\mathbf{L}=\\mathbf{D}- \\mathbf{A}$ la matriz laplaciana de $G$.\n", "\n", "1. Verificar que el vector de unos $[1,1,\\dots,1]^\\top$ es vector propio de $\\mathbf{L}$ con valor propio 0.\n", "2. Aunque $G$ es un grafo no dirigido, podemos asignarle a cada arista en $E$ una orientación \"virtual\" arbitraria: para cada arista elegimos arbitrariamente uno de sus vértices como nodo inicial y el otro como nodo final. Dada esa asignación, definimos la *matriz de incidencia signada* $\\tilde{\\mathbf{B}} \\in \\{-1,0,1\\}^{N_v\\times N_e}$ con entrada $i,e$ dada por:\n", "$$\\tilde{\\mathbf{B}}_{ie} = \\left\\lbrace\n", "\\begin{array}{r l}\n", "1,& \\text{si el vértice } i \\text{ es el nodo inicial de la arista } e\\\\\n", "-1,& \\text{si el vértice } i \\text{ es el nodo final de la arista } e\\\\\n", "0,& \\text{en otro caso}\n", "\\end{array}\n", "\\right. .\n", "$$\n", "Demostrar que la matriz laplaciana puede descomponerse como $\\mathbf{L}=\\tilde{\\mathbf{B}}\\tilde{\\mathbf{B}}^\\top$.\n", "\n", "3. Sea $\\mathbf{x} = [x_1,\\dots,x_{N_v}]^\\top \\in \\mathbb{R}^{N_v}$ un vector cualquiera. Usando el resultado de la parte 2, mostrar que\n", "$$\\mathbf{x}^\\top \\mathbf{L}\\mathbf{x} = \\sum_{(i,j) \\in E} (x_i-x_j)^2$$\n", "Concluir que $\\mathbf{L}$ es una matriz simétrica y semidefinida positiva.\n", "\n", "4. Demostrar que si $G$ no es conexo entonces $\\mathbf{L}$ es diagonal por bloques, donde cada bloque corresponde a la matriz laplaciana de cada una de las componentes conexas de $G$. Probar que en este caso el subespacio propio asociado al valor propio 0 tiene dimensión mayor o igual a 2." ], "metadata": { "id": "7sY2tZrFS5xm" } }, { "cell_type": "markdown", "metadata": { "id": "d0d-zG2Noh0O" }, "source": [ "# Reconocimientos\n", "\n", "La primera parte de la sección '**Una introducción a PyTorch Geometric**' está basada en [este notebook](https://colab.research.google.com/drive/16tqEHKOLUgYvXKx1V3blfYGpQb1_09MG?usp=sharing#scrollTo=bbny-iTO7NQN) del curso **[CS224W: Machine Learning with Graphs](http://web.stanford.edu/class/cs224w)** de Stanford." ] } ], "metadata": { "colab": { "private_outputs": true, "provenance": [] }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.10" } }, "nbformat": 4, "nbformat_minor": 0 }