rukh · lab

// M4 · lección 03

LoRA escrita a mano

models/lora.py entero: la tabla de objetivos sobre una proyección fusionada, el LoRALinear con una A y una B por rodaja, fundir sin mutar, el adaptador apilado en dos tensores para el grafo ONNX, los catorce tests que definen operativamente qué es LoRA y las dos configuraciones de los adaptadores de estilo.

  • tiempo de trabajo120 min
  • además, ejecución sin supervisión+ 15 min de GPU y red
  • nivel avanzado
  • actualizado el23 de septiembre de 2026

Lección 3 de 10 del módulo «Fine-tuning e instrucción». Viene de «Los tokens de Elo y el corpus que faltaba» y sigue en «Estilo y texto PGN».

Qué vas a construir

src/rukh/models/lora.py entero y los catorce tests que lo definen. Es el único fichero del módulo que implementa una técnica publicada, así que detrás de cada parte hay una afirmación del método y un test que la comprueba.

Al terminar tendrás un envoltorio que congela un nn.Linear y le suma una corrección de rango bajo por cada rodaja de salida, dos maneras de fundir esa corrección (una que muta y otra que no) y un formato de adaptador con tres formas, porque PyTorch, safetensors y un grafo ONNX quieren los mismos números colocados de tres maneras.

El docstring, que es el diseño

src/rukh/models/lora.py
"""LoRA written by hand: low-rank adapters over the frozen weights of a ``MoveDecoder``.
A full fine-tune of ``medium`` writes 115 M numbers and produces a 1.4 GB checkpoint, so a
project that wants five styles needs five copies of the same model. LoRA asks a different
question: instead of moving ``W``, learn a correction ``B A`` whose rank is small enough that the
two factors together are a rounding error next to ``W``. With ``r = 8`` on the query and value
projections of a 16-layer decoder that is 393 216 numbers -- 0.34 % of the model, 1.6 MB in
``float32`` -- and the base weights never change, so every adapter shares one copy of them.
Two details of *this* decoder shape the implementation.
The first is that attention uses one fused ``qkv`` projection (``layers.SelfAttention``), so
there is no ``q_proj`` module to wrap. Adapting "query and value" means adapting two contiguous
output ranges of a single ``nn.Linear``, which is why ``LoRALinear`` takes a tuple of output
slices and gives **each one its own ``A`` and ``B``**. Sharing one ``A`` between the query and
the value slice would be cheaper and would not be LoRA: the whole point is that each adapted
matrix gets its own subspace.
The second is that wrapping renames parameters: ``blocks.0.attn.qkv.weight`` becomes
``blocks.0.attn.qkv.base.weight``, which no existing checkpoint knows about. So the wrappers are
never what gets saved. ``save_adapter`` writes only ``A`` and ``B``, keyed by the path of the
module they adapt, and ``merge_lora`` folds the correction back into ``W`` and puts the plain
``nn.Linear`` back where it was, which is what the exporter and the publisher see.
"""

src/rukh/models/lora.pylíneas 1-24 · p4

Los dos detalles del docstring separan esta implementación de la de cualquier tutorial. El primero: no hay un módulo q_proj que envolver, sino una qkv fusionada, y cada rango adaptado recibe su propia A y su propia B. Compartir una A sería más barato y no sería LoRALoRALow-Rank Adaptation: en vez de modificar una matriz de pesos W, se aprende una corrección B·A de rango r (A de r × d, B de d × r, con B inicializada a cero para que el modelo adaptado sea el base en el paso 0) y se suma a W escalada por alpha/r. Entrena pocos parámetros —en medium, 393 216 con r = 8 sobre consulta y valor, el 0,34 %—, se guarda como un adaptador de 1,6 MB y se puede cambiar en caliente. QLoRA hace lo mismo sobre un modelo base cuantizado a 4 bits.: cada matriz adaptada tiene que tener su propio subespacio de rangoRango (de una matriz)Cuántas direcciones independientes describe una matriz. Una de 768×768 puede tener hasta 768; el producto B·A de LoRA con r=8 tiene como mucho 8, y ahí está todo: es lo que lo abarata (12 288 números en vez de 589 824) y también lo que lo limita, porque cualquier corrección que el adaptador quiera aprender tiene que caber en esas ocho direcciones. r. El segundo: envolver renombra los parámetros, así que los envoltorios nunca son lo que se guarda.

Las importaciones y la lista de lo que el módulo exporta se pueden saltar en la primera lectura, pero sin ellas el fichero no importa: si las saltas, trae lora.py entero como dice el recuadro del principio (git checkout p4 -- src/rukh/models/lora.py, antes de teclear nada de él).

// FontaneríaLas importaciones y __all__, la superficie pública del módulo
src/rukh/models/lora.py
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import TYPE_CHECKING
import torch
from pydantic import Field
from torch import Tensor, nn
from rukh.config import BaseConfig
if TYPE_CHECKING:
from collections.abc import Iterator
__all__ = [
"TARGETS",
"AdapterLayout",
"LoraConfig",
"LoRALinear",
"adapter_layout",
"adapter_params",
"apply_lora",
"load_adapter",
"lora_modules",
"merge_lora",
"merged_state_dict",
"save_adapter",
"stack_adapter",
]
ADAPTER_FILE = "adapter.safetensors"
ADAPTER_CONFIG = "adapter_config.json"

src/rukh/models/lora.pylíneas 26-59 · p4

__all__ es la superficie pública: un envoltorio, una configuración, funciones para aplicar, recorrer, fundir, guardar y cargar, y tres para el camino del grafo (AdapterLayout, adapter_layout, stack_adapter), que existen por la demo y se usan en la lección 6.

La tabla de objetivos

src/rukh/models/lora.py
# Logical target names and where they live in a block. The names are what a reader of the
# LoRA papers expects ("we adapt Wq and Wv"); the values say which module carries that matrix
# in this decoder and which output range of it belongs to the target. The fused projection
# writes query, key and value in that order, each `d_model` wide, so the ranges are given in
# units of `d_model` and resolved against the block at apply time.
TARGETS: dict[str, tuple[str, int, int]] = {
"q": ("attn.qkv", 0, 1),
"k": ("attn.qkv", 1, 2),
"v": ("attn.qkv", 2, 3),
"attn_out": ("attn.proj", 0, 1),
"mlp_in": ("mlp.fc", 0, 1),
"mlp_out": ("mlp.proj", 0, 1),
}

src/rukh/models/lora.pylíneas 61-73 · p4

La tabla traduce los nombres de los artículos («adaptamos Wq y Wv») a dónde vive esa matriz en este decoder y qué rango de su salida le corresponde. Los rangos van en unidades de d_model, así que la misma tabla vale para tiny, small y medium; la excepción es mlp_in, porque mlp.fc mide ff de ancho.

src/rukh/models/lora.py
class LoraConfig(BaseConfig):
"""Rank, scaling and which matrices of each block get an adapter."""
r: int = Field(default=8, ge=1)
alpha: int = Field(default=16, ge=1)
"""The correction is scaled by ``alpha / r``, so raising the rank does not also raise how
hard the adapter pushes. It is the convention of the paper and of ``peft``; keeping it means
a rank sweep measures rank and not two things at once."""
dropout: float = Field(default=0.0, ge=0.0, lt=1.0)
targets: tuple[str, ...] = ("q", "v")
def check(self) -> None:
unknown = [name for name in self.targets if name not in TARGETS]
if unknown:
raise ValueError(f"unknown LoRA targets {unknown}; known: {sorted(TARGETS)}")
if not self.targets:
raise ValueError("targets must name at least one matrix")
@property
def scale(self) -> float:
return self.alpha / self.r

src/rukh/models/lora.pylíneas 76-96 · p4

La corrección se escala por alpha / r, así que subir el rango no sube además lo fuerte que empuja el adaptador. Sin ese cociente, un barrido de rangos mediría dos cosas a la vez: cuántas direcciones tiene la corrección y cuánto pesa.

LoRALinear: el envoltorio

El envoltorio usa cuatro piezas de PyTorch que no han salido hasta ahora con este papel (el resto está en el mínimo de PyTorch de M2). Antes de leerlo, una LoRA de 4×4 y rango 1 escrita a mano, para ver las cuatro en diez líneas. Se puede pegar en uv run python:

import math
import torch
import torch.nn.functional as F
torch.manual_seed(0)
x = torch.randn(2, 4) # dos entradas de 4 números
w = torch.randn(4, 4).requires_grad_(False) # el peso congelado de una nn.Linear(4, 4)
print(torch.allclose(F.linear(x, w), x @ w.T)) # F.linear(x, w) es x @ w.T
a = torch.empty(1, 4) # A: r = 1 fila de 4 columnas
torch.nn.init.kaiming_uniform_(a, a=math.sqrt(5)) # al azar, entre -0,5 y 0,5
a.requires_grad_(True) # entrenable
b = torch.zeros(4, 1, requires_grad=True) # B: 4 filas de r = 1, a cero
y = F.linear(x, w) + F.linear(F.linear(x, a), b) # W x + B (A x)
print(torch.equal(y, F.linear(x, w))) # paso 0: B = 0, es el modelo base
print(a.numel() + b.numel(), "entrenables frente a", w.numel())
with torch.no_grad():
b.fill_(0.5) # como si el entrenamiento hubiera movido B
print(tuple((b @ a).shape), torch.linalg.matrix_rank(b @ a).item()) # 4x4, de rango 1
True
True
8 entrenables frente a 16
(4, 4) 1
  • F.linear(x, w) es x @ w.T: lo que hace una nn.Linear sin sesgo, con el peso guardado como (salidas × entradas). LoRALinear la llama dos veces seguidas, primero con A (que baja de 4 a r números) y luego con B (que sube de r a 4).
  • requires_grad_(False) apaga el gradiente de un tensor: autograd no calcula nada para él y el optimizador no lo mueve. Es todo lo que significa «congelar».
  • kaiming_uniform_(a, a=math.sqrt(5)) rellena A con números al azar entre ±1/√(entradas), aquí ±0,5. Es la misma inicialización que PyTorch usa por defecto en una nn.Linear; el a=math.sqrt(5) es el parámetro que la hace coincidir, no una cifra con significado propio.
  • B a cero hace que el segundo print salga True: en el paso 0 la corrección vale exactamente cero y el modelo adaptado es el modelo base.

La última línea es la idea entera: B A tiene la forma de W, 4×4, pero rango 1, y se entrena con 8 números en vez de 16. En la consulta de medium, de 768×768, con r = 8 son 12 288 frente a 589 824. El envoltorio real es esto mismo con una A y una B por rodaja:

src/rukh/models/lora.py
class LoRALinear(nn.Module):
"""A frozen ``nn.Linear`` plus one low-rank correction per adapted output slice.
``slices`` are half-open ``(start, stop)`` ranges over the output features. A plain linear
gets one range covering everything; the fused ``qkv`` gets one range per adapted projection,
which is how "query and value but not key" is expressed on a matrix that holds all three.
"""
def __init__(
self,
base: nn.Linear,
r: int,
alpha: int,
dropout: float,
slices: tuple[tuple[int, int], ...],
) -> None:
super().__init__()
if not slices:
raise ValueError("a LoRALinear needs at least one output slice")
self.base = base
self.base.weight.requires_grad_(False)
if self.base.bias is not None:
self.base.bias.requires_grad_(False)
self.r = r
self.scale = alpha / r
self.slices = tuple(slices)
self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
in_features = base.in_features
# Built where the weight they correct already lives. `apply_lora` runs *after* the model
# has been moved to the device (the training loop loads the checkpoint first, and loading
# needs the module names of a plain decoder), so a factor created on the CPU by default
# would meet a CUDA activation on the first forward. Every unit test of this file runs on
# the CPU, where the mistake is invisible.
where = {"device": base.weight.device, "dtype": base.weight.dtype}
# `A` is drawn from the same Kaiming-uniform the paper uses and `B` starts at exactly
# zero, so the adapted model *is* the base model at step 0. Any other initialisation
# would move the weights before a single gradient arrived.
self.a = nn.ParameterList(
nn.Parameter(torch.empty(r, in_features, **where)) for _ in range(len(self.slices))
)
self.b = nn.ParameterList(
nn.Parameter(torch.zeros(stop - start, r, **where)) for start, stop in self.slices
)
for a in self.a:
nn.init.kaiming_uniform_(a, a=math.sqrt(5))

src/rukh/models/lora.pylíneas 99-143 · p4

Tres decisiones, comentadas en el fichero porque ninguna se deduce del código:

  • Los factores se construyen donde ya vive el peso que corrigen. apply_lora corre después de mover el modelo a la GPU, y un factor creado en CPU se encontraría una activación de CUDA en la primera pasada. Los tests corren en CPU, donde el fallo es invisible; por eso uno comprueba la propiedad (el factor está donde el peso) y no el dispositivo.
  • B arranca en cero, así que el modelo adaptado es el modelo base en el paso 0. A sale de la misma kaiming_uniform_ del artículo.
  • Una A y una B por rodaja, en nn.ParameterList, porque las rodajas pueden tener anchuras distintas.
src/rukh/models/lora.py
def forward(self, x: Tensor) -> Tensor:
out = self.base(x)
dropped = self.drop(x)
delta = out.new_zeros(out.shape)
for (start, stop), a, b in zip(self.slices, self.a, self.b, strict=True):
delta[..., start:stop] = torch.nn.functional.linear(
torch.nn.functional.linear(dropped, a), b
)
return out + self.scale * delta
@torch.no_grad()
def merged_weight(self) -> Tensor:
"""``W + (alpha / r) * B A`` with each correction written into its own output slice."""
weight = self.base.weight.detach().clone()
for (start, stop), a, b in zip(self.slices, self.a, self.b, strict=True):
weight[start:stop] += self.scale * (b @ a).to(weight.dtype)
return weight
def extra_repr(self) -> str:
return f"r={self.r}, scale={self.scale}, slices={self.slices}"

src/rukh/models/lora.pylíneas 145-164 · p4

El dropout se aplica a la entrada del adaptador, como en el artículo. merged_weight() es la misma operación sobre los pesos en vez de sobre las activaciones, y clona el peso base para poder llamarla sin destruir el modelo.

Aplicar los adaptadores

src/rukh/models/lora.py
def _target_slices(
cfg: LoraConfig, d_model: int, ff: int
) -> dict[str, tuple[tuple[int, int], ...]]:
"""Group the configured targets by the module they live in, as output ranges of it."""
by_module: dict[str, list[tuple[int, int]]] = {}
for name in cfg.targets:
module_name, lo, hi = TARGETS[name]
width = ff if module_name == "mlp.fc" else d_model
by_module.setdefault(module_name, []).append((lo * width, hi * width))
return {name: tuple(sorted(ranges)) for name, ranges in by_module.items()}
def _get_module(root: nn.Module, path: str) -> nn.Module:
module = root
for part in path.split("."):
module = getattr(module, part)
return module
def _set_module(root: nn.Module, path: str, value: nn.Module) -> None:
parts = path.split(".")
parent = root
for part in parts[:-1]:
parent = getattr(parent, part)
setattr(parent, parts[-1], value)

src/rukh/models/lora.pylíneas 167-191 · p4

En _target_slices está la excepción del MLP: una rodaja calculada en la unidad equivocada no daría error, daría un adaptador que cubre la cuarta parte de la matriz. Y agrupar por módulo hace que «consulta y valor» sea un solo LoRALinear con dos rodajas, porque no se pueden poner dos envoltorios sobre la misma nn.Linear.

src/rukh/models/lora.py
def apply_lora(model: nn.Module, cfg: LoraConfig) -> int:
"""Freeze ``model``, wrap the configured matrices of every block and return trainable params.
Everything outside the adapters is frozen, including the embeddings and the final norm: a
LoRA run that also trains the embedding table is not a LoRA run, and the count this returns
would stop meaning what it says.
"""
cfg.check()
for param in model.parameters():
param.requires_grad_(False)
blocks = getattr(model, "blocks", None)
if blocks is None:
raise ValueError("model has no `blocks`; LoRA is applied per transformer block")
d_model = model.cfg.d_model
ff = getattr(model.cfg, "ff", 4 * d_model)
plan = _target_slices(cfg, d_model, ff)
for block in blocks:
for module_name, slices in plan.items():
base = _get_module(block, module_name)
if not isinstance(base, nn.Linear):
raise TypeError(f"{module_name} is a {type(base).__name__}, not nn.Linear")
_set_module(block, module_name, LoRALinear(base, cfg.r, cfg.alpha, cfg.dropout, slices))
return sum(p.numel() for p in model.parameters() if p.requires_grad)

src/rukh/models/lora.pylíneas 194-216 · p4

Todo lo que está fuera de los adaptadores se congela, incluidos los embeddings y la norma final: una ejecución de LoRA que entrene la tabla de embeddings ya no es LoRA. El número que devuelve, el que acaba en el log y en la card, se cuenta del modelo y no de la fórmula; un test comprueba que los dos coinciden.

src/rukh/models/lora.py
def lora_modules(model: nn.Module) -> Iterator[tuple[str, LoRALinear]]:
"""Every adapter in the model, by the dotted path of the matrix it adapts."""
for name, module in model.named_modules():
if isinstance(module, LoRALinear):
yield name, module
def adapter_params(model: nn.Module) -> Iterator[nn.Parameter]:
"""The parameters a LoRA run trains: the ``A`` and ``B`` of every adapter, nothing else."""
for _, module in lora_modules(model):
yield from module.a
yield from module.b

src/rukh/models/lora.pylíneas 219-230 · p4

lora_modules recorre por ruta punteada porque esa ruta es lo que identifica un adaptador cuando ya no está montado, por ejemplo en el fichero safetensors.

Fundir, de dos maneras

src/rukh/models/lora.py
@torch.no_grad()
def merge_lora(model: nn.Module) -> int:
"""Fold every adapter into the weight it adapts and put the plain ``nn.Linear`` back.
After this the model is indistinguishable from one that was fine-tuned in full: same module
names, same state dict, same ONNX graph. That is the property that lets an adapter be
published as a 1.6 MB file and still be exportable as an ordinary model.
"""
merged = 0
for name, adapter in list(lora_modules(model)):
base = adapter.base
base.weight.copy_(adapter.merged_weight())
base.weight.requires_grad_(True)
if base.bias is not None:
base.bias.requires_grad_(True)
_set_module(model, name, base)
merged += 1
return merged

src/rukh/models/lora.pylíneas 233-250 · p4

merge_lora muta: pliega cada corrección en el peso y pone el nn.Linear de vuelta. Después, el modelo es indistinguible de uno afinado entero, y por eso un adaptador de 1,6 MB se puede exportar, cuantizar y servir como un modelo cualquiera.

src/rukh/models/lora.py
@torch.no_grad()
def merged_state_dict(model: nn.Module) -> dict[str, Tensor]:
"""The state dict this model *would* have if every adapter were folded in, without folding.
``merge_lora`` mutates, which is what an export wants and what a training loop does not: a
checkpoint written mid-run must not leave the model unable to take another step. So the
folding is done on a copy of the numbers and the wrappers are left alone, and what comes out
has the module names of a plain ``MoveDecoder`` -- which is what every loader, exporter and
publisher in this project expects to read.
"""
adapters = dict(lora_modules(model))
merged: dict[str, Tensor] = {}
for name, tensor in model.state_dict().items():
owner, _, leaf = name.rpartition(".")
base_owner, _, base_leaf = owner.rpartition(".")
if base_leaf == "base" and base_owner in adapters:
value = adapters[base_owner].merged_weight() if leaf == "weight" else tensor
merged[f"{base_owner}.{leaf}"] = value.detach().cpu()
continue
if any(name.startswith(f"{path}.{factor}.") for path in adapters for factor in ("a", "b")):
continue # the adapter's own factors; they live in the adapter file, not here
merged[name] = tensor.detach().cpu()
return merged

src/rukh/models/lora.pylíneas 253-275 · p4

Mutar es lo que quiere una exportación y lo que no quiere un bucle de entrenamiento: un checkpoint escrito a mitad de ejecución no puede dejar el modelo incapaz de dar otro paso. merged_state_dict pliega sobre una copia: renombra .base.weight al peso fundido, copia el sesgo tal cual y descarta los factores, que viven en su propio fichero. Lo que sale tiene los nombres de un MoveDecoder normal.

Cómo el bucle guarda un checkpoint de LoRA es fontanería del entrenamiento: basta con saber que se guardan los pesos fundidos, sin optimizador. Se puede saltar en la primera lectura; el «Ejecútalo» del final entrena con LoRA dentro del bucle, así que si lo saltas, trae los dos ficheros: git checkout p4 -- src/rukh/train/loop.py src/rukh/train/checkpoint.py (el loop.py de p4 ya lleva el init_from que tecleaste en la lección anterior).

// FontaneríaEl guardado: checkpoints de LoRA con los pesos fundidos y sin optimizador
src/rukh/train/loop.py
def save(path: Path, step: int) -> Path:
return write_checkpoint(
path,
step=step,
model=model,
# A LoRA run trains `A` and `B`; the optimizer state of two small matrices is not
# worth 460 MB per checkpoint, and it cannot be resumed into anyway.
optimizer=None if cfg.lora is not None else optimizer,
cfg=cfg,
model_cfg=model_cfg,
vocab_hash=vocab_hash,
manifest_sha=manifest_sha,
best_val=best_val,
run_id=this_run,
state=merged_state_dict(model) if cfg.lora is not None else None,
)

src/rukh/train/loop.pylíneas 227-242 · p4

En una ejecución de LoRA el optimizador no se guarda: no se podría reanudar dentro de un checkpoint con los pesos fundidos.

src/rukh/train/checkpoint.py
*,
step: int,
model: nn.Module,
optimizer: torch.optim.Optimizer | None,
cfg: Mapping[str, Any],
model_cfg: Mapping[str, Any],
vocab_hash: str | None = None,
data_manifest_sha: str | None = None,
git_sha: str | None = None,
best_val: float | None = None,
run_id: str | None = None,
state: Mapping[str, torch.Tensor] | None = None,
) -> Path:
"""Write one checkpoint atomically (temporary file plus replace) and return its path.
``run_id`` is the MLflow run that wrote it, so ``--resume`` can carry on logging into the
same run and the loss curve stays one line instead of two.
``state`` replaces ``model.state_dict()``. A LoRA run passes the merged weights, so what
lands on disk is an ordinary decoder checkpoint that every loader, exporter and publisher
already reads, instead of a wrapped model only this repository could open.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
source = state if state is not None else model.state_dict()
payload: dict[str, Any] = {
"step": int(step),
"model_state": {k: v.detach().cpu() for k, v in source.items()},

src/rukh/train/checkpoint.pylíneas 103-130 · p4

El parámetro state es todo lo que hizo falta cambiar en el guardado, y no hay ninguna rama if lora dentro: el fichero que escribe no sabe que LoRA existe.

Guardar y cargar el adaptador

El formato del fichero del adaptador se puede saltar en la primera lectura: safetensors con las claves del modelo base y la configuración al lado. Dos tests lo comprueban, así que si lo saltas, trae lora.py entero: git checkout p4 -- src/rukh/models/lora.py (sustituye lo que llevas tecleado por la versión de referencia; si prefieres conservarlo, git diff p4 -- src/rukh/models/lora.py te enseña solo las líneas que faltan).

// Fontaneríasave_adapter y load_adapter: safetensors con las rutas del modelo base y su adapter_config.json
src/rukh/models/lora.py
def save_adapter(model: nn.Module, path: Path | str, cfg: LoraConfig) -> Path:
"""Write ``A`` and ``B`` to safetensors, keyed by the module they adapt, plus the config.
The keys are the paths the adapter *would* have in the base model
(``blocks.0.attn.qkv.lora_A.0``), not the paths the wrappers create, so loading does not
depend on how the wrapping happened to be spelled.
"""
from safetensors.torch import save_file
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
tensors: dict[str, Tensor] = {}
for name, adapter in lora_modules(model):
for index, (a, b) in enumerate(zip(adapter.a, adapter.b, strict=True)):
tensors[f"{name}.lora_A.{index}"] = a.detach().cpu().contiguous()
tensors[f"{name}.lora_B.{index}"] = b.detach().cpu().contiguous()
if not tensors:
raise ValueError("model has no LoRA adapters to save")
save_file(tensors, target.as_posix())
config_path = target.with_name(ADAPTER_CONFIG)
config_path.write_text(cfg.model_dump_json(indent=2) + "\n", encoding="utf-8")
return target

src/rukh/models/lora.pylíneas 278-299 · p4

Las claves son las rutas que el adaptador tendría en el modelo base (blocks.0.attn.qkv.lora_A.0), no las de los envoltorios: si LoRALinear renombra algún día sus ParameterList, los adaptadores publicados siguen cargándose. Se usa safetensorssafetensorsFormato de serialización de tensores que el Hub de Hugging Face espera y previsualiza, y que no ejecuta código al leerse. Guarda tensores y no alias, así que un modelo con tied embeddings —donde lm_head.weight y tokens.weight son un tensor con dos nombres— tiene que publicar el nombre atado fuera del fichero y volver a atarlo al cargar. en vez de torch.save porque el fichero se publica, y un pickle de PyTorch ejecuta código al cargarse. Y la configuración va al lado, en adapter_config.json: sin el r, el alpha y los objetivos, apply_lora no sabría qué envolver.

src/rukh/models/lora.py
def load_adapter(model: nn.Module, path: Path | str) -> LoraConfig:
"""Apply the adapter stored next to ``path`` to a base model and load its weights."""
from safetensors.torch import load_file
target = Path(path)
cfg = LoraConfig.model_validate_json(
target.with_name(ADAPTER_CONFIG).read_text(encoding="utf-8")
)
apply_lora(model, cfg)
tensors = load_file(target.as_posix())
state: dict[str, Tensor] = {}
for name, adapter in lora_modules(model):
for index in range(len(adapter.a)):
state[f"{name}.a.{index}"] = tensors[f"{name}.lora_A.{index}"]
state[f"{name}.b.{index}"] = tensors[f"{name}.lora_B.{index}"]
missing, unexpected = model.load_state_dict(state, strict=False)
if unexpected:
raise ValueError(f"adapter carries tensors the model has no place for: {unexpected}")
return cfg

src/rukh/models/lora.pylíneas 302-320 · p4

strict=False es obligatorio, porque el state dict solo trae los factores, y por eso unexpected se comprueba a mano: un tensor que el modelo no sabe dónde poner pasaría en silencio.

El adaptador como dos tensores

Esta sección existe por la demo: AdapterLayout, adapter_layout y stack_adapter no se usan hasta la lección 6, y la idea está en el párrafo entre los dos pliegues. Pero el segundo pliegue trae también adapter_size y describe, que el «Ejecútalo» de justo después importa. Así que o tecleas al menos esas dos funciones, o traes el fichero entero: git checkout p4 -- src/rukh/models/lora.py (o miras con git diff p4 -- src/rukh/models/lora.py qué te falta, sin tocar lo tuyo).

// FontaneríaAdapterLayout y adapter_layout: la forma de un adaptador apilado
src/rukh/models/lora.py
class AdapterLayout(BaseConfig):
"""How a whole adapter is laid out as two stacked tensors, one per factor.
``save_adapter`` writes one ``A`` and one ``B`` per adapted slice, keyed by the module they
belong to. That is the right shape to load into PyTorch and the wrong shape to feed a graph:
an ONNX input is one tensor, not thirty-two. Stacking turns the whole adapter into
``A (layers, slices, r, in_features)`` and ``B (layers, slices, width, r)``, which is what
the browser uploads once per move and what lets a 1.6 MB file change a 440 MB model without
downloading a second one.
The stacking needs the layers to agree: one adapted module per block, every slice the same
width, the same input width everywhere. That covers the query, key and value ranges of the
fused ``qkv`` -- which is what this project trains and publishes -- and it does not cover an
adapter that also touches the MLP, whose ``fc`` is four times as wide. That case has a path
of its own (``merge_lora`` and an ordinary export); it just cannot travel as one tensor.
"""
module: str
"""Dotted path of the adapted matrix inside a block, e.g. ``attn.qkv``."""
layers: int
slices: tuple[tuple[int, int], ...]
r: int
scale: float
in_features: int
@property
def width(self) -> int:
return self.slices[0][1] - self.slices[0][0]
@property
def shape_a(self) -> tuple[int, int, int, int]:
return (self.layers, len(self.slices), self.r, self.in_features)
@property
def shape_b(self) -> tuple[int, int, int, int]:
return (self.layers, len(self.slices), self.width, self.r)
@property
def numel(self) -> int:
return math.prod(self.shape_a) + math.prod(self.shape_b)

src/rukh/models/lora.pylíneas 323-362 · p4

Aquí empieza la parte que existe por la demo. Una entrada de ONNX es un tensor, no treinta y dos, así que el adaptador se apila en A (capas, rodajas, r, entrada) y B (capas, rodajas, ancho, r): es la diferencia entre 1,6 MB por estilo y 231 MB por estilo. Apilar exige que todas las capas coincidan, y eso cubre la qkv fusionada pero no un adaptador que toque el MLP; ese caso se funde y se exporta como un modelo normal.

// Fontaneríastack_adapter, describe y adapter_json: apilar, describir y etiquetar el adaptador
src/rukh/models/lora.py
def adapter_layout(model: nn.Module) -> AdapterLayout:
"""Read the layout off a model that already has its adapters applied."""
found = list(lora_modules(model))
if not found:
raise ValueError("model has no LoRA adapters")
names = [name for name, _ in found]
suffixes = {name.split(".", 2)[-1] for name in names}
if len(suffixes) != 1:
raise ValueError(
f"adapters on more than one matrix per block ({sorted(suffixes)}) cannot be stacked "
"into one tensor; merge them instead"
)
first = found[0][1]
for name, adapter in found:
if adapter.slices != first.slices or adapter.base.in_features != first.base.in_features:
raise ValueError(f"{name} does not have the same shape as {names[0]}")
widths = {stop - start for start, stop in first.slices}
if len(widths) != 1:
raise ValueError(f"slices of different widths ({sorted(widths)}) cannot be stacked")
return AdapterLayout(
module=suffixes.pop(),
layers=len(found),
slices=first.slices,
r=first.r,
scale=first.scale,
in_features=first.base.in_features,
)

src/rukh/models/lora.pylíneas 365-391 · p4

Fíjate en el primer mensaje de error: en vez de «no soportado», dice qué hacer en su lugar («merge them instead»). Un error que nombra la alternativa ahorra media hora de leer código ajeno.

src/rukh/models/lora.py
@torch.no_grad()
def stack_adapter(model: nn.Module) -> tuple[Tensor, Tensor]:
"""The whole adapter as ``(A, B)``, stacked layer by layer in the order the blocks run."""
layout = adapter_layout(model)
a = torch.stack([torch.stack([p.detach() for p in ad.a]) for _, ad in lora_modules(model)])
b = torch.stack([torch.stack([p.detach() for p in ad.b]) for _, ad in lora_modules(model)])
if a.shape != layout.shape_a or b.shape != layout.shape_b: # pragma: no cover - guarded above
raise ValueError(f"stacked {tuple(a.shape)}/{tuple(b.shape)} against {layout}")
return a.cpu().float().contiguous(), b.cpu().float().contiguous()
def adapter_size(model: nn.Module) -> tuple[int, int]:
"""``(adapter parameters, base parameters)``: the ratio the whole method exists for."""
adapter = sum(p.numel() for p in adapter_params(model))
total = sum(p.numel() for p in model.parameters())
return adapter, total - adapter

src/rukh/models/lora.pylíneas 394-409 · p4

stack_adapter sale siempre en CPU y en float32, porque el destino es un búfer plano que descarga un navegador.

src/rukh/models/lora.py
def describe(model: nn.Module, cfg: LoraConfig) -> str:
"""One line for the run log: rank, targets and how little of the model is being trained."""
adapter, base = adapter_size(model)
share = 100.0 * adapter / (adapter + base)
targets = ", ".join(cfg.targets)
return (
f"LoRA r={cfg.r} alpha={cfg.alpha} on [{targets}]: "
f"{adapter:,} trainable of {adapter + base:,} ({share:.2f} %)"
)
def adapter_json(cfg: LoraConfig, base_repo: str, extra: dict[str, object] | None = None) -> str:
"""The JSON that travels with a published adapter: what it is and what it mounts on."""
payload: dict[str, object] = {"lora": cfg.model_dump(mode="json"), "base_model": base_repo}
if extra:
payload.update(extra)
return json.dumps(payload, indent=2) + "\n"

src/rukh/models/lora.pylíneas 412-428 · p4

describe es la línea que el bucle escribe en el log al empezar, y adapter_json el fichero que viaja con el adaptador publicado diciendo sobre qué modelo se monta.

Las dos piezas que faltan del bucle

Son dos engarces en loop.py; se pueden saltar en la primera lectura. Sin ellos la configuración lora: no hace nada, así que si los saltas, trae el fichero (si no lo trajiste ya con el guardado): git checkout p4 -- src/rukh/train/loop.py src/rukh/train/checkpoint.py.

// FontaneríaEl bucle: aplicar lora: de la configuración y rechazar --resume
src/rukh/train/loop.py
lora: LoraConfig | None = None
"""Train low-rank adapters instead of the weights.
With it on, everything outside the adapters is frozen, the optimizer only ever sees ``A`` and
``B``, and the checkpoints hold the **merged** weights so the rest of the toolchain never
learns that LoRA happened. The adapter itself is written next to them as its own small file,
which is the artefact worth publishing."""

src/rukh/train/loop.pylíneas 87-93 · p4

src/rukh/train/loop.py
if cfg.lora is not None:
if resume is not None:
raise ValueError(
"--resume cannot continue a LoRA run: its checkpoints hold merged weights, "
"which no longer say where the adapter ended. Start it again with init_from."
)
apply_lora(model, cfg.lora)
log.info("%s", describe(model, cfg.lora))

src/rukh/train/loop.pylíneas 183-190 · p4

Una ejecución de LoRA no se puede reanudar con --resume: la suma W + BA fundida no se puede descomponer de vuelta. El bucle lo rechaza diciendo por qué y qué hacer.

src/rukh/train/loop.py
if cfg.lora is not None:
# The artefact worth publishing: a couple of megabytes that mount on weights the
# reader already has, next to the merged checkpoint the rest of the toolchain reads.
adapter = save_adapter(model, out_dir / ADAPTER_FILE, cfg.lora)
log.info("adapter written to %s (%.1f MB)", adapter, adapter.stat().st_size / 1e6)

src/rukh/train/loop.pylíneas 296-300 · p4

Las dos configuraciones de estilo

configs/train/lora-e4.yaml
# A style adapter: 393 216 numbers that make `medium-v4` open 1. e4.
#
# Everything outside `A` and `B` is frozen, so the 115 M weights on disk are the ones the reader
# already downloaded and the adapter is 1.6 MB on top of them. `r = 8` on the query and value
# projections is the configuration of the paper, and it is also the smallest one that has a chance:
# whatever the adapter learns has to pass through eight dimensions per matrix.
#
# 1 500 steps of 51 200 tokens is 76.8 M, about half a pass over the 200 000-game slice. Short on
# purpose -- an adapter that trains until it has memorised the slice stops being a style and
# becomes a worse model, and the Elo of base-plus-adapter is what says which of the two happened.
preset: medium
init_from: checkpoints/medium-v4-20260919-174623/best.pt
tokens_dir: data/tokens-style-e4/uci
lora:
r: 8
alpha: 16
dropout: 0.0
targets: [q, v]
block: 200
batch_size: 32
grad_accum: 8
# Twenty times the fine-tuning rate, because only 0.34 % of the model is moving and `B` starts at
# exactly zero: at 1e-4 the adapter would still be near the identity after 1 500 steps.
lr: 2.0e-4
min_lr_ratio: 0.1
warmup: 100
max_steps: 1500
weight_decay: 0.0
betas: [0.9, 0.95]
grad_clip: 1.0
precision: bf16
compile: true
eval_every: 250
eval_batches: 50
ckpt_every: 750
out_dir: checkpoints
seed: 42
run_name: lora-e4
unique_run_name: true
workers: 4
log_every: 10

configs/train/lora-e4.yamllíneas 1-41 · p4

El comentario de lr es el ajuste de LoRA que más se equivoca. Solo se mueve el 0,34 % del modelo y B arranca en cero, así que con la tasa del afinado completo el adaptador seguiría cerca de la identidad después de 1 500 pasos: parecería que «no hace nada», como un fallo de la implementación. Ojo con el «twenty times» del comentario: 2,0e-4 frente al 1e-4 de medium-elo es el doble, no veinte veces. Las recetas habituales para LLM suben la tasa con LoRA bastante más que eso, a menudo un orden de magnitud por encima del afinado completo; aquí bastó con doblarla, y la medida de que bastó es la de la lección 9: el adaptador lleva 1. e4 al 99,85 %. weight_decay: 0.0 va en la misma línea: penalizar la norma de B la empujaría hacia el cero del que acaba de salir.

La otra cifra del comentario que no cuadra es la de las pasadas. El recorte de entrenamiento son 200 000 partidas y 16,0 M tokens (unos 80 por partida, lo que dice data/tokens-style-e4/uci/train/meta.json), así que los 76,8 M tokens de 1 500 pasos son casi cinco pasadas, no media. Que el adaptador no acabe memorizando el recorte no lo garantiza el número de pasos: lo comprueban el top-1 y los puzles de la lección 9, que no se mueven.

La segunda configuración cambia tres líneas:

configs/train/lora-d4.yaml
# A style adapter: 393 216 numbers that make `medium-v4` open 1. d4.

configs/train/lora-d4.yamllínea 1 · p4

configs/train/lora-d4.yaml
tokens_dir: data/tokens-style-d4/uci

configs/train/lora-d4.yamllínea 13 · p4

configs/train/lora-d4.yaml
run_name: lora-d4

configs/train/lora-d4.yamllínea 38 · p4

Las otras treinta y ocho son idénticas, así que la diferencia entre los dos adaptadores solo puede venir del corpus.

Terminal
uv run rukh train --config configs/train/lora-e4.yaml
uv run rukh train --config configs/train/lora-d4.yaml

Cada uno son 1 500 pasos y unos siete minutos, y deja adapter.safetensors de 1 578 824 bytes con su adapter_config.json al lado. Los dos leen corpus que se construyen en la lección siguiente: lánzalos cuando la termines.

Los catorce tests que definen qué es LoRA

Estos tests son la definición operativa de LoRA en este repositorio: cada uno comprueba una afirmación del método, y si alguno falla, lo que has escrito es otra cosa.

Si quieres entender LoRA por sus propiedades, estos tests son la mejor lectura del fichero; para seguir la lección puedes dejarlos plegados. El «Ejecútalo» que los sigue los corre, así que si no los tecleas, tráelos: git checkout p4 -- tests/unit/test_lora.py.

// Teststests/unit/test_lora.py: identidad al empezar, subespacios por rodaja, fundidos y formato
tests/unit/test_lora.py
"""Tests for rukh.models.lora: the hand-written adapters and what makes them LoRA.
The point of writing LoRA instead of importing it is that every claim the method makes can be
checked here: it starts as the identity, it trains only the factors, folding it back gives the
same model, and the file it produces is three orders of magnitude smaller than the weights.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import torch
from rukh.models import DecoderConfig, MoveDecoder, preset
from rukh.models.lora import (
LoraConfig,
LoRALinear,
adapter_params,
apply_lora,
load_adapter,
lora_modules,
merge_lora,
save_adapter,
)
pytestmark = pytest.mark.unit
def _model(seed: int = 0) -> MoveDecoder:
torch.manual_seed(seed)
return MoveDecoder(DecoderConfig(n_layer=2, n_head=2, d_model=16, block=8, dropout=0.0)).eval()
def _idx() -> torch.Tensor:
torch.manual_seed(1234)
return torch.randint(1, 100, (2, 8))

tests/unit/test_lora.pylíneas 1-37 · p4

El decoder de prueba es diminuto y sembrado, para montar dos modelos con los mismos pesos y comparar; _idx() siembra aparte para que la entrada no dependa de cuántos modelos se hayan construido antes.

tests/unit/test_lora.py
def test_an_untrained_adapter_is_the_identity() -> None:
"""`B` starts at zero, so applying LoRA changes nothing until a gradient arrives.
Any other initialisation would move the model before it learned anything, and every
comparison against the base would start from a model that is already different.
"""
idx = _idx()
model = _model()
with torch.no_grad():
before, _ = model(idx)
apply_lora(model, LoraConfig(r=4))
with torch.no_grad():
after, _ = model(idx)
torch.testing.assert_close(before, after)
def test_only_the_factors_are_trainable() -> None:
model = _model()
trainable = apply_lora(model, LoraConfig(r=4, targets=("q", "v")))
by_grad = [p for p in model.parameters() if p.requires_grad]
assert trainable == sum(p.numel() for p in by_grad)
assert {id(p) for p in by_grad} == {id(p) for p in adapter_params(model)}

tests/unit/test_lora.pylíneas 40-61 · p4

El primero es la afirmación más fácil de romper sin enterarse: un adaptador sin entrenar es la identidad. El segundo compara por id(): no basta con que el recuento cuadre, los parámetros entrenables tienen que ser los mismos objetos que las A y las B.

tests/unit/test_lora.py
def test_trainable_count_is_the_formula_on_the_card() -> None:
"""`2 * r * d_model` per adapted `d_model`-wide matrix, per layer, and nothing else."""
cfg = LoraConfig(r=4, targets=("q", "v"))
model = _model()
trainable = apply_lora(model, cfg)
n_layer, d_model = model.cfg.n_layer, model.cfg.d_model
assert trainable == n_layer * len(cfg.targets) * 2 * cfg.r * d_model
def test_the_fused_projection_gives_each_target_its_own_factors() -> None:
"""Query and value are two slices of one `qkv` matrix, not one adapter over both.
Sharing a single `A` between them would be cheaper and would not be LoRA: each adapted
matrix is supposed to get its own low-rank subspace.
"""
model = _model()
apply_lora(model, LoraConfig(r=4, targets=("q", "v")))
adapters = dict(lora_modules(model))
assert set(adapters) == {"blocks.0.attn.qkv", "blocks.1.attn.qkv"}
qkv = adapters["blocks.0.attn.qkv"]
d = model.cfg.d_model
assert qkv.slices == ((0, d), (2 * d, 3 * d)) # query and value, key untouched
assert len(qkv.a) == len(qkv.b) == 2
assert not torch.equal(qkv.a[0], qkv.a[1])
def test_the_key_slice_never_moves_when_only_q_and_v_are_adapted() -> None:
model = _model()
apply_lora(model, LoraConfig(r=4, targets=("q", "v")))
d = model.cfg.d_model
for _, adapter in lora_modules(model):
with torch.no_grad():
for b in adapter.b:
b.normal_()
weight = adapter.merged_weight()
torch.testing.assert_close(weight[d : 2 * d], adapter.base.weight[d : 2 * d])
assert not torch.allclose(weight[:d], adapter.base.weight[:d])

tests/unit/test_lora.pylíneas 64-100 · p4

Tres tests sobre la proyección fusionada, la parte que no está en ningún tutorial. El de la fórmula calcula por un segundo camino el número que publica la card. El de los subespacios comprueba que las dos A no son iguales, que es lo que detectaría una implementación que comparte factores entre rodajas. Y el de la clave llena las B de ruido, funde y comprueba que la franja central del peso no se ha movido.

tests/unit/test_lora.py
def test_merging_reproduces_the_adapted_model_exactly() -> None:
"""`W + BA` folded into the weight has to give the same logits as the wrapper computing it.
This is the property that lets an adapter be published as a small file and still be
exported, quantised and served as an ordinary model.
"""
idx = _idx()
model = _model()
apply_lora(model, LoraConfig(r=4, alpha=8, targets=("q", "v")))
torch.manual_seed(7)
with torch.no_grad():
for _, adapter in lora_modules(model):
for b in adapter.b:
b.normal_(std=0.05)
adapted, _ = model(idx)
assert merge_lora(model) == model.cfg.n_layer
assert not list(lora_modules(model))
with torch.no_grad():
merged, _ = model(idx)
torch.testing.assert_close(adapted, merged, rtol=1e-5, atol=1e-6)
def test_a_merged_model_has_the_state_dict_of_a_plain_decoder() -> None:
model = _model()
keys_before = set(model.state_dict())
apply_lora(model, LoraConfig(r=4))
assert "blocks.0.attn.qkv.base.weight" in model.state_dict()
merge_lora(model)
assert set(model.state_dict()) == keys_before

tests/unit/test_lora.pylíneas 103-131 · p4

Fundir tiene dos propiedades y dos tests: los logits coinciden con rtol=1e-5, y el state dict vuelve a tener las mismas claves que antes de aplicar LoRA, ni una .base. ni una .a.0.

tests/unit/test_lora.py
def test_adapter_round_trips_through_safetensors(tmp_path: Path) -> None:
idx = _idx()
cfg = LoraConfig(r=4, alpha=8, targets=("q", "v"))
trained = _model()
apply_lora(trained, cfg)
torch.manual_seed(11)
with torch.no_grad():
for _, adapter in lora_modules(trained):
for b in adapter.b:
b.normal_(std=0.05)
expected, _ = trained(idx)
path = save_adapter(trained, tmp_path / "adapter.safetensors", cfg)
assert path.with_name("adapter_config.json").is_file()
restored = _model()
loaded = load_adapter(restored, path)
assert loaded == cfg
with torch.no_grad():
got, _ = restored(idx)
torch.testing.assert_close(expected, got)

tests/unit/test_lora.pylíneas 134-154 · p4

La ida y vuelta por safetensors es el test del formato publicado: se mete ruido en las B, se guarda, se carga sobre un modelo limpio y los logits tienen que coincidir. La aserción sobre adapter_config.json evita el peor fallo del formato: unos pesos publicados sin la configuración que dice cómo montarlos.

tests/unit/test_lora.py
def test_the_adapter_is_a_rounding_error_next_to_the_published_model() -> None:
"""The number the whole method exists for, on the model that is actually published.
The toy decoder of these tests is 39 k parameters and two thirds of that is the embedding
table, so its ratio says nothing. `medium` is the size the claim is about: r=8 on query and
value across 16 layers of `d_model` 768 is 393 216 numbers, 0.34 % of 115 M -- 1 572 864 bytes,
the 1.6 MB file a reader downloads instead of a second copy of the weights.
"""
cfg = LoraConfig(r=8, alpha=16, targets=("q", "v"))
medium = preset("medium")
adapter = medium.n_layer * len(cfg.targets) * 2 * cfg.r * medium.d_model
published_params = 115_120_128 # `medium`, as every card and report of the project says
assert adapter == 393_216
assert adapter / published_params < 0.004
assert adapter * 4 == 1_572_864 # bytes in float32: 1.6 MB, the size of the published file
def test_gradients_reach_the_factors_and_nothing_else() -> None:
model = _model()
apply_lora(model, LoraConfig(r=4, targets=("q", "v")))
idx = _idx()
_, loss = model(idx, idx)
assert loss is not None
loss.backward()
assert all(p.grad is not None for p in adapter_params(model))
assert model.tokens.weight.grad is None
for _, adapter in lora_modules(model):
assert adapter.base.weight.grad is None

tests/unit/test_lora.pylíneas 157-184 · p4

El primero no prueba código: prueba una frase de la documentación. Hace la aritmética sobre el preset medium real, para que el día que alguien lo cambie la card no siga diciendo 1,6 MB. El segundo comprueba que los gradientes llegan a los factores y a nada más: declarar requires_grad_(False) es una cosa, y que el grafo lo respete, otra.

tests/unit/test_lora.py
def test_targets_are_validated() -> None:
with pytest.raises(ValueError, match="unknown LoRA targets"):
apply_lora(_model(), LoraConfig(targets=("qq",)))
with pytest.raises(ValueError, match="at least one"):
apply_lora(_model(), LoraConfig(targets=()))
def test_mlp_targets_use_the_feed_forward_width() -> None:
"""`mlp.fc` is `ff` wide, not `d_model`; a slice computed in the wrong unit would truncate."""
model = _model()
apply_lora(model, LoraConfig(r=2, targets=("mlp_in", "mlp_out")))
adapters = dict(lora_modules(model))
assert adapters["blocks.0.mlp.fc"].slices == ((0, model.cfg.ff),)
assert adapters["blocks.0.mlp.proj"].slices == ((0, model.cfg.d_model),)
def test_scale_follows_alpha_over_r() -> None:
base = torch.nn.Linear(4, 4)
adapter = LoRALinear(base, r=2, alpha=8, dropout=0.0, slices=((0, 4),))
assert adapter.scale == 4.0
def test_the_factors_are_built_where_the_weight_they_correct_lives() -> None:
"""`apply_lora` runs after the model has been moved to its device.
The training loop loads the checkpoint first -- loading needs the module names of a plain
decoder -- so by the time the adapters go on, the weights are already on the GPU. A factor
created on the CPU by default meets a CUDA activation on the first forward and the run dies
there. Every other test in this file runs on the CPU, where the bug cannot be seen; this one
checks the property that makes it impossible instead of the device it happens to run on.
"""
model = _model()
base = model.blocks[0].attn.qkv.weight
apply_lora(model, LoraConfig(r=4, targets=("q", "v")))
for _, adapter in lora_modules(model):
for factor in [*adapter.a, *adapter.b]:
assert factor.device == base.device
assert factor.dtype == base.dtype

tests/unit/test_lora.pylíneas 187-224 · p4

Los cuatro últimos son cortos. El más interesante es el del dispositivo: como el fallo solo se ve en GPU y la suite corre en CPU, comprueba la propiedad que lo hace imposible (el factor vive donde el peso) en vez del dispositivo en el que resulta correr.

Qué has aprendido

Una técnica publicada, escrita entera, con las dos adaptaciones que pide este decoder: una A y una B por rodaja de una proyección fusionada, y dos fundidos con políticas opuestas sobre el original. El artefacto tiene tres formas porque tiene tres destinos: ParameterList para entrenar, safetensors por clave para cargar y dos tensores apilados para un grafo. Y los catorce tests convierten «implementamos LoRA» en una afirmación comprobable.

Lo siguiente son los datos que entrenan esos adaptadores. Sigue en «Estilo y texto PGN».