Proyecto: el modelo hecho función
30 min de lectura
Las piezas del mini-GPT están repartidas por las celdas del bloque. codificar y decodificar
salieron de la lección sobre BPE (byte-pair encoding); los
pesos, de la del entrenamiento; muestrear, de
la del muestreo; llenar y avanzar, de
la de la caché. Cada celda que ha querido texto las ha vuelto a
cargar y a coser a mano. La lección sobre el aprendizaje en
contexto acabó dando la razón para coserlas de una vez: con quieto, todo lo que
hace un modelo depende del texto que recibe, así que lo único que el resto del curso necesita del
mini-GPT es una manera de dárselo y de recoger lo que escribe. Este proyecto la construye.
El curso anterior terminó en algo parecido. En
el proyecto del Transformer del curso
anterior, paso(entrada) era «el modelo hecho función»: recibía ids y devolvía una
distribución, y quien lo llamaba tenía que saber qué es un id, dónde va <GO> y qué hacer
con probabilidades. La función de esta lección recibe texto y devuelve texto, y
quien la llama no maneja un solo id. Pero una firma sólo dice qué entra y qué sale. ¿Da dos veces
lo mismo? ¿Cuándo para? ¿Qué hace con un texto que no cabe en las 64 posiciones del mini-GPT? Las
respuestas son el contrato, y lo que el curso construya encima se apoyará en ellas, no en el código.
Mira una llamada entera, pieza a pieza, antes de ponerle nombre a nada:
modelo("La Nela", max_tokens=4, semilla=0), que devuelve no traf.
| pieza | recibe | devuelve | |
|---|---|---|---|
| 1 | codificar | La Nela | los ids 461 y 373: La, Nela |
| 2 | llenar | 461, 373 | los logits de la posición 2, y una caché con 2 posiciones |
| 3 | muestrear | esos logits | 309: no |
| 4 | avanzar y muestrear, tres veces | 309, 276, 266 | la caché crece a 3, 4 y 5 posiciones, y salen 276, 266 y 102: t, ra, f |
| 5 | decodificar | 309, 276, 266, 102 | no traf |
Tres cosas de la traza. El texto acaba a mitad de palabra, porque max_tokens cuenta tokens y
traf son tres. El último token, el 102, no entra en la caché: nadie va a pedir lo que viene
detrás. Y lo que devuelve empieza por un espacio: es la continuación, y La Nela seguido de
no traf es el texto entero. Los cuatro sorteos los decidió la semilla, y con otra habrían
salido otros.
La firma y el contrato
La firma de una función es lo que cabe en su primera línea: el nombre, los parámetros con sus
valores por defecto y lo que devuelve. La de modelo es ésta:
def modelo(
prompt,
max_tokens=32,
temperatura=1.0,
top_p=0.9,
parar=None,
semilla=None,
) -> str: ...El contrato es lo que promete a quien la llama y lo que le pide. Son siete cláusulas, y cada una sale de una lección del bloque:
- Continúa. Devuelve sólo lo que sigue al prompt:
prompt + textoes el texto entero. - Sortea como en el muestreo. Cada token sale del softmax de los logits divididos entre
temperatura, cortado al núcleo de masatop_py renormalizado.temperatura=0es la generación voraz, ytop_p=Noneno corta. - Para tras
max_tokenstokens, que son tokens y no palabras, o antes, en la primera aparición depararen lo generado (el prompt no cuenta). Esa cadena de parada (stop sequence) no se devuelve. - Con semilla, es una función. Los mismos argumentos y la misma
semilladan el mismo texto; sin semilla, cada llamada es un sorteo nuevo. - No recuerda. Ninguna llamada cambia ni deja nada para la siguiente. Lo que el modelo tenga que saber va en el prompt, cada vez.
- Lee como mucho los últimos tokens, y más allá rellena la caché (la sección siguiente).
- Pide un prompt no vacío, porque el primer token se da y no se predice, como estableció la lección sobre el modelo de lenguaje causal, y que no acabe en espacio (la última sección de este apartado). Si no, falla en lugar de escribir.
La segunda cláusula dice de qué es muestra lo que devuelve. Llamemos a los tokens del prompt,
todos (sin subíndice es la secuencia entera, como el caso de la lección anterior, no el token
), e a los que sortea. Cada sale de la distribución
de la lección sobre el muestreo, calculada con los logits que la
red da tras leer el prompt y lo sorteado antes; escribámosla , con
el texto leído a la vista, como hacía . Como cada sorteo sabe los
anteriores, la probabilidad de que modelo escriba justo es el producto
Con temperatura=1 y top_p=None no hay nada entre los logits y el sorteo, y mientras el prompt y
lo sorteado quepan en la ventana cada factor es la condicional del modelo. El producto es entonces
la regla de la cadena de la primera lección del bloque, recorrida en el otro sentido:
Sortear token a token, cada uno sabiendo los anteriores, es sortear continuaciones enteras con la
probabilidad que el modelo les da. No es una aproximación. Cualquier otra temperatura o núcleo da
otra distribución, hecha con los mismos logits, y temperatura=0 la reduce a un solo texto, el de
la voraz. La semilla no cambia la distribución: elige cuál de sus muestras sale.
Más allá de la ventana: rellenar la caché
La sexta cláusula obliga a decidir algo. La lección sobre la caché
dejó dicho que la del mini-GPT no se desliza: sus posiciones son una tabla de
filas, y en cuanto se suelta el primer token cada uno cambia de posición y lo guardado deja de valer.
generar, la función de minigpt.py, lo esquiva sin caché: en cada token pasa por la red los
últimos 64 enteros. modelo usa la caché mientras cabe y, cuando está llena y hace falta otro token,
la rellena: la tira y la vuelve a llenar, en un solo prefill, con los últimos
tokens del texto. En modelo, .
Contemos filas, cada una lo que aquella lección llamó (el término de la atención, que crece con la posición, se queda fuera: en la posición 64 es un octavo de ). Tras un relleno, la caché guarda posiciones y crece de una en una hasta : son avances, de una fila cada uno. Cada avance deja sortear un token, y el relleno otro, así que un ciclo escribe tokens y calcula filas:
frente a las de generar, una pasada entera por token. El precio es contexto:
el token que se sortea justo después de un relleno lee tokens y no 64, y cada uno de
los siguientes, uno más. es el mando entre los dos extremos. Con
cada token lee al menos 63, y el ciclo da dos tokens por 64
filas; con sale a una fila por token, y el que sigue a un relleno lee uno solo.
Treinta y dos es generoso, y se puede decir por qué. La lección sobre el aprendizaje en contexto midió que lo que el mini-GPT lee más allá de unos ocho tokens no le sirve para predecir: dejarle 32 no le quita nada que use. Con un modelo que sí aprovechara lo que lee, dejaría de ser una cuestión de coste y pasaría a serlo de calidad.
Donde el texto deja ver los tokens
Quien llama a modelo no maneja ids, pero el tokenizador sigue dentro y asoma en dos cláusulas. La
tercera ya ha salido en la traza: max_tokens cuenta tokens, y el texto puede quedarse a mitad de
palabra. La séptima necesita un párrafo más.
El mini-GPT sólo ha leído su corpus tal como lo corta codificar, y un prompt se codifica solo, sin
el texto que vendría detrás. Si su final no se corta como se cortaría dentro de un texto más largo,
lo que el modelo lee al final no dice lo que quería decir quien escribió el prompt: dice lo que
decía en el corpus. El caso que se da sin buscarlo es el espacio final. La lección sobre BPE cortó el texto en pre-tokens con el espacio pegado a la palabra
que lo sigue, así que en la novela casi todas las palabras empiezan por un token que ya lleva su
espacio. Un espacio suelto sólo aparece delante de los pocos comienzos de palabra que las fusiones
no llegaron a pegar al espacio: Golfín empieza por un espacio suelto seguido de G.
Un prompt que acaba en espacio termina en ese token, y el mini-GPT entiende lo único que ha aprendido de él: que viene una de esas palabras. El arreglo de fondo es deshacer el último token del prompt y obligar al primero que se sortee a empezar por sus bytes (token healing), y no es de este bloque. El contrato hace lo honesto con lo que tiene, que es rechazar el prompt.
El mini-GPT, hecho función y congelado
Las tres celdas corren sobre el mini-GPT, el checkpoint del bloque, sin entrenarlo. Un cambio de
nombre antes de empezar: en las celdas de las lecciones anteriores, modelo era el objeto
MiniGPT; desde aquí modelo es la función, y el objeto se llama red, porque es lo que se ve
cuando se mira dentro.
La primera reúne el motor: el tokenizador, la red, y llenar y avanzar tal como los dejó la
lección sobre la caché, con la red como primer argumento en lugar de una variable global (una
función que se va a congelar no puede depender de nada que no reciba). Después hace a mano la
llamada de la traza.
import numpy as np
from pyodide.http import open_url
exec(open_url("/courses/llm-agents/bpe.py").read()) # codificar, decodificar
exec(open_url("/courses/llm-agents/minigpt.py").read()) # MiniGPT, softmax, layer_norm, muestrear
F = [tuple(par) for par in json.load(open_url("/courses/llm-agents/bpe-merges.json"))]
red = MiniGPT.cargar(open_url("/courses/llm-agents/minigpt.json").read()) # la red, por dentro
def llenar(red, ids):
"""El prefill: los logits de la última posición de ids y la caché de cada capa."""
Z, (_, capas, _, _) = red.adelante(np.array([ids]))
return Z[0, -1], [(K[0], V[0]) for (_, _, _, K, V, _, _), _ in capas] # (h, t, d_k) cada una
def avanzar(red, x, cache):
"""Los logits tras el token x, en la posición siguiente a la caché, que crece una fila."""
p, L, h, d = red.p, red.cfg["n_capas"], red.cfg["h"], red.cfg["d_model"]
d_k, t = d // h, cache[0][0].shape[1] # t: posiciones ya guardadas
H = p["E"][x] + p["P"][t] # (d,): la fila nueva, sola
for l in range(L):
Hn, _ = layer_norm(H, p[f"{l}.ln1_g"], p[f"{l}.ln1_b"])
q, k, v = (u.reshape(h, 1, d_k) for u in np.split(Hn @ p[f"{l}.Wqkv"], 3))
K = np.concatenate([cache[l][0], k], axis=1) # (h, t + 1, d_k)
V = np.concatenate([cache[l][1], v], axis=1)
cache[l] = (K, V)
a = softmax(q @ K.transpose(0, 2, 1) / np.sqrt(d_k)) # (h, 1, t + 1)
H = H + (a @ V).reshape(d) @ p[f"{l}.Wo"]
H = H + red.ffn(H[None, None], l)[0][0, 0]
return layer_norm(H, p["lnf_g"], p["lnf_b"])[0] @ p["E"].T
# La llamada de la traza, a mano: lo que la función de la celda siguiente esconde.
rng = np.random.default_rng(0)
ids = codificar("La Nela", F)
z, cache = llenar(red, ids)
print("prompt:", ids, [decodificar([i], F) for i in ids], " caché:", cache[0][0].shape[1], "posiciones")
for _ in range(4):
if len(ids) > 2: # lo sorteado entra en la caché
z = avanzar(red, ids[-1], cache)
x = muestrear(z, 1.0, None, 0.9, rng)
ids.append(x)
print("sortea %3d %-6r caché: %d posiciones" % (x, decodificar([x], F), cache[0][0].shape[1]))
print(repr(decodificar(ids[2:], F)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Son los números de la traza, y es la última vez que los ves. La segunda celda escribe
hacer_modelo(red, fusiones), que recibe la red y el tokenizador y devuelve modelo con los dos
dentro. Lo que se congela es la firma: detrás puede haber otra red con la misma firma, que es lo que
el bloque siguiente hará cuando cambie los pesos. El bucle es el de la traza con las cláusulas
puestas: las dos comprobaciones de la séptima al entrar, el prefill con los últimos 64 tokens como
mucho, el relleno cuando la caché está llena y la parada.
import time
def hacer_modelo(red, fusiones):
"""modelo(prompt) -> texto, con la red y el tokenizador dentro, y nada más."""
T_ctx = red.cfg["T_ctx"]
T_min = T_ctx // 2 # lo que conserva al rellenar
def modelo(prompt, max_tokens=32, temperatura=1.0, top_p=0.9, parar=None, semilla=None):
"""Continúa prompt con hasta max_tokens tokens y devuelve sólo la continuación."""
assert prompt, "prompt vacío: el primer token se da, no se predice"
assert not prompt.endswith(" "), "el prompt acaba en espacio: el espacio va con la palabra siguiente"
ids = codificar(prompt, fusiones)
rng = np.random.default_rng(semilla) # sin semilla, un sorteo nuevo
z, cache = llenar(red, ids[-T_ctx:]) # el prefill, con lo que quepa
nuevos = []
for _ in range(max_tokens):
if nuevos: # lo sorteado entra en la caché
if cache[0][0].shape[1] < T_ctx:
z = avanzar(red, nuevos[-1], cache)
else: # llena: no se desliza, se rellena
z, cache = llenar(red, (ids + nuevos)[-T_min:])
nuevos.append(muestrear(z, temperatura, None, top_p, rng))
texto = decodificar(nuevos, fusiones)
if parar is not None and parar in texto:
return texto[:texto.index(parar)] # hasta la parada, sin ella
return decodificar(nuevos, fusiones)
return modelo
modelo = hacer_modelo(red, F)
print(repr(modelo("La Nela")))
print(repr(modelo("La Nela")))
a = modelo("La Nela", semilla=1)
modelo("—¿Qué") # otra llamada por medio
print("misma semilla, mismo texto:", a == modelo("La Nela", semilla=1))
print("voraz:", repr(modelo("La Nela", temperatura=0)))
print("hasta el punto:", repr(modelo("La Nela", 64, parar=".", semilla=0)))
t0 = time.perf_counter()
largo = modelo("La Nela", 200, semilla=2)
print("200 tokens en %.2f s; los últimos:" % (time.perf_counter() - t0), repr(largo[-60:]))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Las dos primeras líneas cambian cada vez que la ejecutas: sin semilla, cada llamada es un sorteo.
Con la semilla 1 sale el mismo texto antes y después de otra llamada por medio, que no le ha dejado
nada. La voraz vuelve al ciclo de no, de la lección sobre el muestreo, y con parar="."
sale una frase sin su punto, no trafá como me dijo lo le jo, que empieza como la traza
porque la semilla 0 sortea lo mismo. Doscientos tokens, tres veces la ventana y cinco rellenos,
tardan menos de un segundo.
La tercera comprueba el contrato contra lo que no depende de él. Dentro de la ventana, modelo tiene
que escribir lo mismo que generar con la misma semilla, porque la caché da los mismos logits y el
sorteo es el mismo; fuera, tienen que separarse donde empieza el relleno. Después mira el espacio
final y, al acabar, carga la copia congelada de modelo y la compara con la tuya.
ids = codificar("La Nela", F)
def referencia(n, s):
"""Los ids que escribe generar, de minigpt.py: la pasada entera en cada token, sin caché."""
return red.generar(ids, n, top_p=0.9, rng=np.random.default_rng(s))[len(ids):]
for s in range(2):
print("semilla %d, 40 tokens: el mismo texto que generar: %s"
% (s, modelo("La Nela", 40, semilla=s) == decodificar(referencia(40, s), F)))
t0 = time.perf_counter(); m = modelo("La Nela", 80, semilla=0)
t1 = time.perf_counter(); g = referencia(80, 0); t2 = time.perf_counter()
comunes = max(k for k in range(len(g) + 1) if m.startswith(decodificar(g[:k], F)))
print("80 tokens: coinciden los %d primeros (modelo %.2f s, generar %.2f s)" % (comunes, t1 - t0, t2 - t1))
print()
for prompt in ["Dijo que", "Dijo que "]:
q = softmax(llenar(red, codificar(prompt, F))[0])
print("%-11r %-28s ->" % (prompt, [decodificar([i], F) for i in codificar(prompt, F)]),
" ".join("%r %.2f" % (decodificar([i], F), q[i]) for i in np.argsort(-q)[:4]))
print("« Golfín» son", [decodificar([i], F) for i in codificar(" Golfín", F)])
try:
modelo("Dijo que ")
except AssertionError as e:
print("modelo('Dijo que '):", e)
print()
congelado = {}
exec(open_url("/courses/llm-agents/modelo.py").read(), congelado) # lo que cargan los bloques 2 y 3
print("la copia congelada escribe lo mismo:",
all(congelado["modelo"]("La Nela", semilla=s) == modelo("La Nela", semilla=s) for s in range(3)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Lo que dijo la derivación. Con dos semillas, 40 tokens de modelo y de generar son el mismo
texto. Con 80 coinciden los 63 primeros: el 63 se sortea con la ventana justo llena (los 2 del prompt
y 62 sorteados), y el 64 es el primero tras un relleno, que modelo sortea leyendo 32 tokens y
generar leyendo 64. modelo tarda unas cinco veces menos, y no las casi treinta que dan las
filas: en Pyodide cada pasada paga un coste fijo, sea de una fila o de 64, así que el reloj no sigue
a la cuenta, y la cuenta es la que vale.
Y el espacio. Tras Dijo que, las favoritas son no, se, me, palabras
con su espacio; tras Dijo que , u, j, é, trozos de los que sólo siguen a
un espacio suelto. Un carácter de más ha cambiado la distribución entera, y modelo se niega a
sortear de ella.
La última línea es la que congela el proyecto. /courses/llm-agents/modelo.py contiene la función de la segunda celda con el motor de la primera, y escribe lo mismo que la tuya. Desde el bloque siguiente, una celda que quiera el mini-GPT lo carga con una línea y lo llama sin abrirlo:
exec(open_url("/courses/llm-agents/modelo.py").read())Comprueba tu intuición
Tres preguntas, sobre de qué es muestra lo que devuelve, sobre lo que cuesta rellenar y sobre lo que promete el contrato, y un reto: el bucle de la ventana, contra un motor de juguete.
Llamas a modelo(prompt, 20, temperatura=1.0, top_p=None) con un prompt de diez tokens. ¿De qué es una muestra lo que devuelve?
Otra versión de modelo rellena la caché con los 16 últimos tokens en lugar de 32, con la misma ventana de 64. Superada la ventana, ¿cuántas filas calcula por token, de media? Cuenta filas, como la lección.
Se acepta un margen de ±0.01.
Marca lo que el contrato de modelo garantiza a quien lo llama.
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Escribe continuar(ids, n, llenar, avanzar, T_ctx, T_min): el bucle de modelo sin el
texto y sin el sorteo. Devuelve la lista de los n ids que siguen a ids, eligiendo en
cada posición la favorita, el argmax de los logits.
llenar(ids) hace el prefill y devuelve (z, cache); avanzar(x, cache) mete x en
la caché, que crece una posición, y devuelve los logits siguientes. len(cache) es
cuántas posiciones guarda, y no puede pasar de T_ctx: cuando esté llena y haga falta
otro token, rellénala con los T_min últimos tokens del texto, prompt y elegidos juntos.
Un prompt más largo que la ventana entra con sus T_ctx últimos. Ni una llamada de más:
llenar al empezar y en cada relleno, y nada después del último token.
Debajo tienes el motor de juguete de las pruebas, para ensayar con él.
La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.
modelo cumple su contrato, y el contrato no dice nada de responder. Promete continuar: devolver lo
que, según , suele venir después del prompt. Escríbele una pregunta y la tratará como
Marianela trata las suyas, como la línea de un diálogo que sigue, sin contestarla. No es un defecto
del tamaño. Un modelo grande entrenado sólo a predecir falla igual, con mejor ortografía, porque en
su texto lo que sigue a una pregunta es lo que suele seguirla, y eso no siempre es la respuesta.
Hacer que un modelo haga lo que se le pide exige cambiarle algo más que el prompt, y ése es el bloque siguiente. Su primera lección, sobre por qué predecir no es obedecer, abre con esta misma función, cargada en una línea, y con una pregunta que se queda sin respuesta.
Para profundizar3 fuentes · 1 paper, 2 artículos
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Prompt Boundaries and Token Healing
La costura del espacio final en modelos grandes, con un prompt que acaba en «http:» como caso, y el arreglo que esta lección deja fuera: retroceder un token y obligar al primero sorteado a empezar por sus bytes.
- How to generate text: using different decoding methods for language generation with Transformers
La función de generar de una biblioteca de verdad, con GPT-2 detrás: los mismos mandos que modelo (voraz, temperatura, top-k, top-p, longitud máxima) y la búsqueda en haz que este bloque dejó fuera.
- Training language models to follow instructions with human feedback
Su introducción es el problema con el que acaba esta lección: un modelo entrenado sólo a predecir continúa lo que se le pide en lugar de hacerlo. El resto del artículo es el bloque siguiente.