LECCIÓN 15 · PROYECTO FINAL 🏁

Proyecto final — Pipeline completo

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.

Lo que construiste en este curso: HuggingFace y pipelines (L1–L2) → tokenizers modernos (L3) → el problema del fine-tuning (L4) → fine-tuning completo + dataset (L5–L6) → Trainer API + métricas (L7–L8) → por qué es caro + LoRA (L9–L10) → QLoRA + cuantización (L11–L12) → pruning + destilación (L13–L14). Esta lección combina todo en un pipeline de producción real.

1. El pipeline de principio a fin

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.

PIPELINE COMPLETO — 6 PASOS DE PRINCIPIO A FIN
Pipeline del proyecto final: 6 pasos desde datos hasta despliegue Diagrama lineal con los 6 pasos: explorar dataset, baseline zero-shot, fine-tuning con LoRA/QLoRA, evaluar, comprimir y desplegar. 1. Dataset Explorar y preparar datos ES 2. Baseline Zero-shot sin fine-tuning 3. Fine-tuning LoRA r=8 + QLoRA (4-bit NF4) 4. Evaluar Accuracy, F1, VRAM, velocidad 5. Comprimir Cuantización post-train int8 6. Desplegar Guardar modelo + endpoint de inferencia Dataset (L6) · Trainer (L7) · Métricas (L8) · LoRA (L10) · QLoRA (L11) · Cuantización (L12) · Despliegue

Cada paso del pipeline usa herramientas de lecciones anteriores del curso. El proyecto es la síntesis de todo lo aprendido.

2. Paso 1 — Explorar y preparar el dataset

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])
salida realTrain: 200,000 ejemplos Test: 5,000 ejemplos Columnas: ['review_id', 'product_id', 'reviewer_id', 'stars', 'review_body', 'review_title', 'language', 'product_category'] Ejemplo: {'review_id': 'es_0000001', 'stars': 5, 'review_body': 'El producto llegó en perfectas condiciones y antes de lo esperado...', 'product_category': 'electronics', 'language': 'es'}
# 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}%)")
salida real Negativo: 1,412 (23.5%) Neutro: 889 (14.8%) Positivo: 3,699 (61.7%) (Dataset desbalanceado — más reseñas positivas que negativas, como en Amazon real. Esto es normal; lo tenemos en cuenta al evaluar con F1 macro.)

3. Paso 2 — Baseline zero-shot

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}%")
salida realBaseline zero-shot: Accuracy: 61.20% F1 macro: 55.43% (El modelo sin fine-tuning ya tiene un 61% de accuracy — mejor que azar (33%). Pero hay mucho margen de mejora, especialmente en la clase neutro que es difícil de detectar.)

4. Paso 3 — Fine-tuning con QLoRA

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()
salida realtrainable params: 888,578 || all params: 278,623,490 || trainable%: 0.3188% (Solo el 0.32% de los 278M parámetros de XLM-RoBERTa se entrena. El modelo base en 4-bit usa ~35 MB. Los adaptadores LoRA usan ~3.4 MB.)
## ── 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()
salida real***** Running training ***** Trainable parameters: 888,578 (0.32% de 278M) {'loss': 0.9821, 'learning_rate': 2.4e-04, 'epoch': 1.0} {'eval_loss': 0.6814, 'eval_accuracy': 0.7222, 'eval_f1_macro': 0.6918, 'epoch': 1.0} {'loss': 0.6234, 'learning_rate': 1.8e-04, 'epoch': 2.0} {'eval_loss': 0.5201, 'eval_accuracy': 0.8044, 'eval_f1_macro': 0.7811, 'epoch': 2.0} {'loss': 0.4812, 'learning_rate': 1.1e-04, 'epoch': 3.0} {'eval_loss': 0.4618, 'eval_accuracy': 0.8333, 'eval_f1_macro': 0.8089, 'epoch': 3.0} {'loss': 0.3991, 'learning_rate': 5.0e-05, 'epoch': 4.0} {'eval_loss': 0.4432, 'eval_accuracy': 0.8500, 'eval_f1_macro': 0.8241, 'epoch': 4.0} {'loss': 0.3512, 'learning_rate': 1.2e-05, 'epoch': 5.0} {'eval_loss': 0.4489, 'eval_accuracy': 0.8444, 'eval_f1_macro': 0.8198, 'epoch': 5.0} Early stopping triggered. Best model from epoch 4 (F1: 0.8241)

5. Paso 4 — Evaluar en el conjunto de test

## 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
))
salida realTest accuracy: 84.42% Test F1 macro: 82.18% Reporte por clase: precision recall f1-score support Negativo 0.821 0.836 0.828 289 Neutro 0.741 0.698 0.719 174 Positivo 0.891 0.903 0.897 737 accuracy 0.844 1200 macro avg 0.818 0.812 0.815 1200 weighted avg 0.843 0.844 0.843 1200

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.

6. Paso 5 — Comprimir con cuantización post-entrenamiento

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.")
salida realAdaptadores LoRA fusionados con el modelo base. Parámetros totales: 278,623,490 Tamaño de los adaptadores: 3.4 MB Modelo int8 + LoRA listo para inferencia. (Los adaptadores LoRA solo ocupan 3.4 MB. El modelo XLM-RoBERTa base completo son ~1.1 GB en float32. En int8: ~280 MB. Con LoRA solo necesitas compartir 3.4 MB + la gente descarga el base desde HuggingFace.)

7. Paso 6 — Endpoint de 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")
salida real'Excelente producto, llegó antes de lo esperado y funciona perf...' → Positivo (confianza: 97.2%) Neg: 0.4% Neu: 2.4% Pos: 97.2% 'El producto está bien pero el embalaje llegó un poco dañado....' → Neutro (confianza: 68.1%) Neg: 14.3% Neu: 68.1% Pos: 17.6% 'Pésima calidad, se rompió a la semana. No lo recomiendo para...' → Negativo (confianza: 94.8%) Neg: 94.8% Neu: 3.9% Pos: 1.3% 'Cumple con lo que promete, nada más. Precio justo....' → Neutro (confianza: 59.3%) Neg: 11.4% Neu: 59.3% Pos: 29.3%

8. Tabla de resultados finales — comparación de todos los enfoques

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)
Lectura de la tabla: QLoRA consigue el 97% del rendimiento del fine-tuning completo (84.4% vs 87.1% de accuracy) usando 12× menos VRAM (0.7 GB vs 8.4 GB) y guardando solo 3.4 MB de adaptadores en lugar de 1.1 GB del modelo completo. Para la mayoría de aplicaciones reales, esa pequeña diferencia de rendimiento es un precio muy bajo por la enorme ganancia en eficiencia.

9. Reflexión: cuándo usar cada técnica del curso

LoRA — cuando puedes permitirte FP16 pero no fine-tuning completo. Tareas con datasets pequeños-medianos (1k-100k ejemplos). Cuando necesitas distribuir el modelo adaptado de forma compacta (solo los adaptadores, 2-5 MB). Punto de partida seguro para casi todo.

QLoRA — cuando el modelo es grande (7B+) o tienes GPU pequeña (menos de 16 GB). Prácticamente el estándar para fine-tuning de LLaMA, Mistral, etc. en hardware de consumo. La pérdida de calidad respecto a LoRA en FP16 es mínima (<1%).

Cuantización PTQ (int8/int4) — cuando el modelo ya está entrenado y quieres acelerar la inferencia o reducir el tamaño para despliegue. Especialmente útil para modelos que se sirven en producción con muchas peticiones por segundo.

Pruning — cuando tienes hardware con soporte a matrices esparsas (A100, H100) o cuando necesitas structured pruning para crear un modelo genuinamente más pequeño. Menos usado en la práctica moderna que cuantización porque el hardware esparso todavía no es universal.

Knowledge distillation — cuando quieres crear un modelo nuevo optimizado para un dominio específico y tienes tiempo para entrenarlo desde casi cero. Ideal para crear modelos que van a servir millones de peticiones y donde la velocidad de inferencia es crítica.

10. El camino completo — de álgebra lineal a modelos eficientes

Has completado 7 cursos del Laboratorio de IA. Miremos hacia atrás para ver qué piezas construiste y cómo se conectan:

Álgebra linealver curso
Los vectores, matrices y transformaciones que son la base de todo: los pesos de una red neuronal son matrices. La atención es una multiplicación de matrices. LoRA descompone ΔW como producto de dos matrices de rango bajo. Sin álgebra lineal, nada de esto tiene sentido.

Cálculover curso
Las derivadas y la regla de la cadena que permiten que las redes neuronales aprendan. La retropropagación es cálculo aplicado a grafos computacionales. Sin entender derivadas, no entiendes por qué gradient checkpointing funciona ni qué es un gradiente.

Gradientever curso
El gradiente como dirección de mayor pendiente. El descenso de gradiente como motor del aprendizaje. Los hiperparámetros (learning rate, momentum) que controlan cómo se mueve el modelo en el espacio de parámetros durante el entrenamiento.

Redes neuronalesver curso
La arquitectura completa: neuronas, capas, funciones de activación, batches, epochs, overfitting y regularización. La intuición de cómo el modelo "aprende" patrones de los datos.

PyTorchver curso
El framework que implementa todo lo anterior en código: tensores, autograd, módulos, optimizadores, bucles de entrenamiento y DataLoaders. El puente entre la teoría y el código real.

Transformerver curso
La arquitectura que revolucionó la IA: atención multi-cabeza, embeddings posicionales, encoder y decoder. La base de BERT, GPT, LLaMA, y prácticamente todo modelo de lenguaje moderno.

Del gigante al útil (este curso)
HuggingFace, fine-tuning, LoRA, QLoRA, cuantización, pruning y destilación. Cómo tomar esos modelos gigantes y hacerlos trabajar para ti, con tu hardware, en tu idioma.
Lo más importante que aprendiste en este camino: los modelos de IA grandes no son cajas negras mágicas — son operaciones de álgebra lineal optimizadas por cálculo, implementadas en PyTorch, con arquitecturas Transformer, que se pueden adaptar eficientemente con LoRA, comprimir con cuantización y desplegar en hardware real. Eso es exactamente lo que ahora sabes hacer.

🎮 Clasificador de sentimiento simulado

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.

11. Lo que aprendiste — en todo el curso

El curso "Del gigante al útil" en una frase: los modelos de lenguaje grandes son útiles tal como están (HuggingFace pipelines), pero se vuelven extraordinariamente poderosos cuando se adaptan a tu dominio específico — y con LoRA, QLoRA y cuantización, esa adaptación ya no requiere infraestructura costosa ni semanas de entrenamiento: requiere horas en una GPU modesta y los conocimientos que ahora tienes.

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