4  Fundamentos de Clasificación de Texto

El objetivo de la unidad es

Paquetes usados

from microtc.utils import tweet_iterator, load_model, save_model
from b4msa.textmodel import TextModel
from EvoMSA.tests.test_base import TWEETS
from EvoMSA.utils import bootstrap_confidence_interval
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import recall_score, precision_score, f1_score
from sklearn.naive_bayes import MultinomialNB
from scipy.stats import norm, multinomial, multivariate_normal
from scipy.special import logsumexp
from collections import Counter
from matplotlib import pylab as plt
from os.path import join
import numpy as np

Video explicando la unidad


4.1 Introducción

El problema de categorización (clasificación) de texto es una tarea de PLN que desarrolla algoritmos capaces de identificar la categoría de un texto de un conjunto de categorías previamente definidas. Por ejemplo, en análisis de sentimientos pertenece a esta tarea y su objetivo es el detectar la polaridad (e.g., positiva, neutral, o negativa) del texto. Cabe mencionar, que diferentes tareas de PLN pueden ser formuladas como problemas de clasificación, e.g., la tarea de preguntas y respuestas, vinculación de enunciados, entre otras.

El problema de clasificación de texto se puede resolver desde diferentes perspectivas; el camino que se seguirá corresponde a aprendizaje supervisado. Los problemas de aprendizaje supervisado comienzan con un conjunto de pares, donde el primer elementos del par corresponde a las entradas (variables independientes) y el segundo es la respuesta (variable dependiente). Sea \(\mathcal D = \{(\text{texto}_i, y_i) \mid i=1,\ldots, N\}\) donde \(y \in \{c_1, \ldots c_K\}\) y \(\text{texto}_i\) contiene el texto.

4.2 Teorema de Bayes

Una manera de modelar este problema es modelando la probabilidad de observar la clase \(\mathcal Y\) dada la entrada, es decir, \(\mathbb P(\mathcal Y \mid \mathcal X)\). El Teorema de Bayes ayuda a expresa esta expresión en términos de elementos que se pueden medir de un conjunto de entrenamiento.

La probabilidad conjunta se puede expresar como \(\mathbb P(\mathcal X, \mathcal Y)\), esta probabilidad es conmutativa por lo que \(\mathbb P(\mathcal X, \mathcal Y)=\mathbb P(\mathcal Y, \mathcal X).\) En este momento se puede utilizar la definición de probabilidad condicional que es \(\mathbb P(\mathcal Y, \mathcal X)=\mathbb P(\mathcal Y \mid \mathcal X) \mathbb P(\mathcal X).\) Utilizando estas ecuaciones el Teorema de Bayes queda como

\[ \mathbb P(\mathcal Y \mid \mathcal X) = \frac{ \mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)}{\mathbb P(\mathcal X)}, \tag{4.1}\]

donde al término \(\mathbb P(\mathcal X \mid \mathcal Y)\) se le conoce como verosimilitud, \(\mathbb P(\mathcal Y)\) es la probabilidad a priori y \(\mathbb P(\mathcal X)\) es la evidencia.

Es importante mencionar que la evidencia se puede calcular mediante la probabilidad total, es decir:

\[ \mathbb P(\mathcal X) = \sum_{y \in \mathcal Y} \mathbb P(\mathcal X \mid \mathcal Y=y) \mathbb P(\mathcal Y=y). \tag{4.2}\]

4.3 Modelado Probabilístico (Distribución Categórica)

Se inicia la descripción de clasificación de texto presentando un ejemplo sintético que ejemplifica los supuestos que se realizan en el modelo. La distribución categórica modela el evento de seleccionar \(K\) eventos, los cuales pueden estar codificados como caracteres. Si esta selección se realiza \(\ell\) veces se cuenta con una secuencia de eventos representados por caracteres. Por ejemplo, los \(K\) eventos pueden ser representados por los caracteres w, x, y y z. Utilizando este proceso se puede utilizar para ejemplificar el proceso de asociar una secuencia a una clase, e.g., positiva o negativa.

El primer paso es seleccionar los parámetros de dos distribuciones tal y como se muestra en las siguientes primeras dos líneas. Cada distribución se asume que es la generadora de una clase. El segundo paso es tomar una muestra de cada distribución, en particular se toman \(1000\) muestras con el siguiente procedimiento. En cada iteración se toma una muestra de una distribución Gausiana (\(\mathcal N(15, 3)\)), la variable aleatoria se guarda en la variable length. Esta variable aleatoria representa la longitud de la secuencia. El tercer paso es sacar la muestra de las distribuciones categóricas definidas previamente. Las muestras son guardadas en la lista D junto con la clase a la que pertenece \(0\) y \(1.\)

pos = multinomial(1, [0.20, 0.20, 0.35, 0.25])
neg = multinomial(1, [0.35, 0.20, 0.25, 0.20])
length = norm(loc=15, scale=3)
D = []
m = {k: chr(122 - k) for k in range(4)}
id2w = lambda x: " ".join([m[_] for _ in x.argmax(axis=1)])
for l in length.rvs(size=1000):
    D.append((id2w(pos.rvs(round(l))), 1))
    D.append((id2w(neg.rvs(round(l))), 0))

La Tabla 4.1 muestra los primeros cuatro ejemplos generados con el procedimiento anterior. La primera columna muestra la secuencia y asociada a cada secuencia se muestra la clase que corresponde a la secuencia.

Tabla 4.1: Conjunto generado de clasificación de texto
Texto Clase
w x w y w x w z x x w y x z y Positivo
w z x w x w z z x z z x x w x Negativo
y z z y y y y x z w w w x x z Positivo
w w z z x y z w w z y y z z y Negativo

El primer paso es encontrar la verosimilitud dado el conjunto de datos D. El siguiente código calcula la verosimilitud de la clase positiva.

D_pos = []
[D_pos.extend(data.split()) for data, k in D if k == 1]
words, l_pos = np.unique(D_pos, return_counts=True)
w2id = {v: k for k, v in enumerate(words)}
l_pos = l_pos / l_pos.sum()
l_pos
array([0.25362367, 0.34747501, 0.19703488, 0.20186644])

Un procedimiento equivalente se puede realizar para obtener la verosimilitud de la clase negativa.

D_neg = []
[D_neg.extend(data.split()) for data, k in D if k == 0]
_, l_neg = np.unique(D_neg, return_counts=True)
l_neg = l_neg / l_neg.sum()
l_neg
array([0.19624065, 0.24482097, 0.20133695, 0.35760143])

La probabilidad a priori se puede calcular con la siguientes instrucciones.

_, priors = np.unique([k for _, k in D], return_counts=True)
N = priors.sum()
prior_pos = priors[1] / N
prior_neg = priors[0] / N

Una ves que se han identificador los parámetros, estos pueden ser utilizados para predecir la clase dada una secuencia. El primer paso es calcular la verosimilitud, e.g., \(\mathbb P(\)w w x z\(\mid \mathcal Y)\). Se observa que la secuencia tiene se tiene que transformar en términos, esto se puede realizar con el método split. Después, los términos se convierten al identificador que corresponde al parámetro del token con el mapa w2id. Una vez que se identifica el índice se conoce el valor del parámetro, se calcula el producto (como o la suma si se hace todo en términos del logaritmo) y se regresa el valor de la verosimilitud.

def likelihood(params, txt):
    params = np.log(params)
    _ = [params[w2id[x]] for x in txt.split()]
    tot = sum(_)
    return np.exp(tot)

La verosimilitud se combina con la probabilidad a priori, con esta información se calcula la evidencia y para obtener la probabilidad a posteriori tanto para la clase positiva (post_pos) como para la negativa (post_neg). La clase corresponde a la etiqueta que presenta la máxima probabilidad, última línea (hy).

post_pos = [likelihood(l_pos, x) * prior_pos for x, _ in D]
post_neg = [likelihood(l_neg, x) * prior_neg for x, _ in D]
evidence = np.vstack([post_pos, post_neg]).sum(axis=0)
post_pos /= evidence
post_neg /= evidence
hy = np.where(post_pos >= post_neg, 1, 0)

4.3.1 Clasificador de Texto

En la sección anterior se trabajo desde la creación de un conjunto de datos sintético que fue generado mediante dos distribuciones Categóricas, donde a cada distribución se le asignó una clase, e.g., positiva o negativa. Esto permitió observar todas las partes de modelado, en la realidad se desconoce el procedimiento que genera los textos y el proceso de aprendizaje empieza con un conjunto de datos, en este ejemplo se utilizará un conjunto de datos de polaridad que tiene cuatro clases, negativo (N), neutral (N), ausencia de polaridad (NEU), y positivo (P).

Es pertinente mencionar que el conjunto de datos fue etiquetado usando un clasificador de texto y ninguna valoración humana fue realizada para verificar que las etiquetas sean correctas.

Este conjunto se usa dentro de EvoMSA (Graff et al. (2020)) como conjunto de prueba para realizar pruebas unitarias.

El conjunto de datos se obtiene con la siguiente instrucción.

D = [(x['text'], x['klass'])
     for x in tweet_iterator(TWEETS)]

Como se puede observar, \(\mathcal D\) es equivalente al usado en el ejemplo de la Sección 4.3. La diferencia es que la secuencia de letras está cambiada con un enunciado. Aún así, es una metodología factible es obtener los tokens using el método split. Otro método es obtener los tokens usando el segmentador descrito en la Sección 2.5.

El siguiente código usa la clase TextModel para segmentar el texto considerando solamente las palabras. El texto segmentado se guarda en la variable D.

tm = TextModel(token_list=[-1])
tok = tm.tokenize
D = [(tok(x), y) for x, y in D]

Antes de estimar la verosimilitud, es necesario codificar los tokens usando un índice. Esto se realiza para guardar los parámetros en un arreglo y poder hacer operaciones utilizando la librería numpy. El siguiente código asocia un índice a cada token y el esta asociación se guarda en el diccionario w2id.

words = set()
[words.update(x) for x, y in D]
w2id = {v: k for k, v in enumerate(words)}

Anteriormente, las cases se habían representado de manera numérica; donde la clase positiva se había asociado al número \(1\), mientras que la clase negativa se había asociado al número \(0\). En esta ocasión las clases son cadenas de caracteres, para seguir con un procedimiento similar al presentado previamente, se decide codificar las clases con un número natural, este procedimiento se hace al mismo tiempo que se calcula la distribución a priori. El siguiente código muestra este procedimiento, con la característica de que el logaritmo de la distribución a prior se guarda en la variable priors.

uniq_labels, priors = np.unique([k for _, k in D], return_counts=True)
priors = np.log(priors / priors.sum())
uniq_labels = {str(v): k for k, v in enumerate(uniq_labels)}

En este momento se está en la posibilidad de estimar los parámetros de la verosimilitud para cada clase. Se asume que los datos provienen de una distribución Categórica y que cada token es independiente. Los parámetros de la verosimilitud se pueden guardar en una matriz (variable l_tokens \(\in \mathbb R^{K,d}\)) con \(K\) renglones, que corresponden a los parámetros de cada clase y \(d\) columnas que son el vocabulario, es decir, los diferentes tokens que tiene la representación. El primer paso es calcular la frecuencia de cada token por clase, lo cual se puede realizar con el siguiente código.

l_tokens = np.zeros((len(uniq_labels), len(w2id)))
for x, y in D:
    w = l_tokens[uniq_labels[y]]
    cnt = Counter(x)
    for i, v in cnt.items():
        w[w2id[i]] += v

El siguiente paso es normalizar la frecuencia, el algoritmo que se utiliza para normalizar es el suavizado de Laplace, por lo cual se le añade un valor pequeño, en este caso, \(0.1\) a cada frecuencia, esto se observa en la primera línea del siguiente código. La segunda linea normaliza las frecuencia y finalmente se guarda el logaritmo de la frecuencia normalizada.

l_tokens += 0.1
l_tokens = l_tokens / np.atleast_2d(l_tokens.sum(axis=1)).T
l_tokens = np.log(l_tokens)

4.3.1.1 Predicción

Una vez que todos los parámetros han sido estimados, es tiempo de usar el modelo para clasificar cualquier texto dato. La siguiente función calcula la distribución posterior. El primer paso es segmentar el texto (segunda línea) y calcular la frecuencia de cada término en el texto. La frecuencia se guarda en el diccionario cnt, el cual se convierte en el vector x usando la función de w2id. El siguiente paso es calcular la verosimilitud y la probabilidad a priori. El producto se calcula en el espacio de los logaritmos, por lo tanto se observa que la verosimilitud y la probabilidad a priori se suman. El último paso es calcular la evidencia y normalizar el resultado; la evidencia se calcula utilizando la función logsumexp.

def posterior(txt):
    x = np.zeros(len(w2id))
    cnt = Counter(tm.tokenize(txt))
    for i, v in cnt.items():
        try:
            x[w2id[i]] += v
        except KeyError:
            continue
    _ = (x * l_tokens).sum(axis=1) + priors
    l = np.exp(_ - logsumexp(_))
    return l

La función posterior predice todos los textos en \(\mathcal D\); las predicciones se utilizan para estimar la exactitud. Para poder estimar la exactitud, las clases en \(\mathcal D\) se tienen que codificar con la nomenclatura utilizada previamente, la cual es encuentra en el diccionario uniq_labels tal y como se muestra en la segunda linea del siguiente código.

hy = np.array([posterior(x).argmax() for x, _ in D])
y = np.array([uniq_labels[y] for _, y in D])
(y == hy).mean()
0.974

4.3.1.2 Entrenamiento

El procedimiento utilizado en aprendizaje supervisado está dividido en dos etapas; la primera etapa es el entrenamiento y la otra es la etapa de predicción. La función posterior atiende la segunda etapa. La función training, que se muestra a continuación, presenta el procedimiento de entrenamiento. La función requiere el conjunto de datos (D) y una instancia de TextModel (tm) para realizar la segmentación.

def training(D, tm):
    tok = tm.tokenize
    D =[(tok(x), y) for x, y in D]
    words = set()
    [words.update(x) for x, y in D]
    w2id = {v: k for k, v in enumerate(words)}
    uniq_labels, priors = np.unique([k for _, k in D], return_counts=True)
    priors = np.log(priors / priors.sum())
    uniq_labels = {str(v): k for k, v in enumerate(uniq_labels)}
    l_tokens = np.zeros((len(uniq_labels), len(w2id)))
    for x, y in D:
        w = l_tokens[uniq_labels[y]]
        cnt = Counter(x)
        for i, v in cnt.items():
            w[w2id[i]] += v
    l_tokens += 0.1
    l_tokens = l_tokens / np.atleast_2d(l_tokens.sum(axis=1)).T
    l_tokens = np.log(l_tokens)
    return w2id, uniq_labels, l_tokens, priors