¿Filas o columnas?
Un archivo CSV o una base orientada a filas guarda cada registro junto: todos los campos de la operación 1, luego los de la 2, etc. El análisis rara vez lo quiere. Calcular el precio medio de diez millones de operaciones solo necesita la columna price, pero una disposición por filas obliga al lector a saltar todos los demás campos. Una disposición columnar mantiene cada columna contigua: una consulta lee solo las columnas que nombra, los valores parecidos quedan juntos y se comprimen muy bien, y la aritmética sobre una columna recorre un único bloque compacto de memoria. Por eso los lagos de datos cuantitativos usan Parquet en disco y Arrow en memoria.
# The same table as rows and as columns
rows = [{"symbol": "AAPL", "price": 184.25, "qty": 100},
{"symbol": "MSFT", "price": 410.5, "qty": 50},
{"symbol": "AAPL", "price": 184.5, "qty": 25}]
columns = {name: [row[name] for row in rows] for name in rows[0]}
print(columns) # {'symbol': ['AAPL', 'MSFT', 'AAPL'], 'price': [184.25, 410.5, 184.5], 'qty': [100, 50, 25]}
print(sum(columns["price"]) / len(rows)) # 259.75 the average only touched one column
back = [{name: columns[name][i] for name in columns} for i in range(len(rows))]
print(back == rows) # True nothing is lost either wayParquet: grupos de filas, estadísticas y codificaciones
Un archivo Parquet divide la tabla en grupos de filas (digamos un millón de filas cada uno); dentro de un grupo cada columna se guarda como un fragmento propio y el archivo registra el mínimo y el máximo de cada fragmento. Una consulta de price entre 180 y 190 lee primero las estadísticas y salta todo grupo cuyo rango no puede contener tal precio (predicate pushdown), y lee solo las columnas pedidas (projection pushdown). Las columnas también se codifican: una columna con pocos valores distintos, como el símbolo, se guarda como un pequeño diccionario más un diminuto código entero por fila, y el resultado se comprime. Un archivo de datos de mercado repetitivos suele ser diez veces menor que el CSV.
# Dictionary encoding turns a few distinct values into a small dictionary plus integer codes
def dictionary_encode(values):
positions, dictionary, codes = {}, [], []
for value in values:
if value not in positions:
positions[value] = len(dictionary)
dictionary.append(value)
codes.append(positions[value])
return dictionary, codes
symbols = ["AAPL", "MSFT", "AAPL", "AAPL", "MSFT", "NVDA"] * 1000
dictionary, codes = dictionary_encode(symbols)
print(dictionary, codes[:6]) # ['AAPL', 'MSFT', 'NVDA'] [0, 1, 0, 0, 1, 2]
print([dictionary[c] for c in codes] == symbols) # True decoding restores the column
print(len(dictionary), "distinct values for", len(symbols), "rows") # 3 distinct values for 6000 rows# Statistics let a reader skip whole row groups without opening them
def groups_to_read(stats, low, high):
return [i for i, (minimum, maximum) in enumerate(stats) if maximum >= low and minimum <= high]
stats = [(180.0, 183.5), (183.0, 186.0), (185.5, 190.0), (189.0, 195.0)] # (min, max) of price per row group
print(groups_to_read(stats, 184.0, 185.0)) # [1] only the second group can hold a price in this range
print(groups_to_read(stats, 189.5, 192.0)) # [2, 3]
print(groups_to_read(stats, 200.0, 210.0)) # [] no group needs to be read at allArrow, pandas y leer menos
Apache Arrow define la misma disposición columnar para la memoria, de modo que programas y lenguajes pueden pasarse una tabla sin convertirla: pandas, Polars, DuckDB y Spark pueden ver una misma tabla Arrow sin copia. En la práctica se usa pandas.read_parquet(path, columns=["symbol", "price"], filters=[("price", ">", 180)]), que empuja la elección de columnas y el filtro al lector, o pyarrow.parquet.read_table. Escribe los datos particionados por una columna como la fecha (df.to_parquet(path, partition_cols=["date"])) para que una consulta de un día abra un solo directorio. Mantén grupos de filas lo bastante grandes para comprimir bien pero lo bastante pequeños para que las estadísticas descarten muchos, y ordena los datos por la columna de filtro para que cada grupo cubra un rango estrecho.
# Sorting by the filter column makes the row groups cover narrow, non-overlapping ranges
import random
rng = random.Random(7)
prices = [round(rng.uniform(100, 200), 2) for _ in range(10_000)]
def group_stats(values, size):
groups = [values[i : i + size] for i in range(0, len(values), size)]
return [(min(g), max(g)) for g in groups]
def groups_to_read(stats, low, high):
return [i for i, (a, b) in enumerate(stats) if b >= low and a <= high]
unsorted_groups = len(groups_to_read(group_stats(prices, 1000), 150.0, 151.0))
sorted_groups = len(groups_to_read(group_stats(sorted(prices), 1000), 150.0, 151.0))
print(unsorted_groups, sorted_groups) # 10 1 without sorting every group's range overlaps the query- El almacenamiento columnar mantiene cada columna junta: las consultas leen solo las columnas necesarias, los valores parecidos comprimen bien y la aritmética por columna es rápida.
- Parquet divide los datos en grupos de filas con estadísticas mín/máx y codifica con diccionario los valores repetidos; los lectores saltan los grupos y columnas que no necesitan.
- Arrow es el gemelo en memoria que comparten pandas, Polars y DuckDB; particiona por fecha, ordena por la columna de filtro y añade en vez de editar.
A continuación: bases de datos de series temporales, almacenes pensados para datos con marca de tiempo como ticks y métricas.
Lab: qué se califica
- Convertir filas en columnas con
to_columns25 pts - Reconstruir las filas con
to_rows25 pts - Reducir repeticiones con
dictionary_encode25 pts - Saltar lo que se pueda con
groups_to_read25 pts