{ "cells": [ { "cell_type": "markdown", "metadata": { "id": "t9ZNGOygrFsT" }, "source": [ "Redes Neuronales para Lenguaje Natural, 2024\n", "\n", "---\n", "# POS-tagging con redes LSTM\n", "\n", "En este notebook construiremos un etiquetador de categorías gramaticales (POS-tagger) usando una arquitectura LSTM en Pytorch. Los datos que utilizaremos son una versión reducida del corpus AnCora en español, etiquetada según el estándar Universal Dependencies.\n", "\n", "\n", "---\n", "\n" ] }, { "cell_type": "code", "source": [ "!pip install torchmetrics" ], "metadata": { "id": "J9T3R5kwh0wW" }, "execution_count": null, "outputs": [] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "F8yN8Ywyushh" }, "outputs": [], "source": [ "import string\n", "import random\n", "import numpy as np\n", "import torch\n", "import torch.nn as nn\n", "import torch.optim as optim\n", "import torch.nn.functional as F\n", "import torchmetrics as tm\n", "\n", "BATCH_SIZE = 64" ] }, { "cell_type": "markdown", "metadata": { "id": "-wlcQnjW3PqL" }, "source": [ "Descargar y abrir los archivos de datos" ] }, { "cell_type": "code", "source": [ "! wget https://eva.fing.edu.uy/mod/resource/view.php?id=195738 -O ancora_mini.zip\n", "! unzip ancora_mini.zip" ], "metadata": { "id": "yO3KckvwvUGC" }, "execution_count": null, "outputs": [] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "d9xCY5UWoaCb" }, "outputs": [], "source": [ "def load_data_file(filename):\n", " with open(filename) as f:\n", " tokens = []\n", " sent = []\n", " for l in f:\n", " if l.startswith('#'):\n", " continue\n", " toks = l.strip().split('\\t')\n", " if (len(toks) >= 3) and ('-' not in toks[0]) and ('.' not in toks[0]):\n", " sent.append((toks[1],toks[3]))\n", " elif len(toks) <= 1:\n", " tokens.append(sent)\n", " sent = []\n", "\n", " if len(sent) > 0:\n", " tokens.append(sent)\n", "\n", " return tokens\n", "\n", "train = load_data_file('ancora_mini_train.txt')\n", "dev = load_data_file('ancora_mini_dev.txt')\n", "test = load_data_file('ancora_mini_test.txt')\n", "print(len(train),len(dev),len(test))" ] }, { "cell_type": "markdown", "source": [ "Imprimimos un par de ejemplos para ver el formato con el que estamos trabajando." ], "metadata": { "id": "4AaOmFrg77ar" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "EChHgBsovMrx" }, "outputs": [], "source": [ "print(train[0])\n", "print(train[20])" ] }, { "cell_type": "markdown", "metadata": { "id": "K4UUqZJT3XAR" }, "source": [ "Descargar una colección de embeddings.\n", "\n", "En este ejercicio se pueden utilizar diferentes colecciones de embeddings, que darán distintos resultados. Proveemos tres alternativas, cada una con sus pros y sus contras.\n", "\n", "1. Embeddings del Spanish Billion Word Corpus (SBWCE): tamaño 300, están entrenados con más de mil millones de palabras en español y se cargan con gensim.\n", "2. Embeddings de Wikipedia en HuggingFace: tamaño 100, están entrenados con el texto de la Wikipedia en español (mucho menos texto) y se cargan con gensim.\n", "3. Embeddings tipo FastText de TorchNLP: tamaño 300, son otro tipo de vector distinto a word2vec, se cargan con PyTorch-NLP.\n" ] }, { "cell_type": "code", "source": [ "# Alternativa 1\n", "\n", "# Fromas de bajar los embeddings de SBWC:\n", "\n", "# Sitio oficial de la Universidad Nacional de Córdoba\n", "# ! wget https://cs.famaf.unc.edu.ar/~ccardellino/SBWCE/SBW-vectors-300-min5.bin.gz\n", "\n", "# Mirror en el Owncloud de Fing\n", "# ! wget https://www.fing.edu.uy/owncloud/index.php/s/ryHF9xWwox3NrFe/download/SBW-vectors-300-min5.bin.gz\n", "\n", "# Si los enlaces son demasiado lentos: bajarlo una sola vez y copiarlo a google drive\n", "# ! cp /content/drive/MyDrive/SBW-vectors-300-min5.bin.gz .\n", "\n", "# El archivo viene en un gzip, lo descomprimimos\n", "! gzip -d SBW-vectors-300-min5.bin.gz\n", "\n", "# Abrirlos con la biblioteca de embeddings gensim\n", "from gensim.models import KeyedVectors\n", "wv = KeyedVectors.load_word2vec_format(\"./SBW-vectors-300-min5.bin\", binary=True)\n", "print(wv.vectors.shape)\n", "embeddings_size = wv.vectors.shape[1]" ], "metadata": { "id": "U6Qas-YKZ2n5" }, "execution_count": null, "outputs": [] }, { "cell_type": "code", "source": [ "# Alternativa 2\n", "\n", "# Bajar y abrir los embeddings de Wikipedia de HuggingFace\n", "\n", "from gensim.models import KeyedVectors\n", "from huggingface_hub import hf_hub_download\n", "wv = KeyedVectors.load_word2vec_format(hf_hub_download(repo_id=\"Word2vec/wikipedia2vec_eswiki_20180420_100d\", filename=\"eswiki_20180420_100d.txt\"))\n", "print(wv.vectors.shape)\n", "embeddings_size = wv.vectors.shape[1]\n" ], "metadata": { "id": "4SOjAVDwXfQ1" }, "execution_count": null, "outputs": [] }, { "cell_type": "code", "source": [ "# Alternativa 3\n", "\n", "# Para utilizar los embeddings tipo FastText de TorchNLP, primero hay que instalar la biblioteca\n", "! pip install pytorch-nlp\n", "\n", "import torchnlp\n", "from torchnlp.word_to_vector import FastText\n", "\n", "# Cargar los embeddings FastText en español\n", "wv = FastText(language='es')\n", "print(wv.vectors.shape)\n", "embeddings_size = wv.vectors.shape[1]\n", "\n", "# Cuidado! Estos embeddings no están cargados con la biblioteca gensim, por lo que no podemos utilizar sus funcionalidades habituales" ], "metadata": { "id": "KGnrWnL5ppG2" }, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": [ "Haremos un pequeño análisis de cobertura de la colección de embeddings sobre los tokens utilizados en el corpus.\n", "\n", "Consideraremos cuatro clases de tokens: palabras conocidas (están en la colección de embeddings), números, símbolos de puntuación, y palabras desconocidas.\n", "\n", "La heurística para determinar si un token es número o símbolo de puntuación será si comienza con un dígito o símbolo de puntuación." ], "metadata": { "id": "QOaUInwP0fLr" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "e8DBHu1vUjFs" }, "outputs": [], "source": [ "words = []\n", "puncts = []\n", "nums = []\n", "unk = []\n", "labels = set()\n", "for sent in train+dev+test:\n", " for (t,p) in sent:\n", " if t in wv:\n", " words.append(t)\n", " elif t[0] in string.punctuation:\n", " puncts.append(t)\n", " elif t[0] in ['0','1','2','3','4','5','6','7','8','9']:\n", " nums.append(t)\n", " else:\n", " unk.append(t)\n", " labels.add(p)\n", "\n", "print(\"Total: w %s p %s n %s u %s\" % (len(words),len(puncts),len(nums),len(unk)))\n", "words = set(words)\n", "puncts = set(puncts)\n", "nums = set(nums)\n", "unk = set(unk)\n", "print(\"Únicas: w %s p %s n %s u %s\" % (len(words),len(puncts),len(nums),len(unk)))\n" ] }, { "cell_type": "markdown", "source": [ "Por lo que vemos del análisis utilizando SBWC, tenemos vectores para todas las palabras conocidas y para los dígitos, y solamente hay un 0.05% de palabras que no están en la colección de embeddings. Utilizaremos un embedding aleatorio para representar estas palabras desconocidas.\n", "\n", "A esto hay que agregarle los símbolos de puntuación: SBWC no contiene embeddings para estos símbolos. Como forma de paliar esta situación, también nos crearemos un embedding aleatorio.\n", "\n", "Pytorch trabajará con secuencias de números enteros, y no con las palabras en sí, por lo que construiremos una forma de pasar cada posible token y cada posible label a una representación numérica.\n", "\n", "Primero comenzamos creando los diccionarios de palabras y labels que utilizaremos. Estos diccionarios contendrán todos los posibles tokens y labels representables en nuestro corpus.\n", "\n", "A los tokens presentes en el corpus, les agregaremos cuatro tokens especiales:\n", "- PAD: para realizar padding de las secuencias (se verá más adelante)\n", "- UNK: representa las palabras desconocidas\n", "- PUNCT: representa símbolos de puntuación\n", "- NUM: representa números\n", "\n", "A los labels les agregaremos solo un label especial NONE que se corresponderá con los tokens de PAD." ], "metadata": { "id": "z5bt8iv_0_Wo" } }, { "cell_type": "code", "source": [ "words = ['','','',''] + sorted(words)\n", "wordict = { w:i for (i,w) in enumerate(words) }\n", "labels = ['NONE'] + sorted(labels)\n", "labeldict = { l:i for (i,l) in enumerate(labels) }\n", "\n", "def get_index(word,wordict):\n", " if word in wordict:\n", " return wordict[word]\n", " elif word[0] in string.punctuation:\n", " return wordict['']\n", " elif word[0] in [0,1,2,3,4,5,6,7,8,9]:\n", " return wordict['']\n", " else:\n", " return wordict['']\n", "\n", "print(\"words\",words[:10],\"...\")\n", "print(\"wordict\",[(w,wordict[w]) for w in words[:10]],\"...\")\n", "print(\"labels\",labels)\n", "print(\"labeldict\",labeldict)" ], "metadata": { "id": "wnpiB3-y044O" }, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": [ "A continuación nos crearemos una matriz más pequeña que tenga solo los embeddings con los que vamos a trabajar, de forma de que ocupen la menor cantidad de memoria posible. Esta matriz servirá como pesos de inicialización de la capa de embeddings de la red.\n", "\n", "Crear una matriz de embeddings que sea del tamaño len(words) * 300 conteniendo los embeddings que representan todos los tokens en orden.\n", "\n", "El embedding correspondiente a los números en SBWC es \"DIGITO\" (usar \"digito\" con los embeddings de Wikipedia), el embedding correspondiente a \"PAD\" será un vector de ceros, y será necesario crearse dos embeddings aleatorios (distintos) del tamaño correcto para representar símbolos de puntuación y palabras desconocidas." ], "metadata": { "id": "Z_ldzX153qbl" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "E1AC-fBfjPU-" }, "outputs": [], "source": [ "# su código aquí...\n", "pad_vector = ...\n", "punct_vector = ...\n", "num_vector = ...\n", "unk_vector = ...\n", "embeddings = ...\n", "\n", "print(\"Matriz de embeddings:\",embeddings.shape)\n", "vocab_size = embeddings.shape[0]\n", "embeddings_dim = embeddings.shape[1]\n" ] }, { "cell_type": "markdown", "source": [ "A continuación crearemos la clase `POSDataset` que hereda de `torch.utils.data.Dataset`, para manejar nuestros conjuntos de datos.\n", "\n", "Notar que hasta ahora nuestros ejemplos tienen este formato:\n", "\n", "`[ (token1,label1), (token2,label2), (token3,label3), ....]`\n", "\n", "Necesitamos que `POSDataset` tenga los atributos `X` e `y`, donde `X` sea una lista de tensores de tipo long con los identificadores de los tokens de cada ejemplo: `[num_token1, num_token2, num_token3,...]` e `y` sea una lista de tensores de tipo long con los indentificadores de las labels de cada ejemplo: `[num_label1, num_label2, num_label3,...]`.\n", "\n", "Completar la función `transform_data` para transformar las particiones al formato necesario. Esta función se utiliza luego en la definición de `POSDataset`." ], "metadata": { "id": "5eh6pVnj5A-d" } }, { "cell_type": "code", "source": [ "from torch.utils.data import Dataset\n", "\n", "def transform_data(partition):\n", " # Su código aquí\n", " ...\n", " return X, y\n", "\n", "class POSDataset(Dataset):\n", " def __init__(self, partition):\n", " self.X, self.y = transform_data(partition)\n", "\n", " # Ordenamos las oraciones por largo\n", " self.X.sort(key = lambda x:len(x))\n", " self.y.sort(key = lambda x:len(x))\n", "\n", " def __len__(self):\n", " return len(self.X)\n", "\n", " def __getitem__(self, idx):\n", " return self.X[idx], self.y[idx]\n", "\n", "train_dataset = POSDataset(train)\n", "dev_dataset = POSDataset(dev)\n", "test_dataset = POSDataset(test)\n", "\n", "print(\"train\",len(train_dataset))\n", "print(\"dev\",len(dev_dataset))\n", "print(\"test\",len(test_dataset))" ], "metadata": { "id": "BmaVqBGoHW-A" }, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": [ "Notar que en el constructor de `POSDataset` ordenamos las oraciones por largo. Esto es para que dentro de un mismo batch queden oraciones de tamaños lo más similares posible.\n", "\n", "Usaremos la clase `torch.utils.data.Dataloader` para crear los minibatches.\n", "\n", "Notar que para poder crear un tensor a partir de un minibatch, todas las oraciones del minibatch deben tener el mismo largo. Para eso utilizaremos el token especial PAD y el label especial NONE que definimos antes.\n", "\n", "Para hacer la transformación necesaria a cada minibatch, el constructor de `Dataloader` recibe un parámetro `collate_fn` que nos permite crear cada minibatch manualmente, dados los ejemplos que contendrá el minibatch.\n", "\n", "Escriba la función `collate_fn` que toma como entrada los ejemplos de un minibatch (una lista de pares de tensores `(num_tokens, num_labels)`), y retorne los tensores `X` e `y` (de rango 2, matrices), donde `X` tenga los identificadores de los tokens (completando con el token PAD), e `y` tenga los identificadores de las labels (completando con el label NONE).\n", "\n", "Puede utilizar el método `nn.utils.rnn.pad_sequence` para realizar el padding de los ejemplos (https://pytorch.org/docs/stable/generated/torch.nn.utils.rnn.pad_sequence.html)." ], "metadata": { "id": "UKBcRroV5oMy" } }, { "cell_type": "code", "source": [ "def collate_fn(batch):\n", " # Su código aquí\n", " ...\n", " return X, y" ], "metadata": { "id": "XsI-KCPGL4it" }, "execution_count": null, "outputs": [] }, { "cell_type": "code", "source": [ "from torch.utils.data import DataLoader\n", "\n", "train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, collate_fn=collate_fn)\n", "dev_loader = DataLoader(dev_dataset, batch_size=BATCH_SIZE, collate_fn=collate_fn)\n", "test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, collate_fn=collate_fn)\n", "\n", "print(\"train\", next(iter(train_loader))[0].shape, next(iter(train_loader))[0].dtype)\n", "print(\"dev\", next(iter(dev_loader))[0].shape, next(iter(dev_loader))[0].dtype)\n", "print(\"test\", next(iter(test_loader))[0].shape, next(iter(test_loader))[0].dtype)" ], "metadata": { "id": "7E66UQCRNwQ-" }, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": [ "Llegó la hora de implementar la red LSTM. Esta red debe tener:\n", "- Una capa de embeddings (torch.nn.Embeddings), la que inicializaremos con la matriz de embeddings que definimos más arriba (poner requires_grad en False para que el entrenamiento sea más rápido, no queremos modificar los pesos de los embeddings)\n", "- Una capa LSTM (torch.nn.LSTM), su entrada es la dimensión de los embeddings, y el tamaño de la capa oculta debe quedar como parámetro\n", "- Una capa lineal (torch.nn.Linear) que va de la capa oculta de la LSTM a la salida final, que tiene tamaño len(labels)\n" ], "metadata": { "id": "0upYt_I87w2J" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "680r8lnDt-80" }, "outputs": [], "source": [ "class LSTMTagger(nn.Module):\n", "\n", " def __init__(self, ....):\n", " super(LSTMTagger, self).__init__()\n", " # su código aquí\n", "\n", " def forward(self, batch):\n", " # su código aquí\n", " return tag_scores\n" ] }, { "cell_type": "markdown", "source": [ "Escribir una función para evaluar la red. Debe tomar el dataloader a evaluar, ejecutar la red y calcular el accuracy.\n", "\n", "Puede utilizar la biblioteca `torchmetrics`, usando la clase Accuracy de la siguiente manera:\n", "\n", "`acc = tm.Accuracy(task=\"multiclass\",num_classes=len(labels),ignore_index=labeldict['NONE'])`" ], "metadata": { "id": "Mdvn0Xm99Y0L" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "LpYoFJOnyFlm" }, "outputs": [], "source": [ "def evaluate(net,dataloader):\n", " # su código aquí\n", " acc = ...\n", " print(\"accuracy\",acc)\n", " return acc\n" ] }, { "cell_type": "markdown", "source": [ "Instanciar y entrenar la red. Utilizaremos la función de pérdida CrossEntropyLoss, notar que para eso es necesario que lo que devuelva la red sea la salida de la última capa, sin aplicar la función de activación Softmax." ], "metadata": { "id": "mwV2j1ee-F16" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "QU9y99W07qBZ" }, "outputs": [], "source": [ "loss_function = nn.CrossEntropyLoss(ignore_index=labeldict['NONE'])\n", "\n", "lstmnet = LSTMTagger(...) # los parámetros apropiados\n", "evaluate(lstmnet,dev_loader) # seguro va a dar muy cerca de 0 al principio!\n", "\n", "optimizer = optim.SGD(lstmnet.parameters(), lr=0.1)\n", "\n", "for epoch in range(...): # elegir\n", " for word_batch,label_batch in train_loader:\n", " # su código aquí, se debe:\n", " # - inicializar los gradientes\n", " # - ejecutar la red sobre el batch (paso forward)\n", " # - usar la loss function para comparar la salida de la red con los labels esperados\n", " # - calcular los gradientes (paso backward)\n", " # - realizar el descenso por gradiente\n", "\n", " # ya que estamos, imprimimos la performance sobre dev luego de completada una epoch\n", " evaluate(lstmnet,dev_loader)\n", "\n" ] }, { "cell_type": "markdown", "source": [ "Finalmente, evaluar el resultado de la mejor red encontrada sobre el corpus de test." ], "metadata": { "id": "FRaogiT09l14" } }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "KitTLt2rSNRF" }, "outputs": [], "source": [ "evaluate(lstmnet,test_loader)" ] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 0 }