El notebook, celda por celda
Clasificación de hate speech tal cual fue desarrollada: código, entrenamiento y métricas reales. Los outputs que muestran tweets crudos del dataset se omiten en la versión web por contener lenguaje ofensivo; el resto está intacto.
Librerías
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from datasets import Dataset
from sklearn.model_selection import train_test_split
from sklearn import metrics
from sklearn.metrics import (
classification_report,
confusion_matrix,
precision_recall_fscore_support
)
import evaluate
import torch
from torch import cuda
from datasets import (
Dataset,
Value,
ClassLabel,
Features,
DatasetDict
)
import transformers
from transformers import (
AutoTokenizer,
RobertaModel,
RobertaTokenizer,
BertModel,
BertTokenizer,
DistilBertModel,
DistilBertTokenizer,
DataCollatorWithPadding,
AutoModelForSequenceClassification,
TrainingArguments,
Trainer,
pipeline
)
import json
from tqdm import tqdm
import logging
logging.basicConfig(level=logging.ERROR)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
devicedevice(type='cuda')
Introducción
El conjunto de datos utilizado en este estudio se construyó a partir de tweets obtenidos a través de la API de Twitter y filtrados utilizando un léxico de discurso de odio proporcionado por Hatebase.org. Inicialmente, se identificaron y recopilaron tweets que contenían términos específicos catalogados como discurso de odio por la comunidad. Este proceso resultó en una muestra de 85.4 millones de tweets, provenientes de 33,458 usuarios de Twitter.
Posteriormente, se realizó una selección aleatoria de tweets de esta muestra inicial para su análisis detallado. Estos tweets fueron evaluados manualmente por trabajadores de CrowdFlower (CF), quienes clasificaron cada tweet en una de tres categorías: discurso de odio, lenguaje ofensivo pero no discurso de odio, y ni ofensivo ni discurso de odio.
Estructura del dataset
Para el desarrollo de la tarea, se deben concentrar en dos columnas:
- tweet: Contiene la sentencia que debe ser evaluada.
- label: Clasificación asociada a cada sentencia de la variable tweet.
Respecto a la variable label, se tiene la siguiente codificación:
- 0 - discurso de odio
- 1 - lenguaje ofensivo
- 2 - ninguno
El conjunto de datos ya está divido en train (df_train) y test (df_test)
1 - Entrenar clasificador con RoBERTa (10 puntos)
Utilizando el modelo RoBERTa-base, construya un clasificador para la tarea planteada.
Calcule las métricas Precision, Recall y F1-score para el conjunto test (general y por clase). Comente sus resultados.
Considere un entrenamiento por 3 épocas.
Observación
- Debe trabajar con GPU. El tiempo de entrenamiento es de 20 minutos, aproximadamente. Se recomienda guardar el modelo para no repetir entrenamiento.
- Respecto a la implementación desarrollada en la ayudantía 3, deben modificar la función
compute_metrics. A continuación, se presenta la función que debe ser utilizada:
def compute_metrics(eval_pred):
predictions, labels = eval_pred
predictions = np.argmax(predictions, axis=1)
return f1.compute(references=labels, predictions=predictions, average='micro')
Respuesta:
df_train = pd.read_csv("df_train.csv")
df_test = pd.read_csv('df_test.csv')df_train.head()(output omitido en la versión web: contiene ejemplos crudos del dataset)
df_test.head()(output omitido en la versión web: contiene ejemplos crudos del dataset)
train_ds = Dataset.from_pandas(df_train[["tweet", "label"]])
test_ds = Dataset.from_pandas(df_test[["tweet", "label"]])ds = DatasetDict()
ds["train"] = train_ds
ds["test"] = test_ds
dsDatasetDict({
train: Dataset({
features: ['tweet', 'label'],
num_rows: 19826
})
test: Dataset({
features: ['tweet', 'label'],
num_rows: 4957
})
})ds["train"][0](output omitido en la versión web: contiene ejemplos crudos del dataset)
MODEL_NAME = "roberta-base"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)def tokenize_fn(batch):
return tokenizer(
batch["tweet"],
truncation=True,
max_length=128
)
train_ds = train_ds.map(tokenize_fn, batched=True)
test_ds = test_ds.map(tokenize_fn, batched=True)Map: 0%| | 0/19826 [00:00<?, ? examples/s]Map: 0%| | 0/4957 [00:00<?, ? examples/s]
train_dsDataset({
features: ['tweet', 'label', 'input_ids', 'attention_mask'],
num_rows: 19826
})test_dsDataset({
features: ['tweet', 'label', 'input_ids', 'attention_mask'],
num_rows: 4957
})f1 = evaluate.load("f1")
accuracy_metric = evaluate.load("accuracy")data_collator = DataCollatorWithPadding(tokenizer=tokenizer, padding="longest")model = AutoModelForSequenceClassification.from_pretrained(
MODEL_NAME,
num_labels=3
)Some weights of RobertaForSequenceClassification were not initialized from the model checkpoint at roberta-base and are newly initialized: ['classifier.dense.bias', 'classifier.dense.weight', 'classifier.out_proj.bias', 'classifier.out_proj.weight'] You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
LABEL_NAMES = ["hate_speech(0)", "offensive(1)", "neither(2)"]
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=1)
p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support(labels, preds, average="macro", zero_division=0)
p_micro, r_micro, f1_micro, _ = precision_recall_fscore_support(labels, preds, average="micro", zero_division=0)
p_cls, r_cls, f1_cls, _ = precision_recall_fscore_support(labels, preds, average=None, zero_division=0)
metrics = {
"precision_macro": p_macro,
"recall_macro": r_macro,
"f1_macro": f1_macro,
"precision_micro": p_micro,
"recall_micro": r_micro,
"f1_micro": f1_micro,
}
for i, name in enumerate(LABEL_NAMES):
metrics[f"precision_{name}"] = p_cls[i]
metrics[f"recall_{name}"] = r_cls[i]
metrics[f"f1_{name}"] = f1_cls[i]
return metricstraining_args = TrainingArguments(
report_to="none",
output_dir="./roberta_hate_classifier",
evaluation_strategy="epoch",
save_strategy="epoch",
learning_rate=2e-5,
warmup_ratio=0.1,
per_device_train_batch_size=16,
gradient_accumulation_steps=2,
per_device_eval_batch_size=32,
num_train_epochs=3,
weight_decay=0.01,
logging_steps=100,
load_best_model_at_end=True,
metric_for_best_model="f1_macro",
fp16=torch.cuda.is_available(),
)c:\Users\DELL\.conda\envs\NLP\Lib\site-packages\transformers\training_args.py:1575: FutureWarning: `evaluation_strategy` is deprecated and will be removed in version 4.46 of 🤗 Transformers. Use `eval_strategy` instead warnings.warn(
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=test_ds,
data_collator=data_collator,
compute_metrics=compute_metrics
)trainer.train()TrainOutput(global_step=1860, training_loss=0.2667585665179837, metrics={'train_runtime': 258.5366, 'train_samples_per_second': 230.056, 'train_steps_per_second': 7.194, 'total_flos': 1913805805397220.0, 'train_loss': 0.2667585665179837, 'epoch': 3.0})trainer.save_model("./roberta_hate_classifier_final")tokenizer.save_pretrained("./roberta_hate_classifier_final")('./roberta_hate_classifier_final\\tokenizer_config.json',
'./roberta_hate_classifier_final\\special_tokens_map.json',
'./roberta_hate_classifier_final\\vocab.json',
'./roberta_hate_classifier_final\\merges.txt',
'./roberta_hate_classifier_final\\added_tokens.json',
'./roberta_hate_classifier_final\\tokenizer.json')preds_output = trainer.predict(test_ds)
preds = np.argmax(preds_output.predictions, axis=1)
labels = preds_output.label_ids
print(classification_report(labels, preds, target_names=LABEL_NAMES))precision recall f1-score support
hate_speech(0) 0.49 0.43 0.46 286
offensive(1) 0.95 0.96 0.95 3838
neither(2) 0.91 0.91 0.91 833
accuracy 0.92 4957
macro avg 0.78 0.76 0.77 4957
weighted avg 0.91 0.92 0.92 4957El modelo RoBERTa-base alcanza un desempeño global alto en test (Micro-F1 ≈ 0.917) y un rendimiento robusto en las clases Offensive y Neither (F1 > 0.90). Sin embargo, la clase Hate Speech presenta el peor desempeño (F1 ≈ 0.46) debido al desbalance del dataset y la dificultad semántica para distinguir entre insultos ofensivos y discurso de odio explícito. A lo largo de las 3 épocas se observa una mejora progresiva en Macro-F1 y en la clase minoritaria, aunque el margen de mejora en hate speech es limitado sin técnicas adicionales como re-weighting, oversampling o focal loss.
2 - Uso de LLM como clasificador: Zero-shot (8 puntos)
Seleccione una muestra aleatoria de 20 ejemplos por categoría desde el conjunto de test (en total son 60 ejemplos).
Utilice el modelo meta-llama/Llama-3.2-3B-Instruct para obtener predicciones para cada comentario. Justifique el diseño del prompt.
Ver modelo en: https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct
Calcule las métricas Precision, Recall y F1-score para el conjunto analizado (general y por clase). Comente los resultados.
Observación
- Para utilizar este modelo debe registrarse en huggingface y solicitar permiso. Al importarlo, debe ingresar el token de huggingface. Revisar la ayudantía 3.
- Para esta pregunta deben trabajar con GPU.
Respuesta:
df_sample = (
df_test
.groupby("label", group_keys=False)
.apply(lambda x: x.sample(n=20, random_state=42))
.reset_index(drop=True)
)
print(df_sample["label"].value_counts())
df_sample.head()(output omitido en la versión web: contiene ejemplos crudos del dataset)
MODEL_NAME = "meta-llama/Llama-3.2-3B-Instruct"
pipe = pipeline(
"text-generation",
model=MODEL_NAME,
torch_dtype=torch.bfloat16,
device_map=device,
)config.json: 0%| | 0.00/878 [00:00<?, ?B/s]c:\Users\DELL\.conda\envs\NLP\Lib\site-packages\huggingface_hub\file_download.py:147: UserWarning: `huggingface_hub` cache-system uses symlinks by default to efficiently store duplicated files but your machine does not support them in C:\Users\DELL\.cache\huggingface\hub\models--meta-llama--Llama-3.2-3B-Instruct. Caching files will still work but in a degraded version that might require more space on your disk. This warning can be disabled by setting the `HF_HUB_DISABLE_SYMLINKS_WARNING` environment variable. For more details, see https://huggingface.co/docs/huggingface_hub/how-to-cache#limitations. To support symlinks on Windows, you either need to activate Developer Mode or to run Python as an administrator. In order to activate developer mode, see this article: https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development warnings.warn(message)model.safetensors.index.json: 0%| | 0.00/20.9k [00:00<?, ?B/s]Downloading shards: 0%| | 0/2 [00:00<?, ?it/s]model-00001-of-00002.safetensors: 0%| | 0.00/4.97G [00:00<?, ?B/s]model-00002-of-00002.safetensors: 0%| | 0.00/1.46G [00:00<?, ?B/s]generation_config.json: 0%| | 0.00/189 [00:00<?, ?B/s]tokenizer_config.json: 0%| | 0.00/54.5k [00:00<?, ?B/s]tokenizer.json: 0%| | 0.00/9.09M [00:00<?, ?B/s]special_tokens_map.json: 0%| | 0.00/296 [00:00<?, ?B/s]Device set to use cuda
SYSTEM_PROMPT = """You are a strict content moderation classifier.
Return ONLY one label id: 0, 1, or 2.
Labels:
0 = hate speech (attacks a protected group or uses hateful slurs targeting group identity)
1 = offensive language (insults/profanity/abuse but not targeted hate speech)
2 = neither (not offensive and not hate speech)
Return only the digit (0/1/2). No words, no explanations."""import re
def llm_predict(text: str) -> int:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Tweet: {text}\nLabel:"}
]
out = pipe(
messages,
max_new_tokens=5,
do_sample=False,
temperature=0.0,
return_full_text=False
)
generated = out[0]["generated_text"].strip()
m = re.findall(r"\b([0-2])\b", generated)
return int(m[-1]) if m else 2y_true = df_sample["label"].tolist()
y_pred = [llm_predict(t) for t in df_sample["tweet"].tolist()]
print("Predicciones completadas")Predicciones completadas
LABEL_NAMES = ["hate_speech(0)", "offensive(1)", "neither(2)"]
print(classification_report(y_true, y_pred, target_names=LABEL_NAMES, digits=4))
p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support(y_true, y_pred, average="macro", zero_division=0)
p_micro, r_micro, f1_micro, _ = precision_recall_fscore_support(y_true, y_pred, average="micro", zero_division=0)
print("\nGLOBAL:")
print("Precision Macro:", round(p_macro, 4))
print("Recall Macro:", round(r_macro, 4))
print("F1 Macro:", round(f1_macro, 4))
print("Precision Micro:", round(p_micro, 4))
print("Recall Micro:", round(r_micro, 4))
print("F1 Micro:", round(f1_micro, 4))precision recall f1-score support
hate_speech(0) 1.0000 0.1000 0.1818 20
offensive(1) 0.4222 0.9500 0.5846 20
neither(2) 0.9231 0.6000 0.7273 20
accuracy 0.5500 60
macro avg 0.7818 0.5500 0.4979 60
weighted avg 0.7818 0.5500 0.4979 60
GLOBAL:
Precision Macro: 0.7818
Recall Macro: 0.55
F1 Macro: 0.4979
Precision Micro: 0.55
Recall Micro: 0.55
F1 Micro: 0.55El modelo LLM en modo zero-shot obtiene un rendimiento global limitado (accuracy 0.55, macro-F1 0.50). En particular, la clase hate speech presenta un recall extremadamente bajo (0.10), lo que indica que el modelo casi nunca asigna dicha categoría, probablemente por ser una etiqueta sensible y ambigua. Por otro lado, offensive presenta recall alto (0.95) pero precisión baja (0.42), evidenciando que el modelo tiende a clasificar como ofensivo la mayoría de textos agresivos, incluyendo casos neutrales y de odio. En general, estos resultados muestran que un enfoque zero-shot puede ser útil como baseline rápido, pero es menos confiable que un modelo fine-tuned como RoBERTa para distinguir clases cercanas.
El prompt fue diseñado con los siguientes criterios:
Definición clara de las clases: se describió cada categoría (hate speech, offensive, neither) para reducir ambiguedad, especialmente entre hate speech y offensive, que suelen ser clases cercanas.
Formato de salida controlado: se solicitó explícitamente que el modelo respondiera únicamente con un dígito (0, 1 o 2), evitando explicaciones o texto adicional. Esto facilita el procesamiento automático de predicciones y el cálculo de métricas.
Zero-shot real: no se incluyen ejemplos etiquetados en el prompt. De esta forma, el modelo realiza la clasificación usando únicamente la instrucción y su conocimiento general.
Reproducibilidad: se utilizó inferencia determinista (temperature=0 y do_sample=False) para obtener resultados consistentes en múltiples ejecuciones.
3 - Uso de LLM como clasificador: Few-shot (8 puntos)
Utilice el modelo meta-llama/Llama-3.2-3B-Instruct para obtener predicciones sobre los 60 comentarios seleccionados. Especifique el prompt utilizado, indicando los criterios para seleccionar los ejemplos que serán incluidos en el prompt.
Calcule las métricas Precision, Recall y F1-score para el conjunto analizado (general y por clase). Comente los resultados.
Respuesta:
FEWSHOT_SYSTEM_PROMPT = """You are a strict content moderation classifier.
Return ONLY one label id: 0, 1, or 2.
Labels:
0 = hate speech (attacks a protected group or uses hateful slurs targeting group identity)
1 = offensive language (insults/profanity/abuse but not targeted hate speech)
2 = neither (not offensive and not hate speech)
Return only the digit (0/1/2). No words, no explanations."""df_sample = (
df_test.groupby("label", group_keys=False)
.apply(lambda x: x.sample(n=20, random_state=42))
.reset_index(drop=True)
)(output omitido en la versión web: contiene ejemplos crudos del dataset)
fewshot_examples = (
df_train.groupby("label", group_keys=False)
.apply(lambda x: x.sample(n=2, random_state=123))
.reset_index(drop=True)
)
fewshot_examples(output omitido en la versión web: contiene ejemplos crudos del dataset)
def build_fewshot_block(df_examples):
lines = []
for _, row in df_examples.iterrows():
lines.append(f"Tweet: {row['tweet']}\nLabel: {row['label']}\n")
return "\n".join(lines)
fewshot_block = build_fewshot_block(fewshot_examples)
print(fewshot_block)(output omitido en la versión web: contiene ejemplos crudos del dataset)
print(pipe.model.config._name_or_path)
print(pipe.model.device)meta-llama/Llama-3.2-3B-Instruct cuda:0
import re
def llama_predict_fewshot(text: str) -> int:
user_prompt = f"""
We will do few-shot classification.
Here are labeled examples:
{fewshot_block}
Now classify this tweet:
Tweet: {text}
Return ONLY the digit 0, 1, or 2.
Label:
""".strip()
messages = [
{"role": "system", "content": FEWSHOT_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
out = pipe(
messages,
max_new_tokens=5,
do_sample=False,
temperature=0.0,
return_full_text=False
)
generated = out[0]["generated_text"].strip()
m = re.findall(r"\b([0-2])\b", generated)
return int(m[-1]) if m else 2y_true = df_sample["label"].tolist()
y_pred = [llama_predict_fewshot(t) for t in df_sample["tweet"].tolist()]LABEL_NAMES = ["hate_speech(0)", "offensive(1)", "neither(2)"]
print(classification_report(y_true, y_pred, target_names=LABEL_NAMES, digits=4))
p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support(y_true, y_pred, average="macro", zero_division=0)
p_micro, r_micro, f1_micro, _ = precision_recall_fscore_support(y_true, y_pred, average="micro", zero_division=0)
print("\nGLOBAL:")
print("Precision Macro:", round(p_macro, 4))
print("Recall Macro:", round(r_macro, 4))
print("F1 Macro:", round(f1_macro, 4))
print("Precision Micro:", round(p_micro, 4))
print("Recall Micro:", round(r_micro, 4))
print("F1 Micro:", round(f1_micro, 4))precision recall f1-score support
hate_speech(0) 1.0000 0.1000 0.1818 20
offensive(1) 0.4130 0.9500 0.5758 20
neither(2) 0.7500 0.4500 0.5625 20
accuracy 0.5000 60
macro avg 0.7210 0.5000 0.4400 60
weighted avg 0.7210 0.5000 0.4400 60
GLOBAL:
Precision Macro: 0.721
Recall Macro: 0.5
F1 Macro: 0.44
Precision Micro: 0.5
Recall Micro: 0.5
F1 Micro: 0.5En la configuración few-shot, se incluyeron ejemplos balanceados por clase seleccionados desde el conjunto de entrenamiento con el objetivo de guiar al modelo en la frontera entre hate speech, offensive language y texto neutral. Sin embargo, los resultados muestran que few-shot no mejora el desempeño global respecto a zero-shot (accuracy 0.50 vs 0.55; macro-F1 0.44 vs 0.50). La clase hate speech mantiene un recall muy bajo (0.10), lo que indica que el modelo sigue siendo conservador al asignar esta etiqueta. La principal degradación ocurre en la clase neither, cuyo recall disminuye de 0.60 a 0.45, sugiriendo que el modelo tiende a clasificar más textos neutrales como ofensivos luego de observar los Attachment ejemplos. Esto evidencia que few-shot puede ser sensible a la selección de ejemplos y que una mala representatividad o un número reducido de demostraciones puede perjudicar el rendimiento en clases no dominantes.
Los ejemplos usados dentro del prompt fueron seleccionados bajo los siguientes criterios:
Los ejemplos few-shot fueron extraídos exclusivamente del conjunto de entrenamiento (df_train) y no del conjunto de test, evitando que el modelo vea instancias del conjunto evaluado.
Balance entre clases, se seleccionó el mismo número de ejemplos para cada clase (por ejemplo, 2 ejemplos por clase), con el fin de no inducir un sesgo hacia una categoría dominante.
Ejemplos representativos y claros, se priorizaron tweets con características evidentes para cada clase, evitando ejemplos ambiguos que podrían confundir el criterio del modelo. Hate speech (0), Offensive (1), Neither (2).
4 - Conclusiones (4 puntos)
- Compare los resultados obtenidos por cada modelo implementado.
- Indique aspectos positivos y negativos de cada enfoque de solución.
Respuesta:
En este trabajo probamos tres enfoques distintos para clasificar tweets en hate speech (0), offensive (1) y neither (2): un modelo supervisado entrenado (RoBERTa) y dos estrategias con LLM (zero-shot y few-shot).
En términos generales, el mejor rendimiento lo obtuvo RoBERTa fine-tuned, con un accuracy cercano a 0.92 y un macro-F1 de ~0.77. Este resultado era esperable, porque el modelo fue entrenado directamente con ejemplos del dataset y logró aprender patrones típicos y propios del lenguaje en Twitter (insultos, expresiones comunes, tono informal, funadas, etc.). Además, ilustro un desempeño consistente en las clases offensive (F1≈0.95) y neither (F1≈0.91). Sin embargo, el punto más débil sigue siendo la clase hate speech, con un F1≈0.46, lo cual se explica principalmente por el desbalance de clases y por la dificultad natural de separar odio de lenguaje ofensivo cuando ambos pueden tener vocabulario agresivo muy similar.
Por otro lado, al usar el LLM en modo zero-shot, el desempeño bajó bastante (accuracy 0.55, macro-F1 ~0.50). El modelo fue particularmente conservador con la clase hate speech: alcanzó una precisión muy alta, pero con un recall extremadamente bajo (solo detectó una pequeña fracción de los casos reales). En la práctica esto significa que el LLM evitó etiquetar tweets como hate speech salvo cuando el caso era muy evidente. Además, se notó una tendencia a usar la etiqueta offensive como categoría por defecto cuando el texto tenía tono agresivo, lo que llevó a confundir parte de los tweets neutrales o de odio dentro de esa clase.
En el caso few-shot, la idea era que al mostrarle ejemplos etiquetados dentro del prompt, el modelo debería mejorar la separación entre clases. Sin embargo, en esta ejecución ocurrió lo contrario: el desempeño fue incluso un poco menor (accuracy 0.50, macro-F1 ~0.44). La clase hate speech se mantuvo igual de difícil (recall 0.10), y la degradación más grande se vio en la clase neither, donde el modelo empezó a confundir más textos neutrales como ofensivos. Esto deja en evidencia algo importante: el few-shot puede ser útil, pero es muy sensible a la selección de ejemplos. Si los ejemplos no son lo suficientemente representativos, o si son pocos y sesgan el tono del prompt, el modelo puede aprender muy mal la frontera entre categorías.
En resumen, RoBERTa entrenado es claramente la mejor solución si el objetivo es obtener un clasificador confiable y con buen rendimiento. Los enfoques con LLM (zero-shot/few-shot) son atractivos porque son rápidos y no requieren entrenamiento, pero en este problema específico muestran limitaciones fuertes, especialmente al distinguir hate speech de offensive. En este caso, el few-shot no logró ayudar y reafirmó que, para tareas sensibles y con clases cercanas, el entrenamiento supervisado sigue siendo la estrategia más robusta.