// 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.
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
"""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 aproject that wants five styles needs five copies of the same model. LoRA asks a differentquestion: instead of moving ``W``, learn a correction ``B A`` whose rank is small enough that thetwo factors together are a rounding error next to ``W``. With ``r = 8`` on the query and valueprojections 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``), sothere is no ``q_proj`` module to wrap. Adapting "query and value" means adapting two contiguousoutput ranges of a single ``nn.Linear``, which is why ``LoRALinear`` takes a tuple of outputslices and gives **each one its own ``A`` and ``B``**. Sharing one ``A`` between the query andthe value slice would be cheaper and would not be LoRA: the whole point is that each adaptedmatrix 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 arenever what gets saved. ``save_adapter`` writes only ``A`` and ``B``, keyed by the path of themodule 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."""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
from __future__ import annotations
import jsonimport mathfrom pathlib import Pathfrom typing import TYPE_CHECKING
import torchfrom pydantic import Fieldfrom 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"__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
# 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),}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.
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.rLa 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 torchimport torch.nn.functional as F
torch.manual_seed(0)x = torch.randn(2, 4) # dos entradas de 4 númerosw = 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 columnastorch.nn.init.kaiming_uniform_(a, a=math.sqrt(5)) # al azar, entre -0,5 y 0,5a.requires_grad_(True) # entrenableb = 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 baseprint(a.numel() + b.numel(), "entrenables frente a", w.numel())with torch.no_grad(): b.fill_(0.5) # como si el entrenamiento hubiera movido Bprint(tuple((b @ a).shape), torch.linalg.matrix_rank(b @ a).item()) # 4x4, de rango 1TrueTrue8 entrenables frente a 16(4, 4) 1F.linear(x, w)esx @ w.T: lo que hace unann.Linearsin sesgo, con el peso guardado como (salidas × entradas).LoRALinearla llama dos veces seguidas, primero conA(que baja de 4 arnúmeros) y luego conB(que sube dera 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))rellenaAcon números al azar entre ±1/√(entradas), aquí ±0,5. Es la misma inicialización que PyTorch usa por defecto en unann.Linear; ela=math.sqrt(5)es el parámetro que la hace coincidir, no una cifra con significado propio.Ba cero hace que el segundoprintsalgaTrue: 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:
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))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_loracorre 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. Barranca en cero, así que el modelo adaptado es el modelo base en el paso 0.Asale de la mismakaiming_uniform_del artículo.- Una
Ay unaBpor rodaja, ennn.ParameterList, porque las rodajas pueden tener anchuras distintas.
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}"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
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)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.
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)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.
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.blora_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
@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 mergedmerge_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.
@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 mergedMutar 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
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, )En una ejecución de LoRA el optimizador no se guarda: no se podría reanudar dentro de un checkpoint con los pesos fundidos.
*, 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()},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
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 targetLas 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.
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 cfgstrict=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
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)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
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, )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.
@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 - adapterstack_adapter sale siempre en CPU y en float32, porque el destino es un búfer plano que descarga
un navegador.
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"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
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.""" 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))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.
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)Las dos configuraciones de estilo
# 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: mediuminit_from: checkpoints/medium-v4-20260919-174623/best.pttokens_dir: data/tokens-style-e4/ucilora: r: 8 alpha: 16 dropout: 0.0 targets: [q, v]block: 200batch_size: 32grad_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-4min_lr_ratio: 0.1warmup: 100max_steps: 1500weight_decay: 0.0betas: [0.9, 0.95]grad_clip: 1.0precision: bf16compile: trueeval_every: 250eval_batches: 50ckpt_every: 750out_dir: checkpointsseed: 42run_name: lora-e4unique_run_name: trueworkers: 4log_every: 10El 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:
# A style adapter: 393 216 numbers that make `medium-v4` open 1. d4.tokens_dir: data/tokens-style-d4/ucirun_name: lora-d4Las otras treinta y ocho son idénticas, así que la diferencia entre los dos adaptadores solo puede venir del corpus.
uv run rukh train --config configs/train/lora-e4.yamluv run rukh train --config configs/train/lora-d4.yamlCada 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 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 bechecked here: it starts as the identity, it trains only the factors, folding it back gives thesame model, and the file it produces is three orders of magnitude smaller than the weights."""
from __future__ import annotations
from pathlib import Path
import pytestimport torch
from rukh.models import DecoderConfig, MoveDecoder, presetfrom 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))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.
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)}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.
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])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.
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_beforeFundir 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.
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)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.
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 NoneEl 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.
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.dtypeLos 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».