import pprint
from collections import Counter
import numpy as np
from scipy.stats import norm, multinomial
from CompStats.metrics import macro_recall
from encexp.utils import load_dataset
from transformers import AutoTokenizer
pp = pprint.PrettyPrinter(width=60, compact=True).pprint4 Fundamentos de Clasificación de Texto
El objetivo de la unidad es analizar los fundamentos de un modelo de clasificación de texto.
Paquetes usados
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, el 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.\)
D = []
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)
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(dict(text=id2w(pos.rvs(round(l))),
clase=1))
D.append(dict(text=id2w(neg.rvs(round(l))),
clase=0))- 1
- Distribución de la clase positiva (i.e., \(\mathbb P(\mathcal X \mid \mathcal Y=1)\))
- 2
- Distribución de la clase negativa (i.e., \(\mathbb P(\mathcal X \mid \mathcal Y=1)\))
- 3
- Distribución del tamaño de la secuencia
- 4
- Función para representar un número en un caracter
- 5
- Función para crear una secuencia de caracteres
- 6
-
Ciclo donde
les la longitud de la cadena - 7
- Ejemplo positivo
- 8
- Ejemplo negativo
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.
| Texto | Clase |
|---|---|
| x y w z w y x z y y z z x z x | Positivo |
| w z z x z z w w z x z z x y z | Negativo |
| x x x y w z y z w w x y z w z x z z y w w w | Positivo |
| x x y x w y y y z w y z z z y z y z z w z y | Negativo |
El primer paso es encontrar los parámetros para poder calcular \(\mathbb P(\mathcal X \mid \mathcal Y=1)\) y \(\mathbb P(\mathcal X \mid \mathcal Y=0)\). Para realizar esto, se puede adaptar la clase ModeloNGram (Listado 3.5) para el caso donde \(N=1\) y adicionalmente se requiere una modificación para que el método ModeloNGram considere los caracteres w, x, y y z como palabras o segmentos, el siguiente código hace estas adecuaciones.
class ModeloPalabra(ModeloNGram):
def __init__(self, k_suavizado: float=1):
super(ModeloPalabra, self).__init__(1, k_suavizado, False)
def segmentar(self, texto):
return self.get_text(texto).split()Habiendo definido la clase ModeloPalabra los parametros de \(\mathbb P(\mathcal X \mid \mathcal Y)\) se pueden calcular con las siguiente instrucciones; en la variable pos_l se tienen los parámetros para \(\mathbb P(\mathcal X \mid \mathcal Y=1)\) y en neg_l para \(\mathbb P(\mathcal X \mid \mathcal Y=0).\)
pos_l = ModeloPalabra(k_suavizado=0).fit(
[x for x in D if x['clase'] == 1]
)
neg_l = ModeloPalabra(k_suavizado=0).fit(
[x for x in D if x['clase'] == 0]
)- 1
- Estimación de los parametros de \(\mathbb P(\mathcal X \mid \mathcal Y=1)\)
- 2
- Estimación de los parametros de \(\mathbb P(\mathcal X \mid \mathcal Y=0)\)
Recordando que se conocen los valores de los paramétros de \(\mathbb P(\mathcal X \mid \mathcal Y)\) dado que se trata de un problema sinténtico (ver Listado 4.1), es oportuno comparar los parametros exactos con respecto a los parámetros estimados mediante el conjunto D.
m = {k: chr(122 - k) for k in range(4)}
pp([(m[k], pos_l.frase[m[k]] / pos_l.hist) for k in range(4)])- 1
- Relación entre el índice y el caracter
- 2
- Parametros de la verosimilitud de la clase positiva (ver Listado 4.1)
[('z', 0.2013485546431671), ('y', 0.1950731023432806),
('x', 0.3565658588690834), ('w', 0.2470124841444689)]
Se puede observar que \(\mathbb P(\mathcal X=w \mid \mathcal X=1)=0.25\) y la estimación tiene un valor de \(0.2470\)
Para ejemplificar el uso de pos_l y neg_l, el siguiente ejemplo los utiliza para calcular la verosimilitud de los primeros dos ejemplo del conjunto D.
pos_vero = pos_l.log_prob(D[:2])
neg_vero = neg_l.log_prob(D[:2])El logaritmo de la probabilidad a priori se puede calcular con la siguientes instrucciones.
_, priors = np.unique([x['clase'] for x in D],
return_counts=True)
priors = np.log(priors)
N = np.log(priors.sum())
pos_prior = priors[1] - N
neg_prior = priors[0] - NUna ves que se han identificador los parámetros, estos pueden ser utilizados para predecir la clase dada unas secuencias. El primer paso es calcular la verosimilitud (i.e., \(\mathbb P(\mathcal X \mid \mathcal Y)\)) para cada clase y multiplicar este valor por la probabilidad a priori (i.e., \(\mathbb P(\mathcal Y)\)) para obtener \(\mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\) tal y como se muestra en el siguiente código.
pos_cond = np.exp(
pos_l.log_prob(D) + pos_prior
)
neg_cond = np.exp(neg_l.log_prob(D) + neg_prior)- 1
- \(\mathbb P(\mathcal X \mid \mathcal Y=1) \mathbb P(\mathcal Y=1)\)
- 2
- \(\mathbb P(\mathcal X \mid \mathcal Y=0) \mathbb P(\mathcal Y=0)\)
El siguiente paso para calcular la probabilidad a posteriori es calcular la evidencia la cual corresponde a la suma de \(\mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\) tal y como se muestra en la siguiente instrucción.
evidencia = np.vstack([pos_cond, neg_cond]).sum(axis=0)Finalmente la probabilidad a posteriori (Ecuación 4.1) para la clase positiva y negativa se calcula con las siguientes instrucciones.
pos_post = pos_cond / evidencia
neg_post = neg_cond / evidencia- 1
- Probabilidad de la clase positiva
- 2
- Probabilidad de la clase negativa
Finalmente la case corresponde al evento más probable, es decir, la secuencia corresponde a la clase positiva si \(\mathbb P(\mathcal Y=1 \mid \mathcal X) > \mathbb P(\mathcal Y=0 \mid \mathcal X)\). Esto se puede calcular usando la función np.where. Teniendo las predicciones se puede mendir el rendimiento del modelo en el conjunto de entrenamiento. En el siguiente fragmento, el rendimiento corresponde al promedio de la cobertura por clase, i.e., macro-recall.
hy = np.where(pos_post > neg_post, 1, 0)
y = [x['clase'] for x in D]
pp(macro_recall(y, hy, name='Entrenamiento').statistic)0.765
Ejercicio 4.1 Genere un conjunto de prueba donde la longitud media de las secuencia sea 25. Mida el rendimiento (macro-recall) en el conjunto de prueba e indique si el rendimiento en el conjunto de prueba es superior al encontrado en el conjunto de entrenamiento.
- Verdadero
- Falso
4.4 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, aunque es relevante mencionar que se desconoce el procedimiento que genera los textos y el proceso de aprendizaje empieza con un conjunto de datos.
En esta sección utilizaremos el siguiente conjunto de datos
dataset = load_dataset(lang='es', dataset='train')el cual tiene asociado a cada texto el pais de procedencia. De este conjunto se seleccionan solo los textos que provienen de México y Argentina como se muestra en la siguiente instrucción
ar_mx = [x for x in dataset if x['country'] in ['ar', 'mx']]El 20% de los datos se utilizarán para hacer el conjunto de prueba y el 80% serán el conjunto de entrenamiento, esto con el objetivo de medir el rendimiento del clasificador de texto que se desarrollará.
s = int(len(ar_mx) * 0.8)
entrenamiento = ar_mx[:s]
prueba = ar_mx[s:]Como se puede observar, los conjuntos en las variables entrenamiento y prueba son similares al conjunto D – creado en Listado 4.1. La diferencia es que la secuencia de letras está cambiada por un texto y que la clase corresponde al pais donde se originó el texto.
En la Sección 4.3 se extendió la clase ModeloNGram (Listado 3.5) para considerar que las palabras solo eran cuatro caracteres y que el procedimiento de segmentar correspondía al espacio; esto dío lugar a la clase ModeloPalabra que se utilizó para calcular los parámetros \(\mathbb P(\mathcal X \mid \mathcal Y)\). Por otror lado, el conjunto en las variables entrenamiento y prueba ha sido previamente analiza con la clase ModeloNGram. Es relevante recordar que ModeloNGram utiliza para segmentar el método implementado en bilmaLAT (ver Sección 2.7), entonces se puede concluir que esta clase sirve para calcular los parámetros \(\mathbb P(\mathcal X \mid \mathcal Y)\) en el conjunto de prueba. En el siguiente código se utiliza ModeloNGram para estimar los parámetros correpondientes a \(\mathbb P(\mathcal X \mid \mathcal Y=\)mx\()\) y \(\mathbb P(\mathcal X \mid \mathcal Y=\)ar\().\)
mx_l = ModeloNGram(N=1, k_suavizado=0.01).fit(
[x for x in entrenamiento if x['country'] == 'mx']
)
ar_l = ModeloNGram(N=1, k_suavizado=0.01).fit(
[x for x in entrenamiento if x['country'] == 'ar']
)El logaritmo de la probabilidad a priori se puede calcular con la siguientes instrucciones.
uniq_labels, priors = np.unique(
[x['country'] for x in entrenamiento],
return_counts=True
)
priors = np.log(priors)
N = np.log(priors.sum())
ar_prior = priors[0] - N
mx_prior = priors[1] - NVerifique que el primer valor de uniq_labels corresponde a ar
Hasta el momento se tienen todos los elementos para calcular el logaritmo de \(\mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\); en esta ocasión se crea una función para utilizarla en cualquier conjunto \(\mathcal X\) tal y como se muestra en el siguiente código.
def log_prob_conditional(X):
ar_cond = ar_l.log_prob(X) + ar_prior
mx_cond = mx_l.log_prob(X) + mx_prior
return np.c_[ar_cond, mx_cond]En el siguiente código se utiliza la función log_pro_conditional en el conjunto que se encuentra en la variable prueba y que no fue utilizado para estimar los parámetros.
ll = log_prob_conditional(prueba)El logaritmo de \(\mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\) contiene la información necesaria para estimar la clase de cada texto. La clase corresponde al valor máximo, que corresponde a la probabilidad máxima cuando se normaliza con la evidencia. El siguiente código calcula la clase para cada texto que está en la variable prueba y calcula el rendimiento; medido con el promedio de la cobertura (i.e., macro-recall).
hy = uniq_labels[ll.argmax(axis=1)]
y = [x['country'] for x in prueba]
score = macro_recall(y, hy, name='N=1')
pp(score.statistic)0.7866257819679501
4.5 Encapsulando el clasificador
El clasificador implementado en la sección anterior se puede encapsular en la clase ClasificadorMNGram para facilitar su uso. Lo parámetros de esta calse son N que corresponde al tamaño del modelo y k_suavizado que corresponde al parámetro utilizado para estimar la probabilidad de palabras que no se encuentran en el vocabulario (ver Sección 3.3.1).
Después de haber inicializado la clase, es necesario estimar los parámetros, esto se hace llamando al método ClasificadorMNGram.fit, el cual regresa la instancia de la clase. El método ClasificadorMNGram.predict se utiliza para predecir la clase de los textos dados.
class ClasificadorMNGram:
def __init__(self, N=1, k_suavizado=0.01):
self.N = N
self.k_suavizado = k_suavizado
self.modelos = None
def fit(self, X, y):
if self.modelos is not None:
return self
self.uniq_labels, priors = np.unique(
y, return_counts=True
)
self.priors = np.log(priors) - np.log(priors.sum())
modelos = []
for clase in self.uniq_labels:
_ = ModeloNGram(
N=self.N,
k_suavizado=self.k_suavizado
).fit([x for x, _y in zip(X, y)
if _y == clase])
modelos.append(_)
self.modelos = modelos
return self
def predict_log_proba(self, X):
X = np.column_stack(
[m.log_prob(X) for m in self.modelos]
)
return X + self.priors
def predict(self, X):
X = self.predict_log_proba(X)
return self.uniq_labels[X.argmax(axis=1)]- 1
- Tamaño de la ventanda
- 2
- Valor del suavizado
- 3
-
Lista de
ModeloNGram, uno para cada clase - 4
-
Metodo para estimar los parámetros recibe los textos en
Xy las clases asociadas eny - 5
- Verifica si el modelo ya fue entrenado
- 6
- Logaritmo de la probabilida a priori, i.e. \(\log \mathbb P(\mathcal Y)\)
- 7
- Ciclo por cada clase
- 8
-
ModeloNGrampor cada clase, i.e., \(\mathbb P(\mathcal X \mid \mathcal Y)\), donde \(\mathcal Y\) corresponde aclase - 9
-
Selecciona los textos que corresponden a la etiqueta
clase - 10
- Calcula \(\log \mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\)
- 11
- Calcula \(\log \mathbb P(\mathcal X \mid \mathcal Y)\) para cada clase
- 12
- Regresa la suma de los logaritmos
- 13
-
Predice la clase de cada texto en
X - 14
- \(\log \mathbb P(\mathcal X \mid \mathcal Y) \mathbb P(\mathcal Y)\)
- 15
- Selecciona la clase que tiene la mayor probabilidad
En las siguientes lineas de código se ejemplica como se hace una instancia de ClasificadorMNGram con el conjunto de datos usado en la Sección 4.4.
cl = ClasificadorMNGram().fit(
entrenamiento,
[x['country'] for x in entrenamiento]
)En la siguiente linea se predicen las clases del conjunto prueba y se mide el rendimiento; en la salida se puede observar que el rendimiento de ClasificadorMNGram es igual al encontrado por N=1 que fue el realizado en la Sección 4.4.
hy = cl.predict(prueba)
score(hy, name='ClasificadorMNGram').statistic{'N=1': 0.7866257819679501, 'ClasificadorMNGram': 0.7866257819679501}
Ejercicio 4.2 Indique ¿cuál es el rendimiento (macro-recall) (en los datos de la variable prueba) del modelo ClasificadorMNGram (entrenado con los datos de la variable entrenamiento) con el parámetro N=2 y k_suavizado=0.1?
- 0.7883
- 0.7866
- 0.8048
Ejercicio 4.3 Indique si el rendimiento obtenido utilizando macro-recall en el conjunto prueba de ClasificadorMNGram y el de ClasificadorMNGram(N=1, k_suavizado=0.1) es significativamente diferente.
- Verdadero
- Falso