SenseiTeoría desde cero0/32

5 · Datos y almacenamientoLección 1 de 5

Archivos y formatos: JSON, CSV, Parquet y YAML

Files and formats: JSON, CSV, Parquet and YAML

Cómo se escriben los datos en bytes para que otro programa, otro día, los lea igual: texto o binario, UTF-8, CSV, JSON Lines, YAML, compresión, Parquet por columnas, esquemas que cambian sin romper a nadie y archivos de pesos que no ejecutan nada al abrirse.

⏱ 59 min lo esencial · 72 min todo15 conceptos nuevos2 calculadoras9 ejerciciosDatos
Secciones14
  1. 01Un archivo es una fila de bytes
  2. 02Texto o binario: el mismo número de dos maneras
  3. 03CSV y JSON Lines: tablas y registros en texto
  4. 04YAML: configuración que escriben personas extra
  5. 05Compresión: lo repetido se escribe una vez
  6. 06Por filas o por columnas: Parquet
  7. 07El esquema y cómo cambia
  8. 08Archivos de pesos: pickle y safetensors
  9. 09Errores comunes
  10. 10En la vida real extra
  11. 11Ejercicios
  12. 12Lo que sigue
  13. 13Palabras que vas a oír
  14. 14Chequeo
La pregunta

La foto de Ana, cada predicción y el modelo entrenado terminan en archivos. ¿Cómo se escriben esos bytes para que otro programa los lea igual, y por qué el log de predicciones de C01 va en Parquet y no en CSV?

Ana sube la foto de sus tenis y, en el camino de C01, esa foto deja varios archivos. El JPEG de ≈ 200 KB que mandó la app se guarda. La predicción se escribe como una línea de log (el registro de lo que pasó) de unos 300 bytes. Y el modelo que la contestó vive en un archivo de pesos de ≈ 98 MB que alguien entrenó semanas antes.

Cada archivo lo escribe un programa y lo lee otro, a veces meses después y en otro lenguaje. Para que funcione, los dos tienen que estar de acuerdo, byte por byte, en qué significa cada cosa. Ese acuerdo es el formato.

Al final de la lección vas a poder contestar la pregunta del título con números: los ≈ 3 GB diarios de logs de C01 bajan a unos cientos de MB en Parquet, y la revisión diaria de drift lee apenas una parte de eso.

01

Un archivo es una fila de bytes

Esencial

A file is a row of bytes

En «El sistema operativo» viste que el sistema de archivos organiza el disco en carpetas y archivos, y que a cada archivo se llega por su ruta. Ahora abre uno.

Un archivo (file) es una fila de bytes con nombre: el primero, el segundo, el tercero… hasta el último. Nada más. El sistema operativo sabe cuántos bytes mide, quién es su dueño y cuándo cambió, pero no sabe qué son.

Un byte es un número del 0 al 255, y nada en él dice si es una letra, un pedazo de un píxel o parte de un peso.

El formato es un acuerdo

Un formato de archivo (file format) es un acuerdo entre quien escribe y quien lee: qué va en cada posición, cómo se escribe cada número, dónde empieza y dónde termina cada cosa. El de JPEG dice cómo vienen comprimidos los píxeles; el de JSON, que { abre un objeto. Con el acuerdo, un programa en otro lenguaje lee lo que escribió tu Python. Sin él, los bytes son ruido.

Prueba con cuatro bytes: 48 6F 6C 61. Van en hexadecimal, de 0 a 9 y de A a F, como en «Cómo guarda números una computadora»: 48 es 4 × 16 + 8 = 72 y 6F es 6 × 16 + 15 = 111.

  • Leídos como letras, dicen Hola: en la tabla de letras que verás en la siguiente sección, el 72 es la H.
  • Leídos como un entero de 4 bytes, con el más chico primero (en la siguiente sección verás por qué importa el orden), son 1,634,496,328.
  • Leídos como un float32, son ≈ 2.7 × 10²⁰.

Los mismos bytes, tres respuestas. Las decide el formato, no los bytes.

La extensión no es el formato

El .jpg del final del nombre es la extensión: una pista para que el sistema sepa con qué programa abrir el archivo. Pero es parte del nombre, no del contenido. Si renombras tenis.jpg como tenis.png, ni un byte cambia: sigue siendo un JPEG, ahora con el nombre equivocado.

Por eso muchos formatos empiezan con unos bytes fijos que funcionan como firma: los bytes mágicos (magic bytes). Todo JPEG empieza con FF D8 FF. Todo PNG, con 89 50 4E 47, donde los últimos tres son las letras «PNG». Un programa cuidadoso mira la firma, no la extensión.

Los primeros cuatro bytes de seis archivos, en hexadecimal. tenis.jpg empieza con FF D8 FF E0: es JPEG, y la firma son los tres primeros. logo.png empieza con 89 50 4E 47: es PNG. modelo.pt empieza con 50 4B 03 04, las letras PK: es un zip. logs.gz empieza con 1F 8B: es gzip. dia.parquet empieza con 50 41 52 31, las letras PAR1: es Parquet. Abajo, un archivo llamado tenis.png que empieza con FF D8 FF E0: el nombre dice PNG, pero los bytes dicen JPEG. La extensión es parte del nombre; la firma es parte del contenido. ARCHIVO PRIMEROS 4 BYTES FORMATO tenis.jpg FF D8 FF E0 JPEG logo.png 89 50 4E 47 PNG «‰PNG» modelo.pt 50 4B 03 04 zip «PK» logs.gz 1F 8B 08 00 gzip dia.parquet 50 41 52 31 Parquet «PAR1» tenis.png FF D8 FF E0 ¡JPEG! El nombre dice PNG; los bytes, JPEG. En azul, la firma que reconoce cada formato.
Los primeros cuatro bytes de seis archivos, en hexadecimal. Cada formato tiene su firma. El último archivo se llama .png, pero empieza como un JPEG: la extensión mintió.

Python puede hacer lo mismo: leer los primeros bytes y compararlos con las firmas conocidas. Aquí los archivos viven en la memoria, para no escribir nada en tu disco:

python
import gzip, io, json, zipfile

def adivinar(b):                       # mira los primeros bytes, no el nombre
    if b[:3] == b"\xff\xd8\xff":          return "JPEG"
    if b[:8] == b"\x89PNG\r\n\x1a\n":     return "PNG"
    if b[:2] == b"\x1f\x8b":              return "gzip"
    if b[:4] == b"PK\x03\x04":            return "zip (como un .pt de PyTorch)"
    if b[:4] == b"PAR1":                  return "Parquet"
    if b[:1] in (b"{", b"["):             return "probablemente JSON"
    return "no lo reconozco"

caja = io.BytesIO()
with zipfile.ZipFile(caja, "w") as z:
    z.writestr("modelo/data.pkl", b"...")
archivos = {
    "tenis.jpg": b"\xff\xd8\xff\xe0" + bytes(20),      # el inicio de un JPEG
    "tenis.png": b"\xff\xd8\xff\xe0" + bytes(20),      # ¡también es un JPEG!
    "logo.png": b"\x89PNG\r\n\x1a\n" + bytes(20),
    "logs.gz": gzip.compress(b'{"top1": "Tenis"}\n', mtime=0),
    "modelo.pt": caja.getvalue(),
    "dia.parquet": b"PAR1" + bytes(20),
    "labels.json": json.dumps(["Tenis", "Botas"]).encode(),
}
for nombre, b in archivos.items():
    print(f"{nombre:<12} {b[:4].hex(' '):<12} → {adivinar(b)}")
Salida
tenis.jpg    ff d8 ff e0  → JPEG
tenis.png    ff d8 ff e0  → JPEG
logo.png     89 50 4e 47  → PNG
logs.gz      1f 8b 08 00  → gzip
modelo.pt    50 4b 03 04  → zip (como un .pt de PyTorch)
dia.parquet  50 41 52 31  → Parquet
labels.json  5b 22 54 65  → probablemente JSON

JSON no tiene firma: un { al principio es solo una buena pista. Un .gz es un archivo comprimido con gzip, que verás en la sección de compresión. Y el .pt de PyTorch resulta ser un zip, varios archivos empacados en uno (un zip puede comprimirlos, pero PyTorch guarda los tensores sin comprimir, para leerlos directo); en la sección de pesos verás qué hay adentro.

Esto se usa en la puerta de C01. El gateway acepta JPEG, PNG o WebP (otro formato de imagen, de Google) de hasta 10 MB, y no le cree al nombre del archivo: revisa la firma antes de gastar CPU en decodificar, y si no es de un formato aceptado, contesta 415, el código de estado HTTP de «tipo de contenido no soportado». La Ronda (M01) avisa que llegan de todo: PNG con transparencia, fotos de 40 megapíxeles y archivos corruptos.

Un detalle que aprovechan Parquet y safetensors: un programa no tiene que leer un archivo de principio a fin. Puede pedir «los bytes del 4,000,000 al 4,000,100» (en Python, f.seek(4_000_000) y f.read(100)), y solo esos viajan desde el disco.

02

Texto o binario: el mismo número de dos maneras

Esencial

Text or binary: the same number two ways

Hay dos familias de formatos. En uno de texto, los bytes son letras: abres el archivo con cualquier editor y lo lees. CSV, JSON y YAML son de texto. En un formato binario (binary format), los bytes guardan los números como los guarda la memoria: un entero de 32 bits (un int32) son 4 bytes y un float32 son 4 bytes, sin letras de por medio. Parquet, Avro, safetensors, JPEG y protobuf son binarios.

El número 1,234,567 de dos maneras. Como texto son siete letras, del 1 al 7, y cada una ocupa un byte: 31, 32, 33, 34, 35, 36 y 37 en hexadecimal. Como entero de 32 bits son cuatro bytes, 87, D6, 12 y 00, con el más chico primero, porque 1,234,567 es 0x0012D687. Esos mismos cuatro bytes, leídos como float32, dan 1.73 por 10 a la menos 39: los bytes no cambian, cambia quien los lee. EL NÚMERO 1,234,567 Como texto: 7 letras, 7 bytes 1 2 3 4 5 6 7 31 32 33 34 35 36 37 Abajo, el byte de cada letra, en hexadecimal. Como int32: 4 bytes, el más chico primero 87 D6 12 00 el más chico el más grande 0x0012D687 = 1,234,567 Los mismos 4 bytes como float32: 1.73 × 10⁻³⁹, un número que no tiene nada que ver. Los bytes no cambian; cambia quien lee.
El número 1,234,567 de dos maneras. Como texto, siete letras de un byte. Como int32, cuatro bytes, con el más chico primero. Leídos como float32, esos mismos cuatro bytes dan un número diminuto que no tiene nada que ver.
1,234,567 en texto y en int32La cuenta
Como textouna letra por dígito: «1» es el byte 0x31, «2» el 0x32…
7 dígitos × 1 B
7 bytes
Como int32el número en base 2, en 4 bytes fijos
1,234,567 = 0x0012D687
4 bytes
El orden en que se escribenel byte más chico primero
00 12 D6 87, al revés
87 D6 12 00
Un 7 como textocon números chicos, el texto gana
1 dígito × 1 B
1 byte contra 4
Resultado
El texto crece con los dígitos; el binario mide siempre lo mismo. Un score como 0.8312 son 6 letras en texto y 4 bytes en float32.

Fíjate en el orden: 87 D6 12 00, el byte más chico primero. Se llama little-endian («el extremo chico primero»). Un formato binario tiene que decir en qué orden escribe; si el que lee se equivoca, el 1,234,567 se vuelve −2,016,013,824, sin ningún error.

Para ir más hondoLittle-endian y big-endian

Casi todas las CPUs de hoy, las de Intel, AMD y casi todos los ARM, guardan los números en la memoria con el byte más chico primero: little-endian. El orden contrario, big-endian, el más grande primero, es como escribimos los números las personas y como los mandan muchos protocolos de red, por eso también se le dice «orden de red». Parquet, safetensors y Avro escriben sus números de tamaño fijo en little-endian, así que en una computadora normal sus números se copian tal cual a la memoria, sin darles la vuelta. Los nombres vienen de Los viajes de Gulliver: dos pueblos que se peleaban por cuál extremo del huevo romper primero.

El módulo struct de Python convierte números en bytes y al revés. "<i" es un int32 little-endian y "<f", un float32:

python
import struct

n = 1_234_567
texto = str(n).encode("ascii")              # cada dígito, una letra de 1 byte
int32 = struct.pack("<i", n)                # 4 bytes, el byte chico primero
print("texto  ", len(texto), "bytes:", texto.hex(" "))
print("int32  ", len(int32), "bytes:", int32.hex(" "))
print("de vuelta:", int(texto), "y", struct.unpack("<i", int32)[0])

score = 0.8312
print("'0.8312' ocupa", len(str(score)), "bytes; en float32,", len(struct.pack("<f", score)))
print("el float32 guardó", struct.unpack("<f", struct.pack("<f", score))[0])

# los mismos 4 bytes, leídos con otro formato
print("los bytes del int32 leídos como float32:", struct.unpack("<f", int32)[0])
print("leídos al revés (byte grande primero):", struct.unpack(">i", int32)[0])
Salida
texto   7 bytes: 31 32 33 34 35 36 37
int32   4 bytes: 87 d6 12 00
de vuelta: 1234567 y 1234567
'0.8312' ocupa 6 bytes; en float32, 4
el float32 guardó 0.8312000036239624
los bytes del int32 leídos como float32: 1.7299968412060964e-39
leídos al revés (byte grande primero): -2016013824

Qué gana cada familia

TextoBinario
LeerloCon cualquier editorCon un programa que conozca el formato
Tamaño de un númeroCrece con los dígitosFijo: 4 u 8 bytes
TiposLos adivina quien leeVienen escritos
RapidezHay que convertir letras en númerosSe copian los bytes
Si se daña un byteSe ve y a veces se arregla a manoPuede arruinar el resto

En la lección de APIs viste el caso grande: el tensor de la foto de Ana ocupa 602,112 bytes en float32 y ≈ 3.07 MB como JSON, cinco veces más. Texto para lo chico que leen personas; binario para lo grande que solo leen máquinas.

Las letras también son números: UTF-8

Un archivo de texto también son bytes. ¿Qué byte es cada letra? La tabla clásica es ASCII (1963): 128 caracteres, uno por byte. La A es el 65 (0x41) y el dígito «1» es el 49 (0x31), el de la cuenta de arriba. Pero ASCII nació en inglés: no trae ñ ni acentos.

Unicode le dio después un número a cada carácter de cada idioma. Se escribe con «U+» y el número en hexadecimal: la ñ es el U+00F1 (241) y el tenis 👟, el U+1F45F (128,095). Para escribir esos números en bytes, el formato que ganó es UTF-8 (1992), que usa de 1 a 4 bytes según el carácter:

  • 1 byte para los 128 de ASCII, iguales que antes. Un archivo ASCII ya es UTF-8 válido.
  • 2 bytes para la ñ, los acentos y alfabetos como el griego o el cirílico.
  • 3 bytes para el € y la mayoría de los caracteres chinos y japoneses.
  • 4 bytes para casi todos los emoji. Unos pocos, de los primeros que entraron a Unicode, como el ☕, usan 3.
El texto «Niño» seguido de un espacio y el emoji de un tenis, en UTF-8. Son seis caracteres y diez bytes. N es 4E, i es 69, o es 6F y el espacio es 20: un byte cada uno, como en ASCII. La ñ ocupa dos bytes, C3 y B1. El tenis ocupa cuatro: F0, 9F, 91 y 9F. Abajo, los primeros cinco bytes leídos como Latin-1, que toma cada byte como una letra: 4E es N, 69 es i, C3 es Ã, B1 es ± y 6F es o. Niño se vuelve Niño. «NIÑO 👟»: 6 CARACTERES, 10 BYTES N i ñ o esp. 👟 4E 69 C3 B1 6F 20 F0 9F 91 9F 1 1 2 bytes 1 1 4 bytes LEÍDOS COMO LATIN-1: UN BYTE, UNA LETRA 4E 69 C3 B1 6F N i à ± o Latin-1 no sabe que C3 y B1 van juntos. Niño → Niño Es UTF-8 leído con la tabla equivocada. Los bytes están bien; la lectura, no.
«Niño 👟» en UTF-8: seis caracteres y diez bytes. Abajo, los mismos bytes leídos como Latin-1: cada byte se vuelve una letra, y la ñ sale como «Ã±».

Ojo con len(): en Python cuenta caracteres, y «Niño 👟» da 6. Lo que mide el archivo son sus 10 bytes.

¿Qué pasa si alguien lee esos bytes con la tabla equivocada? Antes de Unicode, cada región usaba su propia tabla de 256 caracteres, uno por byte; la de Europa occidental se llama Latin-1. Latin-1 lee el C3 como «Ã» y el B1 como «±», y «Niño» se vuelve «Niño». Si alguna vez viste «Ã³» donde iba una «ó», ya conoces el problema: es UTF-8 leído como Latin-1. Se le dice mojibake, en japonés «caracteres transformados». Al revés, Latin-1 leído como UTF-8 da error o el símbolo �.

python
for texto in ("A", "ñ", "€", "👟", "niño"):
    b = texto.encode("utf-8")
    print(f"{texto!r:<7} {len(texto)} carácter(es) → {len(b)} bytes: {b.hex(' ')}")

b = "niño".encode("utf-8")
print("con la codificación equivocada (latin-1):", b.decode("latin-1"))
try:
    "niño".encode("latin-1").decode("utf-8")
except UnicodeDecodeError as e:
    print("al revés, Python se queja:", e.reason)
print("¿se arregla?", "niño".encode("latin-1").decode("utf-8"))
Salida
'A'     1 carácter(es) → 1 bytes: 41
'ñ'     1 carácter(es) → 2 bytes: c3 b1
'€'     1 carácter(es) → 3 bytes: e2 82 ac
'👟'     1 carácter(es) → 4 bytes: f0 9f 91 9f
'niño'  4 carácter(es) → 5 bytes: 6e 69 c3 b1 6f
con la codificación equivocada (latin-1): niño
al revés, Python se queja: invalid continuation byte
¿se arregla? niño

La regla práctica: escribe y lee siempre en UTF-8, y dilo explícitamente: open(ruta, encoding="utf-8"). Hoy es el formato de casi toda la web (el 99 % de los sitios, según W3Techs en noviembre de 2026), de JSON (su estándar, el RFC 8259, lo exige entre sistemas) y de Linux. Un RFC es uno de los documentos numerados donde se publican los estándares de internet.

Para ir más hondoAntes de Unicode, una tabla por país

ASCII usaba 7 bits: quedaban libres los valores del 128 al 255 de cada byte. Cada región los llenó a su manera. Latin-1 (ISO 8859-1), la de Europa occidental, puso ahí la ñ (0xF1), la é y la ü. Otras tablas usaron esos mismos bytes para el griego, el cirílico o el hebreo, y Windows tuvo las suyas, como la 1252. El mismo archivo se veía distinto según el país de la computadora, y un texto no podía mezclar ruso con español.

Unicode (1991) separó dos preguntas: qué número tiene cada carácter (en Unicode 17, de septiembre de 2025, son 159,801) y cómo se escribe ese número en bytes. UTF-8 es una respuesta a la segunda; hay otras, como UTF-16, que usa Windows por dentro y JavaScript para sus textos. UTF-8 ganó por un detalle: los primeros bits de cada byte dicen si es un carácter de un byte (0…), el inicio de uno largo (110…, 1110… o 11110…) o una continuación (10…). Así, un programa que cae en medio de un texto encuentra dónde empieza el siguiente carácter, y ningún byte de ASCII aparece nunca dentro de un carácter largo. La ñ, 241 en binario 1111 0001, se reparte así: 11000011 10110001 = C3 B1.

03

CSV y JSON Lines: tablas y registros en texto

Esencial

CSV and JSON Lines: tables and records as text

Los dos formatos de texto con los que más se guardan datos son CSV, para tablas, y JSON Lines, para registros que llegan uno por uno.

CSV: una tabla en texto, simple y frágil

CSV (comma-separated values, valores separados por comas) guarda una tabla así: una fila por línea, los campos separados por comas y, casi siempre, los nombres de las columnas en la primera línea.

text
codigo,titulo,precio
130,Sandalias de playa,350

Cualquiera lo escribe a mano y cualquier programa lo abre, desde Excel hasta una base de datos. Y se rompe en cuanto el dato trae lo que el formato usa para separar:

  1. Una coma dentro de un campo. «Tenis naranjas, talla 26» tiene una coma. La regla es encerrar el campo entre comillas dobles.
  2. Comillas dentro del campo. Botas "de piel" se escribe "Botas ""de piel""": la comilla se duplica.
  3. Un salto de línea dentro del campo. Un título con un enter adentro, entre comillas, ocupa dos líneas del archivo. Un programa que lee línea por línea parte la fila en dos.
  4. Ningún tipo. Todo es texto. Un programa puede leer el código 007 como el número 7, y el cero se pierde. ¿03/04/2026 es 3 de abril o 4 de marzo? ¿Un campo vacío es nada o un cero? El archivo no lo dice.
  5. Nada dice cómo está escrito. Ni la codificación de caracteres ni el separador: en los países donde la coma es el punto decimal, Excel suele usar punto y coma.

Las reglas de las comillas se escribieron en 2005 (el RFC 4180), pero no todos los programas las siguen. Nunca partas un CSV con split(","): usa un lector que conozca las comillas, como el módulo csv de Python.

python
import csv, io

filas = [
    ["codigo", "titulo", "precio"],
    ["007", "Tenis naranjas, talla 26", "899.50"],
    ["012", 'Botas "de piel"', "1200"],
    ["130", "Sandalias\nde playa", "350"],
]
caja = io.StringIO()
csv.writer(caja).writerows(filas)        # el módulo csv pone comillas donde hacen falta
texto = caja.getvalue()
print(texto)

print("partiendo en comas a mano:", texto.splitlines()[1].split(","))
leidas = list(csv.reader(io.StringIO(texto)))
print("con el módulo csv:", leidas[1])
print("filas leídas:", len(leidas), "· la sandalia:", repr(leidas[3][1]))
print("todo es texto:", type(leidas[1][2]).__name__, "· y si lo conviertes:", int(leidas[1][0]))
Salida
codigo,titulo,precio
007,"Tenis naranjas, talla 26",899.50
012,"Botas ""de piel""",1200
130,"Sandalias
de playa",350

partiendo en comas a mano: ['007', '"Tenis naranjas', ' talla 26"', '899.50']
con el módulo csv: ['007', 'Tenis naranjas, talla 26', '899.50']
filas leídas: 4 · la sandalia: 'Sandalias\nde playa'
todo es texto: str · y si lo conviertes: 7

El archivo tiene 5 líneas pero 4 filas, porque la sandalia ocupa dos. Partido a mano, el título de los tenis se vuelve dos campos. Y aun bien leído, todo llega como texto: el 007 sobrevive solo mientras nadie lo convierta.

CSV sirve para tablas chicas, para pasarle datos a una persona con una hoja de cálculo y para exportes de una sola vez. No para millones de filas que un programa lee cada día.

JSON Lines: un registro por línea

JSON lo conociste en la lección de APIs: texto, seis tipos y el nombre de cada campo en cada objeto. Para guardar millones de registros, un solo arreglo [{…}, {…}, …] tiene dos problemas. Agregar un registro obliga a mover el ] del final. Y para leer cualquiera, el lector casi siempre carga el arreglo completo en la memoria; si quedó cortado, no se lee nada.

JSON Lines (también llamado NDJSON; extensión .jsonl) lo arregla con una regla: un valor JSON completo por línea, casi siempre un objeto, sin comas entre líneas ni corchetes alrededor. Su especificación (jsonlines.org) pide además UTF-8 y que cada línea termine con \n. Cada línea es independiente. Eso da tres cosas:

  • Se añade al final. Abrir el archivo en modo «agregar» ("a") y escribir una línea. No hay nada que reescribir.
  • Se lee por partes. Línea por línea, con la memoria de una sola línea. Y un archivo grande se reparte entre varios workers partiéndolo por líneas.
  • Una línea rota es un registro perdido, no el archivo entero.

Lo que no da: un esquema. Nada revisa que score sea siempre un número.

Así escribe C01 su log de predicciones. El gateway imprime una línea JSON por request a su salida estándar (stdout, la salida de texto del programa, la que verías en la terminal), y otro programa, Fluent Bit, la recoge y la manda a S3, el almacén de archivos de AWS, la nube de Amazon (lo verás en «Object storage, data lakes y warehouses»). Son unos 300 bytes; la hora la agrega quien guarda el log. Aquí, partida para que se lea (en el archivo va en una sola línea):

text
{"request_id": "9f2c41d0", "caller": "app-ios",
 "model_version": "resnet50-2026-10-01",
 "top5": [[17, 0.83], [42, 0.06], [3, 0.04], [88, 0.02], [5, 0.01]],
 "ms": {"pre": 9, "triton": 24, "post": 2, "total": 41},
 "bytes": 201344, "sha256": "8f0be2119c3d", "status": 200}

Campo por campo: quién llamó (caller), qué versión del modelo contestó, las cinco categorías más probables con su número y su score (top5), los milisegundos de cada paso (ms), el tamaño de la foto, el principio de su huella digital (sha256, un hash que verás en «Tiempo, hashes e identificadores») y el código de estado HTTP de la respuesta.

Los logs de C01 en JSON LinesLa cuenta
Predicciones al díauna por foto: ≈ 10 M fotos
—
≈ 10,000,000
Bytes por línea
—
≈ 300 B
Al día
10⁷ × 300 B
≈ 3 GB
Con 90 días de retenciónlo que se guarda antes de borrar
3 GB × 90
≈ 270 GB
Al segundo, en el pico de diseño
500 × 300 B
≈ 150 KB/s
Resultado
Escribir no cuesta: 150 KB por segundo no es nada para un disco. Lo caro es guardar 270 GB y leerlos cada día.

Este programa escribe tres líneas, simula que el proceso murió a la mitad de la cuarta y lee el archivo línea por línea:

python
import json, os, tempfile

ruta = os.path.join(tempfile.mkdtemp(), "predicciones.jsonl")
def registrar(evento):                       # abrir en modo "a": se añade al final
    with open(ruta, "a", encoding="utf-8") as f:
        f.write(json.dumps(evento, ensure_ascii=False, separators=(",", ":")) + "\n")

registrar({"request_id": "r-001", "top1": "Tenis", "score": 0.83, "ms": 41})
registrar({"request_id": "r-002", "top1": "Botas", "score": 0.61, "ms": 38})
registrar({"request_id": "r-003", "top1": "Sandalias", "score": 0.9, "ms": 44})
with open(ruta, "a", encoding="utf-8") as f:
    f.write('{"request_id": "r-004", "top1": "Ten')   # el proceso murió a la mitad

buenos, rotos = [], 0
with open(ruta, encoding="utf-8") as f:
    for linea in f:                          # se lee una línea a la vez, no todo el archivo
        try:
            buenos.append(json.loads(linea))
        except json.JSONDecodeError:
            rotos += 1
print("registros buenos:", len(buenos), "· rotos:", rotos)
with open(ruta, "rb") as f:
    print("la primera línea mide", len(f.readline()), "bytes")

arreglo = json.dumps(buenos)                 # lo mismo como un solo arreglo JSON
try:
    json.loads(arreglo[:-10])                # cortado al final
except json.JSONDecodeError as e:
    print("el arreglo cortado no se lee:", e.msg)
Salida
registros buenos: 3 · rotos: 1
la primera línea mide 59 bytes
el arreglo cortado no se lee: Expecting property name enclosed in double quotes

separators=(",", ":") quita los espacios después de cada coma y cada dos puntos, bytes que se repetirían millones de veces. ensure_ascii=False deja la ñ en sus 2 bytes de UTF-8 en vez de escribirla como \u00f1, que son 6.

04

YAML: configuración que escriben personas

Extra

YAML: configuration people write

Hay archivos que no escribe un programa, sino una persona: cuántas réplicas del servidor correr, qué imagen usar, cuándo mandar una alerta. Para eso se usa casi siempre YAML (las letras quieren decir, en broma, YAML Ain't Markup Language, «YAML no es un lenguaje de marcado»). En C01 lo vas a ver en tres lugares:

  • Los manifiestos de Kubernetes, el orquestador que corre los contenedores de C01: en YAML se declara el estado que quieres («2 réplicas de esta imagen, con una GPU cada una») y Kubernetes lo hace realidad.
  • El docker-compose.yml, que levanta juntos los contenedores en una sola máquina para probar.
  • Las reglas de alertas de Prometheus, el sistema que guarda las métricas.

Un pedazo de manifiesto se ve así:

yaml
# Un Deployment de C01, recortado: cuántas copias del gateway correr y con qué imagen
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gateway
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: gateway
          image: registry.example.com/c01/gateway:1.4.2
          env:
            - name: TOP_K
              value: "5"

Se lee casi como una lista del súper, y esa es la idea. Las reglas son pocas:

  • La indentación manda. Lo que está más a la derecha pertenece a lo de arriba. No hay llaves: dos espacios de más o de menos cambian el significado, y los tabuladores no se permiten.
  • nombre: valor es un campo; una línea que empieza con - es un elemento de una lista.
  • # empieza un comentario. JSON no tiene comentarios; YAML sí, y en una configuración importan.
  • YAML acepta JSON: {"replicas": 2} también es YAML válido.
  • --- en su propia línea separa dos documentos dentro del mismo archivo. Así, un solo .yaml puede traer el Deployment de C01 (qué contenedores correr) y su Service (la dirección fija por la que se llega a ellos).
  • | al final de una línea guarda el texto de abajo tal cual, con sus saltos de línea: sirve para meter un script o un archivo de configuración completo dentro del YAML.

¿Por qué no configurar en JSON? Se puede, y algunas herramientas lo aceptan, pero JSON no admite comentarios, pide comillas y llaves en todo, y una coma de más al final rompe el archivo. Y al revés: ¿por qué no guardar datos en YAML? Porque su lector es lento, adivina los tipos y la indentación se rompe al editar a mano un archivo de un millón de líneas. YAML es para lo que escriben y revisan personas, casi siempre guardado en Git, el programa que lleva la historia del código, junto al código.

El problema de Noruega

YAML adivina los tipos, como CSV, pero con más ganas. La versión 1.1 del estándar, que muchos lectores todavía siguen (entre ellos PyYAML, el de Python), trata como verdadero o falso las palabras yes, no, on y off. El código de país de Noruega es NO: una lista de países escrita en YAML convierte a Noruega en false. Por eso se le dice «el problema de Noruega».

No es el único. version: 1.10 se lee como el número 1.1, y hora: 22:30 se lee en PyYAML como 1,350, porque la versión 1.1 entiende los números con dos puntos como de base 60 (22 × 60 + 30). La versión 1.2 (2009) dejó como booleanos solo true y false y quitó la base 60; el 1.10 sigue siendo el número 1.1. Y no todos los lectores la siguen.

La defensa es sencilla: pon entre comillas todo texto que pueda parecer otra cosa. Por eso, en el manifiesto de arriba, value: "5" va entre comillas: las variables de entorno (los ajustes que un proceso recibe al arrancar) tienen que ser texto en Kubernetes, y un 5 suelto sería un número.

PyYAML no viene con Python (se instala con pip install pyyaml), así que esto es una ilustración, sin salida. Lo que imprime está en los comentarios:

python
import yaml

print(yaml.safe_load("pais: NO\nversion: 1.10\nhora: 22:30"))
# {'pais': False, 'version': 1.1, 'hora': 1350}
print(yaml.safe_load('pais: "NO"\nversion: "1.10"\nhora: "22:30"'))
# {'pais': 'NO', 'version': '1.10', 'hora': '22:30'}

Usa siempre safe_load. El load completo de PyYAML, con el cargador que no es seguro, puede crear objetos de Python cualquiera a partir del archivo: el mismo riesgo que vas a ver con pickle en la sección de pesos.

05

Compresión: lo repetido se escribe una vez

Esencial

Compression: write the repeated part once

Los 3 GB diarios de logs de C01 están llenos de repeticiones. Cada línea dice "model_version": "resnet50-2026-10-01", los mismos 38 bytes diez millones de veces. Cada línea repite los nombres de los campos. La compresión (compression) aprovecha eso: escribe los datos con menos bytes y, al leerlos, los reconstruye.

La idea más común, la del algoritmo LZ77 (1977) que usan gzip y zlib, es cambiar lo repetido por una referencia: «aquí van otra vez los 38 bytes que aparecieron hace 300 bytes». Una referencia ocupa 2 o 3 bytes en vez de 38. Después, los símbolos que más aparecen se escriben con menos bits.

Sin pérdida o con pérdida

  • Sin pérdida (lossless): al descomprimir salen exactamente los mismos bytes. gzip, zlib, zstd, snappy y el PNG son así. Es lo único aceptable para un log, un dataset o unos pesos: un byte distinto es un dato equivocado.
  • Con pérdida (lossy): se tira lo que una persona casi no nota. Es el JPEG de la foto de Ana, que descarta detalles finos de los píxeles. Sirve para fotos, audio y video, nunca para datos.

Por qué lo repetido comprime y lo al azar no

Comprimir tiene un límite: no se puede escribir algo con menos bits que la sorpresa que trae. Es la entropía de la lección de distribuciones. Si un campo casi siempre vale lo mismo, trae poca sorpresa por valor y cabe en casi nada. Si cada byte es imposible de adivinar, como un hash (una huella del contenido que parece un número al azar), ningún compresor lo achica. Y comprimir algo ya comprimido, como un JPEG, no gana nada: lo que sale de un buen compresor ya parece al azar.

Este programa arma 10,000 líneas de log de mentiras y comprime tres cosas del mismo tamaño con zlib, que viene con Python. El nivel va del 1 (rápido) al 9 (más chico):

python
import json, random, zlib

random.seed(7)
categorias = ["Tenis", "Botas", "Sandalias", "Pantuflas", "Zapatos de vestir"]
lineas = []
for i in range(10_000):                      # 10,000 líneas de log de mentiras
    lineas.append(json.dumps({"request_id": f"r-{i:06d}", "model_version": "resnet50-2026-10-01",
                              "top1": random.choice(categorias), "score": round(random.random(), 4),
                              "ms": random.randint(25, 90)}))
logs = ("\n".join(lineas) + "\n").encode()

pruebas = {
    "una letra repetida": b"a" * len(logs),
    "logs JSON Lines": logs,
    "bytes al azar": random.randbytes(len(logs)),   # como un JPEG: ya no tiene repeticiones
}
for nombre, datos in pruebas.items():
    for nivel in (1, 9):
        c = zlib.compress(datos, level=nivel)
        print(f"{nombre:<19} nivel {nivel}: {len(datos):>9,} → {len(c):>9,} B  ({len(datos) / len(c):6.1f} veces)")
assert zlib.decompress(zlib.compress(logs)) == logs   # sin pérdida: regresan los mismos bytes
print("bytes por línea:", round(len(logs) / len(lineas)))
Salida
una letra repetida  nivel 1: 1,149,280 →     5,036 B  ( 228.2 veces)
una letra repetida  nivel 9: 1,149,280 →     1,138 B  (1009.9 veces)
logs JSON Lines     nivel 1: 1,149,280 →   115,433 B  (  10.0 veces)
logs JSON Lines     nivel 9: 1,149,280 →    83,178 B  (  13.8 veces)
bytes al azar       nivel 1: 1,149,280 → 1,149,636 B  (   1.0 veces)
bytes al azar       nivel 9: 1,149,280 → 1,149,641 B  (   1.0 veces)
bytes por línea: 115

Tres cosas que notar. Lo repetido desaparece casi por completo. Los bytes al azar crecen un poco: el compresor agrega su encabezado y no encuentra nada que quitar. Y estos logs de mentiras comprimen más que los de verdad: aquí los request_id van en orden (r-000001, r-000002…), y en C01 son al azar, igual que el hash de cada imagen. En la sección de Parquet vas a ver un log más realista.

Para ir más hondoLos compresores que vas a ver
CompresorDe dóndePara qué
gzip y zlibLos dos usan DEFLATE (de PKZIP, 1993; publicado como el RFC 1951 en 1996); vienen con PythonArchivos .gz, HTTP, lo que debe abrir cualquiera
zstd (Zstandard)Facebook, 2016Comprime como gzip o mejor y mucho más rápido; muy usado en Parquet
snappyGoogle, 2011Muy rápido, comprime menos; es el de omisión al escribir Parquet en pyarrow (versión 26), pandas y Spark
lz42011Parecido a snappy: velocidad antes que tamaño

La regla para elegir: si se lee muchas veces y se escribe una, conviene comprimir más (zstd con un nivel alto); si se escribe sin parar y se lee poco, conviene lo rápido (snappy o lz4). Y un JPEG, un MP4 o un zip no se vuelven a comprimir: ya no hay nada que quitar.

¿Cuánto ahorra comprimir los logs de C01? La Ronda estima que en Parquet comprimido ocupan de 5 a 10 veces menos que el JSON crudo. Juega con la cuenta:

Cuánto pesan los logs de prediccionesCalculadora

Arranca con C01: ≈ 10 M predicciones al día, ≈ 300 B por línea de JSON Lines, 90 días de retención y Parquet 5 veces más chico que el JSON crudo.

JSON Lines al día3.00 GB
Parquet al día600 MB
JSON Lines guardado en toda la retención270 GB
Parquet guardado en toda la retención54.0 GB
Lo que se ahorraen almacenamiento, sin contar que cada lectura también baja216 GB
Lo que se escribe por segundo, en promedioescribir nunca es el problema34.7 KB

Con los números de C01, 3 GB al día se vuelven 600 MB, y 270 GB en 90 días se vuelven 54 GB. Sube las líneas a 5,000 B, como un log de depuración, y mira cómo se dispara todo.

Prueba con 5 y con 10 veces: el log de 90 días pasa de 270 GB a entre 54 y 27 GB. Sube la retención a 365 días: en JSON Lines ya pasa de 1 TB.

06

Por filas o por columnas: Parquet

Esencial

Rows or columns: Parquet

Piensa en el log de C01 ya convertido en una tabla: una fila por predicción, una columna por campo. Hay dos maneras de poner esa tabla en una fila de bytes.

  • Por filas (row-oriented): todos los campos de la primera predicción, luego todos los de la segunda, y así. Es lo que hacen CSV y JSON Lines.
  • Por columnas: todos los request_id, luego todos los top1, luego todos los score… Es un formato columnar (columnar format).
Una tabla de cuatro predicciones con cuatro columnas: request_id, top1, score y ms. Por filas, como en CSV o JSON Lines, los 16 valores quedan intercalados: los cuatro de la fila 1, luego los cuatro de la fila 2, y así; para sacar top1 y score hay que leer los 16. Por columnas, como en Parquet, quedan los cuatro request_id juntos, luego los cuatro top1, los cuatro score y los cuatro ms; para sacar top1 y score basta con leer dos tramos seguidos de cuatro valores, 8 de 16, y los otros dos tramos ni se tocan. LA TABLA: 4 PREDICCIONES, 4 COLUMNAS request_id top1 score ms 1 · r-001 Tenis 0.83 41 2 · r-002 Botas 0.61 38 3 · r-003 Tenis 0.90 44 4 · r-004 Sandalias 0.72 52 POR FILAS · CSV, JSON LINES 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 4 Para sacar top1 y score: lees los 16 valores. POR COLUMNAS · PARQUET 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 Para sacar top1 y score: lees 8, en dos tramos seguidos. Los tramos tenues ni se tocan. Con 30 columnas y 2 leídas: ≈ 7 % de los bytes.
La misma tabla de cuatro predicciones y cuatro columnas, escrita por filas y por columnas. Para sacar top1 y score, por filas hay que leer los 16 valores; por columnas, solo dos tramos seguidos de 4.

¿Por qué importa? Cada día, el job de drift de C01 (el programa que revisa si las predicciones de hoy se parecen a las de antes) cuenta cuántas veces salió cada categoría y cómo se reparte el score. Lee dos columnas, top1 y score, de diez millones de filas.

Por filas, para sacar dos campos hay que leer todo, porque están intercalados. Por columnas, cada columna es un tramo seguido del archivo: el programa salta con seek a los dos tramos que necesita y no toca lo demás.

Leer 2 de 30 columnas de un día de logsLa cuenta
Filasun día de C01
—
10,000,000
Columnasuna suposición redonda: el top-5 y las latencias, cada valor en su columna
—
≈ 30
Bytes por valor, en promedio30 × 10 B = los 300 B de la línea
—
≈ 10 B
Por filas, en JSON Lines
10⁷ × 30 × 10 B
3 GB
Por columnas, solo 22 de 30 es ≈ 6.7 %
10⁷ × 2 × 10 B
200 MB
Y comprimidasla Ronda: de 5 a 10 veces; aquí, 5
200 MB ÷ 5
40 MB
Resultado
75 veces menos bytes. Al leer a 500 MB/s, 6 segundos contra 0.08 segundos.

Tres ventajas más de ir por columnas

  1. Comprime mejor. En una columna van juntos valores del mismo tipo y muy parecidos: diez millones de model_version iguales se vuelven casi nada.
  2. Cada columna se escribe en su tipo. Los score van como float32, 4 bytes cada uno, y no como letras.
  3. Se pueden saltar pedazos. Lo ves en un momento, con los row groups.

Este programa lo comprueba con Python puro. Arma 10,000 filas parecidas al log de C01, con ids al azar, y las mide de tres maneras: como JSON Lines, por filas sin los nombres (como un CSV) y por columnas, cada una comprimida aparte con zlib:

python
import json, random, zlib

random.seed(7)
n = 10_000
tabla = {                                    # un log de C01 de mentiras, ya como columnas
    "request_id": [random.randbytes(8).hex() for _ in range(n)],
    "caller": [random.choice(["app-ios", "app-android", "web"]) for _ in range(n)],
    "model_version": ["resnet50-2026-10-01"] * n,
    "top1": [random.randint(0, 499) for _ in range(n)],
    "score": [round(random.betavariate(5, 2), 3) for _ in range(n)],
    "ms": [random.randint(25, 90) for _ in range(n)],
    "bytes": [random.randint(80_000, 400_000) for _ in range(n)],
    "status": [random.choice([200] * 99 + [500]) for _ in range(n)],
}
nombres = list(tabla)
kb = lambda b: f"{len(b) / 1000:,.0f} KB"
z = lambda b: zlib.compress(b, 6)

jsonl = "".join(json.dumps({c: tabla[c][i] for c in nombres}) + "\n" for i in range(n)).encode()
filas = "\n".join(",".join(str(tabla[c][i]) for c in nombres) for i in range(n)).encode()
columnas = {c: "\n".join(map(str, tabla[c])).encode() for c in nombres}
print(f"JSON Lines:   {kb(jsonl):>8} → {kb(z(jsonl)):>6} con zlib")
print(f"por filas:    {kb(filas):>8} → {kb(z(filas)):>6} con zlib")
for c, bloque in columnas.items():
    print(f"   {c:<14} {kb(bloque):>7} → {kb(z(bloque)):>6} ({len(bloque) / len(z(bloque)):5.1f} veces)")
total = sum(len(z(b)) for b in columnas.values())
print(f"por columnas: {total / 1000:,.0f} KB con zlib, sumando las 8")
dos = len(z(columnas["top1"])) + len(z(columnas["score"]))
print(f"para leer top1 y score: {dos / 1000:,.0f} KB por columnas, {kb(z(filas))} por filas")
Salida
JSON Lines:   1,666 KB → 252 KB con zlib
por filas:      686 KB → 220 KB con zlib
   request_id      170 KB →  99 KB (  1.7 veces)
   caller           80 KB →   4 KB ( 19.2 veces)
   model_version   200 KB →   1 KB (378.1 veces)
   top1             38 KB →  15 KB (  2.4 veces)
   score            59 KB →  18 KB (  3.3 veces)
   ms               30 KB →  11 KB (  2.8 veces)
   bytes            69 KB →  32 KB (  2.1 veces)
   status           40 KB →   0 KB (130.7 veces)
por columnas: 180 KB con zlib, sumando las 8
para leer top1 y score: 33 KB por columnas, 220 KB por filas

Del JSON Lines crudo (1,666 KB) a las columnas comprimidas (180 KB) hay ≈ 9 veces: dentro del «de 5 a 10» de la Ronda. El request_id, al azar, apenas baja a la mitad. Y lo más grande no es la compresión: para leer top1 y score bastan 33 KB en vez de 220.

Parquet

Parquet es el formato columnar de casi todo el mundo de los datos: lo leen y lo escriben los programas y servicios que analizan datos, como pandas, Spark, DuckDB, BigQuery, Snowflake y Athena. Un archivo Parquet tiene tres partes:

  • Row groups («grupos de filas»): la tabla partida en bloques de filas seguidas. Dentro de cada uno, cada columna va junta y comprimida aparte.
  • Estadísticas: para cada columna de cada row group, su mínimo, su máximo y cuántos nulos tiene (un nulo, null, es un valor que falta).
  • Un pie (footer) al final del archivo, con el esquema (cada columna, su tipo y si admite nulos), dónde empieza cada row group y las estadísticas. El archivo empieza y termina con los bytes PAR1.

Quien lee empieza por el final: lee el pie, decide qué row groups y qué columnas necesita, y salta directo a esos bytes.

Las estadísticas sirven para las preguntas que filtran. «¿Qué requests tardaron más de 500 ms?» Si un row group dice que su máximo de ms es 210, ahí no hay ninguna: se salta sin leerlo.

Un archivo Parquet con ocho row groups. Para cada uno, el pie guarda el máximo de la columna ms: 180, 210, 950, 190, 205, 230, 175 y 640. La pregunta es qué requests tardaron más de 500 ms. Solo el row group 3, con máximo 950, y el 8, con máximo 640, pueden tener alguna: son los únicos que se leen. Los otros seis tienen su máximo por debajo de 500 y se saltan sin leer un solo dato. Se leen 2 de 8, el 25 por ciento. 8 ROW GROUPS · MÁXIMO DE ms La pregunta: ¿qué requests tardaron más de 500 ms? 500 ms 180 210 950 190 205 230 175 640 1 2 3 4 5 6 7 8 Se lee: su máximo pasa de 500 (2 de 8) Se salta sin leerlo: ahí no puede haber ninguna
Un archivo de ocho row groups, con el máximo de la columna ms de cada uno. Para la pregunta «ms > 500», el lector mira las estadísticas del pie y solo abre los dos row groups cuyo máximo pasa de 500.

Funciona cuando los valores parecidos quedan juntos: si las latencias altas están regadas por todos los row groups, no se salta nada.

El precio de ir por columnas: no se puede añadir una fila suelta. Para escribir un row group hay que juntar antes muchas filas, y cambiar una fila obliga a reescribir el archivo. Por eso C01 no escribe Parquet en cada request: escribe JSON Lines, que se añade al final, y después un proceso junta muchas líneas y las convierte en Parquet.

Para ir más hondoParquet por dentro: páginas, diccionarios y archivos chicos

Parquet nació en 2013, de Twitter y Cloudera, inspirado en un artículo de Google sobre su sistema Dremel, y hoy es un proyecto de la fundación Apache.

Dentro de cada row group, cada columna (su column chunk) se parte en páginas: la documentación de Parquet recomienda 8 KB, y pyarrow usa 1 MB por omisión. Los row groups son mucho más grandes: la misma documentación recomienda de 512 MB a 1 GB, y pyarrow corta uno cada 1,048,576 filas (unos diez para un día de C01). Cada página se codifica antes de comprimirse. Dos codificaciones hacen casi todo el trabajo:

  • Diccionario (dictionary encoding): los valores distintos de la columna se escriben una vez, en una lista, y cada fila guarda solo su posición. Los tres caller («app-ios», «app-android», «web») son 0, 1 y 2, y cada fila necesita 2 bits en vez de 3 a 11 letras.
  • Repeticiones (run-length encoding): «el valor 0, 40,000 veces seguidas» se escribe como un par de números. Diez millones de model_version iguales quedan en unos cuantos bytes.

Después de eso se comprime cada página con snappy, zstd o gzip. Por eso un Parquet real gana más que el zlib de nuestro programa: primero quita lo repetido con conocimiento del tipo, y después comprime.

Una trampa conocida: los archivos chicos. Cada Parquet tiene su pie y cada lectura empieza por pedirlo. Mil archivos de 100 KB son mil pies que leer y mil viajes al almacén; un archivo de 100 MB es uno. Por eso la conversión de C01 junta los logs de muchas horas en pocos archivos grandes por día, y la Ronda (M13) recomienda agrupar en archivos grandes.

El log de una predicción, de JSON Lines a Parquet · Toca cada caja para ver qué formato tiene ahí el log de Ana. «Recorrer» sigue una predicción desde el gateway hasta Parquet, y el otro recorrido, la revisión diaria de drift.
  • Gateway · 1 línea JSON a stdout
    Después de contestarle a Ana, el gateway imprime una línea JSON Lines de ≈ 300 bytes: request_id, model_version, top-5, latencias, bytes de la foto y el principio de su hash. Escribir una línea no hace esperar a nadie.
  • Fluent Bit · junta líneas en lotes
    Un programa que corre en cada máquina: lee lo que los contenedores escriben a stdout, junto con la hora en que el sistema guardó cada línea, y lo manda en lotes. Si se atrasa o se cae, los logs llegan tarde o algunos se pierden, pero ninguna request espera por él.
  • S3: crudo · JSON Lines comprimido
    Los lotes llegan como archivos de JSON Lines comprimidos, por ejemplo con gzip. Sirven para no perder nada, pero así no se consultan: ≈ 3 GB al día sin comprimir, y para leer dos campos hay que leerlo todo.
  • Conversión · JSON Lines → Parquet
    Un job junta muchas líneas, valida el esquema y escribe Parquet: pocos archivos grandes, con row groups y estadísticas. Aquí cada campo recibe su tipo exacto. Fluent Bit también puede escribir Parquet directo en S3, pero saldrían archivos del tamaño de cada lote: chicos.
  • S3: Parquet · por fecha y por modelo
    Los mismos datos, por columnas y comprimidos: de 5 a 10 veces menos que el JSON crudo, según la Ronda. Van en carpetas por fecha y por versión del modelo, una partición, para que nadie abra los días que no necesita.
  • Job de drift · lee top1 y score de un día
    Cada día compara cómo se reparten las categorías y los scores contra los días anteriores. Abre solo la carpeta de ayer y solo dos columnas: ≈ 40 MB en vez de 3 GB.
  • Siguiente dataset · fotos para etiquetar
    Los logs también eligen qué fotos mandar a etiquetar y qué entra al próximo reentrenamiento. Para eso se unen con las etiquetas por el request_id.

Los Parquet de C01 quedan, además, en carpetas por fecha y por versión del modelo: logs/fecha=2026-11-05/model_version=…/. Así, el job de drift ni siquiera abre los archivos de otros días. Es una partición, y la vas a ver a fondo en «Object storage, data lakes y warehouses».

En Python, Parquet se lee con pyarrow (o con pandas, que lo usa por dentro). No viene con Python, así que es una ilustración, sin salida:

python
import pyarrow.parquet as pq

# solo dos columnas y solo las filas con ms > 500: los row groups que no pueden tenerlas ni se leen
tabla = pq.read_table("logs/fecha=2026-11-05/part-0.parquet",
                      columns=["top1", "score"], filters=[("ms", ">", 500)])
print(tabla.num_rows, tabla.schema)
pq.write_table(tabla, "lentas.parquet", compression="zstd")
Cuánto lee una consulta: por filas o por columnasCalculadora

El job de drift de C01 lee 2 de 30 columnas de un día de logs. Por filas hay que leerlo todo; en Parquet, solo esas columnas, comprimidas, y sin los row groups que se pueden saltar.

Por filas (JSON Lines o CSV), lee3.00 GB
En Parquet, lee40.0 MB
Veces menos bytes75.0×
Parte de las columnas que se leen6.7 %
Tiempo de lectura por filas6.00 s
Tiempo de lectura en Parquetmás lo que tarda leer el pie de cada archivo80.0 ms

Con 2 de 30 columnas y compresión de 5, son 75 veces menos bytes: de 6 s a 80 ms. Si la consulta lee las 30 columnas, solo queda la ventaja de la compresión.

Sube las columnas que lee la consulta a 30, como una consulta que pide todas las columnas: la ventaja de las columnas se reduce a la compresión. Y prueba saltarte el 90 % de los row groups.

07

El esquema y cómo cambia

Esencial

The schema and how it changes

Un log de C01 escrito hoy lo va a leer un job de entrenamiento dentro de seis meses. Ese job necesita saber qué columnas hay, de qué tipo es cada una y cuáles pueden faltar. Esa descripción es el esquema de los datos (schema). Aquí va el de un log simplificado, con solo la categoría ganadora y su score en vez del top-5:

ColumnaTipo¿Puede faltar?
request_idtextono
model_versiontextono
top1texto: el nombre de la categoríano
scorefloat32 de 0 a 1no
callertextosí

Es la misma idea del esquema de una API de la lección de APIs, el contrato de cada request y cada respuesta. La diferencia es cuánto dura: una respuesta se lee en milisegundos y se olvida; un archivo se queda años, y lo leen programas que todavía no existen.

¿Quién trae el esquema?

FormatoLo que dice del esquema
CSVSolo los nombres, en la primera línea, si los pusieron. Los tipos los adivina quien lee
JSON LinesLos nombres en cada línea. Tipos a medias: el estándar no distingue entero de decimal ni tiene fechas
ParquetEl esquema completo, con tipos exactos, en el pie de cada archivo
AvroEl esquema completo, escrito en JSON, en el encabezado del archivo o en un registro aparte
protobufEn el archivo .proto, que comparten los dos lados

Por eso un Parquet que recibes de otro equipo «se explica solo»: abres el pie y sabes que score es float32. Un CSV te obliga a preguntar.

La evolución del esquema

Los datos cambian de forma. Un día C01 quiere guardar también qué GPU contestó; otro, alguien quiere llamar confianza al score. Cambiar el esquema de algo que ya está guardado y que otros leen es la evolución del esquema (schema evolution), y la pregunta es siempre la misma: ¿quién se rompe? Hay dos direcciones:

  • Compatible hacia atrás (backward compatible): el lector nuevo puede leer los datos viejos. El job de entrenamiento de hoy lee los logs de hace seis meses.
  • Compatible hacia adelante (forward compatible): el lector viejo puede leer los datos nuevos. El dashboard (el tablero de gráficas) que nadie ha actualizado sigue funcionando con los logs de hoy.
Cambio¿Lector nuevo, datos viejos?¿Lector viejo, datos nuevos?
Agregar un campo opcional, con valor por omisiónSí: usa el valor por omisiónSí: lo ignora
Agregar un campo obligatorioNo: en lo viejo faltaSí: lo ignora
Quitar un campo que tenía valor por omisiónSíSí: usa el valor por omisión
Renombrar un campoNoNo
Cambiar el tipo (de número a texto)NoNo

La regla que sale de la tabla: agregar campos opcionales sí; renombrar o cambiar tipos, no. Si de verdad hace falta un nombre nuevo, se agrega el campo nuevo, se escriben los dos por un tiempo y se quita el viejo cuando nadie lo lee. Es el mismo expand and contract de las APIs.

Este lector aplica un esquema versión 2, que agregó caller como opcional:

python
import json

ESQUEMA_V2 = {                               # campo: (tipo, valor si no viene)
    "request_id": (str, None),               # None: obligatorio
    "top1": (str, None),
    "score": (float, None),
    "caller": (str, "desconocido"),          # nuevo en la v2: opcional, con valor por omisión
}

def leer(linea):
    dato, fila = json.loads(linea), {}
    for campo, (tipo, omision) in ESQUEMA_V2.items():
        if campo not in dato:
            if omision is None:
                raise ValueError(f"falta «{campo}»")
            dato[campo] = omision
        if not isinstance(dato[campo], tipo):
            raise ValueError(f"«{campo}» debería ser {tipo.__name__}")
        fila[campo] = dato[campo]
    return fila

lineas = {
    "v1, vieja": '{"request_id": "r-001", "top1": "Tenis", "score": 0.83}',
    "v2": '{"request_id": "r-002", "top1": "Botas", "score": 0.61, "caller": "web"}',
    "v3, con un campo de más": '{"request_id": "r-003", "top1": "Tenis", "score": 0.9, "caller": "web", "gpu": "t4"}',
    "renombró score": '{"request_id": "r-004", "top1": "Tenis", "confianza": 0.9}',
    "score como texto": '{"request_id": "r-005", "top1": "Tenis", "score": "0.9"}',
}
for nombre, linea in lineas.items():
    try:
        print(f"{nombre:<24} ✓ {leer(linea)}")
    except ValueError as e:
        print(f"{nombre:<24} ✗ {e}")
Salida
v1, vieja                ✓ {'request_id': 'r-001', 'top1': 'Tenis', 'score': 0.83, 'caller': 'desconocido'}
v2                       ✓ {'request_id': 'r-002', 'top1': 'Botas', 'score': 0.61, 'caller': 'web'}
v3, con un campo de más  ✓ {'request_id': 'r-003', 'top1': 'Tenis', 'score': 0.9, 'caller': 'web'}
renombró score           ✗ falta «score»
score como texto         ✗ «score» debería ser float

El lector v2 lee lo viejo (hacia atrás) e ignora el campo de la v3 (hacia adelante). El renombre y el cambio de tipo lo rompen. Mejor que rompa así, con un error claro, que en silencio.

Avro: filas binarias con el esquema aparte

Avro es uno de los formatos binarios por filas más usados para eventos: mensajes que un programa manda uno por uno a otro, como los pagos que entran a un sistema de fraude. Nació en 2009 en el proyecto Apache Hadoop, un sistema para guardar y procesar datos repartidos en muchas máquinas. Tiene dos ideas:

  • El esquema se escribe aparte, en JSON, con el nombre, el tipo y el valor por omisión de cada campo.
  • Los datos no llevan nombres ni marcas: solo los valores, uno tras otro, en el orden del esquema. Es más compacto que JSON y que protobuf, que pone un número de campo delante de cada valor.

El precio: sin el esquema con que se escribió, los bytes no se pueden leer. Avro y Parquet se complementan. Avro, por filas, para escribir eventos uno por uno; Parquet, por columnas, para analizarlos después.

Para ir más hondoAvro por dentro: el esquema, el registro y los bytes

El esquema de una predicción, en Avro:

text
{"type": "record", "name": "Prediccion",
 "fields": [{"name": "request_id", "type": "string"},
            {"name": "score", "type": "float"},
            {"name": "caller", "type": "string", "default": "desconocido"}]}

Un archivo Avro lleva su esquema en el encabezado, una vez, y después miles de registros. Los eventos que viajan uno por uno no pueden cargar el esquema cada vez: llevan al principio un número chico que lo identifica, y los esquemas viven en un registro de esquemas (schema registry), un servicio aparte. En el registro más usado, el de Confluent, cada mensaje empieza con 5 bytes: un byte mágico (0) y 4 bytes con el número del esquema. Quien lee pide el esquema por su número, una vez, y lo guarda. El registro hace algo más valioso: cuando un equipo quiere publicar un esquema nuevo, revisa que sea compatible con el anterior y lo rechaza si no, antes de que salga el primer evento.

Al leer, Avro junta dos esquemas: el del que escribió y el del que lee. Empareja los campos por nombre; si al que lee le falta uno, lo salta, y si el que lee espera uno que no viene, usa su default. Es la tabla de compatibilidad de arriba, hecha regla.

Y los bytes. Cada texto va como su largo seguido de sus bytes de UTF-8, y cada float como sus 4 bytes little-endian. El largo va en un entero de tamaño variable con un truco para los negativos (zigzag): el 5 se escribe como 10 (0x0A), y el 3, como 6. La predicción ("r-001", 0.83, "web") queda así:

ValorBytesCuántos
largo de «r-001»0A1
«r-001»72 2D 30 30 315
0.83E1 7A 54 3F4
largo de «web»061
«web»77 65 623

Son 14 bytes. La misma predicción en JSON, {"request_id": "r-001", "score": 0.83, "caller": "web"}, mide 55: los nombres viajan en cada mensaje.

08

Archivos de pesos: pickle y safetensors

Esencial

Weight files: pickle and safetensors

El último archivo del viaje de Ana es el modelo. Al entrenar, PyTorch guarda un checkpoint: los pesos de cada capa y, si quieres seguir entrenando, el estado del optimizador. Lo normal es guardar el state_dict, un diccionario con un tensor por capa: {"fc.weight": tensor de [500, 2048], "fc.bias": tensor de [500], …}. fc es la última capa de la ResNet-50 (fully connected, «totalmente conectada»), la que convierte 2,048 números en los 500 scores, uno por categoría.

torch.save escribe un zip: por eso el .pt empezaba con PK. Adentro van los bytes de cada tensor y un archivo data.pkl que dice cómo armar el diccionario. Ese archivo es un pickle.

Pickle: un programa disfrazado de datos

Pickle es la forma propia de Python de serializar: guarda casi cualquier objeto, desde un diccionario hasta uno hecho con una clase que tú escribiste (una clase es la plantilla de un tipo de objeto). ¿Cómo puede guardar objetos que no conoce? Porque un pickle no es una tabla de datos: es una lista de instrucciones para una pequeña máquina que los reconstruye, el unpickler. «Pon este texto», «busca la función tal del módulo tal», «llámala con esto».

Y ahí está el problema. Si el archivo dice «busca os.system (la función de Python que corre un comando del sistema, como si lo escribieras en la terminal) y llámala con este comando», la máquina lo hace. Cargar un pickle puede ejecutar cualquier código. La documentación de Python lo advierte: nunca cargues un pickle que no sea de confianza.

Esta demostración es inofensiva: la «trampa» solo llama a print. Pero con la misma receta, un archivo puede llamar a cualquier función:

python
import pickle, pickletools

pesos = {"fc.weight": [0.12, -0.5], "fc.bias": [0.01]}
datos = pickle.dumps(pesos)
print("un diccionario normal:", pickle.loads(datos))

class Sorpresa:                          # cualquiera puede escribir una clase así
    def __reduce__(self):                # «para reconstruirme, llama a esta función»
        return (print, ("¡esto se ejecutó al cargar el archivo!",))

trampa = pickle.dumps({"fc.weight": [0.12, -0.5], "extra": Sorpresa()})
print("el archivo mide", len(trampa), "bytes y nadie ha corrido nada todavía")
resultado = pickle.loads(trampa)         # solo «cargar los pesos»…
print("lo que se cargó:", resultado)
pickletools.dis(pickletools.optimize(pickle.dumps(Sorpresa(), protocol=2)))
Salida
un diccionario normal: {'fc.weight': [0.12, -0.5], 'fc.bias': [0.01]}
el archivo mide 126 bytes y nadie ha corrido nada todavía
¡esto se ejecutó al cargar el archivo!
lo que se cargó: {'fc.weight': [0.12, -0.5], 'extra': None}
    0: \x80 PROTO      2
    2: c    GLOBAL     '__builtin__ print'
   21: X    BINUNICODE '¡esto se ejecutó al cargar el archivo!'
   66: \x85 TUPLE1
   67: R    REDUCE
   68: .    STOP
highest protocol among opcodes = 2

Las últimas líneas son las instrucciones del pickle, traducidas por pickletools: GLOBAL busca la función print, BINUNICODE pone el texto, TUPLE1 lo empaca como sus argumentos y REDUCE la llama. El que carga el archivo no escribió nada de eso.

No es teoría. En 2024, la empresa de seguridad JFrog encontró en Hugging Face, el repositorio público de modelos más grande, archivos de pesos que, al cargarse, abrían una conexión hacia la máquina de un atacante. Y lo mismo vale para los modelos de scikit-learn guardados con pickle o con joblib.

Dos defensas

La primera: torch.load(ruta, weights_only=True). Con esa opción, PyTorch usa una máquina restringida que solo sabe armar tensores y tipos básicos, y se niega a llamar cualquier otra función. Desde PyTorch 2.6 es el comportamiento por omisión; el código viejo y muchos tutoriales cargaban todo. Ayuda mucho, pero sigue siendo un pickle: en abril de 2025 se publicó una manera de saltarla en las versiones hasta la 2.5.1 (la CVE-2025-32434, el número con que quedó registrada en la lista pública de fallas de seguridad; se corrigió en la 2.6). PyTorch viejo, protección vieja.

La segunda, mejor: un formato que no tenga nada que ejecutar. Safetensors, de Hugging Face, es solo esto:

  1. Ocho bytes con el largo del encabezado, un entero little-endian.
  2. El encabezado, en JSON: el nombre de cada tensor, su dtype, su shape y dónde empiezan y terminan sus bytes.
  3. Los bytes de todos los tensores, uno tras otro.

Leerlo es json.loads y copiar bytes: nada se ejecuta. Además, como el encabezado dice dónde está cada tensor, se puede abrir con mmap y leer solo los que hacen falta, como viste en «Memoria, caché y disco». Desde fines de 2023, la librería transformers de Hugging Face guarda los modelos en safetensors por omisión, y es el formato de casi todos los modelos de su Hub. Este programa escribe uno chiquito y lo lee con lo que viene con Python:

python
import json, struct

def escribir_safetensors(tensores):          # {nombre: (dtype, shape, lista de floats)}
    encabezado, datos, inicio = {}, b"", 0
    for nombre, (dtype, shape, valores) in tensores.items():
        crudo = struct.pack(f"<{len(valores)}f", *valores)
        encabezado[nombre] = {"dtype": dtype, "shape": shape, "data_offsets": [inicio, inicio + len(crudo)]}
        datos += crudo
        inicio += len(crudo)
    h = json.dumps(encabezado, separators=(",", ":")).encode()
    return struct.pack("<Q", len(h)) + h + datos   # 8 bytes con el largo, el JSON y los números

archivo = escribir_safetensors({"fc.bias": ("F32", [3], [0.01, -0.2, 0.5]),
                                "fc.weight": ("F32", [3, 2], [0.12, -0.5, 0.3, 0.0, 1.5, -1.0])})
largo = struct.unpack("<Q", archivo[:8])[0]
print("primeros 8 bytes:", archivo[:8].hex(" "), "→ el encabezado mide", largo, "bytes")
encabezado = json.loads(archivo[8:8 + largo])   # solo json.loads: nada que ejecutar
print(encabezado["fc.weight"])
a, b = encabezado["fc.weight"]["data_offsets"]
inicio = 8 + largo
pesos = struct.unpack("<6f", archivo[inicio + a:inicio + b])
print("fc.weight:", [round(x, 2) for x in pesos], "· archivo de", len(archivo), "bytes")
Salida
primeros 8 bytes: 7e 00 00 00 00 00 00 00 → el encabezado mide 126 bytes
{'dtype': 'F32', 'shape': [3, 2], 'data_offsets': [12, 36]}
fc.weight: [0.12, -0.5, 0.3, 0.0, 1.5, -1.0] · archivo de 170 bytes

El 0x7E es 126. La librería real, además, rellena el encabezado con espacios hasta un múltiplo de 8; el formato lo permite y no cambia nada más. Los data_offsets cuentan desde donde termina el encabezado: fc.weight va después de los 12 bytes de fc.bias (3 números × 4 bytes).

Cargar unos pesos: un .pt contra un safetensors · Dos maneras de cargar unos pesos. Toca cada caja; «Recorrer» sigue un .pt con trampa y un safetensors.
  • modelo.pt · zip: data.pkl + tensores
    Lo que escribe torch.save: un zip con los bytes de cada tensor y un pickle, data.pkl, que dice cómo armar el diccionario. Empieza con los bytes PK.
  • Unpickler · sigue las instrucciones
    La máquina que reconstruye el objeto. Sigue las instrucciones del pickle: «busca esta función», «llámala con esto». Si el archivo pide os.system, lo llama.
  • weights_only=True · solo tensores y tipos básicos
    Una lista de lo permitido: tensores, números, textos, listas y diccionarios. Si el pickle pide otra función, la carga se detiene con un error. Es el comportamiento por omisión desde PyTorch 2.6.
  • modelo.safetensors · 8 B + encabezado JSON + bytes
    Ocho bytes con el largo del encabezado, el encabezado en JSON (nombre, dtype, shape y posición de cada tensor) y los bytes de los tensores, uno tras otro.
  • Leer el encabezado · json.loads, nada que ejecutar
    Se leen 8 bytes, después el JSON. Un JSON solo trae datos: no hay manera de pedir que se llame una función.
  • mmap · los bytes, sin copiarlos
    Con las posiciones del encabezado, el archivo se abre con mmap: los tensores aparecen en la memoria y el sistema lee del disco solo lo que se toca.
  • Pesos en memoria · state_dict listo
    El diccionario con un tensor por capa, listo para load_state_dict. Por los dos caminos llegan los mismos números; lo que cambia es qué más pudo pasar al cargar.

Con PyTorch y la librería safetensors instalados, el cambio son dos líneas. Es una ilustración, sin salida:

python
import torch
from safetensors.torch import save_file, load_file

torch.save(modelo.state_dict(), "modelo.pt")                  # un zip con un pickle adentro
estado = torch.load("modelo.pt", map_location="cpu", weights_only=True)
save_file(modelo.state_dict(), "modelo.safetensors")          # encabezado JSON y bytes
modelo.load_state_dict(load_file("modelo.safetensors", device="cpu"))

Y una costumbre que vale para cualquier formato: guarda el hash de cada archivo de pesos en el registro de modelos (el catálogo de las versiones del modelo) y compruébalo antes de cargarlo. Un hash criptográfico es una huella del contenido que cambia si cambia un solo byte; lo vas a ver en «Tiempo, hashes e identificadores».

En su primera versión, el MVP de la Ronda, C01 cargaba el .pt del notebook al arrancar. La versión final ya no: lo exporta a ONNX, un formato para correr el modelo fuera de PyTorch, y de ahí arma un engine de TensorRT compilado para su GPU. Esos dos son de la estación de exportar del Dojo, y ninguno usa pickle.

Para ir más hondoOtros archivos de pesos que vas a encontrar
  • .pth y .bin: los mismos zip con pickle de PyTorch, con otra extensión. Muchos modelos viejos de Hugging Face vienen como pytorch_model.bin.
  • .pkl y .joblib: modelos de scikit-learn, XGBoost o LightGBM guardados con pickle. El mismo riesgo.
  • .h5 y .keras: los de Keras. El .h5 es HDF5, un formato binario para datos científicos con carpetas por dentro; el .keras de Keras 3 es un zip con la configuración en JSON y los pesos en HDF5.
  • GGUF: el de llama.cpp, para correr LLMs en una laptop: un solo archivo con los pesos, casi siempre cuantizados (guardados en menos bits, como 4 u 8 por número), y la información para usarlos.
  • .onnx y .plan: el ONNX es un mensaje de protobuf con el grafo y los pesos; el .plan es el engine de TensorRT, atado a un tipo de GPU y a una versión de TensorRT.

La pregunta para cada uno es la misma: ¿al abrirlo se ejecuta algo? Si sí, solo lo abres si sabes de dónde viene.

09

Errores comunes

Esencial

Common mistakes

1. Creerle a la extensión

Un .jpg que en realidad es un HEIC (el formato de fotos del iPhone), un .csv separado con punto y coma, un .json que es JSON Lines. El programa truena lejos del problema, o no truena y lee basura.

Cómo evitarlo: revisar los bytes mágicos en la puerta (como el gateway de C01) y leer con el lector del formato real. En los datos, declarar el formato y el separador por escrito.

2. Leer con la codificación equivocada

«Niño» en un reporte, «Ã³» en las categorías. Casi siempre es UTF-8 leído como Latin-1 o como la tabla de Windows, muy a menudo al abrir un CSV con doble clic en Excel.

Cómo evitarlo: encoding="utf-8" en cada open, al leer y al escribir. Si ya ves «Ã±», no lo arregles a mano: vuelve a leer el archivo con la codificación correcta.

3. Confiar en los tipos de un CSV

El código 007 se vuelve 7, un código postal de la Ciudad de México como el 06600 pierde su cero, una fecha se voltea, un id largo se redondea. Nadie avisa.

Cómo evitarlo: leer todo como texto y convertir a propósito, con el tipo que dice el contrato. Para datos que se quedan, Parquet: el tipo va escrito.

4. Cargar un pickle ajeno

Bajar un modelo_final.pt de un foro y hacer torch.load(ruta) en una versión vieja de PyTorch es correr el código de un desconocido en tu máquina, con tus permisos.

Cómo evitarlo: pesos en safetensors siempre que se pueda; si no, weights_only=True con PyTorch al día. Fuente conocida, versión fijada y hash comprobado antes de cargar.

5. Dejar que YAML adivine

NO se vuelve false, 1.10 se vuelve 1.1, un puerto como 22:22 se vuelve un número de base 60. El manifiesto «se ve bien» y hace otra cosa.

Cómo evitarlo: comillas en todo texto que pueda parecer número, fecha o booleano, safe_load siempre, y un validador del esquema (en Kubernetes, uno que revise los manifiestos antes de aplicarlos).

6. Renombrar una columna «porque se lee mejor»

Cambiar score por confianza en el log rompe a la vez el job de drift, el dashboard y el entrenamiento del mes que viene, y a veces en silencio.

Cómo evitarlo: solo agregar campos opcionales; para renombrar, escribir los dos campos un tiempo y quitar el viejo cuando nadie lo lea. Validar el esquema al escribir y al leer.

10

En la vida real

Extra

In real life

C01: un formato para cada cosa

QuéFormatoPor qué ese
La foto de AnaJPEG (o PNG o WebP), tal como llegóYa viene comprimida; volver a comprimirla no gana nada
Cada predicciónJSON Lines → Parquet por fecha y model_versionSe añade línea por línea; se consulta por columnas
Las 500 categoríaslabels.json, guardado junto al modeloChico, lo leen personas, va con la versión del modelo
El manifest de cada datasetParquet, una fila por imagen2 M filas: hash, ruta, etiqueta, fuente y fecha
Manifiestos, compose y alertasYAMLLos escriben y revisan personas
Los pesos.pt al entrenar → ONNX → engine de TensorRTEl .pt es un pickle: nunca sale del pipeline propio

El manifest de un dataset (nada que ver con los manifiestos de Kubernetes) es su lista de imágenes: una fila por foto, con su hash, dónde está guardada y su etiqueta. Es un buen ejemplo de por qué no se copian las fotos. Según la Ronda (M09), cada fila mide ≈ 200 B sin comprimir: 2 M filas son ≈ 400 MB, contra ≈ 400 GB de las fotos que referencia. Un dataset nuevo cuesta el 0.1 % de copiar las imágenes. Y la estación 1 del Dojo, «Datos y etiquetas», lee los logs de C01 tal como quedan: data/logs/fecha=AAAA-MM-DD/part-0.parquet, con DuckDB, un programa que hace consultas en SQL (el lenguaje de las bases de datos) directo sobre archivos Parquet.

C09: los pagos como eventos en Avro

El sistema de fraude de C09 recibe ≈ 10 millones de pagos al día. Cada pago entra como un evento de ≈ 1 KB en Avro a un stream de Kafka (el sistema por donde viajan sus eventos) llamado txn-events: ≈ 10 GB al día. Avro tiene sentido ahí: los eventos se escriben uno por uno, ocupan poco y el registro de esquemas impide que un equipo publique un evento con un campo renombrado que rompería al modelo. Lo vas a ver en «Colas y streams».

C07: 100 millones de embeddings en Parquet

C07 guarda cada versión de sus embeddings en Parquet: 100 M vectores de 512 números en float32 son 100 M × 512 × 4 B ≈ 205 GB. En JSON, con los ≈ 20.4 bytes por número que mediste en la lección de APIs, serían ≈ 1 TB, cinco veces más. Y comprimirlos ayuda poco: los últimos bits de cada float son casi al azar.

Los LLMs: safetensors partidos en pedazos

Los modelos de lenguaje abiertos se publican en safetensors, partidos en varios archivos de unos cuantos GB (shards) con un JSON índice que dice en cuál va cada tensor. vLLM, el programa que sirve el LLM de C10, los abre con mmap, y la Ronda le pide cargar solo safetensors: un .bin de PyTorch es un pickle.

Fuera de ML: genes, hojas de cálculo y ceros

En 2016, un estudio revisó artículos de genética con listas de genes en hojas de Excel y encontró errores en cerca de una quinta parte: Excel convertía nombres como SEPT2 o MARCH1 en fechas. En 2020, el comité que nombra los genes humanos les cambió el nombre (SEPTIN2, MARCHF1) para que Excel los dejara en paz. Es el error 3 a escala mundial: un formato sin tipos y un programa que adivina.

En octubre de 2020, Inglaterra dejó de reportar 15,841 casos positivos de COVID, los de las pruebas del 25 de septiembre al 2 de octubre. Los laboratorios mandaban sus resultados en CSV, y un paso del proceso los pasaba al formato viejo de Excel, .xls, que admite 65,536 filas por hoja. Lo que no cabía se cortó sin aviso. 65,536 es 2¹⁶: el tope de un entero de 16 bits sin signo, como viste en «Cómo guarda números una computadora».

Y en México, muchos códigos postales de la Ciudad de México empiezan con 0, del 01000 al 09999, como el 06600 de la colonia Juárez. Una columna de códigos postales leída como número los deja en 6600, un código de cuatro dígitos que no existe. Es el 007 de la sección de CSV, con direcciones reales.

11

Ejercicios

Esencial

Exercises

Resuélvelos con lápiz antes de abrir la solución. Los de logs y consultas se pueden comprobar con las calculadoras.

  1. 1Fácil

    ¿Cuántos bytes ocupan «año» y «Café ☕» en UTF-8? ¿Qué te da len() en Python para cada uno?

    Ver la solución paso a paso
    • «año»: a (1) + ñ (2) + o (1) = 4 bytes. len("año") da 3: cuenta caracteres.
    • «Café ☕»: C, a, f (1 cada una) + é (2) + espacio (1) + ☕ (3: es el U+2615, de los de 3 bytes) = 3 + 2 + 1 + 3 = 9 bytes. len() da 6.
    • Para saber lo que mide en el archivo: len(texto.encode("utf-8")).
  2. 2Fácil

    Te dan cuatro archivos sin extensión. Empiezan con 89 50 4E 47, 50 4B 03 04, 1F 8B y 50 41 52 31. ¿Qué es cada uno?

    Ver la solución paso a paso
    • 89 50 4E 47: PNG (las últimas tres son «PNG»).
    • 50 4B 03 04: zip («PK»). Puede ser un .pt de PyTorch, pero también un .docx o un .xlsx: hay que abrirlo para saber.
    • 1F 8B: gzip, por ejemplo un .jsonl.gz de logs.
    • 50 41 52 31: «PAR1», Parquet.
  3. 3Fácil

    La línea de CSV 012,"Botas, negras",1200 ¿cuántos campos tiene? ¿Qué sale si la partes con split(",")? ¿Y si un programa convierte la primera columna en número?

    Ver la solución paso a paso
    • 3 campos: 012, Botas, negras y 1200. Las comillas protegen la coma.
    • Con split(",") salen 4 pedazos: 012, "Botas, negras" y 1200.
    • Convertida en número, la primera columna vale 12: el cero se perdió.
  4. 4Medio

    C01 guarda 10 M líneas de log al día, de ≈ 300 B, durante 90 días. ¿Cuánto ocupa en JSON Lines? Si en Parquet ocupa 6 veces menos, ¿cuánto se ahorra?

    Ver la solución paso a paso
    • Al día: 10⁷ × 300 B = 3 GB. En 90 días: 3 × 90 = 270 GB.
    • En Parquet: 3 GB ÷ 6 = 0.5 GB al día, y 0.5 × 90 = 45 GB.
    • Ahorro: 270 − 45 = 225 GB. Y cada lectura del job de drift también baja, porque lee solo sus columnas.
  5. 5Medio

    Una tabla de 10 M filas tiene 30 columnas de ≈ 10 bytes cada una. Una consulta lee 3 columnas. ¿Cuántos bytes lee por filas y cuántos en Parquet, si comprime 5 veces? ¿Cuántas veces menos?

    Ver la solución paso a paso
    • Por filas, todo: 10⁷ × 30 × 10 B = 3 GB.
    • En Parquet: 10⁷ × 3 × 10 B = 300 MB, y ÷ 5 = 60 MB.
    • 3,000 MB ÷ 60 MB = 50 veces menos.
  6. 6Medio

    Un Parquet tiene 8 row groups. El máximo de ms en cada uno es 180, 210, 950, 190, 205, 230, 175 y 640. Para «ms > 500», ¿cuántos se leen? ¿Y para «ms < 20», si el mínimo de todos es 25 o más?

    Ver la solución paso a paso
    • «ms > 500»: solo los que tienen máximo arriba de 500, el de 950 y el de 640: 2 de 8, el 25 %.
    • «ms < 20»: si ningún row group tiene mínimo menor que 20, ninguno puede tener una fila así: 0 de 8. La respuesta sale del pie, sin leer datos.
    • Ojo: un row group que se lee no garantiza filas que cumplan. Las estadísticas solo descartan.
  7. 7Medio

    El log de C01 está en la versión 1: request_id, top1 y score. Clasifica estos cambios: (a) agregar caller opcional, con «desconocido» por omisión; (b) renombrar score a confianza; (c) cambiar top1 del nombre de la categoría a su número; (d) agregar gpu obligatorio.

    Ver la solución paso a paso
    • (a) Compatible en las dos direcciones: el lector nuevo usa «desconocido» en lo viejo y el viejo ignora el campo nuevo.
    • (b) Rompe las dos: el lector viejo no encuentra score y el nuevo no encuentra confianza en lo viejo.
    • (c) Rompe las dos: cambió el tipo, y además el significado.
    • (d) Hacia adelante sí, hacia atrás no: el lector viejo lo ignora, pero el nuevo no lo encuentra en los logs viejos.
  8. 8Reto

    Un safetensors empieza con B4 00 00 00 00 00 00 00. ¿Cuánto mide el encabezado? Si dice que fc.weight es F32 de shape [500, 2048] y fc.bias es F32 de shape [500], ¿cuántos bytes miden los datos y el archivo completo? ¿En qué byte del archivo empieza fc.bias, si va después de fc.weight?

    Ver la solución paso a paso
    • El largo es un entero little-endian: el byte más chico es B4 y los demás valen 0. 0xB4 = 11 × 16 + 4 = 180 bytes.
    • fc.weight: 500 × 2048 × 4 B = 4,096,000 bytes. fc.bias: 500 × 4 B = 2,000 bytes. Datos: 4,098,000.
    • Archivo: 8 + 180 + 4,098,000 = 4,098,188 bytes.
    • fc.bias empieza en el offset 4,096,000 de los datos, que en el archivo es 8 + 180 + 4,096,000 = byte 4,096,188. Con seek se salta directo ahí.
  9. 9Reto

    Un compañero te pasa resnet_final.pt, que bajó de un foro, y te pide que lo cargues en el servidor de C01 «para probar». ¿Qué haces, y en qué orden?

    Ver la solución paso a paso
    • No lo cargas en el servidor. Un .pt es un zip con un pickle: cargarlo puede ejecutar cualquier código con los permisos del servidor.
    • Primero, de dónde viene: si existe una versión publicada por una organización conocida, de preferencia en safetensors, se usa esa, en una revisión fijada, y se guarda su hash.
    • Si solo existe el .pt, se abre en una máquina aislada, sin credenciales ni red, con PyTorch al día y weights_only=True. Si la carga se niega, es una alarma: el archivo pide algo más que tensores.
    • Si carga bien, se guarda el state_dict como safetensors, se registra su hash y, como cualquier modelo nuevo, pasa la evaluación antes de acercarse a producción.
12

Lo que sigue

Esencial

What comes next

En la Ronda:

  • M01 · El mapa y la entrevista trae, entre sus estimaciones exprés, el log de una predicción en JSON (≈ 300 B) y avisa que en Parquet comprimido ocupa menos.
  • M09 · Datos y features guarda el manifest de cada dataset en Parquet, valida los esquemas con un contrato ejecutable (tipos, rangos, nulos, frescura) y particiona por fecha en el data lake.
  • M12 · Monitoreo recorre el log de predicciones: JSON a stdout, Fluent Bit, S3 y Parquet particionado por fecha y model_version, para que el job de drift lea solo un día.
  • M15 · Seguridad y confiabilidad explica por qué un .pt es código, weights_only y safetensors.

En el Dojo: la estación 1, «Datos y etiquetas», ya trabaja con los logs de C01 en Parquet, particionados por fecha, y los consulta con DuckDB. La estación 5, «Export, engine y paridad», convierte los pesos a ONNX y a un engine.

En Sensei: las siguientes lecciones de esta fase usan estos archivos. «Bases de datos» guarda filas que se cambian una por una, lo que Parquet no hace bien. «Object storage, data lakes y warehouses» pone los Parquet en S3 y los particiona. «Colas y streams» lleva los eventos Avro por Kafka. Y «Tiempo, hashes e identificadores» explica la hora que el log no trae y el hash que identifica cada foto.

13

Palabras que vas a oír

Esencial

Words you will hear

Estos son los conceptos que presentó esta lección. Cada uno tiene su tarjeta con la definición completa.

ArchivofileUna fila de bytes con nombre, guardada en un disco. El sistema operativo sabe cuánto mide, de quién es y cuándo cambió, pero no qué significan sus bytes: eso lo dice el formato.Más

El sistema de archivos guarda cada archivo en el disco y lo encuentra por su ruta. Por dentro es solo una secuencia de bytes, numerados desde el 0. Un programa puede leerlo completo, de principio a fin, o saltar a cualquier posición (seek) y leer solo un pedazo: formatos como Parquet y safetensors aprovechan eso. También se le puede añadir al final sin reescribir lo anterior, que es lo que hace un log en JSON Lines. Qué significa cada byte lo decide el formato.

Ejemplo. Los bytes 48 6F 6C 61 son «Hola» leídos como letras, 1,634,496,328 leídos como un entero de 4 bytes y ≈ 2.7 × 10²⁰ leídos como float32. El archivo es el mismo; cambia quien lo lee.

Ver en el glosario →

Formato de archivofile formatEl acuerdo entre quien escribe y quien lee sobre qué significa cada byte de un archivo: qué va en cada posición, cómo se escriben los números y dónde empieza y termina cada cosa.Más

Sin el formato, los bytes de un archivo son ruido. Los hay de texto (CSV, JSON, YAML) y binarios (Parquet, Avro, safetensors, JPEG). La extensión del nombre (.jpg) es solo una pista: no cambia los bytes. Muchos formatos empiezan con una firma fija, los bytes mágicos: FF D8 FF en un JPEG, 89 50 4E 47 en un PNG, PK en un zip, PAR1 en un Parquet. Un programa cuidadoso revisa la firma en vez de creerle al nombre.

Analogía. Como el idioma de una carta: las mismas letras dicen otra cosa en español y en portugués, y quien lee tiene que saber en cuál está escrita. Se rompe en que una carta casi siempre se entiende a medias aunque te equivoques de idioma; un archivo binario leído con el formato equivocado da números que no tienen nada que ver, sin ningún aviso.

Ejemplo. Si renombras tenis.jpg como tenis.png, sigue empezando con FF D8 FF: es un JPEG con el nombre equivocado. El gateway de C01 revisa esa firma antes de decodificar.

Ver en el glosario →

Formato binariobinary formatUn formato de archivo que guarda los números como los guarda la memoria (un float32 en 4 bytes, un int32 en 4) en vez de escribirlos como letras. Es compacto y rápido, pero no se lee con un editor de texto.Más

En un formato de texto, el 1,234,567 son siete letras, 7 bytes; en uno binario, un int32 de 4 bytes, siempre. El binario tiene el tipo escrito, se copia sin convertir y no pierde cifras, pero hace falta un programa que conozca el formato, y un byte dañado puede arruinar el resto. Tiene que decir en qué orden escribe los bytes de cada número: casi todos usan little-endian, el más chico primero. No confundir con el sistema binario: todo archivo está hecho de bits, también uno de texto. Son binarios Parquet, Avro, protobuf, safetensors y JPEG.

Ejemplo. El tensor de la foto de Ana ocupa 602,112 bytes en float32 binario y ≈ 3.07 MB escrito como JSON: cinco veces más.

Ver en el glosario →

UTF-8UTF-8La forma más usada de convertir texto en bytes. Cada carácter ocupa de 1 a 4 bytes: las letras de ASCII 1, la ñ y los acentos 2, el € 3 y casi todos los emoji 4.Más

Unicode le da un número a cada carácter de cada idioma (la ñ es el U+00F1); UTF-8 dice cómo escribir ese número en bytes. Un texto en ASCII ya es UTF-8 válido. Los primeros bits de cada byte dicen si empieza un carácter o lo continúa, así que un programa no se pierde a la mitad de un texto. Leer UTF-8 con otra tabla, como Latin-1, da el mojibake «Ã±» en lugar de «ñ». Por eso se escribe y se lee siempre con encoding="utf-8". Es la codificación de casi toda la web, de JSON y de Linux. No es la codificación de features.

Ejemplo. «Niño 👟» son 6 caracteres y 10 bytes: 4E 69 C3 B1 6F 20 F0 9F 91 9F. len() de Python da 6; el archivo mide 10.

Ver en el glosario →

CSVcomma-separated valuesUn formato de texto para tablas: una fila por línea, los campos separados por comas y, casi siempre, los nombres de las columnas en la primera línea. Simple, universal y frágil.Más

Lo abre cualquier programa, desde Excel hasta una base de datos. Se rompe con lo que el dato comparte con el formato: una coma o un salto de línea dentro de un campo (la regla es encerrarlo entre comillas y duplicar las comillas de adentro), un separador distinto (punto y coma en los países con coma decimal) o una codificación distinta de UTF-8. Y no trae tipos: todo es texto, y quien lee adivina. Un código 007 se vuelve 7, una fecha se voltea. Nunca se parte con split(","): se lee con un lector que entienda las comillas, como el módulo csv de Python. Sirve para tablas chicas y para personas; para millones de filas, Parquet.

Ejemplo. La fila 007,"Tenis naranjas, talla 26",899.50 tiene 3 campos; partida con split(",") salen 4, y convertida a números el código queda en 7.

Ver en el glosario →

JSON LinesJSON Lines · NDJSON · .jsonlUn archivo de texto con un valor JSON completo por línea (casi siempre un objeto), sin comas entre líneas ni corchetes alrededor. Se le añaden registros al final y se lee línea por línea: es el formato típico de los logs.Más

Un arreglo JSON gigante obliga a reescribir el final para agregar algo y a cargarlo completo para leerlo; si queda cortado, no se lee nada. En JSON Lines cada línea es independiente: se agrega con el archivo abierto en modo «añadir», se lee con la memoria de una línea, se reparte entre varios workers por líneas y una línea rota pierde un solo registro. No trae esquema: cada línea puede tener campos distintos. Por eso los logs se escriben en JSON Lines y, después, en lotes, se convierten a Parquet para consultarlos.

Ejemplo. El gateway de C01 escribe una línea de ≈ 300 bytes por request a stdout: 10 M al día son ≈ 3 GB, y con 90 días de retención, ≈ 270 GB.

Ver en el glosario →

YAMLYAMLUn formato de texto para configuración que escriben y revisan personas: campos nombre: valor, listas con guiones, comentarios con # y la estructura marcada con la indentación.Más

Es el formato de los manifiestos de Kubernetes, de docker-compose y de las reglas de alertas de Prometheus. Acepta JSON como caso particular y, a diferencia de JSON, admite comentarios. Su riesgo es que adivina los tipos: en la versión 1.1, que siguen muchos lectores como PyYAML, NO, yes y off son booleanos (el «problema de Noruega»), 1.10 es el número 1.1 y 22:30 es un número en base 60. La defensa es poner entre comillas todo texto que pueda parecer otra cosa y cargar con safe_load. Los espacios de la indentación son parte del significado; los tabuladores no se permiten.

Ejemplo. pais: NO se lee como {'pais': False}; pais: "NO", como {'pais': 'NO'}. Por eso las variables de entorno de un manifiesto van entre comillas: value: "5".

Ver en el glosario →

ParquetApache ParquetEl formato columnar más usado para guardar tablas grandes: guarda cada columna junta, en su tipo y comprimida, con su esquema y estadísticas en un pie al final del archivo. Es lo que leen pandas, Spark, DuckDB o Athena.Más

Un archivo Parquet parte la tabla en row groups; dentro de cada uno, cada columna va junta, codificada (con diccionario o contando repeticiones) y comprimida con snappy, zstd o gzip. El pie trae el esquema con tipos exactos, dónde empieza cada row group y, por columna, el mínimo, el máximo y los nulos. Quien lee empieza por el pie, elige columnas y row groups y salta solo a esos bytes. No sirve para añadir filas sueltas ni para cambiar una fila: se escribe en lotes. Empieza y termina con los bytes PAR1. Suele guardarse en carpetas por fecha, una partición, en un data lake.

Ejemplo. Un día de logs de C01: 3 GB en JSON Lines, de 300 a 600 MB en Parquet. El job de drift lee 2 de 30 columnas: ≈ 40 MB.

Ver en el glosario →

Formato columnarcolumnar format · column-orientedUn formato que guarda una tabla columna por columna (todos los valores de la primera, luego todos los de la segunda) en vez de fila por fila. Así una consulta lee solo las columnas que usa, y todo comprime mejor.Más

Por filas, como en CSV o JSON Lines, los campos de cada registro van intercalados, y para sacar dos hay que leer todos. Por columnas, cada columna es un tramo seguido del archivo: se salta a los que hacen falta. Además, los valores parecidos quedan juntos y del mismo tipo, así que comprimen mucho más (compresión), y se pueden guardar estadísticas por bloque para saltarse los que no sirven. El precio: escribir una fila suelta o cambiar una es caro. Por eso lo columnar es para analizar (muchas filas, pocas columnas) y lo de filas, para escribir registro por registro. El ejemplo principal es Parquet.

Ejemplo. Una tabla de 10 M filas y 30 columnas de 10 B: una consulta que lee 2 columnas lee 3 GB por filas y 200 MB por columnas, 40 MB si además comprime 5 veces.

Ver en el glosario →

CompresióncompressionEscribir los mismos datos con menos bytes, cambiando lo repetido por referencias cortas y lo frecuente por códigos chicos. Sin pérdida, al descomprimir salen los mismos bytes exactos; con pérdida, como en JPEG, se tira lo que casi no se nota.Más

Los compresores sin pérdida (gzip y zlib, que vienen con Python, zstd, snappy, lz4) son los únicos aceptables para logs, datasets y pesos: un byte distinto es un dato equivocado. Los de pérdida, como el de JPEG, son para fotos, audio y video. Comprimir tiene un límite: la entropía de los datos. Lo repetido casi desaparece; lo que parece al azar, como un hash o un archivo ya comprimido, no se achica y hasta crece un poco. Cada compresor elige un punto entre tamaño y velocidad: comprimir más cuesta más CPU. En un formato columnar, cada columna se comprime aparte y gana mucho más.

Ejemplo. 10,000 líneas de log de mentiras (1,149,280 bytes) quedan en 83,178 con zlib nivel 9, 13.8 veces menos; la misma cantidad de bytes al azar crece a 1,149,641.

Ver en el glosario →

Esquema de datosschema · data schemaLa descripción de cómo son unos datos guardados: qué columnas o campos tienen, de qué tipo es cada uno y cuáles pueden faltar. Es el contrato entre quien escribe y quien lee, a veces con años de diferencia.Más

Es la misma idea del esquema de una API, pero para datos que se quedan: un archivo lo pueden leer programas que todavía no existen. Cada formato trae más o menos de él: CSV solo los nombres; JSON Lines, nombres y tipos a medias; Parquet, el esquema completo en el pie de cada archivo; Avro, escrito en JSON aparte; protobuf, en el .proto. Validarlo al escribir y al leer atrapa columnas que faltan o cambian de tipo. Pero revisa la forma, no el significado: un cambio de centímetros a milímetros pasa cualquier esquema. Cómo cambia sin romper a nadie es la evolución del esquema.

Ejemplo. El log de C01: request_id texto, model_version texto, top1 texto, score float32 de 0 a 1, todos obligatorios, y caller texto opcional.

Ver en el glosario →

Evolución del esquemaschema evolutionCambiar el esquema de unos datos que ya están guardados y que otros leen, sin romperlos. Agregar un campo opcional con valor por omisión no rompe a nadie; renombrar un campo o cambiarle el tipo, sí.Más

Hay dos preguntas. Compatible hacia atrás: ¿el lector nuevo lee los datos viejos? Compatible hacia adelante: ¿el lector viejo lee los datos nuevos? Un campo opcional con valor por omisión cumple las dos: el nuevo lo rellena en lo viejo y el viejo lo ignora. Un campo obligatorio nuevo rompe hacia atrás. Renombrar o cambiar el tipo rompe las dos. Para renombrar se escribe el campo nuevo junto al viejo un tiempo y se quita el viejo cuando nadie lo lee, como el expand and contract del versionado de una API. Formatos como Avro y Parquet guardan el esquema con los datos, y un registro de esquemas puede rechazar un cambio incompatible antes de publicarlo.

Ejemplo. La v2 del log de C01 agrega caller con «desconocido» por omisión: el job de drift nuevo lee los logs de hace seis meses, y el dashboard viejo sigue funcionando. Renombrar score a confianza los rompería a los dos.

Ver en el glosario →

AvroApache AvroUn formato binario por filas para eventos, con el esquema escrito en JSON aparte de los datos. Los registros llevan solo los valores, sin nombres ni marcas, así que son muy compactos; para leerlos hace falta el esquema con que se escribieron.Más

Nació en 2009 en el proyecto Apache Hadoop. Un archivo Avro lleva su esquema una vez, en el encabezado; los eventos sueltos, como los que viajan por un stream de Kafka, llevan solo un número que lo identifica, y los esquemas viven en un registro de esquemas que rechaza los cambios incompatibles. Al leer, Avro empareja el esquema del que escribió con el del que lee por nombre de campo y usa los valores por omisión: así hace la evolución del esquema. Comparado con protobuf, no pone un número de campo delante de cada valor. Comparado con Parquet, va por filas: Avro para escribir eventos uno por uno, Parquet para analizarlos.

Ejemplo. La predicción ("r-001", 0.83, "web") ocupa 14 bytes en Avro y 55 en JSON. C09 manda cada pago como un evento Avro de ≈ 1 KB: ≈ 10 GB al día.

Ver en el glosario →

PicklepickleLa forma propia de Python de guardar casi cualquier objeto en bytes. Un pickle no es una tabla de datos sino una lista de instrucciones para reconstruir el objeto, y por eso cargarlo puede ejecutar cualquier código.Más

Para reconstruir objetos que no conoce, el pickle trae instrucciones como «busca esta función de este módulo» y «llámala con estos argumentos». Si el archivo pide os.system, la máquina que lo carga lo llama. La documentación de Python advierte que nunca se cargue un pickle que no sea de confianza. El .pt de PyTorch es un zip con un pickle adentro (y también los .pth y .bin); lo mismo los modelos de scikit-learn guardados con pickle o joblib. Defensas: torch.load(..., weights_only=True), por omisión desde PyTorch 2.6, que solo permite tensores y tipos básicos; guardar los pesos en safetensors; y comprobar el hash de cada archivo antes de cargarlo.

Analogía. Como un mueble que llega desarmado con un instructivo, pero el instructivo lo arma una máquina que obedece cualquier paso, incluido «y ahora abre la puerta de la casa». Se rompe en que un instructivo de verdad lo lee una persona que se daría cuenta; la máquina de pickle no revisa nada.

Ejemplo. Una clase con __reduce__ que devuelve (print, ("¡esto se ejecutó!",)) imprime el mensaje en el momento de pickle.loads, sin que quien carga haya escrito ese código.

Ver en el glosario →

SafetensorssafetensorsUn formato para guardar los pesos de un modelo que no puede ejecutar nada al abrirse: 8 bytes con el largo de un encabezado JSON, el encabezado (nombre, tipo de número, forma y posición de cada tensor) y los bytes de los tensores.Más

Lo creó Hugging Face; desde fines de 2023 es el formato en que su librería transformers guarda los modelos por omisión, y el de casi todos los modelos de su Hub. Leerlo es leer 8 bytes, hacer json.loads del encabezado y copiar bytes: no hay instrucciones que seguir, a diferencia de pickle. Como el encabezado dice dónde está cada tensor, se puede abrir con mmap y leer solo los tensores que se usan, sin copiar el archivo a la memoria del programa. Los modelos grandes se parten en varios archivos (shards) con un JSON índice. Para servir, muchas veces los pesos se convierten además a ONNX o a un engine de TensorRT.

Ejemplo. La capa final de la ResNet-50 de C01, fc.weight en F32 de [500, 2048], ocupa 500 × 2048 × 4 = 4,096,000 bytes dentro del archivo, y el encabezado dice en qué byte empieza.

Ver en el glosario →

14

Chequeo

Esencial

Check

Intenta responder en tu cabeza antes de abrir cada respuesta.

  1. 1Si renombras tenis.jpg como tenis.png, ¿qué cambia en el archivo?Ver respuesta

    Nada: la extensión es parte del nombre. Los bytes siguen empezando con FF D8 FF, la firma de JPEG. Un programa cuidadoso mira esa firma.

  2. 2¿Cuántos bytes ocupa la ñ en UTF-8, y qué sale si alguien la lee como Latin-1?Ver respuesta

    Dos: C3 B1. Latin-1 lee cada byte como una letra y sale «Ã±»: «niño» se vuelve «niño».

  3. 3Da tres maneras en que un CSV cambia datos sin avisar.Ver respuesta

    Un campo con coma o salto de línea partido a mano; un 007 que se vuelve 7; una fecha que se voltea.

  4. 4¿Por qué C01 escribe sus logs en JSON Lines y no directamente en Parquet?Ver respuesta

    JSON Lines se añade línea por línea, y una línea rota pierde solo un registro. Parquet necesita juntar muchas filas para cada row group. Se escribe en JSON Lines y se convierte a Parquet en lotes.

  5. 5¿Por qué el job de drift lee mucho menos en Parquet que en JSON Lines?Ver respuesta

    Lee solo sus columnas (2 de 30), comprimidas y en su tipo, y se salta row groups y particiones. En el ejemplo de un día de C01, 40 MB en vez de 3 GB.

  6. 6¿Qué cambios de esquema no rompen a nadie?Ver respuesta

    Agregar un campo opcional con valor por omisión: el lector nuevo lo rellena en lo viejo y el viejo lo ignora. Renombrar o cambiar el tipo rompe a los dos lados.

  7. 7¿Por qué safetensors es más seguro que un .pt?Ver respuesta

    Un .pt trae un pickle: instrucciones que pueden llamar cualquier función al cargarse. Safetensors es un largo, un encabezado JSON y bytes; leerlo no ejecuta nada.

Sigue · 28Bases de datos: tablas, SQL y llave-valorDatos y almacenamiento Léelo en la RondaM01 · El mapa y la entrevistaLo que acabas de leer, con el ojo del entrevistador. ← 26 · Qué tan rápido y cuánto: latencia y throughput