Lignes ou colonnes ?
Un fichier CSV ou une base orientée lignes garde chaque enregistrement ensemble : tous les champs de la transaction 1, puis de la transaction 2, etc. L'analyse le veut rarement. Calculer le prix moyen de dix millions de transactions n'a besoin que de la colonne price, mais une disposition par lignes oblige le lecteur à enjamber tous les autres champs. Une disposition en colonnes garde chaque colonne contiguë : une requête ne lit que les colonnes qu'elle nomme, les valeurs semblables sont côte à côte et se compressent très bien, et l'arithmétique sur une colonne parcourt un seul bloc compact de mémoire. C'est pourquoi les lacs de données quantitatifs utilisent Parquet sur disque et Arrow en mémoire.
# 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 : groupes de lignes, statistiques et encodages
Un fichier Parquet découpe la table en groupes de lignes (disons un million de lignes chacun) ; dans un groupe, chaque colonne est stockée comme un morceau à part, et le fichier enregistre le minimum et le maximum de chaque morceau. Une requête sur price entre 180 et 190 lit d'abord les statistiques et ignore tout groupe dont l'intervalle ne peut pas contenir un tel prix (pushdown de prédicat), et ne lit que les colonnes demandées (pushdown de projection). Les colonnes sont aussi encodées : une colonne à peu de valeurs distinctes, comme le symbole, est stockée comme un petit dictionnaire plus un minuscule code entier par ligne, puis le résultat est compressé. Un fichier de données de marché répétitives est souvent dix fois plus petit que le 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 et lire moins
Apache Arrow définit la même disposition en colonnes pour la mémoire, si bien que programmes et langages peuvent se passer une table sans la convertir : pandas, Polars, DuckDB et Spark peuvent tous voir une même table Arrow sans copie. En pratique on utilise pandas.read_parquet(path, columns=["symbol", "price"], filters=[("price", ">", 180)]), qui pousse le choix des colonnes et le filtre dans le lecteur, ou pyarrow.parquet.read_table. Écrivez les données partitionnées par une colonne comme la date (df.to_parquet(path, partition_cols=["date"])) pour qu'une requête sur un jour n'ouvre qu'un répertoire. Gardez des groupes de lignes assez grands pour bien compresser mais assez petits pour que les statistiques en éliminent beaucoup, et triez les données selon la colonne de filtre pour que chaque groupe couvre un intervalle étroit.
# 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- Le stockage en colonnes garde chaque colonne ensemble : les requêtes ne lisent que les colonnes utiles, les valeurs semblables se compressent bien et l'arithmétique par colonne est rapide.
- Parquet découpe les données en groupes de lignes avec statistiques min/max et encode par dictionnaire les valeurs répétées ; les lecteurs ignorent les groupes et colonnes inutiles.
- Arrow est le jumeau en mémoire partagé par pandas, Polars et DuckDB ; partitionnez par date, triez selon la colonne de filtre, ajoutez au lieu de modifier.
À suivre : les bases de données de séries temporelles, des stockages conçus pour les données horodatées comme les ticks et les métriques.
Lab : ce qui est noté
- Transformer des lignes en colonnes avec
to_columns25 pts - Reconstruire les lignes avec
to_rows25 pts - Réduire les répétitions avec
dictionary_encode25 pts - Ignorer ce qui peut l'être avec
groups_to_read25 pts