Tabla de contenidos
¿Cómo podemos recrear nuestra capacidad de comprensión?
Detrás del módulo de entendimiento del lenguaje natural o Natural Language Understanding de un chatbot no hay mucho más que un algoritmo de clasificación de texto en el que los datos de entrenamiento son las frases de los usuarios y la etiqueta su correspondiente intención.
El objetivo de la clasificación desarrollada en este artículo es deducir qué permiso retribuido está solicitando un empleado al describir su situación. En cualquier caso, el código es extrapolable a cualquier otro dataset de texto supervisado.
Antes de nada, será necesario que importes las librerías implicadas en el desarrollo:
'''Text classification: TFIDF (ELI5) with Sklearn logistic regression'''from sklearn.model_selection import train_test_split from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report import numpy as np import seaborn as sns import pandas as pd import eli5__author__= "https://medium.com/@guzman.gp"
1. Carga y análisis de datos
Lo primero será volcar el dataset desde un fichero CSV, cuyo lenguaje natural ha sido previamente procesado, con el siguiente formato:
- Nombre de la columna de la etiqueta asociada al conocimiento del bot (intención) → “labels”.
- Nombre de la columna de la observación asociada al mensaje texto del usuario (utterance) → “text”.
file_alias = "<nombre de tu fichero>" path_to_data = f'tu/ruta/relativa/{file_alias}.csv'try: df = pd.read_csv(path_to_data, low_memory=False) except FileNotFoundError: raise FileNotFoundError(f"File {file_alias} at path {path_to_data} doesn't exist.")
Visualizamos rápidamente la distribución de observaciones según su clase, para asegurarnos de no sesgar el modelo posteriormente al haber aprendido en exceso sobre unas situaciones concretas:
df['labels'].value_counts().plot.bar();
En el eje de abscisas podéis ver las etiquetas correspondientes a los diferentes permisos retribuidos y en el eje de ordenadas el número de frases de ejemplo por permiso.
Después, dividimos el conjunto de datos de manera que podamos probar el desempeño del modelo con unos datos diferentes a los utilizados para entrenarlo, de manera que mantenga la máxima objetividad posible. Así sabremos si ha aprendido bien a generalizar el conocimiento que tratamos de trasmitirle, o si en cambio, se ha centrado en peculiaridades irrelevantes que predominaban en las frases de entrenamiento.
La disivisón será: un 90% de observaciones (frases) para entrenar y un 10% para probarlo. Además, especificaremos una “semilla” (número fijo al random_state) de manera que la distribución 9:1 escogida en las muestras se mantenga siempre idéntica. Esto nos permitirá saber si el modelo ha mejorado al ajustar sus parámetros, o si realmente ha sido una cuestión de azar, al haber dado con una combinación de subconjuntos que se complementaban especialmente bien en sus respectivos roles de entrenamiento y prueba.
X_train, X_test, y_train, y_test = train_test_split(df['text'], df['labels'], test_size=0.1, random_state=12)
Pero, ¿y si justo la distribución de frases dada por el random_state escogido es una de esas combinaciones atípicas?
En efecto esto puede ocurrir, por ello recurriremos al siguiente método de validación cruzada: Stratified K Fold. El cual repite el entrenamiento del algoritmo el número de veces (K) especificado en el parámetro n_splits, permitiendo reducir la varianza en el resultado.
Es probable que ya hayas oído hablar de esta técnica como K Fold a secas; el muestreo estratificado simplemente se asegura de que la asignación de las frases en cada uno de nuestros K grupos sea proporcional a su predominancia en el conjunto de datos inicial, de manera que la distribución se mantenga lo más natural posible. Así, no condicionamos el desempeño del algoritmo en ninguno de sus K entrenamientos por una posible distribución desbalanceada.
cv = StratifiedKFold(n_splits=10, shuffle=True) # En la imagen anterior hay 5 subgrupos, ¡yo pondré 10!
Como ya sabemos, los algoritmos solo saben interpretar números. Por tanto, antes de entregarle las frases hay que “vectorizarlas”; cada palabra queda representada por un número y cada frase (documento) por el vector resultante de la composición de dichos números. Esta técnica se conoce como Bag of words.
Los vectores resultantes comparten una dimensión equivalente a la cantidad de palabras distintas que componen el corpus. Por ello, cuanto más reducimos este vocabulario más facilitamos al algoritmo la agrupación y posterior clasificación de los vectores de entrada, ya que estaríamos reduciendo la varianza del modelo entrenado. Como es lógico, este filtrado debe mantener las palabras que contienen el significado esencial del texto y descartar el resto (con el uso de stop words, stemming, lemmatization, etc.).
El valor de los números que representan cada palabra encontrada en el corpus de entrenamiento puede escogerse con diferentes criterios. El más simple es la vectorización binaria (one-hot encoder), en el que simplemente asignamos un “1” a aquellas palabras que aparecen en la observación (frase de entrada) y un “0” a las que no.
También podemos optar por los vectores de frecuencia (count vectorizer). Esta técnica va un paso más hallá asignando un número equivalente a la frecuencia de aparición de dicha palabra en todos los textos de entrenamiento utilizados (corpus). De esta manera, el algoritmo dará más peso a las palabras que más se repitan en el contexto estudiado.
Ahora bien, en determinados dominios de conocimiento hay palabras que se repiten en prácticamente todas las frases y que influyen muy poco en el entendimiento del mensaje. Por ejemplo, en el contexto tratado en este caso, las palabras “día/s” son comunes a casi todas las circunstancias. Sin embargo, la presencia de palabras como “matrimonio”, “mudo” y “hospitalizado” en un mensaje suelen ser determinantes en la predicción:
- ¿En caso de matrimonio cuántos días me corresponden? (intención: matrimonio)
- Me mudo. ¿Tengo derecho a algún día? (intención: traslado de domicilio)
- Han hospitalizado a un pariente, ¿puedo cogerme unos días para cuidarle? (intención: hospitalización)
Para resolver este problema se utiliza la técnica TF-IDF (Term Frecuency — Inverse Document Frecuency), en la que el peso que tiene cada palabra (Wi,j) es directamente proporcional a las veces que aparece en las frases de entrenamiento de la clase actual (tfi,j) e inversamente proporcional a las veces que aparece en las frases de entrenamiento de todas las clases (dfi,j).
tfidf_vectorizer = TfidfVectorizer(smooth_idf=True, norm='l2', sublinear_tf=True)
2. Entrenamiento
Para el entrenamiento multiclase vamos a escoger la regresión logística de Sklearn:
lgr_model = LogisticRegression(C=25, solver='saga', max_iter=2000)
Para concatenar ambos pasos utilizaremos las “pipelines” de Sklearn:
lgr_pipeline = Pipeline([('tfidf', tfidf_vectorizer),('clf', lgr_model)])
A continuación procesamos los dos pasos descritos en el pipeline en el paso anterior:
- Vectorización TF-IDF de las frases (lgr_pipeline[0]).
- Entrenamiento del modelo con los vectores resultantes (lgr_pipeline[1]).
lgr_pipeline.fit(X_train, y_train)
Podemos comprobar qué palabras (features) y qué cantidad tiene nuestro corpus:
len(lgr_pipeline[0].get_feature_names()) # 888 en mi caso
3. Validación
Veamos qué desempeño ha conseguido el algoritmo calculando la media de los “K folds” entrenados y cuánto se han dispersado sus puntuaciones:
scores = cross_val_score(lgr_pipeline, X_train, y_train, cv=cv) sc_mean = scores.mean() sc_dev = scores.std()*2 print(f'''Accuracy per fold: {scores} Mean accuracy: {round(scores.mean(),3)} Std. deviation: +- {round(scores.std()*2,3)}''')
¡Nada mal!
Accuracy per fold: [0.96296296 0.95061728 0.98765432 0.95061728 0.95061728 0.96296296 1. 0.975 0.9875 0.9625 ] Mean accuracy: 0.969 Std. deviation: +- 0.034
Con el siguiente código podemos ver la correspondencia entre los pesos de las palabras y los utilizados por el clasificador son equivalentes.
eli5.show_weights(lgr_pipeline, vec=lgr_pipeline[0], top=20, feature_filter=lambda x: x != '<BIAS>') # He seleccionado las 20 palabras más importante de cada clase
Este esquema nos ayudará muchísimo a ver si hemos abusado de alguna palabra o expresión en las frases de entrenamiento… por ejemplo, quizás la palabra “pareja” tiene una importancia excesiva en la intención “pareja de hecho”. Es normal que la palabra “pareja” predomine en las situaciones de esta clase. Esto se podría solucionar concatenando las palabras “pareja de hecho”, de manera que el algoritmo la considerase como una palabra (feature) a parte: “parejadehecho”. De esta manera este sabría cuándo hablas de tu pareja a secas. Por ejemplo en “quiero acompañar a mi pareja al médico” o “voy a contraer matrimonio con mi pareja” frente a “voy a formalizarme como parejadehecho”.
El caso comentado es solo un ejemplo cualquiera de la infinidad de matices que podemos apreciar a simple vista con esta herramienta de visualización.
Además, puedes ampliar la información para evaluar el algoritmo calculando el resto de métricas y la matriz de confusión:
y_pred = lgr_pipeline.predict(X_test) confs_matrix = pd.crosstab(y_test, y_pred) sns.heatmap(confs_matrix, annot=True, cbar=True) y_test = list(y_test) X_test = list(X_test) mistakes = [X_test[i] for i in range(len(y_pred)) if y_test[i] != y_pred[i]] print(classification_report(y_test, y_pred))
Obteniendo los siguientes resultados:
La escasa presencia de números fuera de la diagonal de la matriz nos informa de que las intenciones no se están confundiendo entre sí prácticamente. Es decir, que las clases están bien escogidas y no hay solapamientos en sus respectivas temáticas. En cuanto a las siguientes métricas, su análisis dependerá de las preferencias que quieras aplicar a tu predicción…
precision recall f1-score support consultas_medicas 1.00 0.95 0.97 19 examenes 1.00 1.00 1.00 6 fallecimiento 1.00 1.00 1.00 12 hospitalización 0.86 0.86 0.86 7 matrimonio 0.95 0.95 0.95 20 pareja_hecho 0.90 0.90 0.90 10 preparacion_parto 1.00 1.00 1.00 5 traslado_domicilio 0.92 1.00 0.96 11 accuracy 0.96 90 macro avg 0.95 0.96 0.95 90 weighted avg 0.96 0.96 0.96 90
En mi caso, me quedo con la accuracy o exactitud media de 96% de aciertos como indicador global del desempeño.
Ahora, vamos a indagar un poco más en los errores, a ver qué palabras están confundiendo al algoritmo en su predicción. Para ello imprimo el error seguido de su etiqueta predicha (erróneamente):
print(f'{len(mistakes)} mistakes from {len(y_test)} validation samples:') for i,f in enumerate(mistakes): print(f"X {f} -> {y_test[i]}")
Resultando en…
4 mistakes from 90 validation samples: X necesito enviar empresa algun documento cambio proximamente civil soltero casado -> pareja_hecho X dias permiso retribuido acompañar hospital familiar grado consanguinidad -> matrimonio X ingresado pareja urgencias -> traslado_domicilio X equivalentes uniones matrimonio -> consultas_medicas
Sí, las frases parecen redactadas por el mismísimo Yoda. ¡Recuerda que están preprocesadas, de ahí su redacción telegráfica! El NLP da para un largo artículo, así que podemos resumir que estas frases están filtradas y les faltan palabras comunes sin significado que suelen servir de nexo sintáctico (conocidas como stop words). Así evitamos generar vectores kilométricos…
Y pensar que todo este tiempo Yoda solo estaba aplicando NLP al hablar…
Ahora vamos a estudiar a fondo qué palabras está interpretando mal el algoritmo para reescribir, añadir o quitar determinadas frases de entrenamiento.
Por ejemplo, escogiendo el primer fallo (mistakes[0]): “necesito enviar empresa algun documento cambio próximamente civil soltero casado”, quedaría:
eli5.show_prediction(lgr_pipeline[1], doc=mistakes[0], vec=lgr_pipeline[0])
Ya te habrás dado cuenta de que cada columna (clase) de la tabla contiene la frase errónea pasada como parámetro descompuesta en un ranking. El cual está encabezado por la palabra con más peso y terminado con la de menos peso. En la cabecera de la tabla encontramos la intención correspondiente, con su afinidad (probabilidad de ocurrencia) y peso total acumulado.
Alguna de las palabras de la frase no aparecen, por ejemplo: “soltero”, “documento”, “próximamente”, etc. Esto se debe a que no estaban incluidas en el corpus que generó el vector bag-of-words y deberíamos pensar en incluir algunos ejemplos con ellas en los textos de entrenamiento.
Vemos que en este caso el origen del error es la palabra “cambio”, la cual ha inclinado totalmente la balanza hacia la intención de “traslado de domicilio”, probablemente por su alta frecuencia de ocurrencia en sus frases de entrenamiento en comparación con el resto de clases. ¡Tocará disminuir su frecuencia en la clase equivocada y compensar su ausencia en el resto de clases! 🙂
Tienes un github donde nos muestres todo el proceso con la data que tienes?