Mateo Gonzales Navarrete's Projects

Análisis y clasificación de tweets de usuarios peruanos sobre su posición respecto a la guerra entre Rusia y Ucrania

10 Jul 2022

Curso: Introducción a la Ciencia de Datos (CS351)
Alumno: E. Mateo Gonzales Navarrete
Profesor: Dr. Erick Gomez Nieto
Semestre: 2022-I


La guerra ruso-ucraniana es un conflicto bélico entre Rusia y Ucrania que empezó en febrero del año 2014 y que prosigue hasta ahora. Se pudo observar diversos acontecimientos como el anexión rusa de Crimea y la guerra del Dombás. A inicios de este año, el 24 de febrero, Rusia realizó una invasión a Ucrania a gran escala. De esta guerra, con ya más de 8 años, esta última invasión se volvió muy mediática al tratarse del primera contienda armada entre dos Estado soberanos europeos en el siglo XXI.

Las redes sociales estallaron con comentarios de usuarios de todo el mundo, siendo Twitter una de las redes donde más opiniones son publicadas al respecto en forma de tweets. Los usuarios peruanos también dieron a conocer su opinión al respecto, pudiendo encontrar personas que solo desean que se acabe la guerra y no haya muertos, otros que hacen comentarios basados en sentimientos de dolor, odio o pena; y aquellos que exponen ideas y argumentos apoyando o vilipendiendo a alguno de los bandos.

Objetivos #

El presente artículo tiene como objetivo describir a detalle el proceso llevado a cabo desde la recolección y análisis de datos hasta el entrenamiento de un modelo capaz de clasificar tweets respecto a su posición sobre la guerra, además del análisis de datos utilizando dicho modelo para conocer la opinión general de los usuarios peruanos y detectar posibles cuentas que puedan ser catalogadas como propaganda política.

Objetivos principales: #

  • Conocer la opinión peruana sobre la invasión rusa en Ucrania.
  • Generar un modelo clasificador que permita determinar a qué bando se apoya en un tweet.

Objetivos secundarios: #

  • Conocer las cuentas más activas respecto a este tema.
  • Conocer la variación de interés respecto al tiempo.
  • Averiguar si existe propaganda por alguna parte beligerante.

Pipeline aplicado #

  1. Recolección de datos
  2. Regularización y limpieza de datos
  3. Análisis exploratorio
  4. Etiquetado de datos
  5. Entrenamiento de un modelo para clasificar tweets
  6. Análisis del dataset con nuestro modelo

Recolección de datos #

Todos los datos fueron recolectados de Twitter. Para realizar esta tarea, se utilizó dos herramientas de scraping: Twint y Snscrape. Ambas son herramientas de Open Source Intelligence (OSINT) hechas en Python que permiten interactuar con el API de Twitter. Inicialmente se utilizó Twint, pero debido a incompatibilidades y problemas, se resolvió utilizar Snscrape como alternativa, la cual tiene un mayor soporte y se encuentra más actualizada.

Se realizó una serie de búsquedas utilizando: palabras clave, ubicación geográfica y rango de fechas. Entre las palabras clave utilizadas se tiene: Ucrania, Putin, Donbas, Rusia, entre otros. La especificación de la ubicación geográfica fue parte esencial para poder asegurar que los tweets obtenidos habían sido realizados en Perú. Para este parámetro, se debe especificar un punto geográfico por medio de su latitud y longitud, además de un radio en el que se realizará la búsqueda a partir de ese punto (por ejemplo, -12.06513,-75.20486,100km). De este manera, se utilizó las ciudades capitales de los departamentos del Perú como puntos clave y a partir de ellos se utilizó un radio que no saliera del territorio peruano y que cubra una buena porción territorial. La sobreposición en las áreas de búsqueda era inevitable, por lo que en el paso siguiente se hizo una limpieza de datos para eliminar duplicados. Finalmente, el rango de fechas para las búsquedas se hizo por mes y consideró desde el 24 de enero del 2022 hasta el 29 de junio del 2022.

Se utilizó un script hecho en bash para automatizar los comandos para todas las búsquedas. Cada búsqueda tiene un formato como el siguiente:

snscrape --jsonl --progress --since "2022-02-24" twitter-search "Ucrania geocode:-12.06513,-75.20486,100km until:2022-03-24"

Regularización de formato y limpieza de datos #

Dado que se obtuvieron datos tanto de Twint como de Snscrape, se decidió regularizar el formato de todos los datos dada la posibilidad de haber datos recopilados por Twint que no hayan sido extraídos por Snscrape. Los campos finales con los que se trabajó fueron: el ID del tweet, el contenido, el ID del autor, el username del autor y la fecha.

# Load snscrape items
with open("snscrape_data.json", "r") as f:
  for line in f:
    item = json.loads(line)
    items.append( (item["id"], item["content"], item["user"]["id"], item["user"]["username"], item["date"]) )

# Load twint items
with open("twint_data.json", "r") as f:
  for line in f:
    item = json.loads(line)
    items.append( (item["id"], item["tweet"], item["user_id"], item["username"], item["date"]) )

unique_items = {item[0]: item[1:] for item in items}

tweets = pd.DataFrame([ [key, *value] for key, value in unique_items.items()])
tweets.columns = ["id", "content", "user_id", "username", "date"]

Luego de esto, se filtró los datos por ID para remover los duplicados, pudiendo observar que sí hubo datos de Twint que no fueron recolectados por la otra herramienta.

Finalmente, se hizo un procesamiento y limpieza de los datos, incluyendo la eliminación de espacios múltiples, remoción de puntuación, palabras clave (con las que se hicieron las búsquedas), stopwords, URLs y caracteres no alfanuméricos. También se convirtió todo a minúsculas y se hizo un proceso de lematización.

# 1 Remover URLs
tweets['content_processed'] = tweets['content'].map(lambda x: re.sub(r'https?://\S+', '', x))
# 2 Remover puntuación
tweets['content_processed'] = tweets['content_processed'].map(lambda x: re.sub(r'[,\.!?]', '', x))
# 3 Convertir el contenido a minúsculas
tweets['content_processed'] = tweets['content_processed'].map(lambda x: x.lower())
# 4 Remover tokens que no son alfanuméricos
tweets['content_processed'] = tweets['content_processed'].map(lambda x: ' '.join([word for word in word_tokenize(x) if word.isalpha()]))
# 5 Reemplazar múltiples espacios por uno solo. "Hola      amigo" =>  "Hola amigo"
tweets['content_processed'] = tweets['content_processed'].map(lambda x: re.sub(r"\s+", " ", x))
# 6 Remoción de stopwords
stopwords = nltk.corpus.stopwords.words('spanish')
stopwords += [keyword.lower() for keyword in CORE_TERMS]
tweets['content_processed'] = tweets['content_processed'].map(lambda x: ' '.join([word for word in word_tokenize(x) if not word in stopwords]))
# 7 Lematización
for i in range(len(tweets['content_processed'])):
  tweets['content_processed'][i] = ' '.join([word.lemma for word in nlp(tweets['content_processed'][i]).sentences[0].words]) or ""

Debido al alto costo de realizar la lematización, todos los datos fueron guardados en MongoDB luego de ser procesados. Los datos son actualizados en mongo en caso se detecte un duplicado, caso contrario se inserta. De esta manera, aseguramos que en MongoDB no se tengan datos repetidos.

import pymongo
client = pymongo.MongoClient(settings.MONGO_CONNECTION)

for item in unique_tweets_processed:
  client["war-perception"]["tweets"].update_one(
      item, {"$set": item}, upsert=True
  )

En total, se logró extraer y guardar 26000 tweets únicos.

Análisis exploratorio #

Se realizó un análisis exploratorio de los datos para conocer su distribución, tópicos presentes, interés, e intentar descubrir las tendencias y las cuentas más activas sobre el tema. En la siguiente imagen se puede observar el wordcloud generado en el contenido procesado. En el siguiente gráfico se puede observar la cantidad de tweets respecto al tiempo, viendo varios picos en fechas relevantes. Se puede observar cinco picos principales que corresponden a las siguientes fechas:

  • 24 de febrero: Rusia invade Ucrania.
  • 5 de abril: Filtración de la masacre de Bucha.
  • 27 de abril: 27 de abril: Pedro Castillo dice “Guerra entre Rusia y Croacia”.
  • 14 de mayo: EEUU y otros países brindan mayor apoyo militar a Ucrania (dinero y armas).
  • 22 de junio: Pedro Castillo dice que “lo mejor que pudimos hacer frente al conflicto entre Ucrania y Rusia fue la vacunación”.

Se encontró que las palabras con mayor presencia son: ruso, ucraniano, país, poder, otan, eeuu, mundo, invasión, entre otros. En la siguiente imagen, se puede observar mejor la frecuencia de cada una de estas palabras.

Sin embargo, una mejor forma de medir la relevancia de las palabras a lo largo de todo el conjunto de datos es utilizar la métrica estadística TF-IDF, cuyos resultados se pueden ver en la imagen siguiente, donde hay un ligero cambio en el orden de relevancia de las palabras, pero en general se mantienen las mismas en el top 10.

Además, se hizo uso de skipgrams para observar palabras que aparecen juntas a menudo y su contexto. En las siguientes imágenes se pueden observar un 2-skipgram con un salto de 2 y un 3-skipgram con un salto de 2. Se puede ver en la primera que prima la combinación “invasión rusa” y “fuerza armado” que tienen que ver directamente con la guerra. Entre otros pares se puede ver “última hora” (probablemente relacionado a los noticieros), “unión europeo” y “pedro castillo” (dado que los tweets son de Perú y se habla de la coyuntura actual y el desempeño del actual mandatario).

Finalmente, se realizaron dos proyecciones multidimensionales utilizando los métodos PCA y UMAP. Para el método PCA, se utilizaron 14000 datos para la proyección y luego se utilizó k-means para hallar clusters de datos, utilizando 5 como parámetro de número de clusters, dado que nuestro etiquetado considerará cinco clases globales. Para el método UMAP, se utilizaron 10000 datos para la proyección multidimensional. En ambos casos, el número de datos para realizar la proyección fue menor al tamaño total del dataset debido al espacio en memoria al ejecutar los algoritmos.

Etiquetado de datos #

Se etiquetaron 1500 tweets utilizando cinco labels:

  • pro_russia: El tweet hace comentarios explícitos apoyando a Rusia.
  • against_ukraine: El tweet hace comentarios explícitos en contra de Ucrania o implícitamente apoya a Rusia.
  • neutral: El comentario no muestra una tendencia hacia alguno de los bandos.
  • against_russia: El tweet hace comentarios explícitos en contra de Rusia o implícitamente apoya a Ucrania.
  • pro_ukraine: El tweet hace comentarios explícitos apoyando a Ucrania.

Entrenamiento de un modelo para clasificar tweets #

Se utilizó el modelo pysentimiento, disponible en la plataforma Huggingface bajo el nombre finiteautomata/beto-sentiment-analysis, al cual se le realizó fine-tuning con nuestros datos y labels.

Se utilizaron las herramientas ofrecidas por Huggingface facilitar el proceso de entrenamiento del modelo.

from transformers import AutoTokenizer

base_model_name = "finiteautomata/beto-sentiment-analysis"
training_set_percentage = 0.8

model = base_model_name
tokenizer = AutoTokenizer.from_pretrained(model)
Cinco labels #

Primero, se procedió a realizar el fine-tuning considerando los cinco labels anteriormente mencionados. Se empezó cargando los datos por label.

import re

labels = ["pro_russia", "against_ukraine", "neutral", "against_russia", "pro_ukraine"]
data = {label: [] for label in labels}

for i, label in enumerate(labels):
  with open(f"war_perception_training_data/{label}.txt", "r") as f:
    for line in f:
      data[label].append({"label": i, "text": line.strip()})

for key in data.keys():
  print(data[key][0])

# Output:
# {'label': 0, 'text': '❕Protestas en Bulgaria contra el suministro de armas a Ucrania “¡Nosotros, los búlgaros, no permitimos el suministro de equipo militar a Ucrania e insistimos en que Rusia rompa la espalda de los nazis estadounidenses-ucranianos de una vez por todas!”'}
# {'label': 1, 'text': 'Es inaceptable que la inteligencia sólo sea usada para destruir, boicotear la paz en el planeta, Putin ya ganó, Zelenski quedó como un estúpido bufón que fue utilizado para beneplácito de USA.'}
# {'label': 2, 'text': '🏹🏹Una serie de ataques intensos contra los objetos de los militantes de las Fuerzas Armadas de Ucrania en el asentamiento. Severodonetsk, Rubizhnoye, Lisichansk, Kremennaya, Gornoye, Popasnaya, etc.'}
# {'label': 3, 'text': 'Veganos fans de Putin, en el distrito Pueblo Libre de Lima. Y yo que creía haber visto todo. #vegan #putines #perú #lima en Lima, Perú'}
# {'label': 4, 'text': 'Dios salve a Ucrania'}

Se procedió a separar el conjunto de datos en entrenamiento y pruebas utilizando la regla 80/20.

import pandas as pd
from datasets import Dataset

def preprocess_function(examples):
   return tokenizer(examples["text"], truncation=True)

data_train = {}
data_test = {}
for label in data.keys():
  data_train[label] = pd.DataFrame(data[label][:int(len(data[label])*training_set_percentage)])
  data_test[label] = pd.DataFrame(data[label][int(len(data[label])*training_set_percentage):])

tokenized_train = []
tokenized_test = []
for label in labels:
  tokenized_train += Dataset.from_pandas(pd.DataFrame(data=data_train[label])).map(preprocess_function, batched=True)
  tokenized_test += Dataset.from_pandas(pd.DataFrame(data=data_test[label])).map(preprocess_function, batched=True)

Y se utilizaron las métricas de accuracy y f1_score:

import numpy as np
from datasets import load_metric
 
def compute_metrics(eval_pred):
   load_accuracy = load_metric("accuracy")
   load_f1 = load_metric("f1")
  
   logits, labels = eval_pred
   predictions = np.argmax(logits, axis=-1)
   accuracy = load_accuracy.compute(predictions=predictions, references=labels)["accuracy"]
   f1 = load_f1.compute(predictions=predictions, references=labels, average="weighted")["f1"]
   return {"accuracy": accuracy, "f1": f1}

Luego, se configuraron los argumentos de entrenamiento y se procedió a entrenar el modelo:

from transformers import TrainingArguments, Trainer
 
repo_name = "finetuning-pysentimiento-war-tweets"
 
training_args = TrainingArguments(
   output_dir=repo_name,
   learning_rate=2e-5,
   per_device_train_batch_size=16,
   per_device_eval_batch_size=16,
   num_train_epochs=2,
   weight_decay=0.01,
   save_strategy="epoch",
   push_to_hub=True,
)
 
trainer = Trainer(
   model=model,
   args=training_args,
   train_dataset=tokenized_train,
   eval_dataset=tokenized_test,
   tokenizer=tokenizer,
   data_collator=data_collator,
   compute_metrics=compute_metrics,
)

trainer.train()

Finalmente, se evaluó el rendimiento del modelo con el conjunto de datos de prueba, donde se obtuvo un accuracy de 73.78% y un f1_score de 74.56%.

>> trainer.evaluate()
{'epoch': 30.0,
 'eval_accuracy': 0.7377777777777778,
 'eval_f1': 0.7456093196127739,
 'eval_loss': 1.768933653831482,
 'eval_runtime': 1.5821,
 'eval_samples_per_second': 142.216,
 'eval_steps_per_second': 9.481}

Se realizó el entrenamiento con y sin menciones (es decir, ocurrencias como @mgonnav). Los resultados obtenidos fueron prácticamente mismos.

Tres labels #

El proceso anterior fue repetido pero esta vez considerando únicamente tres labels: pro_russia, neutral y pro_ukraine. En este caso, los datos catalogados como against_ukraine fueron tomados como pro_russia y los datos against_russia fueron tomados como pro_ukraine.

import re
labels = ["pro_russia", "neutral", "pro_ukraine"]
data = {label: [] for label in labels}

for i, label in enumerate(labels):
  with open(f"war_perception_training_data/{label}.txt", "r") as f:
    for line in f:
      data[label].append({"label": i, "text": line.strip()})

with open(f"war_perception_training_data/against_ukraine.txt", "r") as f:
  for line in f:
    data["pro_russia"].append({"label": 0, "text": line.strip()})

with open(f"war_perception_training_data/against_russia.txt", "r") as f:
  for line in f:
    data["pro_ukraine"].append({"label": 2, "text": line.strip()})

El rendimiento del modelo utilizando solo tres labels dio como resultado un accuracy de 78.92% y un f1_score de 79.04%. De igual manera, se realizó el entrenamiento con y sin menciones, obteniendo resultados prácticamente iguales.

>> trainer.evaluate()
{'epoch': 30.0,
 'eval_accuracy': 0.7892376681614349,
 'eval_f1': 0.790367082796823,
 'eval_loss': 1.5405524969100952,
 'eval_runtime': 2.6155,
 'eval_samples_per_second': 85.26,
 'eval_steps_per_second': 5.353}
Modelo clasificador #

Los valores obtenidos por el clasificador de tres labels son mejores que los del modelo que considera cinco labels; sin embargo, al utilizar este modelo se pierde semántica al no saber si se está explícitamente hablando bien de un bando o si solo se está hablando mal del bando contrario. Por este motivo, se ha optado por utilizar el model con cinco clases, el cual ha sido publicado en la plataforma Huggingface y se encuentra disponible en el siguiente repositorio: mgonnav/finetuning-pysentimiento-war-tweets.

Desde esta plataforma, se puede realizar pruebas en la misma interfaz o utilizando código Python. Primero hay que instalar el paquete de transformers:

$ pip install transformers

Y luego podemos llamarlo en el código de la siguiente forma:

from transformers import pipeline
 
sentiment_model = pipeline(model="mgonnav/finetuning-pysentimiento-war-tweets")
sentiment_model(["Los rusos están cometiendo muchos crímenes de guerra."])
# Output:
# [{'label': 'against_russia', 'score': 1.000}]

Análisis del dataset con nuestro modelo #

Se utilizó el modelo anteriormente descrito y escogido para clasificar los 26000 tweets obtenidos. Este proceso tardó 1h17m y luego de esto podemos obtener mayores insights respecto a los datos recopilados. En la siguiente imagen podemos ver la cantidad de tweets por cada una de las categorías que definimos. Se puede ver que la mayoría de las publicaciones son neutrales respecto al tema de la guerra, pudiendo encontrar que mencionan el tema en tono sarcástico o ironónico muchas veces. Los noticieros también aportan la mayoría de este grupo.

Para apreciar mejor la escala de los datos que fueron clasificados como no neutrales, tenemos la siguiente imagen. Se puede apreciar que la mayor parte de los tweets hablan en contra de Rusia; luego, hay una cantidad considerable de tweets a favor de Rusia y en contra de Ucrania; finalmente, tenemos que la menor cantidad de tweets son explícitamente a favor de Ucrania.

Luego, analizamos la cantidad publicaciones hechas por usuario por cada tipo de categoría. En las siguientes imágenes se puede observar que el usuario renzito1970 ha realizado una gran cantidad de publicaciones a favor de Rusia y aún más en contra de Ucrania, siendo el principal defensor y detractor de estos bandos respectivamente. Los demás usuarios presentan una cantidad de tweets más normal. La mediana y el promedio de publicaciones en las categorías pro_russia y against_ukraine fueron (1.0, 1.94) y (1.0, 2.12) respectivamente.

En cuanto a las publicaciones neutrales, vemos que los usuarios con mayor cantidad de publicaciones corresponden a noticieros online, sean grandes empresas o periodismo independiente. Esta es una buena señal, pues simboliza que los noticieros están siendo imparciales. La mediana y promedio de publicaciones en esta categoría fueron (3.70, 1.0).

En cuanto a los tweets en contra de Rusia, podemos ver a los usuarios cristian_s, giovana_527 y monologuistax como los más activos. En este caso, se ve una cantidad de tweet por usuario que baja de manera más suave, a diferencia de la categoría against_ukraine donde un solo usuario creó una considerable cantidad de publicaciones.

Como última categoría tenemos a pro_ukraine, donde el usuario rafaeltorrescr4 se posiciona como el mayor defensor de Ucrania, con casi un cuarto del total de publicaciones a favor de dicho país.

Finalmente, tenemos un gráfico donde se muestra la cantidad de usuarios únicos que hicieron publicaciones por cada categoría. Los valores por categoría son: 1074, 752, 4947, 1387 y 345 respectivamente.

Conclusiones y trabajos futuros #

  • Los usuarios peruanos en Twitter, en general, muestran una posición neutral respecto al tema de la guerra y se encuentran más preocupados por temas de coyuntura nacional.
  • Los noticieros peruanos muestran una posición neutral en la mayoría de sus publicaciones.
  • Las opiniones respecto a la guerra son en general en rechazo a Rusia más que a favor de Ucrania.
  • Existe una considerable cantidad de publicaciones a favor de Rusia y en contra de Ucrania.
  • Existen cuentas con claros sesgos al realizar varias publicaciones apoyando a uno solo de los bandos o atacando al bando contrario. Con la versión actual del modelo, no se puede determinar si existe propaganda política.
  • El modelo obtenido muestra buenos resultados a pesar de haber sido entrenado con un conjunto de datos reducido. Se puede entrenar el modelo con más datos para lograr un mejor desempeño.