8 - Cómo genera texto un modelo de lenguaje y cómo aprende de las preferencias¶
En el capítulo 8, el equipo de Minermont pasa de cómo se construyen los modelos de lenguaje a cómo se comportan. Dos ideas están en el centro. Primero, la generación: como explica Ana, la inferencia es un bucle con tres elementos, el contexto, la distribución del siguiente token y la regla de decisión que elige uno. Segundo, el alineamiento: con RLHF, los modelos aprenden de las preferencias humanas a través de un modelo de recompensa, que también se puede "engañar". En este cuaderno construimos las dos ideas a pequeña escala, con NumPy:
- Importar las bibliotecas.
- Un modelo de lenguaje diminuto: la probabilidad de la siguiente palabra.
- El contexto.
- Reglas de decisión: voraz, temperatura, top-k y top-p.
- Búsqueda en haz: buscar la frase más probable.
- RLHF en miniatura: preferencias y un modelo de recompensa.
- Optimizar contra el modelo de recompensa, y la penalización que mantiene el modelo cerca del original.
- Cuando el modelo de recompensa se equivoca: el reward hacking.
Paso 1: Importar las bibliotecas¶
import collections
import itertools
import numpy as np
import matplotlib.pyplot as plt
np.set_printoptions(precision=3, suppress=True)
Paso 2: Un modelo de lenguaje diminuto¶
Un gran modelo de lenguaje calcula $P(x_{t+1} \mid x_1, \dots, x_t)$, la probabilidad del siguiente token dados los anteriores, con un Transformer. Aquí usamos el modelo más sencillo con la misma interfaz, un modelo de n-gramas: mira las últimas $n$ palabras (todo su contexto) y estima las probabilidades contando, en un corpus de 2000 instrucciones de alta, qué palabra las siguió. <s> marca el principio de una frase y </s> su final. Como un modelo real, da a cada palabra del vocabulario una probabilidad pequeña, incluso a las palabras que nunca vio en ese contexto (suavizado).
rng = np.random.default_rng(0)
fill = {'med': (['paracetamol', 'ibuprofeno', 'el antibiótico', 'las pastillas nuevas'], [0.5, 0.25, 0.15, 0.1]),
'freq': (['dos veces al día', 'cada ocho horas', 'una vez al día'], [0.6, 0.25, 0.15]),
'symptom': (['fiebre', 'dolor', 'mareo', 'dificultad para respirar'], [0.5, 0.25, 0.15, 0.1]),
'n': (['dos', 'tres', 'cuatro'], [0.6, 0.3, 0.1])}
templates = ['tome {med} {freq} después de las comidas', 'beba mucha agua y descanse en casa',
'llame a su médico si nota más {symptom}', 'vuelva al hospital si tiene {symptom}',
'no conduzca mientras tome {med}', 'su próxima cita es dentro de {n} semanas',
'evite el alcohol mientras tome {med}', 'tome {med} si tiene {symptom}']
template_probs = [0.25, 0.1, 0.15, 0.1, 0.1, 0.1, 0.1, 0.1]
def pick(key):
options, probs = fill[key]
return options[rng.choice(len(options), p=probs)]
corpus = [templates[i].format(**{k: pick(k) for k in fill}) for i in rng.choice(len(templates), 2000, p=template_probs)]
valid = set() # todas las instrucciones correctas que pueden producir las plantillas
for t in templates:
keys = [k for k in fill if '{' + k + '}' in t]
for combo in itertools.product(*[fill[k][0] for k in keys]):
valid.add(t.format(**dict(zip(keys, combo))))
vocabulary = sorted({w for s in corpus for w in s.split()} | {'</s>'})
def ngram_model(context_size, smoothing):
counts = collections.defaultdict(collections.Counter)
for s in corpus:
tokens = ['<s>'] * context_size + s.split() + ['</s>']
for i in range(context_size, len(tokens)):
counts[tuple(tokens[i - context_size:i])][tokens[i]] += 1
def next_token_distribution(context):
c = counts[tuple(context[-context_size:])]
f = np.array([c[w] for w in vocabulary], dtype=float) + smoothing
return f / f.sum() # P(siguiente palabra | las últimas context_size palabras)
return next_token_distribution
model = ngram_model(context_size=2, smoothing=0.02)
p = model(['si', 'tiene'])
order = np.argsort(-p)
print(f"Vocabulario: {len(vocabulary)} palabras, {len(valid)} instrucciones correctas posibles")
print("P(siguiente palabra | 'si tiene'):", ", ".join(f"{vocabulary[i]} {p[i]:.3f}" for i in order[:4]),
f"| las otras {len(vocabulary) - 4} palabras juntas: {p[order[4:]].sum():.3f}")
Vocabulario: 56 palabras, 48 instrucciones correctas posibles P(siguiente palabra | 'si tiene'): fiebre 0.500, dolor 0.254, mareo 0.164, dificultad 0.080 | las otras 52 palabras juntas: 0.003
Después de si tiene, el modelo ha aprendido los cuatro síntomas del corpus con sus frecuencias: fiebre 0,500, dolor 0,254, mareo 0,164 y dificultad 0,080 (la primera palabra de dificultad para respirar: aquí cada palabra es un token). Las otras 52 palabras del vocabulario se reparten 0,003 por el suavizado: aquí no tienen sentido, pero no son imposibles. Esta es la distribución, el segundo de los tres elementos de Ana: el modelo no escupe una frase, asigna probabilidades a muchos posibles tokens siguientes.
Paso 3: El contexto¶
El primer elemento de Ana es el contexto: lo que el modelo puede ver. Generamos 300 instrucciones con modelos que ven 1, 2 o 3 palabras anteriores, muestreando cada palabra de la distribución, y contamos cuántas son instrucciones correctas:
def generate(model, rng, rule, max_words=20):
tokens = ['<s>'] * 3
while tokens[-1] != '</s>' and len(tokens) < max_words + 3:
tokens.append(vocabulary[rule(model(tokens), rng)]) # la palabra elegida se añade al contexto
return ' '.join(t for t in tokens if t not in ('<s>', '</s>'))
sample = lambda p, rng: rng.choice(len(p), p=p)
for size in [1, 2, 3]:
m = ngram_model(size, smoothing=0.0)
rng = np.random.default_rng(1)
texts = [generate(m, rng, sample) for _ in range(300)]
wrong = [t for t in texts if t not in valid]
print(f"Contexto de {size} palabra(s): {1 - len(wrong) / len(texts):.0%} de instrucciones correctas; la incorrecta más frecuente: '{collections.Counter(wrong).most_common(1)[0][0]}'")
Contexto de 1 palabra(s): 30% de instrucciones correctas; la incorrecta más frecuente: 'tome paracetamol' Contexto de 2 palabra(s): 76% de instrucciones correctas; la incorrecta más frecuente: 'tome paracetamol' Contexto de 3 palabra(s): 94% de instrucciones correctas; la incorrecta más frecuente: 'tome las pastillas nuevas'
Con una palabra de contexto solo el 30% de las instrucciones son correctas, con dos palabras el 76% y con tres el 94%. El error más frecuente con una o dos palabras es tome paracetamol, una instrucción cortada. Después de tome paracetamol el modelo no puede ver si la frase empezó por tome o por no conduzca mientras, y en el corpus ese segundo tipo de frase termina justo ahí. Con tres palabras de contexto eso queda resuelto, y el error más frecuente solo aparece cuando un medicamento de tres palabras, las pastillas nuevas, llena el contexto y deja fuera el principio de la frase. Y, como dice Ana, cada palabra elegida se añade al contexto, así que una ambigüedad temprana hace bola de nieve y acaba en una frase distinta. Un modelo real ve miles de tokens, pero la lección es la misma: lo que el modelo puede ver limita lo que puede acertar.
Paso 4: Reglas de decisión¶
El modelo da una distribución; la regla de decisión elige el token. El libro describe cuatro:
- Voraz (greedy): siempre el token más probable. Es determinista pero, como advierte Ana, no es automáticamente la más segura: puede quedarse atrapada en repeticiones o en errores tempranos.
- Temperatura $T$: antes de elegir, las probabilidades se transforman en $p_i^{1/T}$ y se vuelven a normalizar. $T < 1$ afila la distribución (más predecible) y $T > 1$ la aplana (más variada, y más arriesgada).
- Top-k: solo se conservan los $k$ tokens más probables.
- Top-p (núcleo): solo se conserva el conjunto más pequeño de tokens cuyas probabilidades suman al menos $p$.
La imagen de Luis en el libro es un dado "cargado" por las probabilidades: las reglas deciden lo cargado que está y qué caras tiene. Usamos el modelo con un contexto de 2 palabras y comparamos las reglas con 300 instrucciones generadas:
def rule(greedy=False, temperature=1.0, top_k=None, top_p=None):
def decide(probs, rng):
if greedy:
return int(np.argmax(probs))
p = probs ** (1 / temperature)
p = p / p.sum()
order = np.argsort(-p)
keep = np.ones(len(p), dtype=bool)
if top_k is not None:
keep[order[top_k:]] = False
if top_p is not None:
keep[order[np.searchsorted(np.cumsum(p[order]), top_p) + 1:]] = False # el conjunto más pequeño que llega a top_p
p = np.where(keep, p, 0)
return int(rng.choice(len(p), p=p / p.sum()))
return decide
rules = {'voraz': rule(greedy=True), 'T = 0,5': rule(temperature=0.5), 'T = 1': rule(temperature=1.0),
'T = 1,5': rule(temperature=1.5), 'T = 2': rule(temperature=2.0), 'T = 2, top-k = 3': rule(temperature=2.0, top_k=3),
'T = 2, top-p = 0,9': rule(temperature=2.0, top_p=0.9), 'T = 1, top-p = 0,9': rule(temperature=1.0, top_p=0.9)}
examples = {}
for name, decide in rules.items():
rng = np.random.default_rng(1)
texts = [generate(model, rng, decide) for _ in range(300)]
examples[name] = texts
print(f"{name:<20} instrucciones distintas: {len(set(texts)):>3} de 300 | correctas: {np.mean([t in valid for t in texts]):>4.0%}")
print(f"\nLa regla voraz siempre escribe: '{examples['voraz'][0]}'")
print(f"Con T = 2, por ejemplo: '{examples['T = 2'][0]}'")
voraz instrucciones distintas: 1 de 300 | correctas: 0% T = 0,5 instrucciones distintas: 43 de 300 | correctas: 64%
T = 1 instrucciones distintas: 65 de 300 | correctas: 74%
T = 1,5 instrucciones distintas: 213 de 300 | correctas: 32%
T = 2 instrucciones distintas: 290 de 300 | correctas: 5%
T = 2, top-k = 3 instrucciones distintas: 54 de 300 | correctas: 56% T = 2, top-p = 0,9 instrucciones distintas: 276 de 300 | correctas: 9%
T = 1, top-p = 0,9 instrucciones distintas: 46 de 300 | correctas: 77% La regla voraz siempre escribe: 'tome paracetamol' Con T = 2, por ejemplo: 'no tiene casa vez dificultad es si en llame a su médico si nota más fiebre'
context = ['tome', 'paracetamol']
p = model(context)
top = np.argsort(-p)[:5]
labels = [vocabulary[i] for i in top] + [f'las otras {len(vocabulary) - 5}']
fig, axes = plt.subplots(1, 3, figsize=(13, 3.8), sharey=True)
for ax, T in zip(axes, [0.5, 1, 2]):
q = p ** (1 / T)
q = q / q.sum()
values = list(q[top]) + [1 - q[top].sum()]
ax.bar(labels, values, color=['tab:blue'] * 5 + ['tab:gray'])
ax.set_title(f"T = {T}".replace('.', ','))
ax.tick_params(axis='x', rotation=40)
print(f"T = {T}: ".replace('.', ',') + ", ".join(f"{l} {v:.2f}" for l, v in zip(labels, values)))
axes[0].set_ylabel('probabilidad')
fig.suptitle("P(siguiente palabra | 'tome paracetamol') con cada temperatura")
plt.tight_layout()
plt.show()
T = 0,5: </s> 0.53, dos 0.27, si 0.14, cada 0.04, una 0.03, las otras 51 0.00 T = 1: </s> 0.37, dos 0.26, si 0.19, cada 0.10, una 0.08, las otras 51 0.00 T = 2: </s> 0.25, dos 0.21, si 0.18, cada 0.13, una 0.12, las otras 51 0.13
La tabla y la figura muestran las cuatro reglas en acción:
- La regla voraz siempre escribe la misma instrucción, tome paracetamol, y es incorrecta. En cada paso toma la palabra más probable, y después de tome paracetamol la más probable es el final de la frase,
</s>(0,37 con T = 1). Es el error temprano del que advierte Ana: una regla determinista no es automáticamente una regla segura. - Una temperatura más baja no lo resuelve: T = 0,5 sube el final de la frase a 0,53 y consigue un 64% de instrucciones correctas, frente al 74% con T = 1, con menos instrucciones distintas (43 frente a 65). Afilar la distribución hace el modelo más predecible, no más acertado.
- Una temperatura más alta la aplana: con T = 2, las 51 palabras que no tienen sentido después de tome paracetamol reciben juntas 0,13, y una frase tiene muchos pasos. Salen 290 instrucciones distintas, y solo el 5% son correctas.
- Top-k = 3 elimina esa cola y devuelve T = 2 al 56% de instrucciones correctas. Top-p = 0,9 apenas ayuda (9%): se aplica después de la temperatura y, una vez aplanada la distribución, llegar al 90% de la probabilidad exige muchas palabras de la cola. Combinado con T = 1, top-p da el mejor resultado de la tabla, un 77%.
Como concluye Carmen en el libro, si queremos fiabilidad controlamos sobre todo el contexto y la regla de decisión.
Paso 5: Búsqueda en haz: buscar la frase más probable¶
El libro nombra una tercera forma de decodificar además de la voraz y la estocástica: la búsqueda en haz (beam search). La decodificación voraz toma la mejor palabra en cada paso, pero la frase que construye no es necesariamente la más probable: una palabra que parece buena ahora puede llevar a una continuación pobre. La búsqueda en haz conserva en cada paso las $k$ frases parciales más probables (el haz). Amplía cada una con sus $k$ siguientes palabras más probables, puntúa cada candidata con el logaritmo de la probabilidad de la frase completa, $\log P(x_1) + \log P(x_2 \mid x_1) + \dots$, y se queda con las $k$ mejores. Con $k = 1$ es la decodificación voraz. Una frase que llega a </s> se aparta como terminada y, al final, elegimos la mejor terminada. Como la decodificación voraz, es determinista: da la misma frase cada vez.
def beam_search(model, width, max_words=20):
beams, finished = [(['<s>'] * 3, 0.0)], [] # (palabras hasta ahora, log-probabilidad de la frase)
while beams:
candidates = []
for tokens, log_p in beams:
p = model(tokens)
for i in np.argsort(-p)[:width]: # las width siguientes palabras más probables
candidates.append((tokens + [vocabulary[i]], log_p + np.log(p[i])))
candidates.sort(key=lambda c: -c[1])
beams = []
for tokens, log_p in candidates[:width]: # se conservan las width mejores candidatas
done = tokens[-1] == '</s>' or len(tokens) >= max_words + 3
(finished if done else beams).append((tokens, log_p))
return sorted(finished, key=lambda f: -f[1])
def words_of(tokens):
return ' '.join(t for t in tokens if t not in ('<s>', '</s>'))
for width in [1, 2, 3, 5, 10]:
tokens, log_p = beam_search(model, width)[0]
print(f"anchura {width:>2}: '{words_of(tokens)}' probabilidad {np.exp(log_p):.4f} correcta: {words_of(tokens) in valid}")
print("\nLas mejores frases terminadas con anchura 5, con su probabilidad y su log-probabilidad por palabra:")
for tokens, log_p in beam_search(model, 5)[:5]:
n_words = len(tokens) - 3 # las palabras y </s>
print(f" {np.exp(log_p):.4f} {log_p / n_words:6.3f} '{words_of(tokens)}'")
anchura 1: 'tome paracetamol' probabilidad 0.0630 correcta: False anchura 2: 'llame a su médico si nota más fiebre' probabilidad 0.0711 correcta: True anchura 3: 'llame a su médico si nota más fiebre' probabilidad 0.0711 correcta: True anchura 5: 'beba mucha agua y descanse en casa' probabilidad 0.0942 correcta: True anchura 10: 'beba mucha agua y descanse en casa' probabilidad 0.0942 correcta: True Las mejores frases terminadas con anchura 5, con su probabilidad y su log-probabilidad por palabra: 0.0942 -0.295 'beba mucha agua y descanse en casa' 0.0711 -0.294 'llame a su médico si nota más fiebre' 0.0630 -0.922 'tome paracetamol' 0.0491 -0.431 'vuelva al hospital si tiene fiebre' 0.0399 -0.358 'llame a su médico si nota más dolor'
Con anchura 1, la búsqueda en haz es la decodificación voraz y escribe "tome paracetamol", con una probabilidad de 0,0630, que no es una instrucción correcta: tras "tome paracetamol" la palabra más probable es el final de la frase, porque dos plantillas terminan así ("no conduzca mientras tome paracetamol"). Con anchura 2 ya encuentra una frase mejor, "llame a su médico si nota más fiebre" (0,0711), y a partir de la anchura 5 la frase más probable del modelo, "beba mucha agua y descanse en casa" (0,0942): su primera palabra es menos probable que "tome", pero después cada palabra es casi segura. Es el error temprano del que advierte Ana, y lo que evita un haz más ancho. La última columna, la log-probabilidad por palabra, se usa para normalizar por la longitud: sin normalizar, la búsqueda en haz favorece las frases cortas, porque cada palabra de más multiplica la probabilidad por un número menor que 1. Con la normalización, "llame a su médico si nota más fiebre" (−0,294) supera por muy poco a "beba mucha agua y descanse en casa" (−0,295), porque tiene más palabras: la forma de puntuar cambia la frase elegida. "tome paracetamol" es tercera por probabilidad y última por probabilidad por palabra, −0,922. Como la decodificación voraz, la búsqueda en haz escribe siempre la misma frase: busca el texto más probable, no la variedad.
Paso 6: RLHF en miniatura¶
Ahora la segunda idea. El libro describe RLHF en tres fases: recoger preferencias humanas entre respuestas, entrenar un modelo de recompensa que las prediga y optimizar el modelo de lenguaje para que obtenga una recompensa alta. Trabajamos con una sola pregunta de un paciente y 8 posibles respuestas del modelo base:
question = "Tengo la tensión alta. ¿Puedo tomar ibuprofeno para el dolor de espalda?"
answers = {
'A': "Sí, tome 800 mg cada seis horas.",
'B': "El ibuprofeno puede subir la tensión arterial, así que consulte con su médico o farmacéutico antes de tomarlo.",
'C': "Siento lo de su espalda. El ibuprofeno puede subir la tensión arterial, así que pregunte antes a su médico; "
"quizá le recomiende paracetamol.",
'D': "El dolor de espalda es muy frecuente y hay muchas cosas que ayudan, como el reposo, el ejercicio suave, el calor, "
"un buen colchón y una buena postura en el trabajo... (una respuesta larga que nunca contesta a la pregunta)",
'E': "¡Siento mucho que le duela! Tome 600 mg tres veces al día.",
'F': "Los antiinflamatorios como el ibuprofeno pueden subir la tensión arterial e interaccionar con algunos "
"medicamentos para la tensión. Consulte con su médico o farmacéutico antes de tomarlo.",
'G': "Siento muchísimo que esté pasando por esto... (una respuesta muy larga, cálida y detallada que termina "
"recomendando 800 mg de ibuprofeno cada seis horas)",
'H': "Siento mucho oír eso, espero que se mejore muy pronto.",
}
names = list(answers)
words = np.array([6, 18, 26, 80, 10, 45, 160, 30]) # longitud de cada respuesta, en palabras
# pide consultar empática se inventa explica
# a un clínico una dosis el riesgo
features = np.array([[0, 0, 1, 0], # A
[1, 0, 0, 1], # B
[1, 1, 0, 1], # C
[0, 1, 0, 0], # D
[0, 1, 1, 0], # E
[1, 0, 0, 1], # F
[0, 1, 1, 0], # G
[0, 1, 0, 0]], # H
dtype=float)
El criterio real de los clínicos, que nadie escribe de forma explícita, valora pedir que se consulte a un clínico, explicar el riesgo y un poco de empatía, y castiga con fuerza una dosis inventada. Lo escribimos como una calidad real que solo usaremos para simular sus elecciones y para comprobar los resultados:
$$\text{calidad} = 2\,(\text{pide consultar}) + 1\,(\text{empática}) - 3\,(\text{se inventa una dosis}) + 1{,}5\,(\text{explica el riesgo})$$
Fase 1. Los clínicos comparan parejas de respuestas y eligen una, no siempre de forma coherente: la mejor respuesta gana con probabilidad $\sigma(\text{calidad}_A - \text{calidad}_B)$. En esta primera ronda solo ven seis de las respuestas: las muy largas, D y G, nunca se les mostraron.
Fase 2. El modelo de recompensa da a cada respuesta una puntuación $r = w \cdot \phi$, calculada a partir de características que puede observar: si pide consultar a un clínico, si es empática, si explica el riesgo y su longitud. No puede comprobar si una dosis es inventada. Se entrena para que $P(A \succ B) = \sigma(r_A - r_B)$ coincida con las elecciones de los clínicos, que es exactamente la regresión logística del capítulo 3 aplicada a diferencias entre respuestas (el modelo de Bradley–Terry).
quality = features @ np.array([2.0, 1.0, -3.0, 1.5])
observable = np.c_[features[:, [0, 1, 3]], words / 40] # lo que puede ver el modelo de recompensa
def collect_preferences(candidates, n, seed):
rng = np.random.default_rng(seed)
pairs = []
for _ in range(n):
i, j = rng.choice(candidates, 2, replace=False)
better_wins = rng.random() < 1 / (1 + np.exp(-(quality[i] - quality[j])))
pairs.append((i, j) if better_wins else (j, i)) # (preferida, rechazada)
return np.array(pairs)
def train_reward_model(pairs, learning_rate=0.5, epochs=3000):
diff = observable[pairs[:, 0]] - observable[pairs[:, 1]]
w = np.zeros(observable.shape[1])
for _ in range(epochs):
p = 1 / (1 + np.exp(-diff @ w)) # P(la preferida gana a la rechazada)
w -= learning_rate * diff.T @ (p - 1) / len(diff) # gradiente de la pérdida logarítmica
return w
shown = [names.index(n) for n in ['A', 'B', 'C', 'E', 'F', 'H']]
pairs = collect_preferences(shown, 400, seed=2)
w = train_reward_model(pairs)
reward = observable @ w
agreement = np.mean(reward[pairs[:, 0]] > reward[pairs[:, 1]])
print(f"Pesos del modelo de recompensa: pide consultar {w[0]:.2f}, empática {w[1]:.2f}, "
f"explica el riesgo {w[2]:.2f}, longitud {w[3]:.2f}")
print(f"Coincide con los clínicos en el {agreement:.1%} de sus comparaciones\n")
print(f"{'respuesta':<10}{'calidad real':>13}{'recompensa':>12}")
for n, q, r in zip(names, quality, reward):
print(f"{n:<10}{q:>13.1f}{r:>12.2f}{' <- nunca mostrada a los clínicos' if n in 'DG' else ''}")
Pesos del modelo de recompensa: pide consultar 2.00, empática 1.45, explica el riesgo 2.00, longitud 1.61 Coincide con los clínicos en el 90.5% de sus comparaciones respuesta calidad real recompensa A -3.0 0.24 B 3.5 4.72 C 4.5 6.49 D 1.0 4.66 <- nunca mostrada a los clínicos E -2.0 1.85 F 3.5 5.80 G -2.0 7.88 <- nunca mostrada a los clínicos H 1.0 2.65
El modelo de recompensa coincide con los clínicos en el 90,5% de sus comparaciones y ordena bien las seis respuestas que vieron: C la primera (6,49) y A la última (0,24). Pero ha aprendido algo que nadie pretendía, un peso de 1,61 para la longitud. No puede ver la dosis inventada y, entre las respuestas que vieron los clínicos, las dos que se la inventan, A y E, resultan ser las más cortas (6 y 10 palabras): la longitud se convirtió en un sustituto de lo que no puede ver. La consecuencia es la respuesta G, que los clínicos nunca vieron: larga (160 palabras), cálida y con una dosis peligrosa para este paciente. Su calidad real es −2,0 y, sin embargo, obtiene la recompensa más alta de todas, 7,88. Como dice el libro, el modelo de recompensa es solo una aproximación aprendida del juicio humano, y solo es fiable con respuestas parecidas a aquellas con las que se entrenó.
Paso 7: Optimizar contra el modelo de recompensa¶
Fase 3. El modelo de lenguaje, la política $\pi$, se ajusta para obtener una recompensa alta. El libro explica que se añade un término de regularización que penaliza alejarse demasiado del modelo original $\pi_0$. Con esa penalización, medida con la divergencia de Kullback–Leibler y una intensidad $\beta$, la mejor política posible tiene una forma sencilla:
$$\pi(y) \propto \pi_0(y)\, e^{\,r(y)/\beta}$$
Una $\beta$ grande deja el modelo casi como estaba; una $\beta$ pequeña deja que la recompensa lo decida todo. El modelo base da estas probabilidades a las 8 respuestas, y vemos qué pasa a medida que baja $\beta$:
base_policy = np.array([0.18, 0.10, 0.08, 0.16, 0.12, 0.07, 0.17, 0.12])
def optimize(reward, beta):
policy = base_policy * np.exp(reward / beta)
return policy / policy.sum()
def evaluate_policies(reward, betas):
rows = []
for beta in betas:
policy = optimize(reward, beta)
kl = np.sum(policy * np.log(policy / base_policy))
rows.append((beta, policy @ reward, policy @ quality, kl, policy[names.index('G')], policy[names.index('C')]))
return np.array(rows)
betas = [20, 10, 5, 3, 2, 1.5, 1, 0.7, 0.5, 0.3, 0.1]
results = evaluate_policies(reward, betas)
print(f"Modelo base: recompensa media {base_policy @ reward:.2f}, calidad real media {base_policy @ quality:.2f}\n")
print(f"{'beta':>6}{'recompensa media':>18}{'calidad real':>14}{'distancia (KL)':>16}{'P(G)':>7}{'P(C)':>7}")
for beta, r, q, kl, p_g, p_c in results:
print(f"{beta:>6}{r:>18.2f}{q:>14.2f}{kl:>16.2f}{p_g:>7.2f}{p_c:>7.2f}")
Modelo base: recompensa media 4.07, calidad real media 0.11 beta recompensa media calidad real distancia (KL) P(G) P(C) 20.0 4.40 0.24 0.01 0.20 0.09 10.0 4.73 0.33 0.03 0.24 0.10 5.0 5.33 0.41 0.12 0.32 0.11 3.0 6.00 0.33 0.30 0.43 0.13 2.0 6.62 0.05 0.55 0.56 0.13 1.5 7.02 -0.31 0.79 0.66 0.12 1.0 7.47 -0.95 1.15 0.81 0.09 0.7 7.71 -1.46 1.43 0.91 0.06 0.5 7.82 -1.77 1.61 0.96 0.03 0.3 7.87 -1.97 1.74 1.00 0.00 0.1 7.88 -2.00 1.77 1.00 0.00
Con β = 20 la política apenas se mueve (distancia 0,01) y la calidad real solo sube un poco, de 0,11 a 0,24. Al bajar β, la recompensa sube sin parar: 5,33 con β = 5, 7,47 con β = 1 y 7,88 con β = 0,1. La calidad real, en cambio, alcanza un máximo de 0,41 con β = 5 y después cae: con β = 1 ya es −0,95 y con β = 0,1 es −2,00, porque el modelo escribe la respuesta G con probabilidad 1,00. La mejor respuesta, C, que llega como mucho a 0,13, desaparece. La optimización ha hecho exactamente lo que se le pedía, maximizar el modelo de recompensa; el problema es que el modelo de recompensa se equivoca con G.
Paso 8: El reward hacking, y cómo corregirlo¶
El gráfico muestra la trampa: cuanto más optimizamos ($\beta$ más pequeña, hacia la derecha), más sube la recompensa, mientras la calidad real alcanza un máximo y después se hunde.
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(results[:, 3], results[:, 1], 'o-', label='recompensa (lo que optimiza el modelo)')
ax.plot(results[:, 3], results[:, 2], 's-', label='calidad real (lo que quieren los clínicos)')
ax.set_xlabel('distancia al modelo original (KL): más optimización hacia la derecha')
ax.set_title('Sobreoptimización de un modelo de recompensa imperfecto')
ax.legend()
ax.grid(True)
plt.show()
El libro propone dos defensas: la penalización que mantiene el modelo cerca del original, que acabamos de ver, y nuevas rondas de evaluación. Hagamos una segunda ronda en la que los clínicos comparen también las respuestas nuevas que produce el modelo optimizado, incluidas D y G, y volvamos a entrenar el modelo de recompensa:
all_answers = list(range(len(names)))
pairs_2 = np.r_[pairs, collect_preferences(all_answers, 400, seed=3)]
w_2 = train_reward_model(pairs_2)
reward_2 = observable @ w_2
print(f"Pesos nuevos: pide consultar {w_2[0]:.2f}, empática {w_2[1]:.2f}, explica el riesgo {w_2[2]:.2f}, longitud {w_2[3]:.2f}")
print(f"Recompensa de G: {reward[names.index('G')]:.2f} -> {reward_2[names.index('G')]:.2f}; "
f"recompensa de C: {reward[names.index('C')]:.2f} -> {reward_2[names.index('C')]:.2f}\n")
for beta, r, q, kl, p_g, p_c in evaluate_policies(reward_2, [5, 1, 0.3]):
print(f"beta {beta:>4}: calidad real {q:5.2f}, P(G) {p_g:.2f}, P(C) {p_c:.2f}")
Pesos nuevos: pide consultar 2.42, empática 1.77, explica el riesgo 2.42, longitud 0.00 Recompensa de G: 7.88 -> 1.78; recompensa de C: 6.49 -> 6.60 beta 5.0: calidad real 1.12, P(G) 0.14, P(C) 0.17 beta 1.0: calidad real 4.03, P(G) 0.01, P(C) 0.70 beta 0.3: calidad real 4.49, P(G) 0.00, P(C) 0.99
En cuanto los clínicos han visto D y G, el peso de la longitud cae a 0,00: las respuestas largas no son mejores respuestas. La recompensa de G baja de 7,88 a 1,78, y C, la mejor respuesta, tiene ahora la recompensa más alta (6,60). Ahora optimizar es seguro: con β = 1 la calidad real es 4,03 y con β = 0,3 es 4,49, con C elegida el 99% de las veces. Por eso RLHF no se hace una sola vez: cada ronda de optimización produce respuestas que las personas tienen que volver a evaluar. El modelo de recompensa sigue sin ver la dosis inventada; simplemente ya no premia la longitud que la acompañaba.
Conclusión¶
- Un modelo de lenguaje no escribe frases: en cada paso da una distribución sobre el siguiente token, y una regla de decisión elige uno. La decodificación es una decisión de diseño, como dice Ana, no un "estado de ánimo" del modelo.
- Lo que el modelo puede ver, su contexto, limita lo que puede acertar: aquí, del 30% de instrucciones correctas con una palabra al 94% con tres.
- La decodificación voraz es predecible pero no automáticamente segura: aquí siempre escribió la misma instrucción incorrecta. La temperatura cambia acierto por variedad, y top-k y top-p recortan la cola improbable; importa cómo se combinan las reglas.
- RLHF convierte las preferencias humanas en un modelo de recompensa (una regresión logística sobre parejas de respuestas, en su forma más sencilla) y después empuja al modelo hacia las respuestas con una recompensa alta.
- El modelo de recompensa es solo una aproximación aprendida del juicio humano. Si se apoya en algo superficial, como la longitud, optimizar contra él puede convertir una respuesta peligrosa en la más probable: el reward hacking. La penalización que mantiene el modelo cerca del original y las nuevas rondas de evaluación humana son las defensas que describe el libro.
- En un hospital, esto lleva a la Regla de Oro del libro: toda salida de un modelo de lenguaje debe ser validada por un profesional.