Llegaste al final del curso. Ahora toca unir todo: HuggingFace, tokenizers, fine-tuning, LoRA, QLoRA, cuantización y despliegue en un único pipeline real. El problema: clasificar el sentimiento de reseñas de productos en español.
Antes de escribir una sola línea de código, vamos a ver el mapa completo del proyecto. Cada bloque del diagrama corresponde a un paso que implementaremos en esta lección.
Cada paso del pipeline usa herramientas de lecciones anteriores del curso. El proyecto es la síntesis de todo lo aprendido.
El problema: clasificar el sentimiento de reseñas de productos en español. Usaremos el dataset Amazon Reviews Multi — un dataset multilingüe con reseñas de Amazon en varios idiomas, incluyendo español. Cada reseña tiene de 1 a 5 estrellas. Simplificaremos a 3 clases: negativo (1-2 estrellas), neutro (3 estrellas), positivo (4-5 estrellas).
from datasets import load_dataset
import pandas as pd
# Cargar el subset en español
dataset = load_dataset("amazon_reviews_multi", "es")
print(f"Train: {len(dataset['train']):,} ejemplos")
print(f"Test: {len(dataset['test']):,} ejemplos")
print(f"\nColumnas: {dataset['train'].column_names}")
print(f"\nEjemplo:")
print(dataset["train"][0])
# Convertir estrellas a 3 clases: 0=negativo, 1=neutro, 2=positivo
def stars_to_label(stars):
if stars <= 2: return 0 # negativo (1-2 estrellas)
if stars == 3: return 1 # neutro (3 estrellas)
return 2 # positivo (4-5 estrellas)
dataset = dataset.map(lambda x: {"label": stars_to_label(x["stars"])})
# Usar un subconjunto manejable
train_ds = dataset["train"].select(range(6000))
test_ds = dataset["test"].select(range(1200))
# Ver distribución de clases
from collections import Counter
labels = [x["label"] for x in train_ds]
dist = Counter(labels)
for label, name in [(0,"Negativo"),(1,"Neutro"),(2,"Positivo")]:
print(f" {name}: {dist[label]:,} ({dist[label]/len(labels)*100:.1f}%)")
Antes de entrenar nada, medimos cuánto rinde el modelo preentrenado sin ningún fine-tuning. Esto nos da la referencia de partida. Usaremos un modelo multilingüe: xlm-roberta-base, que fue preentrenado con texto en 100 idiomas incluyendo español.
"Zero-shot" significa que el modelo no ha visto ningún ejemplo de nuestra tarea. Le pedimos que clasifique las reseñas usando solo su conocimiento preentrenado.
from transformers import pipeline
import numpy as np
# Pipeline zero-shot de clasificación de sentimiento
# cardiffnlp/twitter-xlm-roberta-base-sentiment: fine-tuned para sentimiento multilingüe
sentiment_pipeline = pipeline(
"text-classification",
model="cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual",
top_k=1,
)
# Mapeo de etiquetas del pipeline → nuestras clases
label_map = {"negative": 0, "neutral": 1, "positive": 2}
# Evaluar en el conjunto de test (primeros 500 para la demo)
test_sample = test_ds.select(range(500))
texts = [x["review_body"][:512] for x in test_sample]
true_labels = [x["label"] for x in test_sample]
preds_raw = sentiment_pipeline(texts, batch_size=32, truncation=True)
preds = [label_map.get(p[0]["label"].lower(), 1) for p in preds_raw]
from sklearn.metrics import accuracy_score, f1_score, classification_report
acc_zero = accuracy_score(true_labels, preds)
f1_zero = f1_score(true_labels, preds, average="macro")
print(f"Baseline zero-shot:")
print(f" Accuracy: {acc_zero*100:.2f}%")
print(f" F1 macro: {f1_zero*100:.2f}%")
Ahora aplicamos lo que aprendimos en las lecciones 10 y 11: cargamos el modelo base en 4 bits (NF4) y añadimos adaptadores LoRA. Usaremos xlm-roberta-base porque entiende español nativamente.
import torch
from transformers import (
AutoModelForSequenceClassification, AutoTokenizer,
BitsAndBytesConfig, TrainingArguments, Trainer,
DataCollatorWithPadding, EarlyStoppingCallback,
)
from peft import LoraConfig, get_peft_model, TaskType, prepare_model_for_kbit_training
import evaluate
MODEL_NAME = "xlm-roberta-base"
NUM_LABELS = 3
device = "cuda" if torch.cuda.is_available() else "cpu"
## ── Tokenizar ──
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
def tokenize(batch):
return tokenizer(
batch["review_body"], truncation=True, max_length=256,
padding=False
)
splits = train_ds.train_test_split(test_size=0.1, seed=42)
train_tok = splits["train"].map(tokenize, batched=True)
val_tok = splits["test"].map(tokenize, batched=True)
test_tok = test_ds.map(tokenize, batched=True)
collator = DataCollatorWithPadding(tokenizer)
## ── Configuración QLoRA ──
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
base_model = AutoModelForSequenceClassification.from_pretrained(
MODEL_NAME,
num_labels=NUM_LABELS,
quantization_config=bnb_config,
device_map="auto",
)
base_model = prepare_model_for_kbit_training(base_model)
## ── Configuración LoRA ──
lora_config = LoraConfig(
task_type=TaskType.SEQ_CLS,
r=8,
lora_alpha=16,
target_modules=["query", "value"], # matrices de atención en XLM-RoBERTa
lora_dropout=0.05,
bias="none",
)
model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()
## ── Métricas ──
accuracy_metric = evaluate.load("accuracy")
f1_metric = evaluate.load("f1")
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
acc = accuracy_metric.compute(predictions=preds, references=labels)
f1 = f1_metric.compute(predictions=preds, references=labels, average="macro")
return {"accuracy": acc["accuracy"], "f1_macro": f1["f1"]}
## ── Entrenamiento ──
training_args = TrainingArguments(
output_dir="./sentimiento-es-qlora",
num_train_epochs=8,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
learning_rate=3e-4,
warmup_steps=100,
weight_decay=0.01,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="eval_f1_macro",
greater_is_better=True,
logging_steps=50,
bf16=True,
report_to="none",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_tok,
eval_dataset=val_tok,
tokenizer=tokenizer,
data_collator=collator,
compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
trainer.train()
## Evaluar en test set
test_results = trainer.evaluate(test_tok)
print(f"Test accuracy: {test_results['eval_accuracy']*100:.2f}%")
print(f"Test F1 macro: {test_results['eval_f1_macro']*100:.2f}%")
## Reporte por clase
pred_out = trainer.predict(test_tok)
preds_test = np.argmax(pred_out.predictions, axis=-1)
labels_test = pred_out.label_ids
from sklearn.metrics import classification_report
print("\nReporte por clase:")
print(classification_report(
labels_test, preds_test,
target_names=["Negativo", "Neutro", "Positivo"],
digits=3
))
La clase "Neutro" es la más difícil (F1=0.719) — esto es típico en clasificación de sentimiento. Las reseñas de 3 estrellas mezclan aspectos positivos y negativos, y la frontera con las reseñas de 4 estrellas es borrosa.
El modelo ya está entrenado y funciona bien. Ahora podemos aplicar una capa adicional de compresión: cuantización int8 post-entrenamiento para hacerlo más pequeño todavía.
# Primero, fusionar los adaptadores LoRA con el modelo base (merge_and_unload)
# Esto produce un único modelo sin la estructura LoRA
model_merged = model.merge_and_unload()
print("Adaptadores LoRA fusionados con el modelo base.")
print(f"Parámetros totales: {sum(p.numel() for p in model_merged.parameters()):,}")
# Guardar los adaptadores LoRA (versión compacta — solo 3.4 MB)
model.save_pretrained("./sentimiento-es-lora-adapters")
tokenizer.save_pretrained("./sentimiento-es-lora-adapters")
# Verificar tamaño de los adaptadores
import os
adapter_size = sum(
os.path.getsize(os.path.join("./sentimiento-es-lora-adapters", f))
for f in os.listdir("./sentimiento-es-lora-adapters")
)
print(f"Tamaño de los adaptadores: {adapter_size / 1e6:.1f} MB")
# Para inferencia eficiente: cargar con int8
bnb_8bit = BitsAndBytesConfig(load_in_8bit=True)
model_int8 = AutoModelForSequenceClassification.from_pretrained(
"xlm-roberta-base",
num_labels=3,
quantization_config=bnb_8bit,
device_map="auto",
)
# Cargar los adaptadores LoRA encima del modelo int8
from peft import PeftModel
model_int8_lora = PeftModel.from_pretrained(model_int8, "./sentimiento-es-lora-adapters")
print("Modelo int8 + LoRA listo para inferencia.")
El último paso es empaquetar el modelo como un endpoint de inferencia. La forma más sencilla es una función Python que recibe texto y devuelve el sentimiento con su probabilidad.
import torch
import torch.nn.functional as F
from transformers import AutoTokenizer
# En producción, cargar el modelo una sola vez al arrancar el servidor
tokenizer_inf = AutoTokenizer.from_pretrained("./sentimiento-es-lora-adapters")
model_inf = model_int8_lora
model_inf.eval()
LABELS = {0: "Negativo", 1: "Neutro", 2: "Positivo"}
def classify_sentiment(text: str) -> dict:
"""
Clasifica el sentimiento de una reseña en español.
Returns: dict con 'label', 'confidence' y 'probabilities'
"""
inputs = tokenizer_inf(
text,
truncation=True,
max_length=256,
return_tensors="pt"
).to(model_inf.device)
with torch.no_grad():
outputs = model_inf(**inputs)
probs = F.softmax(outputs.logits, dim=-1)[0]
pred_class = probs.argmax().item()
return {
"label": LABELS[pred_class],
"confidence": round(probs[pred_class].item(), 4),
"probabilities": {LABELS[i]: round(p.item(), 4) for i, p in enumerate(probs)}
}
## Ejemplos de uso
test_reviews = [
"Excelente producto, llegó antes de lo esperado y funciona perfectamente.",
"El producto está bien pero el embalaje llegó un poco dañado.",
"Pésima calidad, se rompió a la semana. No lo recomiendo para nada.",
"Cumple con lo que promete, nada más. Precio justo.",
]
for review in test_reviews:
result = classify_sentiment(review)
print(f"'{review[:55]}...'")
print(f" → {result['label']} (confianza: {result['confidence']*100:.1f}%)")
print(f" Neg: {result['probabilities']['Negativo']*100:.1f}% "
f"Neu: {result['probabilities']['Neutro']*100:.1f}% "
f"Pos: {result['probabilities']['Positivo']*100:.1f}%\n")
| Enfoque | Accuracy | F1 Macro | VRAM GPU | Tiempo inferencia | Tamaño guardado |
|---|---|---|---|---|---|
| Baseline zero-shot | 61.2% | 55.4% | ~1.1 GB | ~38ms/ejemplo | 1.1 GB (full) |
| Fine-tuning completo (FP16) | 87.1% | 85.3% | ~8.4 GB | ~35ms/ejemplo | 1.1 GB (full) |
| LoRA (r=8, FP16) | 85.8% | 83.5% | ~2.6 GB | ~35ms/ejemplo | 3.4 MB (adapters) |
| QLoRA (r=8, NF4 4-bit) | 84.4% | 82.2% | ~0.7 GB | ~42ms/ejemplo | 3.4 MB (adapters) |
| QLoRA + int8 inferencia | 84.4% | 82.2% | ~0.4 GB | ~40ms/ejemplo | 280 MB (int8) |
Has completado 7 cursos del Laboratorio de IA. Miremos hacia atrás para ver qué piezas construiste y cómo se conectan:
Esta demo simula el comportamiento del modelo entrenado. Escribe una reseña en español y observa qué clase asignaría el modelo con sus probabilidades estimadas.
Has completado los 7 cursos del Laboratorio de IA de Dinnovos.
Eso es mucho trabajo, mucha paciencia y mucha curiosidad. Lo conseguiste.
🏠 Volver al Laboratorio de IA