Zeilen oder Spalten?
Eine CSV-Datei oder ein zeilenorientierter Datenspeicher hält jeden Datensatz zusammen: alle Felder von Trade 1, dann von Trade 2 usw. Analysen wollen das selten. Den Durchschnittspreis von zehn Millionen Trades zu berechnen braucht nur die Spalte price, doch ein Zeilenlayout zwingt den Leser, über alle anderen Felder hinwegzusteigen. Ein spaltenorientiertes Layout hält jede Spalte zusammenhängend: Eine Abfrage liest nur die genannten Spalten, ähnliche Werte liegen nebeneinander und komprimieren sehr gut, und Arithmetik über eine Spalte läuft über einen einzigen dichten Speicherblock. Deshalb nutzen quantitative Data Lakes Parquet auf der Platte und Arrow im Speicher.
# 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: Zeilengruppen, Statistiken und Kodierungen
Eine Parquet-Datei teilt die Tabelle in Zeilengruppen (etwa eine Million Zeilen je Gruppe); innerhalb einer Gruppe wird jede Spalte als eigener Block gespeichert, und die Datei vermerkt Minimum und Maximum jedes Blocks. Eine Abfrage für price zwischen 180 und 190 liest zuerst die Statistiken und überspringt jede Zeilengruppe, deren Bereich einen solchen Preis nicht enthalten kann (Predicate Pushdown), und liest nur die angeforderten Spalten (Projection Pushdown). Spalten werden außerdem kodiert: Eine Spalte mit wenigen verschiedenen Werten, etwa das Symbol, wird als kleines Wörterbuch plus einen winzigen Ganzzahlcode je Zeile gespeichert und danach komprimiert. Eine Datei mit sich wiederholenden Marktdaten ist oft zehnmal kleiner als das 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 und weniger lesen
Apache Arrow definiert dasselbe Spaltenlayout für den Speicher, sodass Programme und Sprachen einander eine Tabelle ohne Konvertierung übergeben können: pandas, Polars, DuckDB und Spark sehen dieselbe Arrow-Tabelle ohne Kopie. In der Praxis nutzt man pandas.read_parquet(path, columns=["symbol", "price"], filters=[("price", ">", 180)]), das Spaltenauswahl und Filter in den Reader verlagert, oder pyarrow.parquet.read_table. Schreiben Sie Daten nach einer Spalte wie dem Datum partitioniert (df.to_parquet(path, partition_cols=["date"])), damit eine Abfrage für einen Tag nur ein Verzeichnis öffnet. Halten Sie Zeilengruppen groß genug zum guten Komprimieren, aber klein genug, dass die Statistiken viele überspringen können, und sortieren Sie die Daten nach der Filterspalte, damit jede Gruppe einen engen Bereich abdeckt.
# 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- Spaltenspeicherung hält jede Spalte zusammen: Abfragen lesen nur nötige Spalten, ähnliche Werte komprimieren gut, Spaltenarithmetik ist schnell.
- Parquet teilt Daten in Zeilengruppen mit Min/Max-Statistik und kodiert wiederholte Werte per Wörterbuch; Reader überspringen nicht benötigte Gruppen und Spalten.
- Arrow ist der Speicher-Zwilling, den pandas, Polars und DuckDB teilen; nach Datum partitionieren, nach der Filterspalte sortieren, anhängen statt ändern.
Als Nächstes: Zeitreihendatenbanken, Speicher für zeitgestempelte Daten wie Ticks und Metriken.
Lab: Was bewertet wird
- Mit
to_columnsZeilen in Spalten umwandeln25 Pkt. - Mit
to_rowsdie Zeilen wiederherstellen25 Pkt. - Wiederholungen mit
dictionary_encodeverkleinern25 Pkt. - Mit
groups_to_readüberspringen, was geht25 Pkt.