# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# <p><img src="https://mcd.unison.mx/wp-content/themes/awaken/img/logo_mcd.png" width="100" align="center"></p>

# # Clasificación de documentos 
#
# ## Ingeniería de Características 
#
# ### Maestría en Ciencia de Datos
# ### Universidad de Sonora
#
# #### <NAME> (<EMAIL>)
#

# En esta libreta vamos a aplicar las herramientas necesarias para el tratamiento de documentos cuando la secuencia de *tokens* no es importante, al menos en el largo plazo. Estas técnicas tienen la ventaja de ser más rápidas de implementar, y para la clasificación de documentos suele dar buenos resultados. Es por esta razón que un método como este *siempre* debe de aplicarse en un principio como un resultado mínimo aceptable. 
#
# Vamos a revisar en primer lugar los métodos de extracción de características. En segundo lugar, aplicaremos os métodos usuales para la clasificación de documentos. Por último, vamos a discutir sobre el uso de representaciones densas, así como el uso de métodos basados en aprendizaje profundo para la clasificación de documentos.
#
# Vamos a procesar la información sobre el problema de análisis de sentimiento de *TASS 2015*

# +
# %matplotlib inline
import numpy as np
import pandas as pd
import xml.etree.ElementTree as et
import matplotlib.pyplot as plt
plt.rcParams['figure.figsize'] = (20, 12)

# Para poder convertir los tópicos en un problema de multiple clasificación
from ast import literal_eval 


def lee_datos_TASS2015(archivo, etiquetado=True):
    arbol = et.parse(archivo)
    raiz = arbol.getroot()
    data_dic = []
    for tweet in raiz.iter('tweet'):
        contenido = tweet.find('content').text
        if contenido is not None:
            data_dic.append({
                'texto': contenido,
                'id': tweet.find('tweetid').text,
                'usuario': '@' + tweet.find('user').text,
                'fecha': tweet.find('date').text,               
            })
            if etiquetado:
                data_dic[-1]['polaridad'] = tweet.find('sentiments')[0].find('value').text
                data_dic[-1]['tópicos'] = '[' + ', '.join(
                    ['"' + t.text + '"' for t in tweet.find('topics')]) + ']'
    return pd.DataFrame.from_dict(data_dic)

df_train = lee_datos_TASS2015("general-tweets-train-tagged.xml") 
df_test = lee_datos_TASS2015("general-tweets-test.xml", False)
# -

# y ahora vamos a limpiar los datos usando expresiones regulares y steaming

# +
import re
import nltk

usuarios_re = re.compile(r"@[\w\d]+")
hashtags_re = re.compile(r"#[\w\d]+")
email_re = re.compile(r"\b[\w][\w\.-]*@\w[\w\.-]*\.[a-zA-Z]{2,6}\b")
url_re = re.compile(r"\b(\w+:\/{2})?[\d\w-]+(\.[\d\w-]+)+(/\S+)*\b") 
remplaza_por_espacios_re = re.compile('[\n/(){}\[\]\|@,;\.]')
simbolos_a_eliminar_re = re.compile('[^\d\w #+_]')
palabras_paro = nltk.corpus.stopwords.words('spanish')

def prepara_texto(texto):
    text = texto.lower()
    
    # Codificaciones (problemas con UTF-8, latin1, etc...)
    text = re.sub(r'\\\\', r'\\', text)
    text = re.sub(r'\\\\', r'\\', text)
    text = re.sub(r'\\x\w{2,2}', ' ', text)
    text = re.sub(r'\\u\w{4,4}', ' ', text)
    text = re.sub(r'\\n', ' . ', text)

    # Cambia e_mails, urls y usuarios por palabra clave
    text = re.sub(email_re, '_EMAIL_', text)
    text = re.sub(url_re, '_URL_', text)
    text = re.sub(usuarios_re, '_USR_', text)
    text = re.sub(hashtags_re, '_HASHTAG_', text)
    
    # Las palabras con letras repetidas más de 3 veces 
    # (dos veces por las personas que abusan demasiado)
    text = re.sub(r'([a-zA-Z])\1\1+(\w*)', r'\1\1\2', text)
    text = re.sub(r'([a-zA-Z])\1\1+(\w*)', r'\1\1\2', text)
    
    # Elimina simbolos
    text = re.sub(remplaza_por_espacios_re, ' ', text)
    text = re.sub(simbolos_a_eliminar_re, '', text)
    
    # Palabras de paro
    text = [palabra for palabra in text.split(' ') if palabra not in palabras_paro]
    return ' '.join(text)

tokenizer = nltk.tokenize.WhitespaceTokenizer()
stemmer = nltk.stem.snowball.SpanishStemmer()
def stem_texto(texto):
    return ' '.join(stemmer.stem(token) for token in tokenizer.tokenize(texto))

df_train['texto procesado'] = df_train['texto'].apply(prepara_texto)
df_train['texto stem'] = df_train['texto procesado'].apply(stem_texto)
display(df_train.head())

df_test['texto procesado'] = df_test['texto'].apply(prepara_texto)
df_test['texto stem'] = df_test['texto procesado'].apply(stem_texto)
display(df_test.head())


# +
# Aqui se puede escoger entre 'texto', 'texto procesado' y 'texto stem'
columna = 'texto stem'
x_train = df_train[columna].values
x_test = df_test[columna].values

y_polaridad = df_train['polaridad'].values
y_topico = df_train['tópicos'].apply(literal_eval).values

df_train.polaridad.unique()
# -

# ## 3.1. Extracción de características
#
#
# La extracción de características, sin un método de entrenamiento, solamente puede hacerse con una matriz de características dispersas de dimensión $N \times V$, donde $N$ es el número de documentos y $V$ es el tamaño del vocabulario. Esto es debido al desconocimiento de las relaciones de las palabras entre sí. Esta no solo es una matriz gigantésca, si no que aparte la gran mayoría de las entradas de la matriz van a ser cero.
#
# El método de base es el conocido como *bolsa de palabras (BOW por Bag of words)*, en el cual se asigna un valor de 1 a cada palabra que se encuentre *al menos* una vez en un documento. Como el uso de palabras solas implica que no existe ninguna relación entre palabras, la ampliación del vocabulario a bigramas, trigramas, o $n$-gramas de cualquier orden, permite conservar en cierta forma las relaciones secuenciales más simples, pero a costa de un incremento en forma exponencial de nuestro vocabulario. Vamos a extraer características tanto con unigramas únicamente como con bigramas y unigramas. ¿Qué pasa si se mueven algunos parámetros como `min_df` o `token_pattern`?
#

# +
from sklearn.feature_extraction.text import CountVectorizer

bog_unigramas_vectorizer = CountVectorizer(
    analyzer='word',        #  Separa por palabras (puede ser por caracteres)
    binary=True,            #  BOG (si False, entonces cuenta numero de ocurrencias por documento)
    ngram_range=(1,1),      #  n-gramas (min, max) a utilizarse, en este caso solo unigramas
    token_pattern=r'(\S+)', #  Patron de tokenización. Por default solo letras y al menos dos letras
    min_df=5,               #  Número de documentos (o proporción) mínimo donde un termino valido aparece
    max_df=0.9,             #  Número de documentos (o proporción) máximo donde un termino válido aparece
)

bog_bigramas_vectorizer = CountVectorizer(
    binary=True, 
    ngram_range=(1,2), 
    token_pattern=r'(\S+)', 
    min_df=5, 
    max_df=0.9
)

x_train_bow1 = bog_unigramas_vectorizer.fit_transform(x_train)
x_text_bow1 = bog_unigramas_vectorizer.transform(x_test)

x_train_bow2 = bog_bigramas_vectorizer.fit_transform(x_train)
x_test_bow2 = bog_bigramas_vectorizer.transform(x_test)

print("El objeto x_train_bow1 es del tipo{}".format(type(x_train_bow1)))
print("La dimensión de x_train_bow1 es {}".format(x_train_bow1.shape))
print("La dimensión de x_train_bow2 es {}".format(x_train_bow2.shape))
# -

# [TF-IDF](https://en.wikipedia.org/wiki/Tf–idf) (*term frequency–inverse document frequency*) es una medida de la importancia discriminante de una palabra en un documento perteneciente a un corpus. Esta cantidad se forma por dos componentes:
#
# * *TF* (frecuencia del término), describe qué tanto se emplea un término en un documento. La lógica de este componente es que mientras más veces aparece un término en un documento, más representativo es del mismo. Existen diversa maneras de calcular este valor, siendo la más simple el conteo directo del término en el documento. La mayoría de las opciones, sin embargo, utilizan alguna variante basada en la frecuencia relativa del término en el documento. 
#
# $$
# tf(t, d) = \frac{\textrm{Número de veces que aparece el término }t\textrm{ en el documento }d}
# {\textrm{Número de términos en el documento }d} 
# $$
#
# * *IDF* (frecuencia inversa del documento), representa la frecuencia con que es empleado el término en el corpus. Un término que es utilizado con mucha frecuencia en el contexto, es poco  discriminante. La forma básica de calcular esta cantidad es como el inverso de la frecuencia de documentos en que aparece el término (escalado logarítmicamente).
#
# $$
# idf(t, D) = \log\frac{ 1 + \textrm{Número de documentos en el corpus }D}
# {1 + \textrm{Número de documentos en el corpus }D\textrm{ donde aparece el término t}} + 1
# $$
#
# El valor de TF para un término/palabra es específico para cada documento, mientras que IDF es un valor global del término en el corpus. El valor de TF-IDF se obtiene multiplicando los valores de TF e IDF:
#
# $$
# tf\_idf(t, d, D) = tf(t, d)\cdot idf(t, D)
# $$
# y al final cada renglón es normalizado respecto a todos los términos, de forma que cada documento sea representado por un vector de características con norma unitaria (lo que es particularmente interesante para el entrenamiento de muchos modelos de aprendizaje automático).
#
# Este método de extracción de características es una modificación al BOG y por lo tanto, la clase para el vectorizador TF-IDF es básicamente la misma. 

# +
from sklearn.feature_extraction.text import TfidfVectorizer

tfidf_unigramas_vectorizer = TfidfVectorizer(
    ngram_range=(1,1),      #  n-gramas (min, max) a utilizarse, en este caso solo unigramas
    token_pattern=r'[<KEY>', #  Patron de tokenización. Por default solo letras y al menos dos letras
    min_df=5,               #  Número de documentos (o proporción) mínimo donde un termino valido aparece
    max_df=0.9,             #  Número de documentos (o proporción) máximo donde un termino válido aparece
    norm='l2'               #  Norma del vector de cada palabra, lo mñas típico es la euclidiana ('l2')
)

tfidf_bigramas_vectorizer = TfidfVectorizer(
    ngram_range=(1,2), 
    token_pattern=r'[<KEY>',
    min_df=5, 
    max_df=0.9
)

x_train_tfidf1 = tfidf_unigramas_vectorizer.fit_transform(x_train)
x_text_tfidf1 = tfidf_unigramas_vectorizer.transform(x_test)

x_train_tfidf2 = tfidf_bigramas_vectorizer.fit_transform(x_train)
x_test_tfidf2 = tfidf_bigramas_vectorizer.transform(x_test)

print("El objeto x_train_tfidf1 es del tipo{}".format(type(x_train_tfidf1)))
print("La dimensión de x_train_tfidf1 es {}".format(x_train_tfidf1.shape))
print("La dimensión de x_train_tfidf2 es {}".format(x_train_tfidf2.shape))
# -

# A partir del objeto vectorizador generado (en esta caso `tfidf_bigramas_vectorizer`) se puede obtener información adicional. En particular es importante conocer a que token corresponde cada una de las columnas y viceversa.

# +
vocabulario = tfidf_bigramas_vectorizer.get_feature_names()

for i in [1, 3, 100, 1000, 2000]:
    print('La palabra en la posición {} es "{}"'.format(i, vocabulario[i]))
# display(vocabulario)

print('\nLa palabra "amor" se encuentra en la posición {}'.format(tfidf_bigramas_vectorizer.vocabulary_.get('amor')))

numdoc = np.random.randint(0, 1500)
a = x_train_tfidf2[numdoc, :].toarray().ravel()
mejores = [(vocabulario[i], a[i]) for i in a.argsort()[-1:-4:-1]]

print('\nEl documento {} es:\n\t"{}"'.format(numdoc, x_train[numdoc]))
print('y los 3 tokens con mayor valor de tf-idf son:')
for (token, tfidf) in mejores:
    print("\t{}\t\tvalor de ttf-idf: {:06.4f}".format(token, tfidf))
# -

# En general, para la clasificación de textos el método de TF-IDF da mejores resultados que BOW, y su complejidad computacional es muy similar. 

# ## 3.2. Clasificación de documentos
#
# En esta sección vamos a mostrar dos formas diferentes de clasificar documentos, utilizando los métodos disponibles en *sklearn*: Una para una clasificación en la que cada documento del conjunto de aprendizaje se encuentra etiquetado en una sola clase (como es el caso de la polaridad, para el análisis de sentimientos), y el caso en el cual cada documento puede estar asignado a una o más etiquetas (como es el caso de los tópicos).
#
# Al estar fuera del alcance de este curso la discusión sobre los métodos de aprendizaje supervisado, vamos aretener solamente uno de los métodos más clásicos (regresión logística). Les recomiendo explorar otros métodos, en particular las máquinas de vectores de soporte para clasificación y los bosques aleatorios. Estos dos métodos suelen obtener muy buenos resultados en los diferentes *benchmarks* y competencias.
#
# ### 3.2.1 Análisis de sentimientos (polaridad)
#
# Comencemos por el principio, que es separar los datos de entrenamiento de los de validación 

# +
from sklearn.model_selection import train_test_split

x_entrena, x_valida, y_entrena, y_valida = train_test_split(
    x_train_tfidf2, 
    y_polaridad, 
    test_size=0.1, 
    random_state=10
)
print('Tamaño del conjunto de entrenamiento = {}. \
       Tamaño del conjunto de validación = {}.'.format(x_entrena.shape[0], x_valida.shape[0]))
# -

# Ahora vamos a generar un objeto clasificador, con un [clasificador logístico](http://scikit-learn.org/stable/modules/linear_model.html#logistic-regression), y realizamos el entrenamiento. Para más información sobre la regresión logística un buen tutorial se puede encontrar [aquí (capítulo 2, pero el capítulo 1 tambien está muy bueno](http://cs229.stanford.edu/notes/cs229-notes1.pdf)).

# +
from sklearn.linear_model import LogisticRegression

clf = LogisticRegression(
    penalty='l2',          # Norma a seguir en la optimización ('l2' o 'l1')
    C=1.0,                 # Inverso a la constante de regularización, para mejorar la generalización
    random_state=1,        # Solo para poder tener repetibilidad (muy útil para debuguear)
    multi_class='ovr'      # Para más de dos clases ('ovr' para one-vs-all, 'multinomial' para softmax)
)

clf.fit(x_entrena, y_entrena)
# -

# Y ahora vamos a ver los resultados, de acuerdo a los [indicadores clásicos](https://en.wikipedia.org/wiki/Precision_and_recall).

# +
from sklearn.metrics import classification_report, accuracy_score

def reporte(clasificador, x, t, labels=None):
    y = clasificador.predict(x)
    print("\nPorcentaje de acierto: {}".format(accuracy_score(t, y)))
    print("\nPrecisión, recall y f1-score")
    print(classification_report(t, y, target_names=labels))    

print("\n\nPara los datos de entrenamiento\n" + 40*"=")
reporte(clf, x_entrena, y_entrena)
print("\n\nPara los datos de validación\n" + 40*"=")
reporte(clf, x_valida, y_valida)


# -

# Los [resultados reportados](http://ceur-ws.org/Vol-1397/overview.pdf) con el conjunto de prueba (del que no disponemos los datos) se encuentran distribuidos alrededor del 0.5 de porcentaje de acierto. Es interesante notar como nuestros resultados no son tan alejados (en un primer intento) de los presentados en trabajos que hacen uso de tecnicas extremadamente sofisticadas, como son las [redes neuronales profundas recurrentes](http://ceur-ws.org/Vol-1397/lys.pdf). Sin embargo, no estamos conformes con los resultados y debemos de hacer una mejor clasificación, lo que nos lleva a la *tarea 1*.
#
#

# Por otro lado, es importante la interpretabilidad del modelo. Dado que en la regresión logística se agrega la información de cada una de las características de forma lineal, es posible ver a los coeficientes como una medida de peso de la importancia (positiva y negativa) que tienen cada uno de los *tokens* para cada una de las etiquetas. Veamos cuales son las palabras con más peso positivo y con más peso negativo para una etiqueta.

# +
def tokens_principales(clasificador, clase, vocabulario):
    clase_ind = clasificador.classes_.tolist().index(clase)
    pesos = clasificador.coef_[clase_ind,:]
    indices = pesos.argsort()
    mejores = [(vocabulario[i], pesos[i]) for i in indices[-1:-11:-1]]
    peores = [(vocabulario[i], pesos[i]) for i in indices[:10]]
    
    return mejores, peores

clase_sel = 'N+'
mejores, peores = tokens_principales(clf, clase_sel, vocabulario)

print("Las clases disponibles son: {}".format(clf.classes_.tolist()))
print("Los tokens más positivos para la clase {} son:".format(clase_sel))
for (t, w) in mejores:
    print("\t- {:25}(peso:{:6.4})".format(t,w))
print("Los tokens más negativos para la clase son:")
for (t, w) in peores:
    print("\t- {:25}(peso:{:6.4})".format(t,w))
# -

# Por último, veamos que encuentra con el conjunto de prueba, revisando algunos ejemplos

# +
y_test = clf.predict(x_test_tfidf2)

for i in [1, 10, 100, 500]:
    print("Twwet:\t {}".format(df_test.loc[i,'texto']))
    print('Polaridad estimada: {}'.format(y_test[i]))
# -

# ### 3.2.2. Clasificación de tópicos
#
# Ahora vamos a realizar la clasificación de tópicos, la cual es una tarea más dificil que el análisis de sentimientos, al estár más clases involucradas, y al tener varias etquetas por cada documento. Para esto vamos a utilizar los métodos del módulo *sklearn*. Vamos a preparar los datos para realizar la tarea:

# +
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.multiclass import OneVsRestClassifier

# Se procesa las etiquetas en forma de multietiquetas
topico_count = {}
for y in y_topico:
    for topico in y:
        topico_count[str(topico)] = topico_count.setdefault(topico, 0) + 1    
mlb = MultiLabelBinarizer(classes=sorted(topico_count.keys()))
y_topico_mlb = mlb.fit_transform(y_topico)

# Se separan los datos en conjunto de entrenamiento y validación
x_ent_mlb, x_val_mlb, y_ent_mlb, y_val_mlb = train_test_split(
    x_train_tfidf2, 
    y_topico_mlb, 
    test_size=0.1, 
    random_state=10
)

# Se crea un clasificador para multiples etiquetas
clf_mlb = OneVsRestClassifier(
    LogisticRegression(
        C=5.0,
        penalty='l2'
    )
)

# Se entrena el clasificador
clf_mlb.fit(x_ent_mlb, y_ent_mlb)

# Se revisa los indices de desempeño
print("\n\nPara los datos de entrenamiento\n" + 40*"=")
reporte(clf_mlb, x_ent_mlb, y_ent_mlb, mlb.classes)
print("\n\nPara los datos de validación\n" + 40*"=")
reporte(clf_mlb, x_val_mlb, y_val_mlb, mlb.classes)
# -

# Y para terminar la sección, revisemos algunos tweets del conjunto de prueba

# +
y_test_mlb = clf_mlb.predict(x_test_tfidf2)
y_test_topico = mlb.inverse_transform(y_test_mlb)

for i in [1, 10, 100, 500, 1000, 2000]:
    print("Twwet:\t {}".format(df_test.loc[i,'texto']))
    print('Tópicos estimados: {}'.format(y_test_topico[i]))



StarcoderdataJupyterScriptsDedupFiltered