Lesson 6.2.4 · 70 min
Lightweight fine-tuning for financial tasks: LoRA and QLoRA
Tuning every weight of a language model takes memory almost nobody has. LoRA freezes the model and trains two small matrices per layer; QLoRA also stores the base in 4 bits. This lesson does the parameter and memory math with the real Qwen2.5 configuration, trains LoRA adapters on MiniLM on a CPU to classify the sentiment of synthetic headlines, and compares them with an untrained FinBERT and a logistic regression on embeddings, with a time-based split and a placebo.
- Module
- 6.2 · LLMs for research and trading
- Duration
- 70 min
- Prerequisites
- 6.1.1 · Tokenization, embeddings, financial dictionaries and FinBERT, 5.7.1 · PyTorch: tensors, autograd and the training loop
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
Three ways to adapt a model to a task
A pretrained model knows a lot about language and little about the specific task: separating good, bad and neutral headlines for an issuer, extracting a KPI from a 10-Q or classifying the tone of an analyst's question. There are three ways to bring it closer, from least to most expensive:
Mermaid source
flowchart TB A[Pretrained] --> B[Use as is] A --> C[Train on top] A --> D[Tune weights] D --> E[Full fine-tuning] D --> F[LoRA and QLoRA]
- Use it as is (zero-shot): FinBERT classifies financial sentiment without seeing a single example of your own, or an LLM receives the task in the prompt (lessons 6.1.1 and 6.2.3). Zero cost, but the model brings the biases of its corpus: the language, the style and what was considered positive at the time.
- Freeze it and train on top: the embeddings are computed once and a linear model is fitted (lesson 6.1.1). It is cheap and hard to overfit, but the embeddings do not move: if the encoder does not separate two classes well, the logistic regression cannot fix it.
- Tune the weights: full fine-tuning moves every parameter and needs to store a gradient and the optimizer state for each one. Parameter-efficient fine-tuning (PEFT) methods move only a part. LoRA is the most widely used.
This lesson focuses on the third, with the other two as baselines, and measures it on a small task that fits on a CPU: the sentiment of the headlines in the level 6 synthetic corpus, whose ground truth is known.
The task: headline sentiment with 300 labels
Each original news item in noticias6_corpus.parquet has its sentimiento_verdadero (true sentiment) in
noticias6_verdad.csv: negative, zero for neutral events (shareholder meetings, appointments, conferences) or positive.
The class is its sign. Only the headline is used (a median of 18 tokens with the MiniLM tokenizer) so that training fits
on a CPU in seconds, with a time-based split: 300 headlines from 2019-2022 to train, 200 from 2023 to validate and
choose hyperparameters, and 600 from 2024-2025 as the test set, which is not looked at until the end. The sample corpus
is synthetic and mostly in Spanish, with the rest in English; it stays as is, because the lesson measures precisely how
each model copes with the language.
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 neutral, 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 [("train", tr), ("validation", va),
("test", te)]:
print(f"{nombre:13s} {len(x):5d} headlines · "
f"{x['idioma'].eq('es').mean():.0%} in Spanish · "
f"classes {np.bincount(x['y'], minlength=3)}")
print(te[["titular", "y"]].head(4).to_string(index=False))train 300 headlines · 77% in Spanish · classes [ 96 82 122]
validation 200 headlines · 74% in Spanish · classes [69 45 86]
test 600 headlines · 77% in Spanish · classes [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 0Three hundred labels is a realistic figure: it is what an analyst reviews in an afternoon. Three in four headlines are in Spanish, the language MiniLM and FinBERT know worst, since both were trained on English text. The most frequent class in the test set is the positive one (255 of 600): a classifier that always said “positive” would be right 42.5% of the time.
LoRA: training a low-rank correction
A linear layer computes with of size . Full fine-tuning learns an update of the same size. LoRA (Hu et al., 2021) starts from the observation that this update has, in practice, a low effective rank, and writes it as the product of two narrow matrices:
is frozen; only and are trained. The factor sets the scale of the correction (with
lora_alpha=16 and r=8, it equals 2) so the rank can be changed without retuning the learning rate.
Mermaid source
flowchart LR X[x] --> W[W frozen] X --> A[A: r x k] A --> B[B: d x r] W --> S[sum] B --> S S --> Y[y]
The parameter count is immediate: for and for the adapter. In a MiniLM attention matrix ():
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 has {completa:,} parameters; "
f"A and B, {lora:,} ({lora / completa:.1%})")r = 1: W has 147,456 parameters; A and B, 768 (0.5%)
r = 8: W has 147,456 parameters; A and B, 6,144 (4.2%)
r = 64: W has 147,456 parameters; A and B, 49,152 (33.3%)With peft you choose which layers get adapters (target_modules). The original LoRA recipe puts them on the query
and value projections of the attention; the cell does so in all six MiniLM layers:
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"parameters: {total:,} · trainable: {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()))parameters: 22,786,944 · trainable: 73,728 (0.32%)
Linear (8, 384) (384, 8) B = 0: True73,728 of 22.8 million parameters are trained (0.32%): 6 layers × 2 matrices × 6,144. The query layer is now a peft
Linear with its lora_A (8×384) and its lora_B (384×8). B starts at zero and A at random: that way
and, before training, the adapted model gives exactly the same outputs as the original. The fine-tune starts from the
pretrained model, not from a model randomly broken.
Three properties make LoRA practical:
- Memory: there are no gradients or Adam state for the frozen weights (the next section quantifies it).
- One model, many adapters: the same MiniLM can serve one adapter for headlines, another for analyst questions and another for the risk factors of a 10-K, each a few hundred KB.
- No inference cost: after training, is computed once and the model is back to its original matrices. The paper highlights this against other adapters, which add layers and latency.
How much memory: from 0.5B to 7B, with and without quantization
For an LLM the math matters more. The cell builds Qwen2.5-0.5B (the configuration of the course's local model) and a
configuration with the published dimensions of Qwen2.5-7B (28 layers, a hidden size of 3,584, 18,944 in the
intermediate layer, attention with 28 query heads and 4 key-value heads) on the meta device: PyTorch creates the
structure without allocating memory for the weights, so a 7B model can be counted on a laptop. The adapters go on the
seven linear projections of each block, with rank 8.
from transformers import AutoConfig, AutoModelForCausalLM
from transformers import Qwen2Config
def cuenta(config, modulos, r=8):
"""Total, linear-layer and LoRA parameters (no weights)."""
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, "linear (M)": li / 1e6,
"LoRA r=8 (M)": lo / 1e6, "LoRA (%)": 100 * lo / t}
for k, (t, li, lo) in filas.items()}).T.round(2)) total (M) linear (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.27The totals match the Qwen2.5-7B-Instruct model card (7.61B parameters, 6.53B excluding embeddings)1Note 1 · Qwen2.5-7B-Instruct model card on Hugging Face (accessed 2026-10-23): 7.61B parameters, 6.53B excluding embeddings, 28 layers, 28 query heads and 4 key-value heads, Apache-2.0 license.. Rank-8 LoRA in every linear layer trains 20.2 million: 0.27% of the 7B model and 0.89% of the 0.5B one (in the small one, the embeddings weigh more).
Training memory has four parts: the weights, the gradients, the optimizer state and the activations. With Adam in fp32, each trainable parameter costs 16 bytes (weight, gradient and two 4-byte moments). The cell compares three configurations, without counting the activations, which depend on the batch size and the sequence length:
- Full: everything trainable in fp32 with Adam, 16 bytes per parameter.
- LoRA: the frozen base in bf16 (2 bytes per parameter) and 16 bytes per adapter parameter.
- QLoRA: the frozen linear layers in NF4 (4 bits) with double quantization of the constants, the rest in bf16 and the adapter as in LoRA.
def memoria_gb(total, lineales, lora):
"""GB to train: full, LoRA (bf16 base), QLoRA (NF4)."""
adam = 4 + 4 + 8 # fp32 weight, gradient, two moments
completo = total * adam
lora_bf16 = total * 2 + lora * adam
resto = total - lineales # embeddings, norms and output in bf16
bits = 4 + 0.127 # NF4 + double-quantized constants
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=["full fp32+Adam", "LoRA (bf16 base)",
"QLoRA (NF4 base)"]).round(1)
print(mem.to_string()) Qwen2.5-0.5B Qwen2.5-7B
full fp32+Adam 7.9 121.8
LoRA (bf16 base) 1.1 15.6
QLoRA (NF4 base) 0.5 5.9For the 7B model, full fine-tuning needs 121.8 GB just for weights, gradients and optimizer: several data center GPUs. LoRA brings it down to 15.6 GB, of which 15.2 are the base in bf16. QLoRA compresses precisely that base and reaches 5.9 GB. The adapter state is the same in both (20 million × 16 bytes = 0.3 GB).
QLoRA: the base in 4 bits
QLoRA (Dettmers et al., 2023) trains LoRA adapters on a model whose base is quantized to 4 bits. The paper introduces three pieces: the NF4 type (4-bit NormalFloat, with its 16 levels spread according to the quantiles of a normal distribution, because the weights of a network are distributed approximately normally), double quantization (quantizing the scale constants of each block of 64 weights as well) and paged optimizers (which move the optimizer state to system memory during spikes). According to the paper:
- Fine-tuning a 65B LLaMA in 16 bits needs more than 780 GB of GPU memory; QLoRA does it on a single 48 GB GPU without losing performance relative to 16-bit fine-tuning.
- Scale constants in fp32 cost bits per parameter; with double quantization, bits, about 0.37 bits less (about 3 GB in a 65B model). That is the 4.127 in the cell.
- To match full fine-tuning, adapters have to go on all the linear layers, not just
queryandvalue; the rank, on the other hand, barely mattered in their experiments.
During computation, each block is dequantized to bf16 on the fly: QLoRA saves memory, not operations. The following code
is the one from the transformers and peft documentation (accessed 2026-10-23); it is not run in the course:
bitsandbytes is not in the ia environment and, with a 0.5B model that fits entirely in memory, quantization would add
nothing to measure.
# QLoRA with bitsandbytes (not in the environment; not run)
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", # 4-bit NormalFloat
bnb_4bit_use_double_quant=True, # quantizes the constants
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" is peft's shortcut for “all the linear layers” (except the output layer), which is what
the paper recommends. The transformers documentation lists bitsandbytes backends for NVIDIA GPUs, Intel XPU, Intel Gaudi
and CPU, and warns that with weights in 8 or 4 bits only extra parameters can be trained: the adapters.
Two baselines before training anything
A fine-tune is only worth what it improves over the cheap alternative. The first baseline is FinBERT as is, without seeing a single label of your own (lesson 6.1.1; its weights are licensed CC-BY-NC-SA-4.0). To keep the run short, it is measured on the first 100 test headlines, a random subsample:
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%} on {len(muestra)} headlines")
for idioma in ("en", "es"):
m = muestra["idioma"].to_numpy() == idioma
print(f" {idioma}: {acierto_fb[m].mean():.1%} "
f"({m.sum()} headlines)")FinBERT: 36.0% on 100 headlines
en: 68.2% (22 headlines)
es: 26.9% (78 headlines)FinBERT gets 36.0% right, below the 42.5% of always answering the majority class. In English it reaches 68.2% (only 22 headlines, so very noisy), and in Spanish it sinks to 26.9%: its vocabulary chops Spanish into English fragments (lesson 6.1.1). A financial-domain model is not a model of this domain: another language, another headline style and another notion of neutral.
The second baseline is the one from 6.1.1: the frozen MiniLM embeddings (mean over tokens and unit norm, which is what
sentence-transformers does with this model) and a logistic regression. The cell reuses the model from the previous
section with the adapter disabled (disable_adapter()), so the embeddings are those of the original MiniLM. The
regularization is chosen on validation:
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):
"""Mean over tokens (no padding) and unit norm, as in 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}: validation {lr.score(E['va'], va['y']):.1%}")C = 1: validation 77.0%
C = 10: validation 84.5%
C = 100: validation 86.0%
C = 1000: validation 87.5%Validation accuracy rises with (less regularization) up to 87.5% with ; with it is 86.0%, three headlines out of 200 fewer. The best on validation is chosen, . On the test set:
lr = LogisticRegression(C=1000, max_iter=3000).fit(E["tr"], tr["y"])
pred_lr = lr.predict(E["te"])
print(f"logistic: {np.mean(pred_lr == te['y']):.1%} on test")logistic: 89.3% on test89.3% with 300 labels is a demanding baseline. Any fine-tune has to beat it comfortably to make up for its cost and its risk.
Training the adapters
The classifier is MiniLM with adapters, the same mean over tokens and a linear layer from 384 to 3 classes (the head, fully trainable: 1,155 more parameters). The embedding is multiplied by 10 before the head because, with unit norm, its components are very small and the head would take a long time to separate the classes. It is trained with AdamW, batches of 32, a learning rate of (high for full fine-tuning, normal for LoRA) and four epochs, and at the end of each one the loss is measured on 100 validation headlines from 2023. Each batch is trimmed to the length of its longest headline, with no padding columns that add nothing: with headlines of a median of 18 tokens, that halves the computation.
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):
"""Remove the padding columns this batch does not need."""
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("epoch {} · train {:.2f} · validation {:.2f}"
" · validation accuracy {:.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)epoch 1 · train 1.04 · validation 0.93 · validation accuracy 50.0%
epoch 2 · train 0.72 · validation 0.64 · validation accuracy 77.0%
epoch 3 · train 0.44 · validation 0.40 · validation accuracy 87.0%
epoch 4 · train 0.24 · validation 0.25 · validation accuracy 91.0%The validation loss falls with the training loss over the four epochs (from 0.93 to 0.25) and validation accuracy goes from 50.0% to 91.0%. There is no sign of overfitting yet: validation keeps improving, and with more epochs it would probably improve somewhat more. Training stops at four so the lesson runs in a few minutes of CPU, and for the same reason the per-epoch validation uses 100 headlines and the test 600. In a real problem, the number of epochs is chosen on validation (early stopping) and, once chosen, the test set is looked at only once.
Result on the 2024-2025 test set
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({
"logistic": [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=["all (600)", "English", "Spanish"])
print((100 * tabla).round(1).to_string())
print(f"FinBERT's 100: FinBERT {acierto_fb.mean():.1%} · logistic "
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"only LoRA right: {solo_lo} · only logistic: {solo_lr}"
f" · exact McNemar p = {p:.1e}") logistic LoRA
all (600) 89.3 94.3
English 87.6 91.2
Spanish 89.8 95.2
FinBERT's 100: FinBERT 36.0% · logistic 89.0% · LoRA 95.0%
only LoRA right: 54 · only logistic: 24 · exact McNemar p = 9.0e-04LoRA gets 94.3% of the 600 test headlines right, against 89.3% for the logistic regression. The figure at the top summarizes the three baselines. Since the two models are evaluated on the same headlines, the right comparison is paired: the McNemar test looks only at the disagreements. Only LoRA gets 54 headlines right and only the logistic regression, 24; if they were equally good, those 78 disagreements would split like coin flips, and a 54-24 split or a more extreme one has a probability of .
Where matters as much as how much. In English the improvement is 3.6 points (87.6% to 91.2%, with 137 headlines); in Spanish, 5.4 (89.8% to 95.2%). The adapters mostly correct what the frozen encoder did not separate well. By event type:
por_evento = (pd.DataFrame({"evento": te["evento"], "logistic": ok_lr,
"LoRA": ok_lo})
.groupby("evento").agg(["mean", "size"]))
por_evento = pd.DataFrame({
"n": por_evento[("LoRA", "size")],
"logistic": (100 * por_evento[("logistic", "mean")]).round(0),
"LoRA": (100 * por_evento[("LoRA", "mean")]).round(0)})
print(por_evento.sort_values("logistic").head(6).to_string()) n logistic 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.0The events the logistic regression classifies worst are fires and incidents (46% against 62% with LoRA), takeover offers and rating downgrades: headlines whose sentiment depends on one or two words (“rebaja”, downgrade; “eleva”, raises; “incendio”, fire) that an embedding of the whole sentence dilutes. LoRA does not win everywhere: on product recalls it gets fewer right than the logistic regression (76% against 82%). And beware of the sample sizes: 6 takeover offers and 13 incidents are too few to claim anything event by event.
Merging and serving the adapter
There are two options for serving the model: save only the adapter and load it on top of the original MiniLM, or merge
it into the weights with merge_and_unload():
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__, "· max logit difference < 1e-4:",
dif < 1e-4)
print(f"adapter size in float32: {entren * 4 / 1024:.0f} KB"
f" (the whole model: {total * 4 / 1024**2:.0f} MB)")BertModel · max logit difference < 1e-4: True
adapter size in float32: 288 KB (the whole model: 87 MB)After merging, the model is a normal BertModel again, with the same outputs (differences on the order of float32
rounding) and no extra operations. Unmerged, the adapter takes 288 KB against the model's 87 MB: with LLMs of billions
of parameters, that difference is what makes it possible to keep one adapter per task, per client or per training date
on top of a single copy of the base model.
The risk: memorizing instead of learning
73,728 parameters look like few, but they are far more than 300 examples. A placebo makes it clear: the same adapters (without dropout) and the same head, trained on 100 headlines whose labels have been shuffled at random (four epochs, to keep the run short). There is nothing to learn:
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)epoch 1 · train 1.20 · validation 1.24 · validation accuracy 21.0%
epoch 2 · train 0.96 · validation 1.35 · validation accuracy 17.0%
epoch 3 · train 0.81 · validation 1.51 · validation accuracy 11.0%
epoch 4 · train 0.74 · validation 1.66 · validation accuracy 11.0%The training loss falls from 1.20 to 0.74 in four epochs: the model memorizes which arbitrary label goes with each headline. The validation loss rises from 1.24 to 1.66 and validation accuracy drops from 21% to 11%, far below the 36% of always predicting the most frequent class of those 100 headlines. Worse than chance: since the headlines are templated, what is memorized is a false rule (“fires are good news”) that is applied confidently to new headlines of the same type. Without a separate validation set, the placebo's training curve is indistinguishable from that of a model that learns.
The defenses are those of levels 2 and 5, applied to a language model:
- Time-based split and a test set looked at once. A 2024 headline with the same template as one from 2020 is correlated with it; a random split would mix near-duplicates between training and test and give inflated figures.
- Cheap baselines on the same test set: an untouched model and a logistic regression on embeddings. If the fine-tune does not beat them with a paired test, it is not worth it.
- Placebos and controls: shuffled labels, or a task unrelated to the text. A fine-tune that “learns” there too is measuring its capacity to memorize.
- Count the experiments: rank, layers, learning rate, epochs and seeds are hyperparameters. Choosing the best of 50 on the same validation set has the selection problem of 2.5.4 and 5.5.4.
- The training text has a date too: fine-tuning a model with news up to 2025 and backtesting with news from 2022 is look-ahead. With LLMs the problem goes beyond your own labels and is the subject of lesson 6.2.5.
Exercises
- With the lesson's
cuentafunction, compare rank-64 LoRA onq_projandv_projonly with rank-8 LoRA on every linear layer of Qwen2.5-7B. Which one trains more parameters and how much memory would each need with QLoRA?
Solution
for nombre, mods, r in [("r=64 on q, v", ["q_proj", "v_proj"], 64),
("r=8 on all", 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")r=64 on q, v LoRA 20.2 M · QLoRA 5.9 GB
r=8 on all LoRA 20.2 M · QLoRA 5.9 GBThey tie: 20.2 million parameters and 5.9 GB with QLoRA. It is not a rounding coincidence. Per block, rank 64 on
q_proj (3,584 to 3,584) and v_proj (3,584 to 512, because of the 4 key-value heads) gives parameters, and rank 8 on the seven projections gives $8 \cdot (2 \cdot 7{,}168 + 2 \cdot 4{,}096
- 3 \cdot 22{,}528) = 720{,}896$. With the same budget, the question is where to put it, and the QLoRA paper answers on every linear layer: concentrating it in the attention did not match full fine-tuning in their experiments, and the rank barely mattered. Memory barely depends on this choice because the quantized base dominates it.
- Compute the confusion matrix of the logistic regression and of LoRA on the test set. Which confusions does LoRA correct?
Solution
from sklearn.metrics import confusion_matrix
nombres = ["neg", "neutral", "pos"]
for m, p in [("logistic", pred_lr), ("LoRA", pred_lora)]:
print(m)
print(pd.DataFrame(confusion_matrix(y_te, p), index=nombres,
columns=nombres).to_string())logistic
neg neutral pos
neg 176 6 25
neutral 4 133 1
pos 20 8 227
LoRA
neg neutral pos
neg 175 1 31
neutral 0 138 0
pos 1 1 253Rows are the true class and columns, the predicted one. The logistic regression does not go wrong mainly with the neutral ones, as one might expect, but with polarity: 25 negatives classified as positive and 20 positives as negative. It is what lesson 6.3.1 anticipates: the MiniLM embedding of “La empresa eleva su previsión de ventas” (“The company raises its sales forecast”) and that of “La empresa recorta su previsión de ventas” (“The company cuts its sales forecast”) have a cosine similarity of 0.95, because the frozen model measures what the sentence is about more than which way it points. LoRA gets every neutral one right and 253 of 255 positives, but still confuses negatives with positives (31, more than the logistic regression): with four epochs it has learned to recognize good news well and bad news not quite yet. It is a reminder that an aggregate improvement (89.3% to 94.3%) can hide a specific error that gets worse, and in trading, sign errors are the expensive ones.
- A colleague fine-tunes LoRA with 3,000 headlines from 2019-2025 split at random between training and test and gets 99.5% accuracy. They propose using the model to trade tomorrow's news. What is left to check?
Solution
Almost everything. The random split mixes nearly identical headlines (the same template, another company or another date) between training and test, so the 99.5% partly measures memory; a test set later in time than all the training data is needed. The comparison with cheap baselines on that test set is missing (a logistic regression on embeddings with the same labels), and so is a paired test. A placebo is missing, to know how much the model can memorize with those labels. And, above all, getting the sentiment right is not making money: the price reaction to a news item happens within minutes (lesson 6.1.3), so the signal has to exist before the market incorporates it, with the publication time and the costs of level 2. Finally, these headlines are templated: with real text, the accuracy of any method will be much lower.
Footnotes
-
Qwen2.5-7B-Instruct model card on Hugging Face (accessed 2026-10-23): 7.61B parameters, 6.53B excluding embeddings, 28 layers, 28 query heads and 4 key-value heads, Apache-2.0 license. ↩
Each answer is folded under its question
Self-assessment
A 384×384 linear layer is adapted with rank-8 LoRA. How many new parameters are trained in that layer?
- A147,456, the same as the original matrix
- B3,072, half of r·(d + k)
- C6,144: A is 8×384 and B is 384×8
- D64, the square of the rank
Show answer
Answer C. 6,144: A is 8×384 and B is 384×8
LoRA replaces the update ΔW (384×384) with the product B·A, where A is r×384 and B is 384×r: r·(d + k) = 8·768 = 6,144 parameters, 4.2% of the matrix. The original matrix stays frozen.
When the model is created with peft, the B matrix of each adapter is zero. What for?
- ASo that, before training, the adapted model gives exactly the same output as the original and the fine-tune starts from the pretrained model
- BTo save memory
- CSo that the gradient of A is zero at the first step and it never moves
- DIt is an initialization bug that has to be fixed
Show answer
Answer A. So that, before training, the adapted model gives exactly the same output as the original and the fine-tune starts from the pretrained model
With B = 0, B·A = 0 and the output does not change; A is initialized at random so that the gradient of B is not zero. In the lesson, the logistic regression uses the embeddings with the adapter disabled, and those of the freshly created adapter would be identical.
According to the lesson's math, why does QLoRA make it possible to fine-tune Qwen2.5-7B with about 6 GB when LoRA with the base in bf16 needs about 16 GB?
- ABecause it trains fewer parameters than LoRA
- BBecause it does not use Adam
- CBecause it eliminates activations
- DBecause it stores the frozen weights of the linear layers in NF4 (about 4.1 bits per parameter with double quantization) instead of 16 bits; the adapters and the optimizer are the same
Show answer
Answer D. Because it stores the frozen weights of the linear layers in NF4 (about 4.1 bits per parameter with double quantization) instead of 16 bits; the adapters and the optimizer are the same
The 20 million LoRA parameters and their Adam state weigh the same in both cases (0.3 GB). The difference is in the base: 6,525 million linear weights go from 2 bytes to about 0.52. The math does not include activations, which depend on the batch and the sequence length.
With 300 training headlines, LoRA gets 94.3% right and the logistic regression on frozen embeddings 89.3% on the same test set of 600. Only LoRA gets 54 headlines right and only the logistic regression, 24. What can be concluded?
- ANothing, because 5 points could be noise with 600 headlines
- BThat the difference is significant on this data (exact McNemar, p ≈ 0.001) and that adapting the encoder helps mostly in Spanish; not that it will repeat with real texts
- CThat LoRA always beats a logistic regression
- DThat the logistic regression is badly regularized
Show answer
Answer B. That the difference is significant on this data (exact McNemar, p ≈ 0.001) and that adapting the encoder helps mostly in Spanish; not that it will repeat with real texts
The paired test uses only the disagreements (54 against 24). The improvement is larger in Spanish (89.8% to 95.2%), the language MiniLM knows worse, than in English (87.6% to 91.2%). The headlines come from templates by event type: it is a much easier problem than real text.
The same adapters, trained with shuffled labels, bring the training loss down from 1.20 to 0.74 in four epochs while the validation loss rises from 1.24 to 1.66. What does the placebo teach?
- AThat the model has found a hidden signal
- BThat the learning rate is too low
- CThat 73,728 parameters are enough to memorize 100 labels unrelated to the text: a falling training loss proves nothing without a validation set separated in time
- DThat more epochs are needed
Show answer
Answer C. That 73,728 parameters are enough to memorize 100 labels unrelated to the text: a falling training loss proves nothing without a validation set separated in time
With random labels there is nothing to learn; what goes down is memory. Validation accuracy falls to 11%, far below the 36% of always predicting the most frequent class: the model has learned a false rule (which label goes with each event type) and applies it with confidence. It is the same overfitting lesson as 5.8.3 with another model.
Key points
- LoRA freezes W and trains a rank-r B·A: in MiniLM, 73,728 parameters out of 22.8 million (0.32%); in Qwen2.5-7B with r = 8 in every linear layer, 20.2 million (0.27%).
- Memory without activations for Qwen2.5-7B: 121.8 GB with full fine-tuning in fp32 and Adam, 15.6 GB with LoRA and the base in bf16, 5.9 GB with QLoRA (NF4 and double quantization, 4.127 bits per linear weight).
- On synthetic headlines with 300 labels, LoRA on MiniLM gets 94.3% right in 2024-2025, against 89.3% for a logistic regression on the frozen embeddings and 36.0% for an untrained FinBERT (which does not understand Spanish).
- The adapter is merged into W after training: the resulting model gives the same outputs and adds no latency; unmerged, it takes 288 KB against the model's 87 MB.
- The shuffled-label placebo memorizes: the training loss falls and the validation loss rises. Time-based validation, a cheap baseline and a placebo are the minimum before believing a fine-tune.