Skip to content

English · Español

Lab 01 — Implementar BPE en Python puro

Objetivo: escribir src/minitoken/bpe.py desde cero. Train + encode + decode + save/load + tests de propiedades de ida y vuelta.

Tiempo estimado: 4–6 horas, posiblemente a lo largo de varias sesiones.

Prerrequisito: lab 00 comprometido; src/minitoken/BLUEPRINT.md revisado y refinado según A12.


Lo que produces

  • src/minitoken/__init__.py — re-exporta BPETokenizer, Vocab.
  • src/minitoken/bpe.py — el trainer + encoder + decoder.
  • src/minitoken/vocab.py — dataclass Vocab + helpers de serialización.
  • tests/test_bpe.py — tests unitarios + de propiedades.
  • tests/test_bpe.md (o un docstring al inicio del fichero) — la lista de tests escrita antes de los tests, según CLAUDE.md §1.

Los cuatro ficheros deben pasar mypy --strict y el linting de ruff.

Lista de tests (escribe esto PRIMERO, según CLAUDE.md §1)

Antes de cualquier implementación, escribe la lista de tests que pretendes escribir — como comentarios o en un fichero markdown. La lista debe incluir como mínimo:

  • test_train_minimal_corpus — entrena sobre el corpus juguete del lab-00, verifica que los primeros 5 merges casan con la traza hecha a mano.
  • test_encode_decode_roundtrip_ascii — para 20 cadenas ASCII elegidas a mano, decode(encode(s)) == s.
  • test_encode_decode_roundtrip_unicode — UTF-8 multibyte cubriendo acentos españoles ("mañana", "¿cómo estás?") y emojis ("🦊").
  • test_encode_decode_property (hypothesis) — 1000 cadenas utf-8 aleatorias (ASCII + rango español + emoji), ida y vuelta.
  • test_determinism — entrena dos veces con la misma semilla → vocab + merges idénticos (byte-iguales).
  • test_tiebreak — fixture de corpus con un empate conocido en conteo de pares; verifica que se aplica el desempate lex-ascendente.
  • test_save_load_roundtripBPETokenizer.load(t.save(path)).vocab == t.vocab byte-idéntico.
  • test_empty_stringencode("") == [], decode([]) == "".
  • test_single_byteencode("a") devuelve una lista de longitud 1.
  • test_special_tokens_atomic — los tokens especiales nunca se fusionan a través.
  • test_vocab_size_target_reached — entrenar con vocab_size=300 produce exactamente 300 entradas (256 bytes + especiales + merges).
  • test_out_of_bounds_iddecode([99999]) lanza un error claro.
  • test_mypy_strict — implícito (el paso de CI lo captura).

TODOs

Bloque A — src/minitoken/vocab.py

  • Define Vocab como un dataclass congelado con id_to_bytes: tuple[bytes, ...], bytes_to_id: dict[bytes, int], id_to_display: tuple[str, ...].
  • Añade Vocab.save(path) → escribe vocab.json (id ↔ display ↔ hex(bytes) para inspección humana).
  • Añade Vocab.load(path) → al revés.
  • Tests unitarios: save → load → igualdad.

Bloque B — src/minitoken/bpe.py: entrenamiento

  • class BPETokenizer con __init__(self).
  • _reserve_specials_and_bytes(special_tokens) — rellena los IDs 0..|S|-1 + 0..255 de bytes base.
  • _corpus_to_pretokens(corpus) — convierte cada cadena a una tupla de bytes-singletons; deduplica a un Counter.
  • _count_pairs(pretokens) — devuelve dict[(bytes, bytes), int].
  • _pick_winner(pair_counts) — implementa min(..., key=lambda p: (-count, p)) (conteo máximo, luego par lexicográficamente menor). Documenta el desempate.
  • _apply_merge(pretokens, winner) — devuelve nuevo dict de pretokens con el par reemplazado.
  • train(corpus, vocab_size, special_tokens, verbose) — el bucle principal.
  • Registra una barra de progreso si verbose=True.

Bloque C — src/minitoken/bpe.py: encode/decode

  • encode(text: str) -> list[int]:
  • Codifica UTF-8 el texto.
  • Divide en byte-singletons.
  • Por cada merge en orden, reemplaza (a, b) con ab.
  • Mapea símbolos a IDs.
  • decode(ids: list[int]) -> str:
  • Mapea IDs a bytes; concatena.
  • Decodifica UTF-8 (errors='replace').

Bloque D — save/load

  • save(path: Path):
  • vocab.json (vía Vocab.save).
  • merges.txt (un merge por línea, bytes(a).hex() bytes(b).hex()).
  • config.json ({vocab_size, special_tokens, version}).
  • load(path: Path):
  • Lee los tres ficheros.
  • Reconstruye BPETokenizer.

Bloque E — tests (tests/test_bpe.py)

  • Implementa cada test de la lista de arriba.
  • Usa hypothesis para el property test.
  • Todos los tests pasan.
  • pytest --cov src/minitoken ≥ 90%.

Bloque F — comprobación de cordura sobre el corpus juguete

  • Al final de bpe.py o en experiments/11-toy-train/, ejecuta BPETokenizer().train(["I work"]*2 + ["I worked", "he works", "he worked"], vocab_size=261) (256 bytes + 5 merges).
  • Imprime los merges; verifica que casan con tu traza a mano del lab 00 exactamente.

Restricciones

  • Solo Python puro + NumPy. Nada de tiktoken, nada de transformers, nada de sentencepiece — según la regla dura 4 del §0 de CLAUDE.md.
  • mypy --strict limpio.
  • ruff limpio.
  • bandit limpio — sin eval, sin shells de subprocess sin shell=False, etc.
  • Determinismo impuesto vía el fixture de semilla de tests/conftest.py.
  • Sin pre-tokenización regex en v1. Solo bytes en bruto.
  • Sin símbolos multibyte en líneas de merges.txt. Serializa como hex.

Condiciones de parada

Hecho cuando:

  1. Los cuatro ficheros comprometidos.
  2. Todos los tests pasan; pytest -q está verde.
  3. mypy --strict src/minitoken/ limpio.
  4. La traza del corpus juguete del lab 00 se reproduce exactamente vía tu trainer.
  5. pytest -k "property" --hypothesis-seed=42 pasa sobre 1000 entradas utf-8 aleatorias.

Escollos

  • El orden de iteración del hash cambiando tu desempate. Python 3.7+ tiene dicts ordenados por inserción, pero max(dict, key=...) itera en orden de inserción si múltiples claves empatan. Asegúrate de que el desempate sea sobre la tupla valor (count, pair), sin depender del orden de iteración.
  • El bucle de merges es demasiado lento. El re-conteo en cada iteración naive está bien para nuestro corpus de 300 KiB pero es agonizante para tests si subes vocab_size a 16k. Optimiza: actualizaciones incrementales de conteo de pares (resta conteos de los vecinos del par fusionado, suma conteos de los vecinos del nuevo par). Opcional; OK dejarlo para la "cuestión abierta" del BLUEPRINT.
  • La ida y vuelta falla en el byte \xff. Probablemente un problema de codificación en save/load — asegúrate de serializar bytes como hex, no como un repr de Python.
  • hypothesis encuentra un contraejemplo. El más común: un par sustituto Unicode (un code point U+D800–U+DFFF en aislamiento). El UTF-16 válido los tiene en pares; el UTF-8 válido no los permite. Decisión: filtrarlos de la estrategia del property test, o usar hypothesis.strategies.text(alphabet=...) para restringir a UTF-8 válido.
  • Formato de línea merges.txt equivocado. Común: espacios en el hex de bytes rompen el parser. Usa un separador fijo como \t.

Pista de último recurso

Si tras 4 horas el property test de ida y vuelta falla: imprime los primeros 5 fallos que hypothesis encuentra. Lee los bytes. El bug está casi siempre en la codificación (el paso de aplicación de merge), no en la decodificación.

Cuándo consultar solutions/

Después de los cuatro ficheros comprometidos y los tests verdes. Solución: solutions/01-implement-bpe-ref.md (apertura de fase) contiene una implementación de referencia con anotaciones línea por línea.


Siguiente lab: lab/02-bpe-on-verb-corpus.md.