Salta el contingut

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:

  1. Generar un dataset de prova d'1 milió de files amb Python, amb un esquema realista i tipus de dades variats.
  2. Escriure i llegir el dataset en formats CSV, JSON, Parquet i Avro, usant les biblioteques estàndard de l'ecosistema (pandas, pyarrow, fastavro).
  3. Mesurar i comparar la mida de fitxer, el temps d'escriptura i el temps de lectura per a cada format, amb time.perf_counter().
  4. Demostrar el predicate pushdown i el column pruning de Parquet amb pyarrow, quantificant la millora de rendiment respecte a una lectura completa.
  5. Crear una taula Delta Lake bàsica amb diverses versions i demostrar el time travel, consultant l'estat de la taula en versions anteriors.
  6. 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 (paquet delta-rs per a Python; alternativa: delta-spark si es treballa amb PySpark), faker (o numpy per 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:

pip install pandas pyarrow fastavro deltalake faker

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 categoria en majúscules només a partir de la versió 2).
  • Quin directori _delta_log/ s'ha creat i quants fitxers .json conté.

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:

  1. 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.
  2. 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?
  3. 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