Lección 6.2.4 · 70 min

Fine-tuning ligero para tareas financieras: LoRA y QLoRA

Ajustar todos los pesos de un modelo de lenguaje cuesta memoria que casi nadie tiene. LoRA congela el modelo y entrena dos matrices pequeñas por capa; QLoRA, además, guarda la base en 4 bits. Esta lección hace la cuenta de parámetros y de memoria con la configuración real de Qwen2.5, entrena adaptadores LoRA sobre MiniLM en CPU para clasificar el sentimiento de titulares sintéticos y los compara con FinBERT sin entrenar y con una logística sobre embeddings, con partición temporal y un placebo.

Fig. — Acierto de tres clasificadores de sentimiento de titulares entrenados con 300 ejemplos (prueba: 600 titulares de 2024-2025; *FinBERT, en 100 de ellos)
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Tres formas de adaptar un modelo a una tarea

Un modelo preentrenado sabe mucho de lenguaje y poco de la tarea concreta: separar titulares buenos, malos y neutros para un emisor, extraer un KPI de un 10-Q o clasificar el tono de una pregunta de analista. Hay tres formas de acercarlo, de menos a más coste:

PreentrenadoUsarlo tal cualEntrenar encimaAjustar pesosAjuste completoLoRA y QLoRA
Fuente Mermaid
flowchart TB
  A[Preentrenado] --> B[Usarlo tal cual]
  A --> C[Entrenar encima]
  A --> D[Ajustar pesos]
  D --> E[Ajuste completo]
  D --> F[LoRA y QLoRA]
  1. Usarlo tal cual (zero-shot): FinBERT clasifica sentimiento financiero sin ver un solo ejemplo propio, o un LLM recibe la tarea en el prompt (lecciones 6.1.1 y 6.2.3). Coste cero, pero el modelo trae los sesgos de su corpus: el idioma, el estilo y lo que entonces se consideraba positivo.
  2. Congelarlo y entrenar encima: se calculan los embeddings una vez y se ajusta un modelo lineal (lección 6.1.1). Es barato y difícil de sobreajustar, pero los embeddings no se mueven: si el codificador no separa bien dos clases, la logística no puede arreglarlo.
  3. Ajustar los pesos: el ajuste completo mueve todos los parámetros y necesita guardar un gradiente y el estado del optimizador para cada uno. Los métodos de ajuste eficiente en parámetros (PEFT) mueven solo una parte. LoRA es el más usado.

Esta lección se centra en el tercero, con los otros dos como referencia, y lo mide en una tarea pequeña que cabe en CPU: el sentimiento de los titulares del corpus sintético del nivel 6, cuya verdad se conoce.

La tarea: sentimiento de titulares con 300 etiquetas

Cada noticia original de noticias6_corpus.parquet tiene su sentimiento_verdadero en noticias6_verdad.csv: negativo, cero en los eventos neutros (juntas, nombramientos, conferencias) o positivo. La clase es su signo. Se usa solo el titular (de 18 tokens de mediana con el tokenizador de MiniLM) para que el entrenamiento quepa en CPU en segundos, y una partición temporal: 300 titulares de 2019-2022 para entrenar, 200 de 2023 para validar y elegir hiperparámetros, y 600 de 2024-2025 como prueba, que no se mira hasta el final.

IN [01]python
import numpy as np
import pandas as pd
import torch

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
corpus = pd.read_parquet("noticias6_corpus.parquet")
verdad = pd.read_csv("noticias6_verdad.csv")
d = corpus.merge(verdad, on="id_noticia")
d = d[d["tipo_noticia"] == "original"].copy()
s = d["sentimiento_verdadero"].round(2)
d["y"] = np.sign(s).astype(int) + 1   # 0 neg., 1 neutra, 2 pos.
d["anio"] = d["ts"].dt.year
tr = d[d["anio"] <= 2022].sample(300, random_state=0)
va = d[d["anio"] == 2023].sample(200, random_state=0)
te = d[d["anio"] >= 2024].sample(600, random_state=0)
for nombre, x in [("entrenamiento", tr), ("validación", va),
                  ("prueba", te)]:
    print(f"{nombre:13s} {len(x):5d} titulares · "
          f"{x['idioma'].eq('es').mean():.0%} en español · "
          f"clases {np.bincount(x['y'], minlength=3)}")
print(te[["titular", "y"]].head(4).to_string(index=False))
OUT [01]Titulares etiquetados y partición temporal
entrenamiento   300 titulares · 77% en español · clases [ 96  82 122]
validación      200 titulares · 74% en español · clases [69 45 86]
prueba          600 titulares · 77% en español · clases [207 138 255]
                                                                titular  y
                    Incendio en la planta de Funvia Petroleum en Bilbao  0
           Contrato de 1.606,9 millones de dólares para Rapsa Insurance  2
                            Centis Health lanza la plataforma Centis V6  2
Bezvia Gas llama a revisión a 3.000 unidades de la plataforma Bezvia X4  0

Trescientas etiquetas es una cifra realista: es lo que un analista revisa en una tarde. Tres de cada cuatro titulares están en español, el idioma que peor conocen MiniLM y FinBERT, ambos entrenados con texto inglés. La clase más frecuente en la prueba es la positiva (255 de 600): un clasificador que dijera siempre «positiva» acertaría el 42,5 %.

LoRA: entrenar una corrección de rango bajo

Una capa lineal calcula y=Wxy = W x con WW de d×kd \times k. El ajuste completo aprende una actualización ΔW\Delta W del mismo tamaño. LoRA (Hu et al., 2021) parte de la observación de que esa actualización tiene, en la práctica, rango efectivo bajo, y la escribe como el producto de dos matrices estrechas:

y=Wx+αrBAx,A∈Rr×k, B∈Rd×r, r≪min⁡(d,k)y = W x + \frac{\alpha}{r} B A x, \qquad A \in \mathbb{R}^{r \times k},\ B \in \mathbb{R}^{d \times r},\ r \ll \min(d, k)

WW se congela; solo se entrenan AA y BB. El factor α/r\alpha / r fija la escala de la corrección (con lora_alpha=16 y r=8, vale 2) para poder cambiar el rango sin reajustar la tasa de aprendizaje.

xW congeladaA: r x kB: d x rsumay
Fuente Mermaid
flowchart LR
  X[x] --> W[W congelada]
  X --> A[A: r x k]
  A --> B[B: d x r]
  W --> S[suma]
  B --> S
  S --> Y[y]

La cuenta de parámetros es inmediata: d⋅kd \cdot k para WW y r(d+k)r (d + k) para el adaptador. En una matriz de atención de MiniLM (384×384384 \times 384):

IN [02]python
d_entrada = d_salida = 384
for r in (1, 8, 64):
    completa = d_entrada * d_salida
    lora = r * (d_entrada + d_salida)
    print(f"r = {r:2d}: W tiene {completa:,} parámetros; "
          f"A y B, {lora:,} ({lora / completa:.1%})")
OUT [02]La cuenta de LoRA en una matriz de atención de MiniLM
r =  1: W tiene 147,456 parámetros; A y B, 768 (0.5%)
r =  8: W tiene 147,456 parámetros; A y B, 6,144 (4.2%)
r = 64: W tiene 147,456 parámetros; A y B, 49,152 (33.3%)

Con peft se elige en qué capas poner adaptadores (target_modules). La receta original de LoRA los pone en las proyecciones query y value de la atención; la celda lo hace en las seis capas de MiniLM:

IN [03]python
import transformers
from transformers import AutoModel, AutoTokenizer
from peft import LoraConfig, get_peft_model

transformers.logging.set_verbosity_error()
NOMBRE = "sentence-transformers/all-MiniLM-L6-v2"
tok = AutoTokenizer.from_pretrained(NOMBRE)
cfg = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.1,
                 target_modules=["query", "value"])
base = get_peft_model(AutoModel.from_pretrained(NOMBRE), cfg)
total = sum(p.numel() for p in base.parameters())
entren = sum(p.numel() for p in base.parameters()
             if p.requires_grad)
print(f"parámetros: {total:,} · entrenables: {entren:,} "
      f"({entren / total:.2%})")
capa = base.base_model.model.encoder.layer[0].attention.self.query
print(type(capa).__name__, tuple(capa.lora_A["default"].weight.shape),
      tuple(capa.lora_B["default"].weight.shape),
      "B = 0:", bool((capa.lora_B["default"].weight == 0).all()))
OUT [03]MiniLM con adaptadores LoRA en query y value
parámetros: 22,786,944 · entrenables: 73,728 (0.32%)
Linear (8, 384) (384, 8) B = 0: True

Se entrenan 73 728 de 22,8 millones de parámetros (0,32 %): 6 capas × 2 matrices × 6 144. La capa query es ahora un Linear de peft con su lora_A (8×384) y su lora_B (384×8). B empieza en cero y A al azar: así BA=0BA = 0 y, antes de entrenar, el modelo adaptado da exactamente las mismas salidas que el original. El ajuste parte del modelo preentrenado, no de un modelo estropeado al azar.

Tres propiedades hacen práctico a LoRA:

  • Memoria: no hay gradientes ni estado de Adam para los pesos congelados (la sección siguiente lo cuantifica).
  • Un modelo, muchos adaptadores: el mismo MiniLM puede servir un adaptador para titulares, otro para preguntas de analistas y otro para riesgos de un 10-K, cada uno de unos cientos de KB.
  • Sin coste en inferencia: tras entrenar, W′=W+αrBAW' = W + \frac{\alpha}{r} BA se calcula una vez y el modelo vuelve a tener sus matrices originales. El artículo lo destaca frente a otros adaptadores, que añaden capas y latencia.

Cuánta memoria: de 0,5B a 7B, con y sin cuantización

Para un LLM la cuenta importa más. La celda construye Qwen2.5-0.5B (la configuración del modelo local del curso) y una configuración con las dimensiones publicadas de Qwen2.5-7B (28 capas, 3 584 de dimensión oculta, 18 944 en la capa intermedia, atención con 28 cabezas de consulta y 4 de clave-valor) en el dispositivo meta: PyTorch crea la estructura sin reservar memoria para los pesos, así que se puede contar un modelo de 7B en un portátil. Los adaptadores van en las siete proyecciones lineales de cada bloque, con rango 8.

IN [04]python
from transformers import AutoConfig, AutoModelForCausalLM
from transformers import Qwen2Config

def cuenta(config, modulos, r=8):
    """Parámetros totales, de capas lineales y de LoRA (sin pesos)."""
    with torch.device("meta"):
        m = AutoModelForCausalLM.from_config(config)
    total = sum(p.numel() for p in m.parameters())
    lineales = sum(mod.weight.numel()
                   for n, mod in m.model.layers.named_modules()
                   if isinstance(mod, torch.nn.Linear))
    m = get_peft_model(m, LoraConfig(r=r, lora_alpha=2 * r,
                                     target_modules=modulos))
    lora = sum(p.numel() for p in m.parameters() if p.requires_grad)
    return total, lineales, lora

TODAS = ["q_proj", "k_proj", "v_proj", "o_proj",
         "gate_proj", "up_proj", "down_proj"]
q05 = AutoConfig.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
q7 = Qwen2Config(hidden_size=3584, intermediate_size=18944,
                 num_hidden_layers=28, num_attention_heads=28,
                 num_key_value_heads=4, vocab_size=152064,
                 tie_word_embeddings=False)
filas = {}
for nombre, c in [("Qwen2.5-0.5B", q05), ("Qwen2.5-7B", q7)]:
    filas[nombre] = cuenta(c, TODAS)
print(pd.DataFrame(
    {k: {"total (M)": t / 1e6, "lineales (M)": li / 1e6,
         "LoRA r=8 (M)": lo / 1e6, "LoRA (%)": 100 * lo / t}
     for k, (t, li, lo) in filas.items()}).T.round(2))
OUT [04]Parámetros y memoria: Qwen2.5 de 0,5B y de 7B
              total (M)  lineales (M)  LoRA r=8 (M)  LoRA (%)
Qwen2.5-0.5B     494.03        357.83          4.40      0.89
Qwen2.5-7B      7615.62       6525.29         20.19      0.27

Los totales coinciden con la ficha de Qwen2.5-7B-Instruct (7,61B parámetros, 6,53B sin embeddings)1Nota 1 · Ficha de Qwen2.5-7B-Instruct en Hugging Face (consultada el 2026-10-23): 7,61B parámetros, 6,53B sin embeddings, 28 capas, 28 cabezas de consulta y 4 de clave-valor, licencia Apache-2.0.. LoRA de rango 8 en todas las capas lineales entrena 20,2 millones: un 0,27 % del modelo de 7B y un 0,89 % del de 0,5B (en el pequeño, los embeddings pesan más).

La memoria de entrenamiento tiene cuatro partes: los pesos, los gradientes, el estado del optimizador y las activaciones. Con Adam en fp32, cada parámetro entrenable cuesta 16 bytes (peso, gradiente y dos momentos de 4 bytes). La celda compara tres configuraciones, sin contar las activaciones, que dependen del tamaño del lote y de la longitud de las secuencias:

  • Completo: todo entrenable en fp32 con Adam, 16 bytes por parámetro.
  • LoRA: la base congelada en bf16 (2 bytes por parámetro) y 16 bytes por parámetro del adaptador.
  • QLoRA: las capas lineales congeladas en NF4 (4 bits) con la doble cuantización de las constantes, el resto en bf16 y el adaptador igual que en LoRA.
IN [05]python
def memoria_gb(total, lineales, lora):
    """GB para entrenar: completo, LoRA (base bf16), QLoRA (NF4)."""
    adam = 4 + 4 + 8          # peso fp32, gradiente, dos momentos
    completo = total * adam
    lora_bf16 = total * 2 + lora * adam
    resto = total - lineales  # embeddings, normas y salida en bf16
    bits = 4 + 0.127          # NF4 + constantes con doble cuantiz.
    qlora = lineales * bits / 8 + resto * 2 + lora * adam
    return [x / 1e9 for x in (completo, lora_bf16, qlora)]

mem = pd.DataFrame({k: memoria_gb(*v) for k, v in filas.items()},
                   index=["completo fp32+Adam", "LoRA (base bf16)",
                          "QLoRA (base NF4)"]).round(1)
print(mem.to_string())
OUT [05]Memoria de pesos, gradientes y Adam (sin activaciones)
                    Qwen2.5-0.5B  Qwen2.5-7B
completo fp32+Adam           7.9       121.8
LoRA (base bf16)             1.1        15.6
QLoRA (base NF4)             0.5         5.9
Fig. — Memoria para ajustar Qwen2.5-7B (7 616 millones de parámetros) según el método, sin contar las activaciones

Para el modelo de 7B, el ajuste completo necesita 121,8 GB solo en pesos, gradientes y optimizador: varias GPU de centro de datos. LoRA baja a 15,6 GB, de los que 15,2 son la base en bf16. QLoRA comprime precisamente esa base y llega a 5,9 GB. El estado del adaptador es el mismo en los dos (20 millones × 16 bytes = 0,3 GB).

QLoRA: la base en 4 bits

QLoRA (Dettmers et al., 2023) entrena adaptadores LoRA sobre un modelo cuya base está cuantizada a 4 bits. El artículo introduce tres piezas: el tipo NF4 (NormalFloat de 4 bits, con los 16 niveles repartidos según los cuantiles de una normal, porque los pesos de una red se distribuyen de forma aproximadamente normal), la doble cuantización (cuantizar también las constantes de escala de cada bloque de 64 pesos) y los optimizadores paginados (que mueven el estado del optimizador a la memoria del sistema en los picos). Según el artículo:

  • Ajustar LLaMA de 65B en 16 bits necesita más de 780 GB de memoria de GPU; QLoRA lo hace en una sola GPU de 48 GB sin perder rendimiento frente al ajuste en 16 bits.
  • Las constantes de escala en fp32 cuestan 32/64=0,532/64 = 0{,}5 bits por parámetro; con la doble cuantización, 8/64+32/(64⋅256)=0,1278/64 + 32/(64 \cdot 256) = 0{,}127 bits, unos 0,37 bits menos (unos 3 GB en un modelo de 65B). Es el 4,127 de la celda.
  • Para igualar el ajuste completo hay que poner adaptadores en todas las capas lineales, no solo en query y value; el rango, en cambio, apenas influyó en sus experimentos.

Durante el cálculo, cada bloque se descuantiza a bf16 sobre la marcha: QLoRA ahorra memoria, no operaciones. El código siguiente es el de la documentación de transformers y peft (consultada el 2026-10-23); no se ejecuta en el curso: bitsandbytes no está en el entorno ia y, con un modelo de 0,5B que cabe entero en memoria, la cuantización no aportaría nada que medir.

IN [06]python
# QLoRA con bitsandbytes (no está en el entorno; no se ejecuta)
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import (LoraConfig, get_peft_model,
                  prepare_model_for_kbit_training)

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",          # NormalFloat de 4 bits
    bnb_4bit_use_double_quant=True,     # cuantiza las constantes
    bnb_4bit_compute_dtype=torch.bfloat16,
)
modelo = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct", quantization_config=bnb,
    device_map="auto")
modelo = prepare_model_for_kbit_training(modelo)
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
                 target_modules="all-linear", task_type="CAUSAL_LM")
modelo = get_peft_model(modelo, cfg)
modelo.print_trainable_parameters()

target_modules="all-linear" es el atajo de peft para «todas las capas lineales» (salvo la de salida), que es lo que recomienda el artículo. La documentación de transformers lista backends de bitsandbytes para GPU NVIDIA, Intel XPU, Intel Gaudi y CPU, y advierte de que con pesos en 8 o 4 bits solo se pueden entrenar parámetros adicionales: los adaptadores.

Dos referencias antes de entrenar nada

Un ajuste solo vale lo que mejore sobre la alternativa barata. La primera referencia es FinBERT tal cual, sin ver ninguna etiqueta propia (lección 6.1.1; sus pesos tienen licencia CC-BY-NC-SA-4.0). Para no alargar la ejecución, se mide en los 100 primeros titulares de la prueba, una submuestra aleatoria:

IN [07]python
from transformers import pipeline

finbert = pipeline("text-classification", model="ProsusAI/finbert",
                   device="cpu")
muestra = te.iloc[:100]
codigo = {"negative": 0, "neutral": 1, "positive": 2}
salida = finbert(list(muestra["titular"]), batch_size=32)
pred_fb = np.array([codigo[r["label"]] for r in salida])
acierto_fb = pred_fb == muestra["y"].to_numpy()
print(f"FinBERT: {acierto_fb.mean():.1%} en {len(muestra)} titulares")
for idioma in ("en", "es"):
    m = muestra["idioma"].to_numpy() == idioma
    print(f"  {idioma}: {acierto_fb[m].mean():.1%} "
          f"({m.sum()} titulares)")
OUT [07]Referencia 1: FinBERT sin entrenar (100 titulares de prueba)
FinBERT: 36.0% en 100 titulares
  en: 68.2% (22 titulares)
  es: 26.9% (78 titulares)

FinBERT acierta el 36,0 %, por debajo del 42,5 % de responder siempre la clase mayoritaria. En inglés llega al 68,2 % (solo 22 titulares, así que con mucho ruido), y en español se hunde al 26,9 %: su vocabulario trocea el español en fragmentos ingleses (lección 6.1.1). Un modelo de dominio financiero no es un modelo de este dominio: otro idioma, otro estilo de titular y otra noción de neutro.

La segunda referencia es la de 6.1.1: los embeddings de MiniLM congelados (media de los tokens y norma 1, que es lo que hace sentence-transformers con este modelo) y una regresión logística. La celda reutiliza el modelo de la sección anterior con el adaptador desactivado (disable_adapter()), así que los embeddings son los del MiniLM original. La regularización se elige en validación:

IN [08]python
from sklearn.linear_model import LogisticRegression

def lotes(textos, n=100):
    textos = list(textos)
    for k in range(0, len(textos), n):
        yield tok(textos[k:k + n], truncation=True,
                  max_length=48, padding=True, return_tensors="pt")

def embeber(modelo, x):
    """Media de los tokens (sin relleno) y norma 1, como MiniLM."""
    h = modelo(input_ids=x["input_ids"],
               attention_mask=x["attention_mask"]).last_hidden_state
    m = x["attention_mask"].unsqueeze(-1).float()
    e = (h * m).sum(1) / m.sum(1)
    return torch.nn.functional.normalize(e, dim=-1)

base.eval()
with torch.inference_mode(), base.disable_adapter():
    E = {k: torch.cat([embeber(base, x) for x in lotes(df["titular"])])
         .numpy() for k, df in [("tr", tr), ("va", va), ("te", te)]}
for C in (1, 10, 100, 1000):
    lr = LogisticRegression(C=C, max_iter=3000).fit(E["tr"], tr["y"])
    print(f"C = {C:4d}: validación {lr.score(E['va'], va['y']):.1%}")
OUT [08]Referencia 2: logística sobre los embeddings congelados
C =    1: validación 77.0%
C =   10: validación 84.5%
C =  100: validación 86.0%
C = 1000: validación 87.5%

El acierto en validación sube con CC (menos regularización) hasta el 87,5 % con C=1 000C = 1\,000; con C=100C = 100 es del 86,0 %, tres titulares de 200 menos. Se elige el mejor en validación, C=1 000C = 1\,000. En la prueba:

IN [09]python
lr = LogisticRegression(C=1000, max_iter=3000).fit(E["tr"], tr["y"])
pred_lr = lr.predict(E["te"])
print(f"logística: {np.mean(pred_lr == te['y']):.1%} en prueba")
OUT [09]Logística elegida en validación, medida en prueba
logística: 89.3% en prueba

El 89,3 % con 300 etiquetas es una referencia exigente. Cualquier ajuste tiene que superarla con holgura para compensar su coste y su riesgo.

Entrenar los adaptadores

El clasificador es el MiniLM con adaptadores, la misma media de tokens y una capa lineal de 384 a 3 clases (la cabeza, entrenable entera: 1 155 parámetros más). El embedding se multiplica por 10 antes de la cabeza porque, con norma 1, sus componentes son muy pequeñas y la cabeza tardaría en separar las clases. Se entrena con AdamW, lotes de 32, tasa de aprendizaje 2⋅10−32 \cdot 10^{-3} (alta para un ajuste completo, normal para LoRA) y cuatro épocas, y al final de cada una se mide la pérdida en 100 titulares de validación de 2023. Cada lote se recorta a la longitud de su titular más largo, sin columnas de relleno que no aportan nada: con titulares de 18 tokens de mediana, eso reduce el cálculo a la mitad.

IN [10]python
class Clasificador(torch.nn.Module):
    def __init__(self, base):
        super().__init__()
        self.base = base
        self.cabeza = torch.nn.Linear(384, 3)

    def forward(self, x):
        return self.cabeza(10 * embeber(self.base, x))

def recorta(x):
    """Quita las columnas de relleno que sobran en este lote."""
    n = int(x["attention_mask"].sum(1).max())
    return {c: v[:, :n] for c, v in x.items()}

def entrenar(modelo, df, y, epocas, lr, lote=32, val=va.iloc[:100]):
    x_tr = next(lotes(df["titular"], len(df)))
    x_va = next(lotes(val["titular"], len(val)))
    y_tr, y_va = torch.tensor(y), torch.tensor(val["y"].to_numpy())
    opt = torch.optim.AdamW(
        [p for p in modelo.parameters() if p.requires_grad], lr=lr)
    g = torch.Generator().manual_seed(0)
    perd = torch.nn.functional.cross_entropy
    curva = []
    for ep in range(1, epocas + 1):
        modelo.train()
        orden, suma = torch.randperm(len(y_tr), generator=g), 0.0
        for k in range(0, len(orden), lote):
            b = orden[k:k + lote]
            x_b = recorta({c: v[b] for c, v in x_tr.items()})
            l = perd(modelo(x_b), y_tr[b])
            opt.zero_grad()
            l.backward()
            opt.step()
            suma += l.item() * len(b)
        modelo.eval()
        with torch.inference_mode():
            z = modelo(x_va)
        curva.append((ep, suma / len(y_tr), perd(z, y_va).item(),
                      (z.argmax(1) == y_va).float().mean().item()))
        print("época {} · entrenamiento {:.2f} · validación {:.2f}"
              " · acierto validación {:.1%}".format(*curva[-1]))
    return curva

torch.manual_seed(0)
clf = Clasificador(base)
curva_lora = entrenar(clf, tr, tr["y"].to_numpy(), epocas=4, lr=2e-3)
OUT [10]Entrenar los adaptadores LoRA y la cabeza (4 épocas)
época 1 · entrenamiento 1.04 · validación 0.93 · acierto validación 50.0%
época 2 · entrenamiento 0.72 · validación 0.64 · acierto validación 77.0%
época 3 · entrenamiento 0.44 · validación 0.40 · acierto validación 87.0%
época 4 · entrenamiento 0.24 · validación 0.25 · acierto validación 91.0%

La pérdida de validación baja con la de entrenamiento en las cuatro épocas (de 0,93 a 0,25) y el acierto en validación pasa del 50,0 % al 91,0 %. No hay señal de sobreajuste todavía: la validación sigue mejorando, y con más épocas probablemente mejoraría algo más. Se para en cuatro para que la lección se ejecute en unos minutos de CPU, y por lo mismo la validación por época usa 100 titulares y la prueba 600. En un problema real, el número de épocas se elige en validación (parada temprana) y, una vez elegido, la prueba se mira una sola vez.

Resultado en la prueba de 2024-2025

IN [11]python
from scipy.stats import binomtest

clf.eval()
with torch.inference_mode():
    pred_lora = torch.cat([clf(x).argmax(1)
                           for x in lotes(te["titular"])]).numpy()
y_te = te["y"].to_numpy()
ok_lr, ok_lo = pred_lr == y_te, pred_lora == y_te
tabla = pd.DataFrame({
    "logística": [ok_lr.mean()] + [ok_lr[te["idioma"] == i].mean()
                                   for i in ("en", "es")],
    "LoRA": [ok_lo.mean()] + [ok_lo[te["idioma"] == i].mean()
                              for i in ("en", "es")]},
    index=["todos (600)", "inglés", "español"])
print((100 * tabla).round(1).to_string())
print(f"100 de FinBERT: FinBERT {acierto_fb.mean():.1%} · logística "
      f"{ok_lr[:100].mean():.1%} · LoRA {ok_lo[:100].mean():.1%}")
solo_lo = int((ok_lo & ~ok_lr).sum())
solo_lr = int((ok_lr & ~ok_lo).sum())
p = binomtest(solo_lo, solo_lo + solo_lr).pvalue
print(f"solo acierta LoRA: {solo_lo} · solo la logística: {solo_lr}"
      f" · p de McNemar exacta = {p:.1e}")
OUT [11]Las tres referencias en la misma prueba
             logística  LoRA
todos (600)       89.3  94.3
inglés            87.6  91.2
español           89.8  95.2
100 de FinBERT: FinBERT 36.0% · logística 89.0% · LoRA 95.0%
solo acierta LoRA: 54 · solo la logística: 24 · p de McNemar exacta = 9.0e-04

LoRA acierta el 94,3 % de los 600 titulares de prueba, frente al 89,3 % de la logística. La figura de la cabecera resume las tres referencias. Como los dos modelos se evalúan en los mismos titulares, la comparación correcta es pareada: la prueba de McNemar solo mira los desacuerdos. Solo LoRA acierta 54 titulares y solo la logística, 24; si fueran igual de buenos, esos 78 desacuerdos se repartirían a cara o cruz, y un reparto 54-24 o más extremo tiene una probabilidad de 9,0×10−49{,}0 \times 10^{-4}.

El dónde importa tanto como el cuánto. En inglés la mejora es de 3,6 puntos (87,6 % a 91,2 %, con 137 titulares); en español, de 5,4 (89,8 % a 95,2 %). Los adaptadores corrigen sobre todo lo que el codificador congelado no separaba bien. Por tipo de evento:

IN [12]python
por_evento = (pd.DataFrame({"evento": te["evento"], "logística": ok_lr,
                            "LoRA": ok_lo})
              .groupby("evento").agg(["mean", "size"]))
por_evento = pd.DataFrame({
    "n": por_evento[("LoRA", "size")],
    "logística": (100 * por_evento[("logística", "mean")]).round(0),
    "LoRA": (100 * por_evento[("LoRA", "mean")]).round(0)})
print(por_evento.sort_values("logística").head(6).to_string())
OUT [12]Dónde falla cada uno: acierto por tipo de evento
                    n  logística   LoRA
evento
incidente          13       46.0   62.0
oferta_compra       6       50.0  100.0
rating_rebaja      19       74.0  100.0
previsiones        16       81.0   88.0
analista_baja      56       82.0   86.0
retirada_producto  17       82.0   76.0

Los eventos que peor clasifica la logística son incendios e incidentes (46 % frente a 62 % con LoRA), ofertas de compra y rebajas de rating: titulares cuyo sentimiento depende de una o dos palabras («rebaja», «eleva», «incendio») que un embedding de la frase entera diluye. LoRA no gana en todo: en retiradas de producto acierta menos que la logística (76 % frente a 82 %). Y ojo con las muestras: 6 ofertas de compra y 13 incidentes son pocas para afirmar nada evento a evento.

Fusionar y servir el adaptador

Para servir el modelo hay dos opciones: guardar solo el adaptador y cargarlo sobre el MiniLM original, o fusionarlo en los pesos con merge_and_unload():

IN [13]python
x = next(lotes(te["titular"].iloc[:50], 50))
with torch.inference_mode():
    antes = clf(x)
clf.base = clf.base.merge_and_unload()
with torch.inference_mode():
    despues = clf(x)
dif = (antes - despues).abs().max().item()
print(type(clf.base).__name__, "· diferencia máxima de logits < 1e-4:",
      dif < 1e-4)
print(f"tamaño del adaptador en float32: {entren * 4 / 1024:.0f} KB"
      f" (el modelo entero: {total * 4 / 1024**2:.0f} MB)")
OUT [13]Fusionar el adaptador: mismo resultado, sin coste en inferencia
BertModel · diferencia máxima de logits < 1e-4: True
tamaño del adaptador en float32: 288 KB (el modelo entero: 87 MB)

Tras fusionar, el modelo vuelve a ser un BertModel normal, con las mismas salidas (diferencias del orden del redondeo de float32) y sin ninguna operación extra. Sin fusionar, el adaptador ocupa 288 KB frente a los 87 MB del modelo: con LLMs de miles de millones de parámetros esa diferencia es la que permite guardar un adaptador por tarea, por cliente o por fecha de entrenamiento sobre una única copia del modelo base.

El riesgo: memorizar en lugar de aprender

73 728 parámetros parecen pocos, pero son muchos más que 300 ejemplos. Un placebo lo deja claro: los mismos adaptadores (sin dropout) y la misma cabeza, entrenados con 100 titulares cuyas etiquetas se han barajado al azar (cuatro épocas, para no alargar la ejecución). No hay nada que aprender:

IN [14]python
torch.manual_seed(0)
cfg0 = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.0,
                  target_modules=["query", "value"])
placebo = Clasificador(
    get_peft_model(AutoModel.from_pretrained(NOMBRE), cfg0))
pocos = tr.iloc[:100]
barajadas = np.random.default_rng(0).permutation(pocos["y"].to_numpy())
curva_placebo = entrenar(placebo, pocos, barajadas, epocas=4, lr=5e-3)
OUT [14]Placebo: los mismos adaptadores aprenden etiquetas barajadas
época 1 · entrenamiento 1.20 · validación 1.24 · acierto validación 21.0%
época 2 · entrenamiento 0.96 · validación 1.35 · acierto validación 17.0%
época 3 · entrenamiento 0.81 · validación 1.51 · acierto validación 11.0%
época 4 · entrenamiento 0.74 · validación 1.66 · acierto validación 11.0%
Fig. — Pérdida de entrenamiento y de validación por época de MiniLM con LoRA: con las etiquetas reales (300 titulares) y con las etiquetas barajadas (100)

La pérdida de entrenamiento baja de 1,20 a 0,74 en cuatro épocas: el modelo aprende de memoria qué etiqueta arbitraria va con cada titular. La de validación sube de 1,24 a 1,66 y el acierto en validación cae del 21 % al 11 %, muy por debajo del 36 % de predecir siempre la clase más frecuente de esos 100 titulares. Peor que el azar: como los titulares son de plantilla, lo memorizado es una regla falsa («los incendios son buenas noticias») que se aplica con seguridad a los titulares nuevos del mismo tipo. Sin una validación separada, la curva de entrenamiento del placebo es indistinguible de la de un modelo que aprende.

Las defensas son las de los niveles 2 y 5, aplicadas a un modelo de lenguaje:

  • Partición temporal y prueba que se mira una vez. Un titular de 2024 con la misma plantilla que uno de 2020 está correlacionado con él; una partición aleatoria mezclaría casi duplicados entre entrenamiento y prueba y daría cifras infladas.
  • Referencias baratas en la misma prueba: un modelo sin tocar y una logística sobre embeddings. Si el ajuste no las supera con una prueba pareada, no compensa.
  • Placebos y controles: etiquetas barajadas, o una tarea sin relación con el texto. Un ajuste que «aprende» también ahí está midiendo su capacidad de memorizar.
  • Contar los experimentos: rango, capas, tasa, épocas y semillas son hiperparámetros. Elegir el mejor de 50 en la misma validación tiene el problema de selección de 2.5.4 y 5.5.4.
  • El texto de entrenamiento también tiene fecha: ajustar un modelo con noticias hasta 2025 y backtestear con noticias de 2022 es anticipación. Con LLMs el problema va más allá de las etiquetas propias y es el tema de la lección 6.2.5.

Ejercicios

  1. Con la función cuenta de la lección, compara LoRA de rango 64 solo en q_proj y v_proj con LoRA de rango 8 en todas las capas lineales de Qwen2.5-7B. ¿Cuál entrena más parámetros y cuánta memoria necesitaría cada uno con QLoRA?
Solución
IN [15]python
for nombre, mods, r in [("r=64 en q y v", ["q_proj", "v_proj"], 64),
                        ("r=8 en todas", TODAS, 8)]:
    t, li, lo = cuenta(q7, mods, r=r)
    print(f"{nombre:14s} LoRA {lo / 1e6:5.1f} M · QLoRA "
          f"{memoria_gb(t, li, lo)[2]:.1f} GB")
OUT [15]Rango alto en dos capas frente a rango bajo en todas
r=64 en q y v  LoRA  20.2 M · QLoRA 5.9 GB
r=8 en todas   LoRA  20.2 M · QLoRA 5.9 GB

Empatan: 20,2 millones de parámetros y 5,9 GB con QLoRA. No es casualidad de redondeo. Por bloque, rango 64 en q_proj (3 584 a 3 584) y v_proj (3 584 a 512, por las 4 cabezas de clave-valor) da 64⋅(7 168+4 096)=720 89664 \cdot (7\,168 + 4\,096) = 720\,896 parámetros, y rango 8 en las siete proyecciones da 8⋅(2⋅7 168+2⋅4 096+3⋅22 528)=720 8968 \cdot (2 \cdot 7\,168 + 2 \cdot 4\,096 + 3 \cdot 22\,528) = 720\,896. Con el mismo presupuesto, la pregunta es dónde ponerlo, y el artículo de QLoRA responde que en todas las capas lineales: concentrarlo en la atención no igualó al ajuste completo en sus experimentos, y el rango apenas influyó. La memoria apenas depende de esta elección porque la domina la base cuantizada.

  1. Calcula la matriz de confusión de la logística y de LoRA en la prueba. ¿Qué confusiones corrige LoRA?
Solución
IN [16]python
from sklearn.metrics import confusion_matrix

nombres = ["neg", "neutra", "pos"]
for m, p in [("logística", pred_lr), ("LoRA", pred_lora)]:
    print(m)
    print(pd.DataFrame(confusion_matrix(y_te, p), index=nombres,
                       columns=nombres).to_string())
OUT [16]Matrices de confusión en la prueba
logística
        neg  neutra  pos
neg     176       6   25
neutra    4     133    1
pos      20       8  227
LoRA
        neg  neutra  pos
neg     175       1   31
neutra    0     138    0
pos       1       1  253

Las filas son la clase verdadera y las columnas, la predicha. La logística no se equivoca sobre todo con las neutras, como cabría esperar, sino con la polaridad: 25 negativas clasificadas como positivas y 20 positivas como negativas. Es lo que anticipa la lección 6.3.1: el embedding de MiniLM de «La empresa eleva su previsión de ventas» y el de «La empresa recorta su previsión de ventas» tienen una similitud coseno de 0,95, porque el modelo congelado mide de qué habla la frase más que en qué sentido. LoRA acierta todas las neutras y 253 de 255 positivas, pero sigue confundiendo negativas con positivas (31, más que la logística): con cuatro épocas ha aprendido a reconocer bien las buenas noticias y todavía no del todo las malas. Es un recordatorio de que una mejora agregada (89,3 % a 94,3 %) puede esconder un error concreto que empeora, y en trading los errores de signo son los caros.

  1. Un compañero ajusta LoRA con 3 000 titulares de 2019-2025 repartidos al azar entre entrenamiento y prueba y obtiene un 99,5 % de acierto. Propone usar el modelo para operar con las noticias de mañana. ¿Qué le falta comprobar?
Solución

Casi todo. La partición aleatoria mezcla titulares casi idénticos (la misma plantilla, otra empresa u otra fecha) entre entrenamiento y prueba, así que el 99,5 % mide en parte memoria; hace falta una prueba posterior en el tiempo a todo el entrenamiento. Falta la comparación con referencias baratas en esa prueba (una logística sobre embeddings con las mismas etiquetas) y una prueba pareada. Falta un placebo para saber cuánto puede memorizar el modelo con esas etiquetas. Y, sobre todo, acertar el sentimiento no es ganar dinero: la reacción del precio a una noticia se produce en minutos (lección 6.1.3), así que la señal tiene que existir antes de que el mercado la incorpore, con la hora de publicación y los costes del nivel 2. Por último, estos titulares son de plantilla: con texto real, el acierto de cualquier método será mucho menor.

Footnotes

  1. Ficha de Qwen2.5-7B-Instruct en Hugging Face (consultada el 2026-10-23): 7,61B parámetros, 6,53B sin embeddings, 28 capas, 28 cabezas de consulta y 4 de clave-valor, licencia Apache-2.0. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una capa lineal de 384×384 se adapta con LoRA de rango 8. ¿Cuántos parámetros nuevos se entrenan en esa capa?

    • A147 456, los mismos que la matriz original
    • B3 072, la mitad de r·(d + k)
    • C6 144: A tiene 8×384 y B tiene 384×8
    • D64, el cuadrado del rango
    Ver respuesta

    Respuesta C. 6 144: A tiene 8×384 y B tiene 384×8

    LoRA sustituye la actualización ΔW (384×384) por el producto B·A, con A de r×384 y B de 384×r: r·(d + k) = 8·768 = 6 144 parámetros, un 4,2 % de la matriz. La matriz original se queda congelada.

  2. Al crear el modelo con peft, la matriz B de cada adaptador vale cero. ¿Para qué?

    • APara que, antes de entrenar, el modelo adaptado dé exactamente lo mismo que el original y el ajuste parta del modelo preentrenado
    • BPara ahorrar memoria
    • CPara que el gradiente de A sea cero en el primer paso y no se mueva nunca
    • DEs un error de inicialización que hay que corregir
    Ver respuesta

    Respuesta A. Para que, antes de entrenar, el modelo adaptado dé exactamente lo mismo que el original y el ajuste parta del modelo preentrenado

    Con B = 0, B·A = 0 y la salida no cambia; A se inicializa al azar para que el gradiente de B no sea nulo. En la lección, la logística usa los embeddings con el adaptador desactivado y los del adaptador recién creado serían idénticos.

  3. Según la cuenta de la lección, ¿por qué QLoRA permite ajustar Qwen2.5-7B con unos 6 GB cuando LoRA con la base en bf16 necesita unos 16 GB?

    • APorque entrena menos parámetros que LoRA
    • BPorque no usa Adam
    • CPorque elimina las activaciones
    • DPorque guarda los pesos congelados de las capas lineales en NF4 (unos 4,1 bits por parámetro con la doble cuantización) en lugar de 16 bits; los adaptadores y el optimizador son los mismos
    Ver respuesta

    Respuesta D. Porque guarda los pesos congelados de las capas lineales en NF4 (unos 4,1 bits por parámetro con la doble cuantización) en lugar de 16 bits; los adaptadores y el optimizador son los mismos

    Los 20 millones de parámetros de LoRA y su estado de Adam pesan lo mismo en los dos casos (0,3 GB). La diferencia está en la base: 6 525 millones de pesos lineales pasan de 2 bytes a unos 0,52. La cuenta no incluye activaciones, que dependen del lote y de la longitud de secuencia.

  4. Con 300 titulares de entrenamiento, LoRA acierta el 94,3 % y la logística sobre embeddings congelados el 89,3 % en la misma prueba de 600. Solo LoRA acierta 54 titulares y solo la logística, 24. ¿Qué se puede concluir?

    • ANada, porque 5 puntos podrían ser ruido con 600 titulares
    • BQue la diferencia es significativa en estos datos (McNemar exacta, p ≈ 0,001) y que adaptar el codificador ayuda sobre todo en español; no que vaya a repetirse con textos reales
    • CQue LoRA siempre supera a una logística
    • DQue la logística está mal regularizada
    Ver respuesta

    Respuesta B. Que la diferencia es significativa en estos datos (McNemar exacta, p ≈ 0,001) y que adaptar el codificador ayuda sobre todo en español; no que vaya a repetirse con textos reales

    La prueba pareada usa solo los desacuerdos (54 frente a 24). La mejora es mayor en español (89,8 % a 95,2 %), el idioma que MiniLM conoce peor, que en inglés (87,6 % a 91,2 %). Los titulares salen de plantillas por tipo de evento: es un problema mucho más fácil que el texto real.

  5. Los mismos adaptadores, entrenados con etiquetas barajadas, bajan la pérdida de entrenamiento de 1,20 a 0,74 en cuatro épocas mientras la de validación sube de 1,24 a 1,66. ¿Qué enseña el placebo?

    • AQue el modelo ha encontrado una señal oculta
    • BQue la tasa de aprendizaje es demasiado baja
    • CQue 73 728 parámetros bastan para memorizar 100 etiquetas sin relación con el texto: una pérdida de entrenamiento que baja no prueba nada sin una validación separada en el tiempo
    • DQue hay que entrenar más épocas
    Ver respuesta

    Respuesta C. Que 73 728 parámetros bastan para memorizar 100 etiquetas sin relación con el texto: una pérdida de entrenamiento que baja no prueba nada sin una validación separada en el tiempo

    Con etiquetas al azar no hay nada que aprender; lo que baja es memoria. El acierto en validación cae al 11 %, muy por debajo del 36 % de predecir siempre la clase más frecuente: el modelo ha aprendido una regla falsa (qué etiqueta va con cada tipo de evento) y la aplica con aplomo. Es la misma lección de sobreajuste de 5.8.3 con otro modelo.

Ideas clave

  • LoRA congela W y entrena B·A de rango r: en MiniLM, 73 728 parámetros de 22,8 millones (0,32 %); en Qwen2.5-7B con r = 8 en todas las capas lineales, 20,2 millones (0,27 %).
  • Memoria sin activaciones para Qwen2.5-7B: 121,8 GB con ajuste completo en fp32 y Adam, 15,6 GB con LoRA y la base en bf16, 5,9 GB con QLoRA (NF4 y doble cuantización, 4,127 bits por peso lineal).
  • En titulares sintéticos con 300 etiquetas, LoRA sobre MiniLM acierta el 94,3 % en 2024-2025, frente al 89,3 % de una logística sobre los embeddings congelados y el 36,0 % de FinBERT sin entrenar (que no entiende español).
  • El adaptador se fusiona en W después de entrenar: el modelo resultante da las mismas salidas y no añade latencia; sin fusionar, ocupa 288 KB frente a 87 MB del modelo.
  • El placebo de etiquetas barajadas memoriza: la pérdida de entrenamiento cae y la de validación sube. Validación temporal, una referencia barata y un placebo son el mínimo antes de creer un ajuste.

Recursos

Datos