PR507406 — Benchmark de formats de dades
Tipus: Pràctica tècnica de programació (benchmark amb Python) Durada estimada: 7 hores (2 sessions: una de 3h i la sessió S28 de 3h, més treball autònom) Lliurament: Campus Virtual — Script(s) Python o notebook + taula de resultats + informe breu
Objectius
Al finalitzar aquesta pràctica, l'alumne serà capaç de:
- Generar un dataset de prova d'1 milió de files amb Python, amb un esquema realista i tipus de dades variats.
- Escriure i llegir el dataset en formats CSV, JSON, Parquet i Avro, usant les biblioteques estàndard de l'ecosistema (pandas, pyarrow, fastavro).
- Mesurar i comparar la mida de fitxer, el temps d'escriptura i el temps de lectura per a cada format, amb
time.perf_counter(). - Demostrar el predicate pushdown i el column pruning de Parquet amb pyarrow, quantificant la millora de rendiment respecte a una lectura completa.
- Crear una taula Delta Lake bàsica amb diverses versions i demostrar el time travel, consultant l'estat de la taula en versions anteriors.
- Argumentar, a partir de dades empíriques pròpies, en quins escenaris cal triar cada format de dades.
Materials necessaris
- Python 3.10 o superior
- Biblioteques:
pandas,pyarrow,fastavro,deltalake(paquetdelta-rsper a Python; alternativa:delta-sparksi es treballa amb PySpark),faker(onumpyper a generació sintètica) - Jupyter Notebook o script
.py(a elecció de l'alumne) - Almenys 4 GB de RAM lliure i 2 GB d'espai en disc
- Continguts del Bloc 6 com a referència teòrica: Formats tabulars, Formats columnar, Formats d'evolució
Instal·lació de les dependències:
Sobre l'entorn de treball
Es recomana crear un entorn virtual (venv o conda) específic per a aquesta pràctica, ja que deltalake i pyarrow tenen dependències natives que poden entrar en conflicte amb altres projectes.
Descripció de la pràctica
Part 1 — Generació del dataset (1 hora)
Genera un dataset sintètic d'1 milió de files que simuli transaccions de venda d'un e-commerce, amb un esquema mínim de 8-10 columnes que combini tipus numèrics, categòrics, text i dates:
| Columna | Tipus | Descripció |
|---|---|---|
id_transaccio |
enter | Identificador únic seqüencial |
data |
data | Data de la transacció (darrers 2 anys) |
id_client |
enter | Identificador de client (cardinalitat alta) |
producte |
text | Nom del producte |
categoria |
categòric | 8-10 categories (Electrònica, Llar, Moda...) |
quantitat |
enter | Unitats venudes (1-10) |
preu_unitari |
decimal | Preu en euros |
moneda |
categòric | EUR, USD, GBP |
pais |
categòric | Codi de país de l'enviament |
estat |
categòric | PENDENT, ENVIADA, COMPLETADA, CANCEL·LADA |
Usa Faker per a noms de productes i dades realistes, o numpy/random per a generació vectoritzada (molt més ràpida per a 1M de files):
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
N = 1_000_000
rng = np.random.default_rng(seed=42)
categories = ['Electronica', 'Llar', 'Moda', 'Esport', 'Llibres',
'Joguines', 'Alimentacio', 'Bellesa']
monedes = ['EUR', 'USD', 'GBP']
paisos = ['ES', 'FR', 'DE', 'IT', 'PT', 'UK']
estats = ['PENDENT', 'ENVIADA', 'COMPLETADA', 'CANCEL·LADA']
data_inici = datetime(2024, 1, 1)
dies_aleatoris = rng.integers(0, 730, size=N)
df = pd.DataFrame({
'id_transaccio': np.arange(1, N + 1),
'data': [data_inici + timedelta(days=int(d)) for d in dies_aleatoris],
'id_client': rng.integers(1, 200_000, size=N),
'producte': rng.choice([f'Producte_{i}' for i in range(500)], size=N),
'categoria': rng.choice(categories, size=N),
'quantitat': rng.integers(1, 11, size=N),
'preu_unitari': np.round(rng.uniform(5, 500, size=N), 2),
'moneda': rng.choice(monedes, size=N, p=[0.6, 0.25, 0.15]),
'pais': rng.choice(paisos, size=N),
'estat': rng.choice(estats, size=N, p=[0.1, 0.2, 0.65, 0.05]),
})
print(df.shape)
print(df.dtypes)
print(df.head())
Generació vectoritzada vs Faker fila a fila
Generar 1 milió de files amb Faker cridant fake.name() un milió de cops en un bucle pot trigar diversos minuts. Si uses Faker, genera un catàleg petit de valors (per exemple, 500-1000 noms de producte) i selecciona'n amb numpy.random.choice, com es fa a l'exemple anterior. La generació vectoritzada amb numpy és l'opció recomanada per a 1M de files.
Part 2 — Escriptura i lectura en els quatre formats (2,5 hores)
Per a cada format, escriu el DataFrame a disc, mesura el temps d'escriptura amb time.perf_counter(), mesura la mida del fitxer resultant amb os.path.getsize(), i mesura el temps de lectura completa.
import time
import os
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import fastavro
resultats = []
def mesura(format_nom, funcio_escriptura, funcio_lectura, ruta):
t0 = time.perf_counter()
funcio_escriptura()
t_escriptura = time.perf_counter() - t0
mida = os.path.getsize(ruta) / (1024 ** 2) # MB
t0 = time.perf_counter()
funcio_lectura()
t_lectura = time.perf_counter() - t0
resultats.append({
'format': format_nom,
'mida_mb': round(mida, 1),
'temps_escriptura_s': round(t_escriptura, 2),
'temps_lectura_s': round(t_lectura, 2),
})
# --- CSV ---
mesura(
'CSV',
lambda: df.to_csv('dataset.csv', index=False),
lambda: pd.read_csv('dataset.csv'),
'dataset.csv',
)
# --- JSON (NDJSON, una línia per registre) ---
mesura(
'JSON (NDJSON)',
lambda: df.to_json('dataset.ndjson', orient='records', lines=True, date_format='iso'),
lambda: pd.read_json('dataset.ndjson', lines=True),
'dataset.ndjson',
)
# --- Parquet (compressio Snappy) ---
mesura(
'Parquet',
lambda: df.to_parquet('dataset.parquet', engine='pyarrow', compression='snappy', index=False),
lambda: pd.read_parquet('dataset.parquet', engine='pyarrow'),
'dataset.parquet',
)
# --- Avro ---
esquema_avro = {
'type': 'record',
'name': 'Transaccio',
'fields': [
{'name': 'id_transaccio', 'type': 'long'},
{'name': 'id_client', 'type': 'long'},
{'name': 'producte', 'type': 'string'},
{'name': 'categoria', 'type': 'string'},
{'name': 'quantitat', 'type': 'int'},
{'name': 'preu_unitari', 'type': 'double'},
{'name': 'moneda', 'type': 'string'},
{'name': 'pais', 'type': 'string'},
{'name': 'estat', 'type': 'string'},
]
}
esquema_parsejat = fastavro.parse_schema(esquema_avro)
def escriu_avro():
registres = df.drop(columns=['data']).to_dict('records')
with open('dataset.avro', 'wb') as f:
fastavro.writer(f, esquema_parsejat, registres)
def llegeix_avro():
with open('dataset.avro', 'rb') as f:
return list(fastavro.reader(f))
mesura('Avro', escriu_avro, llegeix_avro, 'dataset.avro')
df_resultats = pd.DataFrame(resultats)
print(df_resultats)
Sobre el camp data en Avro
L'exemple anterior elimina la columna data abans de serialitzar a Avro per simplicitat (Avro requereix declarar logicalType: date i convertir explícitament els valors). Es valora positivament que inclogueu la data correctament tipada amb logicalType, seguint l'exemple de Formats d'evolució.
Part 3 — Taula comparativa i anàlisi (1 hora)
A partir del DataFrame df_resultats, genera una taula final amb totes les mètriques i un gràfic de barres (amb matplotlib o similar) comparant la mida i els temps de cada format.
Respon, fonamentant-te en els teus propis resultats numèrics (no en valors genèrics del temari):
- Quin format ocupa menys espai en disc? Quin ratio de compressió obtens respecte al CSV?
- Quin format és més ràpid a escriure? I a llegir?
- Hi ha algun format que sigui ràpid d'escriure però lent de llegir, o a l'inrevés? Per què creus que passa?
- Si haguessis de triar un format per emmagatzemar aquest dataset per a consultes analítiques freqüents, quin triaries i per què?
Part 4 — Predicate pushdown i column pruning amb pyarrow (1,5 hores)
Sobre el fitxer dataset.parquet generat a la Part 2, demostra empíricament els dos avantatges principals del format columnar.
4.1 Column pruning
Compara el temps de llegir totes les columnes vs llegir només 2-3 columnes necessàries per a una consulta concreta (per exemple, calcular la facturació total: quantitat * preu_unitari).
import time
import pyarrow.parquet as pq
# Lectura completa (totes les columnes)
t0 = time.perf_counter()
taula_completa = pq.read_table('dataset.parquet')
t_complet = time.perf_counter() - t0
# Lectura amb column pruning (només les columnes necessaries)
t0 = time.perf_counter()
taula_podada = pq.read_table(
'dataset.parquet',
columns=['quantitat', 'preu_unitari'],
)
t_podat = time.perf_counter() - t0
print(f"Lectura completa: {t_complet:.3f} s")
print(f"Column pruning: {t_podat:.3f} s")
print(f"Acceleracio: {t_complet / t_podat:.1f}x")
4.2 Predicate pushdown
Compara el temps de filtrar després de carregar tot el fitxer a pandas vs aplicar el filtre amb filters a pq.read_table (que aprofita les estadístiques min/max del footer per descartar row groups sencers).
import time
import pyarrow.parquet as pq
# Opcio A: llegir tot i filtrar amb pandas
t0 = time.perf_counter()
df_complet = pq.read_table('dataset.parquet').to_pandas()
df_filtrat_a = df_complet[
(df_complet['categoria'] == 'Electronica') & (df_complet['preu_unitari'] > 300)
]
t_a = time.perf_counter() - t0
# Opcio B: predicate pushdown amb pyarrow
t0 = time.perf_counter()
taula_filtrada = pq.read_table(
'dataset.parquet',
columns=['id_transaccio', 'categoria', 'preu_unitari'],
filters=[('categoria', '=', 'Electronica'), ('preu_unitari', '>', 300)],
)
df_filtrat_b = taula_filtrada.to_pandas()
t_b = time.perf_counter() - t0
print(f"Filtre amb pandas (post-lectura): {t_a:.3f} s, {len(df_filtrat_a)} files")
print(f"Predicate pushdown (pyarrow): {t_b:.3f} s, {len(df_filtrat_b)} files")
print(f"Acceleracio: {t_a / t_b:.1f}x")
Per a observar millor l'efecte del predicate pushdown
L'efecte és més visible si el fitxer Parquet té diversos row groups (per defecte, pyarrow en crea un de gran per a 1M de files). Prova a escriure el Parquet amb row_group_size=50_000 per forçar múltiples row groups i comprova si l'acceleració del predicate pushdown és més notable.
Verifica els dos resultats (df_filtrat_a i df_filtrat_b) tenen el mateix nombre de files, per assegurar-te que el filtre és equivalent.
Part 5 — Taula Delta Lake i time travel (1,5 hores)
Crea una taula Delta Lake bàsica amb la llibreria deltalake (delta-rs), fes almenys tres versions (escriptura inicial + dues modificacions) i demostra el time travel consultant una versió anterior.
import pandas as pd
from deltalake import DeltaTable, write_deltalake
ruta_delta = 'taula_transaccions_delta'
# Versio 0: escriptura inicial amb un subconjunt del dataset
df_inicial = df.head(100_000).drop(columns=['data'])
write_deltalake(ruta_delta, df_inicial, mode='overwrite')
dt = DeltaTable(ruta_delta)
print(f"Versio actual: {dt.version()}")
# Versio 1: afegir un segon lot de dades (append)
df_lot2 = df.iloc[100_000:150_000].drop(columns=['data'])
write_deltalake(ruta_delta, df_lot2, mode='append')
# Versio 2: sobreescriure amb dades corregides (per exemple, normalitzar la categoria)
dt = DeltaTable(ruta_delta)
df_actual = dt.to_pandas()
df_corregit = df_actual.copy()
df_corregit['categoria'] = df_corregit['categoria'].str.upper()
write_deltalake(ruta_delta, df_corregit, mode='overwrite')
dt = DeltaTable(ruta_delta)
print(f"Versio actual: {dt.version()}")
print(dt.history())
Time travel — consulta una versió anterior de la taula:
from deltalake import DeltaTable
# Llegir la versio 0 (estat inicial, abans de les modificacions)
dt_v0 = DeltaTable(ruta_delta, version=0)
df_v0 = dt_v0.to_pandas()
print(f"Files a la versio 0: {len(df_v0)}")
# Llegir la versio 1
dt_v1 = DeltaTable(ruta_delta, version=1)
df_v1 = dt_v1.to_pandas()
print(f"Files a la versio 1: {len(df_v1)}")
# Comparar amb la versio actual (la mes recent)
dt_actual = DeltaTable(ruta_delta)
df_actual = dt_actual.to_pandas()
print(f"Files a la versio actual ({dt_actual.version()}): {len(df_actual)}")
print(f"Categories versio 0: {sorted(df_v0['categoria'].unique())[:3]}")
print(f"Categories versio actual: {sorted(df_actual['categoria'].unique())[:3]}")
Documenta en el teu informe:
- Quantes versions ha generat la teva taula i quina operació representa cadascuna (consulta
dt.history()). - Una captura o sortida de text que mostri valors diferents entre la versió 0 i la versió actual (per exemple, la columna
categoriaen majúscules només a partir de la versió 2). - Quin directori
_delta_log/s'ha creat i quants fitxers.jsonconté.
Alternativa amb PySpark
Si l'alumne disposa d'un entorn PySpark configurat (per exemple, del Bloc 5), pot usar delta-spark i la sintaxi spark.read.format("delta").option("versionAsOf", N).load(ruta) en lloc de deltalake, seguint l'exemple de Formats d'evolució — Time Travel. Ambdues opcions són vàlides per a aquesta pràctica.
Lliurament
Puja al Campus Virtual:
| Element | Contingut | Format |
|---|---|---|
| Script o notebook | Codi complet i executable de les Parts 1-5, amb comentaris | .py o .ipynb |
| Taula de resultats | Mida, temps d'escriptura i lectura per a CSV/JSON/Parquet/Avro (Part 3) | Inclosa al notebook o .csv/captura |
| Informe breu | Respostes a les preguntes d'anàlisi (Part 3) i a les preguntes de reflexió, amb les captures de pantalla del time travel (Part 5) | PDF, màx. 5 pàgines |
El codi ha de ser executable
A diferència d'altres pràctiques del mòdul, aquesta és una pràctica de programació: el codi lliurat ha de funcionar realment (es comprovarà executant-lo). No es valoraran fragments de codi que no s'hagin provat o que continguin errors de sintaxi.
Consulta la Rúbrica RA6 per als criteris detallats d'avaluació.
Data límit de lliurament: consulta el calendari del Campus Virtual (sessió S28).
Preguntes de reflexió final
Un cop completada la pràctica, reflexiona breument sobre:
- Dels quatre formats provats (CSV, JSON, Parquet, Avro), quin et sembla més adequat per emmagatzemar logs d'una aplicació que rep events contínuament? I per a un data warehouse analític? Justifica-ho amb els teus resultats.
- Quina diferència de rendiment (en ordre de magnitud) has observat entre la lectura completa i el column pruning amb Parquet? T'ha sorprès el resultat?
- Quin avantatge concret aporta Delta Lake sobre Parquet pur per a un cas com el d'aquesta pràctica (dataset que es modifica en lots successius)? En quin moment hauries d'aplicar
VACUUM?
Inclou les respostes a l'informe (3-5 línies per pregunta).
Pràctica PR507406 | Mòdul M5074 Sistemes de Big Data | Institut Sa Palomera (Blanes) | Curs CEIABD 2026-2027