Salta el contingut

Validació amb Python: funcions i assertions

Els tests de pytest (pàgina anterior) verifiquen que un dataset ja carregat compleix unes regles. Però sovint cal validar les dades dins del propi codi del pipeline, en el moment en què es processen, no només després en un test a part. Aquesta pàgina cobreix com escriure aquesta validació directament en Python: quan fer servir assert, quan raise, com construir validadors reutilitzables, i com donar el pas cap a un esquema declaratiu amb Pandera.


assert vs raise: quan usar cadascun

def calcula_descompte(preu: float, percentatge: float) -> float:
    assert 0 <= percentatge <= 100, "El percentatge ha d'estar entre 0 i 100"
    return preu * (1 - percentatge / 100)

assert és còmode però té una limitació important: es pot desactivar globalment executant Python amb l'opció -O (optimitzat), cosa que elimina totes les comprovacions assert del programa sense avisar. Per aquest motiu, assert és adequat per a:

  • Comprovacions internes de desenvolupament i tests (com a la pàgina de pytest).
  • Invariants que mai haurien de fallar si la resta del codi és correcte (errors de programació, no de dades d'entrada).

Per a validacions que han de complir-se sempre, especialment si depenen de dades externes (fitxers, APIs, entrades d'usuari), cal raise amb una excepció explícita:

class ErrorQualitatDades(Exception):
    """Excepció pròpia per distingir errors de dades d'altres errors del pipeline."""

def valida_percentatge(percentatge: float) -> float:
    if not (0 <= percentatge <= 100):
        raise ErrorQualitatDades(
            f"Percentatge fora de rang: {percentatge} (esperat entre 0 i 100)"
        )
    return percentatge

Definir una excepció pròpia (ErrorQualitatDades) en lloc de fer servir ValueError genèric permet que la resta del pipeline capturi específicament errors de qualitat i els tracti diferent d'altres errors (per exemple, enviant-los a una cua de quarantena en lloc d'aturar tot el procés).


Validadors reutilitzables

Repetir la mateixa lògica de validació a cada funció és un antipatró. La solució és extreure-la a funcions validadores independents que es poden combinar:

from typing import Callable
import pandas as pd

def valida_no_nuls(df: pd.DataFrame, columna: str) -> None:
    n_nuls = df[columna].isnull().sum()
    if n_nuls > 0:
        raise ErrorQualitatDades(f"'{columna}' té {n_nuls} valors nuls")

def valida_rang(df: pd.DataFrame, columna: str, minim: float, maxim: float) -> None:
    fora_rang = ~df[columna].between(minim, maxim)
    if fora_rang.any():
        raise ErrorQualitatDades(
            f"'{columna}' té {fora_rang.sum()} valors fora de [{minim}, {maxim}]"
        )

def valida_unic(df: pd.DataFrame, columna: str) -> None:
    if df[columna].duplicated().any():
        raise ErrorQualitatDades(f"'{columna}' té valors duplicats")

def executa_validacions(df: pd.DataFrame, *validadors: Callable[[pd.DataFrame], None]) -> None:
    """Executa una llista de validadors i recull TOTS els errors, no només el primer."""
    errors = []
    for validador in validadors:
        try:
            validador(df)
        except ErrorQualitatDades as e:
            errors.append(str(e))
    if errors:
        raise ErrorQualitatDades("Errors de qualitat trobats:\n- " + "\n- ".join(errors))
from functools import partial

executa_validacions(
    df,
    partial(valida_no_nuls, columna="id_venda"),
    partial(valida_unic, columna="id_venda"),
    partial(valida_rang, columna="import", minim=0, maxim=100_000),
)

Aquest patró —recollir tots els errors en lloc d'aturar-se al primer— és exactament el mateix principi que veurem després amb lazy=True a Pandera i amb els informes de Great Expectations: un pipeline de qualitat útil informa d'una vegada de tots els problemes, no d'un en un a cada re-execució.


Decoradors de validació

Un decorador permet aplicar la validació de forma declarativa, separant "què cal validar" de "com es fa la validació", i reutilitzant-lo sobre qualsevol funció que retorni un DataFrame:

import functools

def valida_sortida(*validadors):
    """Decorador que valida el DataFrame que retorna una funció abans de deixar-lo passar."""
    def decorador(funcio):
        @functools.wraps(funcio)
        def wrapper(*args, **kwargs):
            df_resultat = funcio(*args, **kwargs)
            executa_validacions(df_resultat, *validadors)
            return df_resultat
        return wrapper
    return decorador


@valida_sortida(
    partial(valida_no_nuls, columna="id_venda"),
    partial(valida_unic, columna="id_venda"),
)
def transforma_vendes(df_brut: pd.DataFrame) -> pd.DataFrame:
    return (
        df_brut
        .dropna(subset=["import"])
        .assign(import_amb_iva=lambda d: d["import"] * 1.21)
    )

Amb aquest decorador, qualsevol crida a transforma_vendes() valida automàticament el resultat abans de retornar-lo: si algú modifica la funció en el futur i introdueix sense voler una regressió (per exemple, deixa de filtrar duplicats), la validació ho detectarà a l'instant, sense haver de recordar afegir la comprovació manualment cada vegada.


El pas següent: esquemes declaratius amb Pandera

Escriure validadors a mà és flexible, però per a un dataset amb desenes de columnes, definir-los un a un és repetitiu. Pandera permet definir un esquema declaratiu per a DataFrames de pandas: es descriu com hauria de ser el dataset (tipus, nul·labilitat, unicitat, rangs) en un sol bloc, i Pandera s'encarrega de validar-ho i de reportar tots els errors alhora.

import pandera as pa
from pandera import Column, DataFrameSchema, Check

esquema_vendes = DataFrameSchema(
    columns={
        "id_venda": Column(
            dtype=int,
            checks=Check.greater_than(0),
            nullable=False,
            unique=True,
        ),
        "data": Column(dtype="datetime64[ns]", nullable=False),
        "id_client": Column(dtype=int, checks=Check.greater_than(0), nullable=False),
        "import": Column(
            dtype=float,
            checks=[Check.greater_than(0), Check.less_than(100_000)],
            nullable=False,
        ),
        "email_client": Column(
            dtype=str,
            checks=Check.str_matches(r"^[^@]+@[^@]+\.[^@]+$"),
            nullable=True,   # l'email pot ser buit
        ),
        "pais": Column(
            dtype=str,
            checks=Check.isin(["ES", "FR", "DE", "IT", "PT"]),
            nullable=False,
        ),
    },
    coerce=True,   # intenta convertir els tipus automàticament
    strict=False,  # permet columnes addicionals no declarades
)

try:
    df_validat = esquema_vendes.validate(df, lazy=True)
    print(f"Validació correcta: {len(df_validat):,} registres")
except pa.errors.SchemaErrors as e:
    print("Errors de validació trobats:")
    print(e.failure_cases)   # DataFrame amb: columna, índex, valor invàlid, regla fallida

El paràmetre lazy=True fa que Pandera recopili tots els errors en lloc d'aturar-se al primer, exactament com el patró executa_validacions() que hem escrit a mà més amunt — amb la diferència que Pandera ho fa de forma declarativa i genera un DataFrame d'errors llest per analitzar o exportar.

Validadors escrits a mà Pandera
Corba d'aprenentatge Cap, és Python pur Cal aprendre l'API de Check i Column
Manteniment amb moltes columnes Es torna repetitiu Un sol bloc declaratiu, fàcil de llegir d'un cop d'ull
Informe d'errors Cal construir-lo manualment e.failure_cases ja ve estructurat com a DataFrame
Quan té sentit Regles molt específiques o lògica de negoci complexa Esquemes de dataset amb moltes columnes i regles estàndard

Pandera com a pas previ a Great Expectations

Pandera i Great Expectations (pàgina següent) resolen el mateix problema amb filosofies diferents: Pandera defineix l'esquema com a codi Python (còmode dins d'un pipeline ja escrit en pandas), mentre que Great Expectations tracta la validació com una entitat de primera classe amb el seu propi format de configuració i generació d'informes HTML. Per a projectes petits o migjos, Pandera sol ser suficient; per a organitzacions amb molts equips compartint dades, Great Expectations aporta més estructura.


AC5074/05/03 — Miniactivitat

Se't proporciona el fitxer vendes_errors.csv amb el contingut següent (amb errors deliberats):

id_venda,data,id_client,import,email,pais
1,2026-06-01,101,250.00,client@empresa.cat,ES
2,2026-06-01,102,-50.00,no-es-un-email,XX
3,2026-06-02,,300.00,altre@test.com,FR
1,2026-06-02,104,180.00,valid@correu.es,ES
5,2026-06-03,105,99999999.00,enorme@test.com,DE
6,2026-06-03,106,120.00,,IT

Escriu un script Python (validacio_nom_cognom.py) que:

  1. Llegeixi el CSV amb pandas.
  2. Defineixi dues versions de la validació: (a) amb validadors escrits a mà com els d'aquesta pàgina (valida_no_nuls, valida_rang, valida_unic) i (b) amb un esquema Pandera equivalent, amb les restriccions apropiades per a cada columna (tipus, nul·labilitat, rangs, format d'email, valors acceptats per a pais).
  3. Executi la validació de Pandera amb lazy=True.
  4. Generi un informe de text (o CSV) que llisti cada error trobat: columna, índex de la fila, valor invàlid i regla violada.
  5. Separi el DataFrame en registres vàlids i invàlids i mostri el recompte de cada grup.
  6. Compari, en un breu comentari final, quina de les dues aproximacions (validadors a mà o Pandera) ha estat més ràpida d'escriure i quina genera un informe d'errors més útil.

Inclou comentaris que expliquin per què cada restricció és necessària.


Mòdul M5074 Sistemes de Big Data | Institut Sa Palomera (Blanes) | Curs CEIABD 2026-2027