Módulo 8 · Lenguaje y audio

Lección 3 de 5

Modelos abiertos

En esta páginaEl problema

El problema

Quieres clasificar 500 comentarios de estudiantes con un modelo de lenguaje que corra en tu propia máquina. Tienes una laptop con 8 GB de RAM y sin GPU, y un notebook en la nube con una GPU de 16 GB. El candidato es Qwen2.5-0.5B-Instruct, un modelo publicado en el Hub de Hugging Face. Su repositorio contiene estos archivos:

archivocontenido
config.jsonarquitectura: capas, dimensiones, vocabulario
model.safetensorspesos del modelo
tokenizer.json, vocab.json, merges.txttokenizer: cómo se convierte el texto en ids
tokenizer_config.jsonconfiguración del tokenizer y formato de prompt
generation_config.jsonvalores por defecto para generar texto
README.mdtarjeta del modelo
LICENSElicencia Apache 2.0

Todos, excepto los pesos, son archivos de texto.

Archivos del modelo

Un modelo abierto publica sus pesos: cualquiera puede descargarlos y ejecutar el modelo en su máquina. Los pesos solos no bastan. config.json dice cómo se acomodan en la arquitectura. El tokenizer convierte texto en los mismos ids que vio el modelo al entrenarse. La tarjeta del modelo (README.md) describe uso previsto, datos, evaluación y limitaciones; su encabezado YAML guarda metadatos como la licencia.

La licencia dice qué puedes hacer con los pesos. Apache 2.0 permite usarlos, modificarlos y redistribuirlos, también con fines comerciales, si conservas el aviso de licencia. Otros modelos usan licencias propias con condiciones de uso, y algunos repositorios piden aceptar esas condiciones en el Hub antes de descargar. Pesos abiertos no implica datos ni código de entrenamiento abiertos.

El config.json del modelo tiene nueve campos: la arquitectura y el tipo de dato de los pesos. La celda lo guarda como texto.

Python 3 · ejemplo reproducible
config_json = """{
  "hidden_size": 896,
  "intermediate_size": 4864,
  "num_hidden_layers": 24,
  "num_attention_heads": 14,
  "num_key_value_heads": 2,
  "vocab_size": 151936,
  "max_position_embeddings": 32768,
  "tie_word_embeddings": true,
  "torch_dtype": "bfloat16"
}"""

La celda plegada guarda el encabezado YAML de la tarjeta del modelo.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
tarjeta = """---
license: apache-2.0
pipeline_tag: text-generation
base_model: Qwen/Qwen2.5-0.5B
---
# Qwen2.5-0.5B-Instruct
"""

json.loads convierte el texto JSON en un diccionario de Python. El encabezado YAML va entre las dos primeras líneas ---, y cada una de sus líneas es un par clave: valor.

Python 3 · ejemplo reproducible
import json

config = json.loads(config_json)
metadatos = {}
for linea in tarjeta.split("---")[1].strip().splitlines():
    clave, valor = linea.split(": ")
    metadatos[clave] = valor

print(metadatos["license"], metadatos["base_model"])
print(config["torch_dtype"], config["max_position_embeddings"])
print(config["tie_word_embeddings"], type(config["tie_word_embeddings"]).__name__)
Salida esperada

La salida esperada está oculta.

La licencia es apache-2.0. base_model indica que este modelo es un ajuste de Qwen/Qwen2.5-0.5B, la versión base, entrenada solo para predecir el siguiente token. La versión Instruct se ajustó además con conversaciones para seguir instrucciones. Los pesos se publican en bfloat16 y el contexto máximo es de 32 768 tokens. El true de JSON llega a Python como el booleano True.

Qwen2.5 usa atención con consultas agrupadas (GQA). Las cabezas de consulta se reparten en grupos, y cada grupo comparte una cabeza de clave y una de valor. num_key_value_heads dice cuántas cabezas de clave y de valor hay. Todas las cabezas tienen el mismo tamaño.

Pregunta. Con hidden_size = 896, 14 cabezas de consulta y 2 cabezas de clave y de valor, calcula el tamaño de cada cabeza. Calcula también cuántas cabezas de consulta comparten cada cabeza de clave y el ancho total de las claves.

Python 3 · ejemplo reproducible
tam_cabeza = config["hidden_size"] // config["num_attention_heads"]
por_grupo = config["num_attention_heads"] // config["num_key_value_heads"]
ancho_kv = config["num_key_value_heads"] * tam_cabeza

print(tam_cabeza, por_grupo, ancho_kv)
Salida esperada

La salida esperada está oculta.

Conteo de parámetros

config.json basta para contar los parámetros. En cada una de las 24 capas de Qwen2.5 hay:

  • atención: proyecciones q de d × d, k y v de d × ancho_kv, y o de d × d. Solo q, k y v tienen sesgo.
  • MLP SwiGLU: tres matrices de d × intermediate_size (gate, up y down), sin sesgo.
  • normalización: dos RMSNorm con un vector de d pesos cada una.

Fuera de las capas están el embedding, de vocab_size × d, y una RMSNorm final. La cabeza de salida también mide vocab_size × d. Con tie_word_embeddings = true, la cabeza reutiliza la matriz del embedding y no añade parámetros.

Esta celda cuenta los parámetros de una sola capa, uno por término de la lista.

Python 3 · ejemplo reproducible
d = config["hidden_size"]
ff = config["intermediate_size"]
capas = config["num_hidden_layers"]
V = config["vocab_size"]

# q + k y v + o, con sesgo solo en q, k y v
atencion = (d * d + d) + 2 * (d * ancho_kv + ancho_kv) + d * d
mlp = 3 * d * ff  # gate, up y down
normas = 2 * d

La celda plegada define miles, que escribe un entero con espacios entre los miles para leer los totales.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
def miles(n):
    return f"{n:,}".replace(",", " ")

La siguiente celda multiplica por las 24 capas, suma las partes fuera de ellas y muestra cada parte como fracción del total.

Pregunta. ¿Qué parte tiene más parámetros: embedding, atención o MLP? La tarjeta del modelo dice 0.49B. ¿Coincidirá tu total?

Python 3 · ejemplo reproducible
partes = {
    "embedding": V * d,
    "atención": capas * atencion,
    "MLP": capas * mlp,
    "normas": capas * normas + d,  # + RMSNorm final
    "cabeza de salida": 0 if config["tie_word_embeddings"] else V * d,
}
total = sum(partes.values())

for nombre, n in partes.items():
    print(f"{nombre:17} {miles(n):>12} {n / total:6.1%}")
print(f"{'total':17} {miles(total):>12}")
Salida esperada

La salida esperada está oculta.

Memoria de los pesos

Cada parámetro se guarda con un tipo de dato. float32 usa 4 bytes, bfloat16 y float16 usan 2, int8 usa 1 e int4 usa medio byte. Guardar los pesos en 8 o 4 bits es cuantización, como en el modelo base de QLoRA. La memoria de los pesos es parámetros × bytes por dato. Esta lección usa 1 GB = 10⁹ bytes.

La fórmula se aplica a Qwen2.5-0.5B y a dos modelos de 7 000 y 70 000 millones de parámetros, con los cuatro tipos, y cada resultado se compara con los 8 GB de la laptop y los 16 GB de la GPU.

Pregunta. ¿Cuántos GB ocupan los pesos de Qwen2.5-0.5B en bfloat16? ¿En qué tipos cabe un modelo de 7 000 millones de parámetros en la GPU de 16 GB? ¿Y uno de 70 000 millones?

Ver resultado

La primera tabla da los GB que ocupan los pesos: una fila por modelo y una columna por tipo de dato. La segunda dice con qué tipos los pesos quedan por debajo de la memoria de cada máquina. Por ejemplo, la fila 7B de la primera tabla dice que un modelo de 7 000 millones de parámetros ocupa 14.00 GB en bfloat16, y la fila 7B de la segunda, que en la laptop solo cabe con int8 o int4.

modelofloat32bfloat16int8int4
Qwen2.5-0.5B1.980.990.490.25
7B28.0014.007.003.50
70B280.00140.0070.0035.00
modelotipos que caben en la laptop (8 GB)tipos que caben en la GPU (16 GB)
Qwen2.5-0.5Bfloat32, bfloat16, int8, int4float32, bfloat16, int8, int4
7Bint8, int4bfloat16, int8, int4
70Bningunoninguno
Lectura. En bfloat16, Qwen2.5-0.5B ocupa 988 065 536 bytes. model.safetensors mide 988 097 824: los 32 288 bytes extra son el encabezado con los nombres, formas y tipos de los tensores. Los pesos de este modelo caben en las dos máquinas con cualquier tipo. Un modelo de 7B en bfloat16 ocupa 14 GB: está por debajo de 16 GB, pero deja solo 2 GB para todo lo demás. En la laptop, ese modelo necesita cuantización: con int8 ocupa 7 GB y deja 1 GB libre. Uno de 70B no cabe en ninguna de las dos máquinas, ni con int4.

La memoria de los pesos es un mínimo. Al generar texto también ocupan memoria las activaciones y la caché de claves y valores, que crece con cada token del contexto.

Tokenización por subpalabras

El contexto de un modelo se mide en tokens, no en palabras. Qwen2.5 usa BPE (byte pair encoding) a nivel de bytes; merges.txt guarda sus fusiones. BPE se entrena así:

  1. cada palabra del corpus empieza partida en símbolos sueltos
  2. se cuentan los pares de símbolos vecinos, pesados por la frecuencia de la palabra
  3. el par más frecuente se fusiona en un símbolo nuevo y se guarda como fusión
  4. se repiten los pasos 2 y 3 hasta tener el número de fusiones deseado

Aquí el corpus es de seis palabras con su frecuencia, y el alfabeto son letras. contar_pares hace el paso 2: zip(simbolos, simbolos[1:]) recorre cada par de símbolos vecinos, y un Counter acumula la frecuencia de la palabra en cada par.

Python 3 · ejemplo reproducible
from collections import Counter

corpus = {"baja": 5, "bajo": 4, "caja": 6, "cajas": 3, "rojas": 2, "bajar": 2}


def contar_pares(partidas):
    conteo = Counter()
    for simbolos, frecuencia in partidas.items():
        for par in zip(simbolos, simbolos[1:]):
            conteo[par] += frecuencia
    return conteo

aplicar_fusion hace el paso 3 en una palabra: recorre sus símbolos y, donde encuentra el par, los une en uno solo.

Python 3 · ejemplo reproducible
def aplicar_fusion(simbolos, par):
    resultado = []
    i = 0
    while i < len(simbolos):
        if tuple(simbolos[i : i + 2]) == par:
            resultado.append(simbolos[i] + simbolos[i + 1])
            i += 2
        else:
            resultado.append(simbolos[i])
            i += 1
    return resultado

El entrenamiento parte cada palabra en letras con tuple(palabra), que sirve como clave de diccionario. En cada paso, most_common(1) devuelve el par más frecuente con su conteo.

Pregunta. En baja, bajo, caja, cajas, rojas y bajar, ¿qué par de letras vecinas aparece más veces? ¿Qué palabras quedarán como un solo símbolo después de cuatro fusiones?

Python 3 · ejemplo reproducible
partidas = {tuple(palabra): n for palabra, n in corpus.items()}
fusiones = []
for paso in range(4):
    conteo = contar_pares(partidas)
    par, veces = conteo.most_common(1)[0]
    fusiones.append(par)
    partidas = {tuple(aplicar_fusion(s, par)): n for s, n in partidas.items()}
    print(paso + 1, par, veces)

for simbolos in partidas:
    print(" ".join(simbolos))
Salida esperada

La salida esperada está oculta.

Para tokenizar un texto nuevo, se aplican las fusiones aprendidas en el mismo orden. El vocabulario es el alfabeto más un símbolo por fusión, y cada símbolo tiene un id. tokenizar aplica aplicar_fusion con cada par de fusiones, y enumerate numera los símbolos del vocabulario.

Pregunta. Escribe los tokens de cajas, bajas y sobras. ¿Cuál necesita más tokens?

Python 3 · ejemplo reproducible
alfabeto = sorted(set("".join(corpus)))
vocabulario = alfabeto + ["".join(par) for par in fusiones]
ids = {simbolo: i for i, simbolo in enumerate(vocabulario)}


def tokenizar(palabra):
    simbolos = list(palabra)
    for par in fusiones:
        simbolos = aplicar_fusion(simbolos, par)
    return simbolos


print(len(vocabulario), vocabulario)
for palabra in ["cajas", "bajas", "sobras"]:
    tokens = tokenizar(palabra)
    print(palabra, tokens, [ids[t] for t in tokens])
Salida esperada

La salida esperada está oculta.

Prompt y contexto

Un modelo Instruct se ajustó con conversaciones escritas en un formato fijo. Cada mensaje tiene un rol (system, user o assistant) y va entre marcas especiales. El campo chat_template de tokenizer_config.json define ese formato. En la biblioteca transformers, tokenizer.apply_chat_template lo aplica a una lista de mensajes. Qwen2.5 usa el formato ChatML con las marcas <|im_start|> y <|im_end|>, y cada marca es un solo token. formato_chatml escribe ese formato a mano, sin transformers.

Pregunta. Con un mensaje de sistema y uno de usuario, ¿con qué línea debe terminar el prompt para que el modelo escriba la respuesta del asistente?

Python 3 · ejemplo reproducible
def formato_chatml(mensajes):
    texto = ""
    for mensaje in mensajes:
        texto += f"<|im_start|>{mensaje['role']}\n{mensaje['content']}<|im_end|>\n"
    return texto + "<|im_start|>assistant\n"


mensajes = [
    {"role": "system", "content": "Clasifica cada comentario como positivo o negativo."},
    {"role": "user", "content": "1. La explicación de árboles fue muy clara."},
]
print(formato_chatml(mensajes))
Salida esperada

La salida esperada está oculta.

El límite de contexto cuenta todos los tokens de una llamada: prompt, marcas y respuesta. Supón que ya mediste con el tokenizer que las instrucciones y las marcas ocupan 90 tokens, que cada comentario ocupa 150 y que cada etiqueta de respuesta ocupa 5. -(-a // b) divide redondeando hacia arriba, porque una llamada con menos comentarios también cuenta.

Pregunta. ¿Cuántos comentarios caben en una llamada con los 32 768 tokens de Qwen2.5? ¿Y con los 1 024 de GPT-2? ¿Cuántas llamadas necesitas para los 500 comentarios en cada caso?

Python 3 · ejemplo reproducible
fijos = 90
por_comentario = 150 + 5
comentarios = 500

for modelo, contexto in [("Qwen2.5", config["max_position_embeddings"]), ("GPT-2", 1024)]:
    por_llamada = (contexto - fijos) // por_comentario
    llamadas = -(-comentarios // por_llamada)
    usados = fijos + por_llamada * por_comentario
    print(modelo, contexto, por_llamada, llamadas, usados)
Salida esperada

La salida esperada está oculta.

Cuando un modelo no cabe en tus máquinas, otra opción es una API externa. El proveedor ejecuta el modelo en sus servidores. Tú envías la lista de mensajes por la red y recibes el texto generado. El proveedor aplica el formato de prompt y cobra por tokens de entrada y de salida. El límite de contexto sigue aplicando, así que el cálculo de llamadas es el mismo. Una API necesita conexión, una cuenta y una clave. Tus textos salen de tu máquina, y el proveedor puede cambiar o retirar el modelo. Los Inference Providers de Hugging Face son un ejemplo de este tipo de acceso.

Práctica

Traza guiada

Con las cuatro fusiones aprendidas, escribe a mano los tokens de abajo. Aplica las fusiones en orden y anota el resultado después de cada una. Comprueba con tokenizar("abajo"). El resultado debe tener cuatro tokens. Si obtienes tres, revisa si fusionaste b con aj: esa fusión no está en la lista.

Configuración de 1.5B

Qwen2.5-1.5B-Instruct tiene hidden_size = 1536, intermediate_size = 8960, num_hidden_layers = 28, num_attention_heads = 12, num_key_value_heads = 2, el mismo vocabulario y la cabeza de salida atada. Antes de procesar, predice el tamaño de cada cabeza, el ancho de las claves y cuál de las tres partes grandes crece más respecto a 0.5B. Repite el conteo con esos valores y compara el total con los 1.54B de su tarjeta. Calcula su memoria en bfloat16 y decide en qué tipos cabe en la laptop.

Calculadora de viabilidad

Escribe una función revisar(config, tipo, memoria_gb, fijos, por_ejemplo) que devuelva:

  1. el número de parámetros
  2. los GB de los pesos con el tipo indicado
  3. si los pesos quedan por debajo de memoria_gb
  4. cuántos ejemplos caben en una llamada

Comprueba con assert que, con el config de esta lección, el número de parámetros es 494 032 768 y caben 210 ejemplos con fijos=90 y por_ejemplo=155. Úsala con la configuración de 1.5B y con tie_word_embeddings en False.

Reto opcional

La caché de claves y valores guarda, por cada token del contexto y cada capa, un vector de claves y uno de valores de ancho ancho_kv. Calcula sus bytes por token en bfloat16 y su tamaño con el contexto completo de 32 768 tokens. Compáralo con los 988 MB de los pesos. Repite el cálculo suponiendo que el modelo no usara GQA, con 14 cabezas de clave y de valor.

Errores que revisar

  • Si tu conteo da 630 167 424 parámetros, sumaste una cabeza de salida aunque tie_word_embeddings es true.
  • Si k y v te dan 803 712 parámetros cada una, usaste hidden_size como ancho. En GQA, el ancho es num_key_value_heads × tamaño de cabeza.
  • Si la memoria ocupada al cargar es el doble de tu cálculo, revisa el tipo de dato: cargar en float32 pesos publicados en bfloat16 duplica sus bytes.
  • Si el sistema reporta menos memoria de la que calculaste, revisa las unidades. Muchos programas reportan GiB (2³⁰ bytes): 14 GB son 13.04 GiB.
  • Si el modelo continúa tu texto en lugar de responderlo, revisa que el prompt termine con <|im_start|>assistant y que uses la versión Instruct.

Cierre

Te dan config.json y la tarjeta de un modelo que no conoces. Explica qué campos usas para decidir si sus pesos caben en una GPU de 16 GB y cuántos tokens de entrada le puedes dar en una llamada.

Lista de comprobación

  • ¿Por qué tie_word_embeddings cambia el número de parámetros sin cambiar el tamaño del vocabulario?
  • ¿Por qué la memoria de los pesos es solo un mínimo de la memoria que usa el modelo al generar?
  • ¿Por qué una palabra que no está en el corpus de BPE se puede tokenizar de todos modos?
  • ¿Por qué los ids de un tokenizer no sirven con el embedding de otro modelo?
  • ¿Qué tokens cuentan contra el límite de contexto en una llamada?

Documentación oficial

  • Model cards: secciones de una tarjeta del modelo y metadatos YAML, incluida la licencia.
  • Licenses: identificadores de licencia que usan los repositorios del Hub.
  • Summary of the tokenizers: BPE, BPE a nivel de bytes, WordPiece y otros tokenizers por subpalabras.
  • Chat templates: chat_template, apply_chat_template y add_generation_prompt.
  • Inference Providers: acceso a modelos por API sin ejecutarlos en tu máquina.
Cargando progreso…