Análisis de texto (text mining) con Python

Análisis de texto (text mining) con Python

Joaquín Amat Rodrigo
Diciembre, 2020

Más sobre ciencia de datos: cienciadedatos.net

Introducción


La analítica de texto (minería de texto o text mining) engloba al conjunto de técnicas que permiten estructurar la información heterogénea presente en los textos con el objetivo de identificar patrones tales como el uso de palabras, con los que extraer nueva información.

Twitter es actualmente una dinámica e ingente fuente de contenidos que, dada su popularidad e impacto, se ha convertido en la principal fuente de información para estudios de Social Media Analytics. Análisis de reputación de empresas, productos o personalidades, estudios de impacto de marketing, extracción de opiniones y predicción de tendencias son sólo algunos ejemplos de aplicaciones. Este tutorial pretende servir de introducción al análisis de texto (text mining) con Python. Para ello, se analizan las publicaciones que han hecho en Twitter diferentes personalidades con el objetivo de:

  • Identificar las palabras empleadas por cada uno de los usuarios.

  • Crear un modelo de machine learning capaz de clasificar la autoría de las publicaciones en base a su texto.

  • Análisis de sentimientos.


Extracción datos Twitter


Como ocurre en muchas redes sociales, la propia plataforma pone a disposición de los usuarios una API que permite extraer información. Aunque en la mayoría de casos se trata de web services API, con frecuencia existen librerías que permiten interactuar con la API desde diversos lenguajes de programación. Un ejemplo de ello es Tweepy, un wrapper de Python que se comunica con la API de Twitter.

Debido a que estas APIs se actualizan con relativa frecuencia, y para evitar que el documento deje de mostrar código funcional, se emplean tweets ya extraídos y que pueden encontrarse en el repositorio de github. Los tweets pertenecen a:

  • Elon Musk (@elonmusk) y Bill Gates (@BillGates), dos directivos de empresas tecnológicas.

  • Mayor Ed Lee (@mayoredlee) alcalde de la ciudad de San Francisco.

Librerías


Las librerías utilizadas en este documento son:

In [42]:
# Tratamiento de datos
# ==============================================================================
import numpy as np
import pandas as pd
import string
import re

# Gráficos
# ==============================================================================
import matplotlib.pyplot as plt
from matplotlib import style
import seaborn as sns
#style.use('ggplot') or plt.style.use('ggplot')

# Preprocesado y modelado
# ==============================================================================
from sklearn import svm
from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import confusion_matrix
from sklearn.feature_extraction.text import TfidfVectorizer
import nltk
#nltk.download('stopwords')
from nltk.corpus import stopwords

# Configuración warnings
# ==============================================================================
import warnings
warnings.filterwarnings('ignore')

Datos


Los datos empleados en este documento son tweets de Elon Musk (@elonmusk), Bill Gates (@BillGates) y Mayor Ed Lee (@mayoredlee). El proceso de extracción se realizó en Noviembre de 2017.

In [43]:
# Lectura de datos
# ==============================================================================
url = 'https://raw.githubusercontent.com/JoaquinAmatRodrigo/Estadistica-con-R/master/datos/'
tweets_elon   = pd.read_csv(url + "datos_tweets_@elonmusk.csv")
tweets_edlee  = pd.read_csv(url + "datos_tweets_@mayoredlee.csv")
tweets_bgates = pd.read_csv(url + "datos_tweets_@BillGates.csv")

print('Número de tweets @BillGates: ' + str(tweets_bgates.shape[0]))
print('Número de tweets @mayoredlee: ' + str(tweets_edlee.shape[0]))
print('Número de tweets @elonmusk: ' + str(tweets_elon.shape[0]))
Número de tweets @BillGates: 2087
Número de tweets @mayoredlee: 2447
Número de tweets @elonmusk: 2678
In [44]:
# Se unen los dos dataframes en uno solo
tweets = pd.concat([tweets_elon, tweets_edlee, tweets_bgates], ignore_index=True)

# Se seleccionan y renombran las columnas de interés
tweets = tweets[['screen_name', 'created_at', 'status_id', 'text']]
tweets.columns = ['autor', 'fecha', 'id', 'texto']

# Parseo de fechas
tweets['fecha'] = pd.to_datetime(tweets['fecha'])
tweets.head(3)
Out[44]:
autor fecha id texto
0 elonmusk 2017-11-09 17:28:57+00:00 9.286758e+17 "If one day, my words are against science, cho...
1 elonmusk 2017-11-09 17:12:46+00:00 9.286717e+17 I placed the flowers\n\nThree broken ribs\nA p...
2 elonmusk 2017-11-08 18:55:13+00:00 9.283351e+17 Atatürk Anıtkabir https://t.co/al3wt0njr6

Distribución temporal de los tweets


Dado que cada usuario puede haber iniciado su actividad en Twitter en diferente momento, es interesante explorar si los tweets recuperados solapan en el tiempo.

In [45]:
# Distribución temporal de los tweets
# ==============================================================================
fig, ax = plt.subplots(figsize=(9,4))

for autor in tweets.autor.unique():
    df_temp = tweets[tweets['autor'] == autor].copy()
    df_temp['fecha'] = pd.to_datetime(df_temp['fecha'].dt.strftime('%Y-%m'))
    df_temp = df_temp.groupby(df_temp['fecha']).size()
    df_temp.plot(label=autor, ax=ax)

ax.set_title('Número de tweets publicados por mes')
ax.legend();

Puede observarse un perfil de actividad distinto para cada usuario. Bill Gates ha mantenido una actividad constante de en torno a 30 tweets por mes durante todo el periodo estudiado. Elon Musk muestra una actividad inicial por debajo de la de Bill Gates pero, a partir de febrero de 2016, incrementó notablemente el número de tweets publicados. Ed Lee tiene una actividad muy alta sobre todo en el periodo 2017. Debido a las limitaciones que impone Twitter en las recuperaciones, cuanto más activo es un usuario, menor es el intervalo de tiempo para el que se recuperan tweets. En el caso de Ed Lee, dado que publica con mucha más frecuencia que el resto, con la misma cantidad de tweets recuperados se abarca menos de la mitad del rango temporal que con los otros.

Limpieza y Tokenización


El proceso de limpieza de texto, dentro del ámbito de text mining, consiste en eliminar del texto todo aquello que no aporte información sobre su temática, estructura o contenido. No existe una única forma de hacerlo, depende en gran medida de la finalidad del análisis y de la fuente de la que proceda el texto. Por ejemplo, en las redes sociales, los usuarios pueden escribir de la forma que quieran, lo que suele resultar en un uso elevado de abreviaturas y signos de puntuación. En este ejercicio, se procede a eliminar: patrones no informativos (urls de páginas web), signos de puntuación, etiquetas HTML, caracteres sueltos y números.

Tokenizar un texto consiste en dividir el texto en las unidades que lo conforman, entendiendo por unidad el elemento más sencillo con significado propio para el análisis en cuestión, en este caso, las palabras.

Existen múltiples librerías que automatizan en gran medida la limpieza y tokenización de texto, por ejemplo, la clase feature_extraction.text.CountVectorizer de Scikit Learn, nltk.tokenize o spaCy. A pesar de ello, para este ejemplo, se define una función que, si bien está menos optimizada, tiene la ventaja de poder adaptarse fácilmente dependiendo del tipo de texto analizado.

In [46]:
def limpiar_tokenizar(texto):
    '''
    Esta función limpia y tokeniza el texto en palabras individuales.
    El orden en el que se va limpiando el texto no es arbitrario.
    El listado de signos de puntuación se ha obtenido de: print(string.punctuation)
    y re.escape(string.punctuation)
    '''
    
    # Se convierte todo el texto a minúsculas
    nuevo_texto = texto.lower()
    # Eliminación de páginas web (palabras que empiezan por "http")
    nuevo_texto = re.sub('http\S+', ' ', nuevo_texto)
    # Eliminación de signos de puntuación
    regex = '[\\!\\"\\#\\$\\%\\&\\\'\\(\\)\\*\\+\\,\\-\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^_\\`\\{\\|\\}\\~]'
    nuevo_texto = re.sub(regex , ' ', nuevo_texto)
    # Eliminación de números
    nuevo_texto = re.sub("\d+", ' ', nuevo_texto)
    # Eliminación de espacios en blanco múltiples
    nuevo_texto = re.sub("\\s+", ' ', nuevo_texto)
    # Tokenización por palabras individuales
    nuevo_texto = nuevo_texto.split(sep = ' ')
    # Eliminación de tokens con una longitud < 2
    nuevo_texto = [token for token in nuevo_texto if len(token) > 1]
    
    return(nuevo_texto)

test = "Esto es 1 ejemplo de l'limpieza de6 TEXTO  https://t.co/rnHPgyhx4Z @cienciadedatos #textmining"
print(test)
print(limpiar_tokenizar(texto=test))
Esto es 1 ejemplo de l'limpieza de6 TEXTO  https://t.co/rnHPgyhx4Z @cienciadedatos #textmining
['esto', 'es', 'ejemplo', 'de', 'limpieza', 'de', 'texto', 'cienciadedatos', 'textmining']

La función limpiar_tokenizar() elimina el símbolo @ y # de las palabras a las que acompañan. En Twitter, los usuarios se identifican de esta forma, por lo que @ y # pertenecen al nombre. Aunque es importante tener en cuenta las eliminaciones del proceso de limpieza, el impacto en este caso no es demasiado alto, ya que, si un documento se caracteriza por tener la palabra #datascience, también será detectado fácilmente mediante la palabra datascience.

In [47]:
# Se aplica la función de limpieza y tokenización a cada tweet
# ==============================================================================
tweets['texto_tokenizado'] = tweets['texto'].apply(lambda x: limpiar_tokenizar(x))
tweets[['texto', 'texto_tokenizado']].head()
Out[47]:
texto texto_tokenizado
0 "If one day, my words are against science, cho... [if, one, day, my, words, are, against, scienc...
1 I placed the flowers\n\nThree broken ribs\nA p... [placed, the, flowers, three, broken, ribs, pi...
2 Atatürk Anıtkabir https://t.co/al3wt0njr6 [atatürk, anıtkabir]
3 @Bob_Richards One rocket, slightly toasted [bob, richards, one, rocket, slightly, toasted]
4 @uncover007 500 ft so far. Should be 2 miles l... [uncover, ft, so, far, should, be, miles, long...

Gracias a la característica de los data.frame de pandas de poder contener cualquier tipo de elemento en sus columnas (siempre que sea el mismo para toda la columna), se puede almacenar el texto tokenizado. Cada elemento de la columna texto_tokenizado es una lista que contiene los tokens generados.

De nuevo matizar que, para casos productivos, es preferible emplear los tokenizadores de Scikit Learn, nltk o spaCy.

Análisis exploratorio


A la hora de entender que caracteriza la escritura de cada autor, es interesante estudiar qué palabras emplea, con qué frecuencia, así como el significado de las mismas.

En Python, una de las estructuras que más facilita el análisis exploratorio es el DataFrame de Pandas, que es la estructura en la que se encuentra almacenada ahora la información de los tweets. Sin embargo, al realizar la tokenización, ha habido un cambio importante. Antes de dividir el texto, los elementos de estudio eran los tweets, y cada uno se encontraba en una fila, cumplimento así la condición de tidy data: una observación, una fila. Al realizar la tokenización, el elemento de estudio ha pasado a ser cada token (palabra), incumpliendo así la condición de tidy data. Para volver de nuevo a la estructura ideal se tiene que expandir cada lista de tokens, duplicando el valor de las otras columnas tantas veces como sea necesario. A este proceso se le conoce como expansión o unnest.

Aunque puede parecer un proceso poco eficiente (el número de filas aumenta mucho), este simple cambio facilita actividades de tipo: agrupación, contaje, gráficos...

In [48]:
# Unnest de la columna texto_tokenizado
# ==============================================================================
tweets_tidy = tweets.explode(column='texto_tokenizado')
tweets_tidy = tweets_tidy.drop(columns='texto')
tweets_tidy = tweets_tidy.rename(columns={'texto_tokenizado':'token'})
tweets_tidy.head(3)
Out[48]:
autor fecha id token
0 elonmusk 2017-11-09 17:28:57+00:00 9.286758e+17 if
0 elonmusk 2017-11-09 17:28:57+00:00 9.286758e+17 one
0 elonmusk 2017-11-09 17:28:57+00:00 9.286758e+17 day

Frecuencia de palabras

In [49]:
# Palabras totales utilizadas por cada autor
# ==============================================================================
print('--------------------------')
print('Palabras totales por autor')
print('--------------------------')
tweets_tidy.groupby(by='autor')['token'].count()
--------------------------
Palabras totales por autor
--------------------------
Out[49]:
autor
BillGates     31500
elonmusk      33609
mayoredlee    41878
Name: token, dtype: int64
In [50]:
# Palabras distintas utilizadas por cada autor
# ==============================================================================
print('----------------------------')
print('Palabras distintas por autor')
print('----------------------------')
tweets_tidy.groupby(by='autor')['token'].nunique()
----------------------------
Palabras distintas por autor
----------------------------
Out[50]:
autor
BillGates     4848
elonmusk      6628
mayoredlee    5770
Name: token, dtype: int64

Aunque Elon Musk no es el que más palabras totales ha utilizado, bien porque ha publicado menos tweets o porque estos son más cortos, es el que más palabras distintas emplea.

Longitud media de tweets

In [51]:
# Longitud media y desviación de los tweets de cada autor
# ==============================================================================
temp_df = pd.DataFrame(tweets_tidy.groupby(by = ["autor", "id"])["token"].count())
temp_df.reset_index().groupby("autor")["token"].agg(['mean', 'std'])
Out[51]:
mean std
autor
BillGates 15.144231 3.347354
elonmusk 12.611257 6.933870
mayoredlee 17.170152 3.486314

El tipo de tweet de Bill Gates y Mayor Ed Lee es similar en cuanto a longitud media y desviación. Elon Musk alterna más entre tweets cortos y largos, siendo su media inferior a la de los otros dos.

Palabras más utilizadas por autor

In [52]:
# Top 5 palabras más utilizadas por cada autor
# ==============================================================================
tweets_tidy.groupby(['autor','token'])['token'] \
 .count() \
 .reset_index(name='count') \
 .groupby('autor') \
 .apply(lambda x: x.sort_values('count', ascending=False).head(5))
Out[52]:
autor token count
autor
BillGates 4195 BillGates the 1178
4271 BillGates to 1115
2930 BillGates of 669
2084 BillGates in 590
2207 BillGates is 452
elonmusk 10699 elonmusk the 983
10816 elonmusk to 913
8859 elonmusk of 638
7801 elonmusk is 542
7656 elonmusk in 476
mayoredlee 16650 mayoredlee to 1684
16568 mayoredlee the 1338
11669 mayoredlee amp 1212
14957 mayoredlee our 1096
15964 mayoredlee sf 909

Stop words


En la tabla anterior puede observarse que los términos más frecuentes en todos los usuarios se corresponden con artículos, preposiciones, pronombres…, en general, palabras que no aportan información relevante sobre el texto. Ha estas palabras se les conoce como stopwords. Para cada idioma existen distintos listados de stopwords, además, dependiendo del contexto, puede ser necesario adaptar el listado. Por ejemplo, en la tabla anterior aparece el término amp que procede de la etiqueta html &amp. Con frecuencia, a medida que se realiza un análisis se encuentran palabras que deben incluirse en el listado de stopwords.

In [53]:
# Obtención de listado de stopwords del inglés
# ==============================================================================
stop_words = list(stopwords.words('english'))
# Se añade la stoprword: amp, ax, ex
stop_words.extend(("amp", "xa", "xe"))
print(stop_words[:10])
['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', "you're"]
In [54]:
# Filtrado para excluir stopwords
# ==============================================================================
tweets_tidy = tweets_tidy[~(tweets_tidy["token"].isin(stop_words))]
In [55]:
# Top 10 palabras por autor (sin stopwords)
# ==============================================================================
fig, axs = plt.subplots(nrows=3, ncols=1,figsize=(6, 7))
for i, autor in enumerate(tweets_tidy.autor.unique()):
    df_temp = tweets_tidy[tweets_tidy.autor == autor]
    counts  = df_temp['token'].value_counts(ascending=False).head(10)
    counts.plot(kind='barh', color='firebrick', ax=axs[i])
    axs[i].invert_yaxis()
    axs[i].set_title(autor)

fig.tight_layout()

Los resultados obtenidos tienen sentido si ponemos en contexto la actividad profesional de los usuarios analizados. Mayor Ed Lee es alcalde de San Francisco (sf), por lo que sus tweets están relacionados con la ciudad, residentes, familias, casas... Elon Musk dirige varias empresas tecnológicas entre las que destacan Tesla y SpaceX, dedicadas a los coches y a la aeronáutica. Por último, Bill Gates, además de propietario de microsoft, dedica parte de su capital a fundaciones de ayuda, de ahí las palabras mundo, polio, ayuda...

Correlación entre autores


Una forma de cuantificar la similitud entre los perfiles de dos usuarios de Twitter es calculando la correlación en el uso de palabras. La idea es que, si dos usuarios escriben de forma similar, tenderán a utilizar las mismas palabras y con frecuencias similares. La medida de similitud más utilizada al trabajar con texto es 1 - distancia coseno.

Para poder generar los estudios de correlación se necesita disponer de cada variable en una columna. En este caso, las variables a correlacionar son los autores.

In [56]:
# Pivotado de datos
# ==============================================================================
tweets_pivot = tweets_tidy.groupby(["autor","token"])["token"] \
                .agg(["count"]).reset_index() \
                .pivot(index = "token" , columns="autor", values= "count")
tweets_pivot.columns.name = None
In [57]:
# Test de correlación (coseno) por el uso y frecuencia de palabras
# ==============================================================================
from scipy.spatial.distance import cosine

def similitud_coseno(a,b):
    distancia = cosine(a,b)
    return 1-distancia

tweets_pivot.corr(method=similitud_coseno)
Out[57]:
BillGates elonmusk mayoredlee
BillGates 1.000000 0.567274 0.496346
elonmusk 0.567274 1.000000 0.276732
mayoredlee 0.496346 0.276732 1.000000
In [58]:
# Gráfico de correlación
# ==============================================================================
f, ax = plt.subplots(figsize=(6, 4))
temp = tweets_pivot.dropna()
sns.regplot(
    x  = np.log(temp.elonmusk),
    y  = np.log(temp.BillGates),
    scatter_kws =  {'alpha': 0.05},
    ax = ax
);
for i in np.random.choice(range(temp.shape[0]), 100):
    ax.annotate(
        text  = temp.index[i],
        xy    = (np.log(temp.elonmusk[i]), np.log(temp.BillGates[i])),
        alpha = 0.7
    )
In [59]:
# Número de palabras comunes
# ==============================================================================
palabras_elon = set(tweets_tidy[tweets_tidy.autor == 'elonmusk']['token'])
palabras_bill = set(tweets_tidy[tweets_tidy.autor == 'BillGates']['token'])
palabras_edlee = set(tweets_tidy[tweets_tidy.autor == 'mayoredlee']['token'])

print(f"Palabras comunes entre Elon Musk y Ed Lee: {len(palabras_elon.intersection(palabras_edlee))}")
print(f"Palabras comunes entre Elon Elon Musk y Bill Gates: {len(palabras_elon.intersection(palabras_bill))}")
Palabras comunes entre Elon Musk y Ed Lee: 1760
Palabras comunes entre Elon Elon Musk y Bill Gates: 1758

Aunque el número de palabras comunes entre Elon Musk y Bill Gates y entre Elon Musk y Ed Lee es similar, la correlación basada en su uso es mayor entre Elon Musk y Bill Gates. Esto tiene sentido si se contempla el hecho de que ambos trabajan como directivos de empresas tecnológicas.

Comparación en el uso de palabras


A continuación, se estudia qué palabras se utilizan de forma más diferenciada por cada usuario, es decir, palabras que utiliza mucho un autor y que no utiliza el otro. Una forma de hacer este análisis es mediante el log of odds ratio de las frecuencias. Esta comparación se hace por pares, en este caso se comparan Elon Musk y Mayor Ed Lee.

$$\text{log of odds ratio} = \log{\left(\frac{\left[\frac{n_k+1}{\text{N}+1}\right]_\text{Elon}}{\left[\frac{n_k+1}{\text{N}+1}\right]_\text{Edd}}\right)}$$

siendo $n_k$ el número de veces que aparece el término k en los textos de cada autor y $N$ el número total de términos de cada autor.

Para realizar este cálculo es necesario que, para todos los usuarios, se cuantifique la frecuencia de cada una de las palabras que aparecen en el conjunto de tweets, es decir, si un autor no ha utilizado una de las palabras que sí ha utilizado otro, debe aparecer esa palabra en su registro con frecuencia igual a cero. Existen varias formas de conseguir esto, una de ellas es pivotar y despivotar el dataframe sustituyendo los NaN por cero.

In [60]:
# Cálculo del log of odds ratio de cada palabra (elonmusk vs mayoredlee)
# ==============================================================================
# Pivotaje y despivotaje
tweets_pivot = tweets_tidy.groupby(["autor","token"])["token"] \
                .agg(["count"]).reset_index() \
                .pivot(index = "token" , columns="autor", values= "count")

tweets_pivot = tweets_pivot.fillna(value=0)
tweets_pivot.columns.name = None

tweets_unpivot = tweets_pivot.melt(value_name='n', var_name='autor', ignore_index=False)
tweets_unpivot = tweets_unpivot.reset_index()

# Selección de los autores elonmusk y mayoredlee
tweets_unpivot = tweets_unpivot[tweets_unpivot.autor.isin(['elonmusk', 'mayoredlee'])]

# Se añade el total de palabras de cada autor
tweets_unpivot = tweets_unpivot.merge(
                    tweets_tidy.groupby('autor')['token'].count().rename('N'),
                    how = 'left',
                    on  = 'autor'
                 )

# Cálculo de odds y log of odds de cada palabra
tweets_logOdds = tweets_unpivot.copy()
tweets_logOdds['odds'] = (tweets_logOdds.n + 1) / (tweets_logOdds.N + 1)
tweets_logOdds = tweets_logOdds[['token', 'autor', 'odds']] \
                    .pivot(index='token', columns='autor', values='odds')
tweets_logOdds.columns.name = None

tweets_logOdds['log_odds']     = np.log(tweets_logOdds.elonmusk/tweets_logOdds.mayoredlee)
tweets_logOdds['abs_log_odds'] = np.abs(tweets_logOdds.log_odds)

# Si el logaritmo de odds es mayor que cero, significa que es una palabra con
# mayor probabilidad de ser de Elon Musk. Esto es así porque el ratio sea ha
# calculado como elonmusk/mayoredlee.
tweets_logOdds['autor_frecuente'] = np.where(tweets_logOdds.log_odds > 0,
                                              "elonmusk",
                                              "mayoredlee"
                                    )
In [61]:
print('-----------------------------------')
print('Top 10 palabras más diferenciadoras')
print('-----------------------------------')
tweets_logOdds.sort_values('abs_log_odds', ascending=False).head(10)
-----------------------------------
Top 10 palabras más diferenciadoras
-----------------------------------
Out[61]:
elonmusk mayoredlee log_odds abs_log_odds autor_frecuente
token
tesla 0.012569 0.000037 5.815040 5.815040 elonmusk
residents 0.000046 0.009934 -5.374162 5.374162 mayoredlee
yes 0.005525 0.000037 4.993060 4.993060 elonmusk
rocket 0.005064 0.000037 4.906049 4.906049 elonmusk
community 0.000046 0.005960 -4.863336 4.863336 mayoredlee
spacex 0.004742 0.000037 4.840297 4.840297 elonmusk
sf 0.000276 0.034112 -4.816117 4.816117 mayoredlee
falcon 0.003775 0.000037 4.612288 4.612288 elonmusk
landing 0.003039 0.000037 4.395223 4.395223 elonmusk
housing 0.000092 0.006860 -4.310771 4.310771 mayoredlee
In [62]:
# Top 15 palabras más características de cada autor
# ==============================================================================

top_30 = tweets_logOdds[['log_odds', 'abs_log_odds', 'autor_frecuente']] \
        .groupby('autor_frecuente') \
        .apply(lambda x: x.nlargest(15, columns='abs_log_odds').reset_index()) \
        .reset_index(drop=True) \
        .sort_values('log_odds')

f, ax = plt.subplots(figsize=(4, 7))
sns.barplot(
    x    = 'log_odds',
    y    = 'token',
    hue  = 'autor_frecuente',
    data = top_30,
    ax   = ax
)
ax.set_title('Top 15 palabras más características de cada autor')
ax.set_xlabel('log odds ratio (@elonmusk / mayoredlee)');

Estas palabras posiblemente tendrán mucho peso a la hora de clasificar los tweets.

Term Frequency e Inverse Document Frequency


Uno de los principales intereses en text mining, natural language processing e information retrieval es cuantificar la temática de un texto, así como la importancia de cada término que lo forma. Una manera sencilla de medir la importancia de un término dentro de un documento es utilizando la frecuencia con la que aparece (tf, term-frequency). Esta aproximación, aunque simple, tiene la limitación de atribuir mucha importancia a aquellas palabras que aparecen muchas veces aunque no aporten información selectiva. Por ejemplo, si la palabra matemáticas aparece 5 veces en un documento y la palabra página aparece 50, la segunda tendrá 10 veces más peso a pesar de que no aporte tanta información sobre la temática del documento. Para solucionar este problema se pueden ponderar los valores tf multiplicándolos por la inversa de la frecuencia con la que el término en cuestión aparece en el resto de documentos(idf). De esta forma, se consigue reducir el valor de aquellos términos que aparecen en muchos documentos y que, por lo tanto, no aportan información selectiva.

El estadístico tf-idf mide cómo de informativo es un término en un documento teniendo en cuenta la frecuencia con la que ese término aparece en otros documentos.


Term Frequency (tf)

$$\text{tf (t, d)} = \frac{n_{\text{t}}}{\text{longitud d}}$$

donde $n_{\text{t}}$ es el número de veces que aparece el término $t$ en el documento $d$.

Inverse Document Frequency

$$\text{idf (t)} = \log{\left(\frac{n_{\text{d}}}{n_{\text{(d,t)}}}\right)}$$

donde $n_{\text{d}}$ es el número total de documentos y $n_{\text{(d,t)}}$ el número de documentos que contienen el término $t$.

Estadístico tf-idf

$$\text{tf-idf(t, d)} = \text{tf (t, d)} * \text{idf (t)}$$



En la práctica, para evitar problemas con el logaritmo cuando aparecen valores de 0, se emplea una versión corregida del $\text{idf (t)}$. Esta es la versión implementada en Scikit Learn.

$$\text{idf (t)}= \log\frac{1+n_d}{1+n_{\text{(d,t)}}} + 1$$


En los siguientes apartados se muestra cómo calcular el valor tf-idf Sin embargo, en la práctica, es preferible utilizar implementaciones como TfidfVectorizer de Scikit Learn.

In [63]:
# Cálculo term-frecuency (tf)
# ==============================================================================
tf = tweets_tidy.copy()
# Número de veces que aparece cada término en cada tweet
tf = tf.groupby(["id", "token"])["token"].agg(["count"]).reset_index()
# Se añade una columna con el total de términos por tweet
tf['total_count'] = tf.groupby('id')['count'].transform(sum)
# Se calcula el tf
tf['tf'] = tf["count"] / tf["total_count"]
tf.sort_values(by = "tf").head(3)
Out[63]:
id token count total_count tf
8158 3.546906e+17 bar 1 20 0.05
8159 3.546906e+17 barman 1 20 0.05
8164 3.546906e+17 says 1 20 0.05
In [64]:
# Inverse document frequency
# ==============================================================================
idf = tweets_tidy.copy()
total_documents = idf["id"].drop_duplicates().count()
# Número de documentos (tweets) en los que aparece cada término
idf = idf.groupby(["token", "id"])["token"].agg(["count"]).reset_index()
idf['n_documentos'] = idf.groupby('token')['count'].transform(sum)
# Cálculo del idf
idf['idf'] = np.log(total_documents / idf['n_documentos'])
idf = idf[["token","n_documentos", "idf"]].drop_duplicates()
idf.sort_values(by="idf").head(3)
Out[64]:
token n_documentos idf
50471 sf 914 2.061781
8724 city 355 3.007494
22823 great 327 3.089651
In [65]:
# Term Frequency - Inverse Document Frequency
# ==============================================================================
tf_idf = pd.merge(left=tf, right=idf, on="token")
tf_idf["tf_idf"] = tf_idf["tf"] * tf_idf["idf"]
tf_idf.sort_values(by="id").head()
Out[65]:
id token count total_count tf n_documentos idf tf_idf
0 1.195196e+17 efforts 1 13 0.076923 31 5.445624 0.418894
46 1.195196e+17 job 1 13 0.076923 36 5.296093 0.407392
229 1.195196e+17 nigeria 1 13 0.076923 10 6.577027 0.505925
239 1.195196e+17 phenomenal 1 13 0.076923 14 6.240554 0.480043
253 1.195196e+17 polio 1 13 0.076923 102 4.254639 0.327280

Puede observarse que para el primer tweet (id = 1.195196e+17), todos los términos que aparecen una vez, tienen el mismo valor de tf, sin embargo, dado que no todos los términos aparecen con la misma frecuencia en el conjunto de todos los tweets, la corrección de idf es distinta para cada uno.

De nuevo remarcar que, si bien se ha realizado el cálculo de forma manual con fines ilustrativos, en la práctica, es preferible utilizar implementaciones optimizadas como es el caso de la clase TfidfVectorizer de Scikit Learn.

Clasificación de tweets


Para poder aplicar algoritmos de clasificación a un texto, es necesario crear una representación numérica del mismo. Una de las formas más utilizadas se conoce como Bag of Words. Este método consiste en identificar el set formado por todas las palabras (tokens) que aparecen en el corpus, en este caso el conjunto de todos los tweets recuperados. Con este set se crea un espacio n-dimensional en el que cada dimensión (columna) es una palabra. Por último, se proyecta cada texto en ese espacio, asignando un valor para cada dimensión. En la mayoría de casos, el valor utilizado es el tf-idf.

En el siguiente apartado se construye un modelo de aprendizaje estadístico basado en máquinas de vector soporte (SVM) con el objetivo de predecir la autoría de los tweets. En concreto, se comparan los tweets de Elon Musk y Mayor Ed Lee.

Como modelo se emplea un SVM de Scikit-Learn. Para facilitar la obtención de la matriz TF-IDF se recurre a la clase TfidVectorized también de Scikit-Learn pero, en lugar de utilizar el tokenizador por defecto, se emplea el mismo definido en los apartados anteriores.

Train-Test


En todo proceso de aprendizaje estadístico es recomendable repartir las observaciones en un set de entrenamiento y otro de test. Esto permite evaluar la capacidad del modelo. Para este ejercicio se selecciona como test un 20% aleatorio de los tweets.

In [66]:
# Reparto train y test
# ==============================================================================
datos_X = tweets.loc[tweets.autor.isin(['elonmusk', 'mayoredlee']), 'texto']
datos_y = tweets.loc[tweets.autor.isin(['elonmusk', 'mayoredlee']), 'autor']

X_train, X_test, y_train, y_test = train_test_split(
    datos_X,
    datos_y,
    test_size = 0.2,
    random_state = 123
    
)

Es importante verificar que la proporción de cada grupo es similar en el set de entrenamiento y en el de test.

In [67]:
value, counts = np.unique(y_train, return_counts=True)
print(dict(zip(value, 100 * counts / sum(counts))))
value, counts = np.unique(y_test, return_counts=True)
print(dict(zip(value, 100 * counts / sum(counts))))
{'elonmusk': 52.68292682926829, 'mayoredlee': 47.31707317073171}
{'elonmusk': 50.53658536585366, 'mayoredlee': 49.46341463414634}

Vectorización tf-idf


Empleando los tweets de entrenamiento se crea un matriz tf-idf en la que cada columna es un término, cada fila un documento y el valor de intersección el tf-idf correspondiente. Esta matriz representa el espacio n-dimensional en el que se proyecta cada tweet.

La clase TfidfVectorizer de Scikit Learn automatizan la creación de una matriz df-idf a partir de un corpus de documentos. Entre sus argumentos destaca:

  • encoding: el tipo de codificación del texto, por defecto es 'utf-8'.

  • strip_accents: eliminación de acentos sustituyendolos por la misma letra sin el acento. Por defecto se emplea el método ‘ascii’.

  • lowercase: convertir a minúsculas todo el texto.

  • tokenizer: en caso de querer pasar un tokenizador definido por el usuario o de otra librería.

  • analyzer: tipo de división que realiza el tokenizador. Por defecto separa por palabras ('word').

  • stop_words: lista de stopwords que se eliminan durante el tokenizado. Por defecto utiliza un listado para el inglés.

  • ngram_range: rango de n-gramas incluidos. Por ejemplo, (1, 2) significa que se incluyen unigramas (palabras individuales) y bigramas (pares de palabras) como tokens.

  • min_df: fracción o número de documentos en los que debe de aparecer como mínimo un término para no ser excluido en el tokenizado. Este filtrado es una forma de eliminar ruido del modelo.

In [68]:
def limpiar_tokenizar(texto):
    '''
    Esta función limpia y tokeniza el texto en palabras individuales.
    El orden en el que se va limpiando el texto no es arbitrario.
    El listado de signos de puntuación se ha obtenido de: print(string.punctuation)
    y re.escape(string.punctuation)
    '''
    
    # Se convierte todo el texto a minúsculas
    nuevo_texto = texto.lower()
    # Eliminación de páginas web (palabras que empiezan por "http")
    nuevo_texto = re.sub('http\S+', ' ', nuevo_texto)
    # Eliminación de signos de puntuación
    regex = '[\\!\\"\\#\\$\\%\\&\\\'\\(\\)\\*\\+\\,\\-\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^_\\`\\{\\|\\}\\~]'
    nuevo_texto = re.sub(regex , ' ', nuevo_texto)
    # Eliminación de números
    nuevo_texto = re.sub("\d+", ' ', nuevo_texto)
    # Eliminación de espacios en blanco múltiples
    nuevo_texto = re.sub("\\s+", ' ', nuevo_texto)
    # Tokenización por palabras individuales
    nuevo_texto = nuevo_texto.split(sep = ' ')
    # Eliminación de tokens con una longitud < 2
    nuevo_texto = [token for token in nuevo_texto if len(token) > 1]
    
    return(nuevo_texto)
In [69]:
stop_words = list(stopwords.words('english'))
# Se añade la stopword: amp, ax, ex
stop_words.extend(("amp", "xa", "xe"))
In [70]:
# Creación de la matriz tf-idf
# ==============================================================================
tfidf_vectorizador = TfidfVectorizer(
                        tokenizer  = limpiar_tokenizar,
                        min_df     = 3,
                        stop_words = stop_words
                    )
tfidf_vectorizador.fit(X_train)
Out[70]:
TfidfVectorizer(min_df=3,
                stop_words=['i', 'me', 'my', 'myself', 'we', 'our', 'ours',
                            'ourselves', 'you', "you're", "you've", "you'll",
                            "you'd", 'your', 'yours', 'yourself', 'yourselves',
                            'he', 'him', 'his', 'himself', 'she', "she's",
                            'her', 'hers', 'herself', 'it', "it's", 'its',
                            'itself', ...],
                tokenizer=<function limpiar_tokenizar at 0x7fef27f17e60>)

A la hora de transformar los documentos de test, hay que proyectarlos en la misma matriz obtenida previamente con los documentos de entrenamiento. Esto es importante ya que, si en los documentos de test hay algún término que no aparece en los de entrenamiento o viceversa, las dimensiones de cada matriz no coinciden.

In [71]:
tfidf_train = tfidf_vectorizador.transform(X_train)
tfidf_test  = tfidf_vectorizador.transform(X_test)

Una vez que el objeto TfidfVectorizer ha sido entrenado, se puede acceder a los tokens creados con el método get_feature_names().

In [72]:
print(f" Número de tokens creados: {len(tfidf_vectorizador.get_feature_names())}")
print(tfidf_vectorizador.get_feature_names()[:10])
 Número de tokens creados: 2738
['aa', 'aapi', 'aaronpaul', 'able', 'abort', 'abt', 'ac', 'aca', 'accel', 'acceleration']

Modelo SVM lineal


Como modelo de predicción se emplea un SVM. Para más información sobre cómo entrenar modelos de Scikit learn consultar Machine learning con Python y Scikit-learn.

In [73]:
# Entrenamiento del modelo SVM
# ==============================================================================
modelo_svm_lineal = svm.SVC(kernel= "linear", C = 1.0)
modelo_svm_lineal.fit(X=tfidf_train, y= y_train)
Out[73]:
SVC(kernel='linear')
In [74]:
# Grid de hiperparámetros
# ==============================================================================
param_grid = {'C': np.logspace(-5, 3, 10)}

# Búsqueda por validación cruzada
# ==============================================================================
grid = GridSearchCV(
        estimator  = svm.SVC(kernel= "linear"),
        param_grid = param_grid,
        scoring    = 'accuracy',
        n_jobs     = -1,
        cv         = 5, 
        verbose    = 0,
        return_train_score = True
      )

# Se asigna el resultado a _ para que no se imprima por pantalla
_ = grid.fit(X = tfidf_train, y = y_train)

# Resultados del grid
# ==============================================================================
resultados = pd.DataFrame(grid.cv_results_)
resultados.filter(regex = '(param.*|mean_t|std_t)')\
    .drop(columns = 'params')\
    .sort_values('mean_test_score', ascending = False)
Out[74]:
param_C mean_test_score std_test_score mean_train_score std_train_score
6 2.15443 0.967317 0.003123 0.997073 0.000366
5 0.278256 0.963902 0.001981 0.985244 0.000994
7 16.681 0.959756 0.004154 0.998720 0.000122
8 129.155 0.959024 0.005034 0.998780 0.000193
9 1000 0.959024 0.005034 0.998780 0.000193
4 0.0359381 0.753902 0.013545 0.772683 0.006535
0 1e-05 0.526829 0.000000 0.526829 0.000000
1 7.74264e-05 0.526829 0.000000 0.526829 0.000000
2 0.000599484 0.526829 0.000000 0.526829 0.000000
3 0.00464159 0.526829 0.000000 0.526829 0.000000
In [75]:
# Mejores hiperparámetros por validación cruzada
# ==============================================================================
print("----------------------------------------")
print("Mejores hiperparámetros encontrados (cv)")
print("----------------------------------------")
print(grid.best_params_, ":", grid.best_score_, grid.scoring)

modelo_final = grid.best_estimator_
----------------------------------------
Mejores hiperparámetros encontrados (cv)
----------------------------------------
{'C': 2.154434690031882} : 0.9673170731707316 accuracy
In [76]:
# Error predicciones test
# ==============================================================================
predicciones_test = modelo_final.predict(X=tfidf_test)

print("-------------")
print("Error de test")
print("-------------")

print(f"Número de clasificaciones erróneas de un total de {tfidf_test.shape[0]} " \
      f"clasificaciones: {(y_test != predicciones_test).sum()}"
)
print(f"% de error: {100*(y_test != predicciones_test).mean()}")

print("")
print("-------------------")
print("Matriz de confusión")
print("-------------------")
pd.DataFrame(confusion_matrix(y_true = y_test, y_pred= predicciones_test),
             columns= ["Elon Musk", "Mayor Ed Lee"],
             index = ["Elon Musk", "Mayor Ed Lee"])
-------------
Error de test
-------------
Número de clasificaciones erróneas de un total de 1025 clasificaciones: 21
% de error: 2.048780487804878

-------------------
Matriz de confusión
-------------------
Out[76]:
Elon Musk Mayor Ed Lee
Elon Musk 512 6
Mayor Ed Lee 15 492

Empleando un modelo de SVM lineal con hiperparámetro $C=2.15$ se consigue un test error del 2.05%. Se trata de un porcentaje de error bastante bajo, aún así, explorando otros modelos como pueden ser SVM no lineales o Random Forest podrían alcanzarse mejores resultados.

Análisis de sentimientos


Una forma de analizar el sentimiento de un de un texto es considerando su sentimiento como la suma de los sentimientos de cada una de las palabras que lo forman. Esta no es la única forma de abordar el análisis de sentimientos, pero consigue un buen equilibrio entre complejidad y resultados.

Para llevar a cabo esta aproximación es necesario disponer de un diccionario en el que se asocie a cada palabra un sentimiento o nivel de sentimiento. A estos diccionarios también se les conoce como sentiment lexicon. Dos de los más utilizados son:


In [77]:
# Descarga lexicon sentimientos
# ==============================================================================
lexicon = pd.read_table(
            'https://raw.githubusercontent.com/fnielsen/afinn/master/afinn/data/AFINN-en-165.txt',
            names = ['termino', 'sentimiento']
          )
lexicon.head()
Out[77]:
termino sentimiento
0 abandon -2
1 abandoned -2
2 abandons -2
3 abducted -2
4 abduction -2

Sentimiento promedio de cada tweet


Al disponer de los datos en formato tidy (una palabra por fila), mediante un inner join se añade a cada palabra su sentimiento y se filtran automáticamente todas aquellas palabras para las que no hay información disponible.

In [78]:
# Sentimiento promedio de cada tweet
# ==============================================================================
tweets_sentimientos = pd.merge(
                            left     = tweets_tidy,
                            right    = lexicon,
                            left_on  = "token", 
                            right_on = "termino",
                            how      = "inner"
                      )

tweets_sentimientos = tweets_sentimientos.drop(columns = "termino")

# Se suman los sentimientos de las palabras que forman cada tweet.
tweets_sentimientos = tweets_sentimientos[["autor","fecha", "id", "sentimiento"]] \
                      .groupby(["autor", "fecha", "id"])\
                      .sum().reset_index()
tweets_sentimientos.head()
Out[78]:
autor fecha id sentimiento
0 BillGates 2011-09-29 21:11:15+00:00 1.195196e+17 6
1 BillGates 2011-10-04 19:06:05+00:00 1.213001e+17 -3
2 BillGates 2011-10-06 00:37:29+00:00 1.217459e+17 3
3 BillGates 2011-10-06 00:38:09+00:00 1.217460e+17 6
4 BillGates 2011-10-19 22:12:21+00:00 1.267828e+17 3

Tweets positivos, negativos y neutros


Se calcula el porcentaje de tweets positivos, negativos y neutros para cada autor.

In [79]:
def perfil_sentimientos(df):
    print(autor)
    print("=" * 12)
    print(f"Positivos: {round(100 * np.mean(df.sentimiento > 0), 2)}")
    print(f"Neutros  : {round(100 * np.mean(df.sentimiento == 0), 2)}")
    print(f"Negativos: {round(100 * np.mean(df.sentimiento < 0), 2)}")
    print(" ")

for autor, df in tweets_sentimientos.groupby("autor"):
    perfil_sentimientos(df)
BillGates
============
Positivos: 79.88
Neutros  : 4.59
Negativos: 15.52
 
elonmusk
============
Positivos: 73.14
Neutros  : 4.29
Negativos: 22.57
 
mayoredlee
============
Positivos: 80.73
Neutros  : 4.07
Negativos: 15.2
 

Los tres autores tienen un perfil muy similar. La gran mayoría de tweets son de tipo positivo. Este patrón es común en redes sociales, donde se suele participar mostrando aspectos o actividades positivas. Los usuarios no tienden a mostrar las cosas malas de sus vidas.

Evolución temporal


A continuación, se estudia cómo varía el sentimiento promedio de los tweets agrupados por intervalos de un mes para cada uno de los usuarios.

In [80]:
fig, ax = plt.subplots(figsize=(7, 4)) 

for autor in tweets_sentimientos.autor.unique():
    df = tweets_sentimientos[tweets_sentimientos.autor == autor].copy()
    df = df.set_index("fecha")
    df = df[['sentimiento']].resample('1M').mean()
    ax.plot(df.index, df.sentimiento, label=autor)

ax.set_title("Sentimiento promedio de los tweets por mes")
ax.legend();

La distribución del sentimiento promedio de los tweets se mantiene aproximadamente constante para los 3 usuarios. Existen ciertas oscilaciones, pero prácticamente la totalidad de ellas dentro del rango de sentimiento positivo.

Información de sesión

In [81]:
from sinfo import sinfo
sinfo()
-----
ipykernel   5.3.4
matplotlib  3.3.2
nltk        3.5
numpy       1.19.2
pandas      1.1.3
scipy       1.5.2
seaborn     0.11.0
sinfo       0.3.1
sklearn     0.23.2
-----
IPython             7.18.1
jupyter_client      6.1.7
jupyter_core        4.6.3
jupyterlab          2.2.9
notebook            6.1.4
-----
Python 3.7.9 (default, Aug 31 2020, 12:42:55) [GCC 7.3.0]
Linux-5.4.0-1032-aws-x86_64-with-debian-buster-sid
4 logical CPU cores, x86_64
-----
Session information updated at 2020-12-31 15:23

Bibliografía


Text Mining with R: A Tidy Approach

Search Engines: Information Retrieval in Practice by Trevor Strohman, Donald Metzler, W. Bruce Croft

http://varianceexplained.org/r/trump-tweets/

http://programminghistorian.github.io/ph-submissions/lessons/published/basic-text-processing-in-r

http://cfss.uchicago.edu/fall2016/text01.html

¿Cómo citar este documento?

Análisis de texto (text mining) con Python por Joaquín Amat Rodrigo, disponible con licencia CC BY-NC-SA 4.0 en https://www.cienciadedatos.net/documentos/py25-text-mining-python.html DOI


¿Te ha gustado el artículo? Tu ayuda es importante

Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊


Creative Commons Licence
Este contenido, creado por Joaquín Amat Rodrigo, tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.