Quando lavori con dati provenienti da un database aziendale, da un’API o da un file CSV, è facile immaginare che ogni informazione abbia giĂ il formato corretto.
Nella realtĂ succede raramente.
Una recensione può contenere:
- un voto scritto come
5 stelle; - un codice prodotto scritto come
SKU-12345,sku: 12345oppureSKU 12345; - una data nel formato
12/03/2024,12-03-2024o2024.03.12.
Per un essere umano queste differenze sono quasi irrilevanti.
Per una pipeline di dati possono diventare problemi.
Se vuoi costruire una tabella con colonne come:
recensione | voto | sku | data_acquisto
devi trasformare informazioni testuali non strutturate in dati strutturati.
Ă qui che entrano in gioco le espressioni regolari, o regex.
In questo laboratorio vedremo tre problemi progressivi di data cleaning applicati a un dataset di recensioni e-commerce:
- estrarre un voto;
- riconoscere un codice prodotto;
- normalizzare una data.
L’obiettivo non è semplicemente imparare tre regex. Ă imparare una logica piĂš importante:
una buona regex non deve soltanto trovare ciò che cerchiamo; deve anche evitare di riconoscere ciò che gli assomiglia.
Questo è il problema dei falsi positivi, particolarmente importante quando una regex entra in una pipeline di produzione.
PerchĂŠ proprio le recensioni e-commerce?
Immagina un’azienda che riceva ogni giorno 10.000 recensioni.
Ogni recensione contiene testo libero scritto dai clienti.
Un sistema di analisi vorrebbe trasformare automaticamente quel testo in variabili strutturate:
rating = 5
sku = 12345
data = 2024-03-12
A quel punto quei dati possono essere utilizzati per:
- analizzare la soddisfazione dei clienti;
- confrontare prodotti;
- costruire dashboard;
- individuare anomalie;
- alimentare modelli di machine learning;
- collegare le recensioni alle vendite;
- effettuare analisi temporali.
Il problema è che il testo originale non è necessariamente standardizzato.
Questo rende le regex particolarmente utili nella fase di Extract/Transform di una pipeline ETL.
Prerequisiti teorici rapidi
Prima di iniziare, ricordiamo alcuni metacaratteri fondamentali:
| Simbolo | Significato | Esempio |
|---|---|---|
. |
qualsiasi carattere | a.c |
\d |
una cifra | \d{4} |
\w |
carattere alfanumerico o _ |
\w+ |
\s |
whitespace | spazio, tab, newline |
* |
zero o piĂš occorrenze | a* |
+ |
una o piĂš occorrenze | a+ |
? |
zero o una occorrenza | a? |
{n} |
esattamente n occorrenze | \d{4} |
{n,m} |
da n a m occorrenze | \d{1,2} |
^ |
inizio stringa | ^SKU |
$ |
fine stringa | 12345$ |
\b |
confine di parola | \bSKU\b |
(...) |
gruppo di cattura | (\d+) |
(?P<nome>...) |
gruppo nominato | (?P<voto>\d) |
(?=...) |
lookahead positivo | \d+(?= euro) |
(?<=...) |
lookbehind positivo | (?<=voto: )\d |
| |
OR | rosso|blu |
Una distinzione importante accompagnerĂ tutti gli esercizi:
riconoscere un formato non significa necessariamente validare il dato.
Una regex può stabilire che qualcosa assomiglia a una data. Non può, da sola, sapere che il 31 febbraio non esiste.
đ˘ Esercizio 1 â Estrarre il voto dalle recensioni
Costruire una variabile rating
Immagina di avere raccolto 10.000 recensioni da un marketplace.
Il testo originale potrebbe essere:
"5 stelle, prodotto eccezionale!"
"Ne do 4 stelle ma con riserva."
"Ho messo 3 stelle per la consegna lenta."
Il sistema di analisi, però, non vuole conservare soltanto il testo.
Vuole creare una colonna numerica:
rating
5
4
3
PerchĂŠ?
PerchĂŠ una volta trasformato il voto in un numero puoi calcolare:
df["rating"].mean()
oppure confrontare il rating medio fra prodotti, categorie o periodi.
Il problema è che non possiamo semplicemente cercare il carattere "5".
Una recensione potrebbe infatti contenere:
Il prodotto costa 15 euro e gli darei 4 stelle.
Il 5 di 15 non è un voto.
Questo è il primo vero problema di data cleaning:
non basta trovare un valore; bisogna trovarlo nel contesto corretto.
Strategia
Il nostro pattern deve:
- accettare soltanto voti da 1 a 5;
- permettere uno o piĂš spazi;
- richiedere la parola
stelle; - evitare di estrarre una cifra contenuta dentro un numero piĂš grande.
Possiamo quindi scrivere:
import re
from typing import Optional
VOTO_RE = re.compile(
r"\b([1-5])\s*stelle\b",
re.IGNORECASE
)
Vediamo il pattern pezzo per pezzo:
\b
impone un confine prima della cifra.
([1-5])
cattura una sola cifra compresa fra 1 e 5.
\s*
accetta zero o piĂš spazi.
Quindi vengono riconosciuti sia:
5 stelle
sia:
5stelle
Infine:
stelle\b
richiede la parola stelle come parola autonoma.
Funzione di estrazione
def estrai_voto(testo: str) -> Optional[int]:
"""
Estrae il voto in stelle da una recensione.
Restituisce None se non viene trovato.
"""
match = VOTO_RE.search(testo)
if match is None:
return None
return int(match.group(1))
Ora possiamo testarla:
campioni = [
"5 stelle, prodotto eccezionale!",
"Ne do 4 stelle ma con riserva.",
"Ho messo 3 stelle per la consegna lenta.",
"15 stelle se potessi",
"Nessun voto qui"
]
for testo in campioni:
print(f"{testo!r:45} -> {estrai_voto(testo)}")
Output:
'5 stelle, prodotto eccezionale!' -> 5
'Ne do 4 stelle ma con riserva.' -> 4
'Ho messo 3 stelle per la consegna lenta.' -> 3
'15 stelle se potessi' -> None
'Nessun voto qui' -> None
PerchÊ \b è cosÏ importante?
Consideriamo:
15 stelle
Senza il confine iniziale potremmo trovare:
5 stelle
come sottostringa.
Dal punto di vista del motore regex, sarebbe perfettamente legittimo.
Dal punto di vista del business, sarebbe un errore.
Il 5 di 15 non rappresenta infatti una valutazione.
Questo è un esempio di falso positivo.
Il principio è generalizzabile:
Quando estrai dati da testo libero, devi sempre chiederti non soltanto “cosa può essere un match?”, ma anche “quali stringhe simili non devono essere considerate un match?”
Un caso piĂš difficile
Supponiamo di incontrare:
"Voto: 5/5 stelle"
La regex precedente trova il secondo 5.
Il risultato numerico è corretto, ma abbiamo scoperto una limitazione importante.
La regex non comprende il significato linguistico della frase.
Sa soltanto riconoscere:
5 + stelle
Non sa se quel 5 rappresenti:
- il voto assegnato;
- il massimo disponibile;
- un riferimento testuale.
Questo introduce una distinzione fondamentale nel data cleaning:
la regex riconosce una struttura, non comprende necessariamente il significato del testo.
Quando usare questo approccio?
Ă utile quando devi estrarre rapidamente una variabile strutturata da:
- recensioni;
- ticket di assistenza;
- commenti;
- moduli testuali;
- log;
- descrizioni prodotto.
Se il formato diventa piĂš complesso, conviene combinare regex con regole di parsing e controlli successivi.
đ˘đĄ Esercizio 2 â Estrarre codici prodotto e SKU
Collegare recensioni e catalogo prodotti
Ora immaginiamo un problema piĂš vicino a un vero progetto di Data Engineering.
L’azienda possiede un catalogo prodotti strutturato:
SKU prodotto
12345 Smartphone X
67890 Cuffie Y
11111 Monitor Z
Contemporaneamente possiede migliaia di recensioni.
Per collegare una recensione al prodotto corretto, il codice SKU può essere presente nel testo:
"Ho ordinato SKU-12345 e funziona benissimo."
"Il codice sku: 67890 non esiste sul sito."
"SKU 11111 vs SKU#22222, entrambi ok."
Il nostro obiettivo è trasformare il testo in:
["12345", "67890", "11111", "22222"]
A questo punto gli SKU possono essere utilizzati come chiave per unire i dati della recensione con il catalogo prodotti.
Questa è una situazione tipica di data integration.
Il problema dell’errore umano
Gli utenti non seguono necessariamente lo standard aziendale.
Possiamo quindi avere:
SKU-12345
SKU 12345
SKU:12345
SKU#12345
Vogliamo essere flessibili sul separatore ma rigidi sul codice.
Questa è una strategia molto utile nel data cleaning:
tolleranza sull’input, rigiditĂ sull’output.
Il separatore può cambiare.
Il codice, invece, deve essere composto esattamente da cinque cifre.
Pattern
SKU_RE = re.compile(
r"\bsku[\s:#\-]*(\d{5})\b",
re.IGNORECASE
)
La parte:
\bsku
richiede che sku inizi come parola autonoma.
Poi:
[\s:#\-]*
accetta zero o piĂš separatori.
Infine:
(\d{5})
cattura esattamente cinque cifre.
Il \b finale impedisce che venga catturata soltanto una parte di un codice piĂš lungo.
Implementazione
def estrai_sku(testo: str) -> list[str]:
"""
Estrae tutti gli SKU composti da esattamente 5 cifre.
"""
return [
match.group(1)
for match in SKU_RE.finditer(testo)
]
Test:
testo = """
Recensione 1: Ho ordinato SKU-12345 e funziona benissimo.
Recensione 2: il codice sku: 67890 non esiste sul sito.
Recensione 3: SKU 11111 vs SKU#22222, entrambi ok.
Recensione 4: SKU-9999999 (troppo lungo) non deve matchare.
"""
print(estrai_sku(testo))
Output:
['12345', '67890', '11111', '22222']
PerchÊ il secondo \b è importante?
Consideriamo:
SKU-9999999
Il motore potrebbe iniziare a riconoscere:
SKU-99999
che contiene effettivamente cinque cifre.
Ma non vogliamo questo risultato.
Il nostro codice richiede:
cinque cifre + confine di parola
Dopo le prime cinque cifre, però, c’è ancora una cifra.
Quindi non esiste un confine di parola.
Il match viene rifiutato.
Questo è un esempio di pattern difensivo.
PerchĂŠ finditer()?
Qui non cerchiamo un solo SKU.
Potrebbero essercene molti:
SKU-12345
SKU-67890
SKU-11111
finditer() permette di scorrere tutti i match e, soprattutto, restituisce gli oggetti Match.
Questo diventa utile quando non vuoi soltanto il valore, ma anche informazioni come:
match.start()
match.end()
match.group()
Per esempio, in una pipeline di data quality potresti voler registrare dove nel testo è stato trovato il codice.
Applicazione successiva: join
Una volta estratto:
sku = "12345"
puoi collegarlo a un catalogo Pandas:
df_recensioni.merge(
df_prodotti,
on="sku",
how="left"
)
La regex, quindi, non è il risultato finale.
Ă il primo passaggio che permette di trasformare testo non strutturato in una chiave utilizzabile per una vera analisi dati.
đĄ Esercizio 3 â Normalizzare le date
Costruire una timeline delle recensioni
Ora affrontiamo un problema ancora piĂš interessante.
L’azienda vuole analizzare l’evoluzione delle recensioni nel tempo.
Potrebbe voler rispondere a domande come:
- il rating medio è cambiato dopo un aggiornamento del prodotto?
- le recensioni negative sono aumentate dopo un aumento di prezzo?
- quale periodo genera piĂš reclami?
- quanto tempo passa fra acquisto e recensione?
Per fare questo, la data deve diventare un vero campo temporale.
Il problema è che il testo può contenere:
12/03/2024
12-03-2024
12.03.2024
2024/03/12
2024.03.12
Per l’analista questi valori rappresentano date.
Per Python, prima della normalizzazione, sono semplicemente stringhe differenti.
Obiettivo
Vogliamo trasformare:
12/03/2024
12-03-2024
2024.03.12
in:
2024-03-12
2024-03-12
2024-03-12
Questo formato ISO è particolarmente utile perchÊ è non ambiguo e facilmente utilizzabile nei sistemi di elaborazione dati.
Primo problema: due strutture diverse
Abbiamo due famiglie:
GG/MM/AAAA
e:
AAAA/MM/GG
Possiamo quindi usare un’alternanza:
pattern1 | pattern2
Inoltre vogliamo che il separatore sia coerente.
Per esempio:
12/03/2024
va bene.
Ma:
12/03-2024
no.
Pattern
DATA_RE = re.compile(
r"""
(?P<giorno>\d{1,2})
(?P<sep>[/\-\.])
(?P<mese>\d{1,2})
(?P=sep)
(?P<anno>\d{4})
|
(?P<anno2>\d{4})
(?P<sep2>[/\-\.])
(?P<mese2>\d{1,2})
(?P=sep2)
(?P<giorno2>\d{1,2})
""",
re.VERBOSE
)
Qui compare una tecnica particolarmente importante:
(?P=sep)
Ă un backreference.
Significa:
usa esattamente lo stesso separatore catturato precedentemente.
Se il primo separatore è /, anche il secondo deve essere /.
Dalla sintassi alla semantica
A questo punto abbiamo riconosciuto qualcosa che sembra una data.
Ma non abbiamo ancora dimostrato che sia una data valida.
Per esempio:
31/02/2024
rispetta perfettamente la struttura:
giorno / mese / anno
ma il 31 febbraio non esiste.
Questo è il punto metodologico piĂš importante dell’intero articolo:
regex e validazione semantica sono due problemi diversi.
La regex controlla la struttura.
datetime.date controlla la validitĂ della data.
Implementazione
from datetime import date
from typing import Optional
def normalizza_data(testo: str) -> Optional[str]:
match = DATA_RE.search(testo)
if not match:
return None
dati = match.groupdict()
if dati["giorno"]:
giorno = int(dati["giorno"])
mese = int(dati["mese"])
anno = int(dati["anno"])
else:
giorno = int(dati["giorno2"])
mese = int(dati["mese2"])
anno = int(dati["anno2"])
try:
return date(
anno,
mese,
giorno
).isoformat()
except ValueError:
return None
Il passaggio decisivo è:
date(anno, mese, giorno)
Se la combinazione non è valida, Python solleva ValueError.
In questo modo la pipeline separa chiaramente due livelli:
REGEX
â
riconoscimento della struttura
DATETIME
â
validazione della data
Questa separazione rende il codice piĂš robusto e piĂš facile da manutenere.
Test
test = [
"Acquistato il 12/03/2024, ottimo.",
"Ricevuto il 05-11-2023 con ritardo.",
"Recensione del 2024.01.30 â consigliato.",
"Data sbagliata: 31/02/2024",
"Nessuna data"
]
for testo in test:
print(
f"{testo!r:45} -> "
f"{normalizza_data(testo)}"
)
Output:
'Acquistato il 12/03/2024, ottimo.' -> 2024-03-12
'Ricevuto il 05-11-2023 con ritardo.' -> 2023-11-05
'Recensione del 2024.01.30 â consigliato.' -> 2024-01-30
'Data sbagliata: 31/02/2024' -> None
'Nessuna data' -> None
PerchĂŠ non facciamo fare tutto alla regex?
Potremmo tentare di costruire una regex gigantesca capace di distinguere:
- mesi da 1 a 12;
- giorni da 1 a 31;
- mesi con 30 giorni;
- febbraio;
- anni bisestili.
Ma sarebbe una pessima scelta progettuale.
La regex diventerebbe difficile da leggere, difficile da testare e difficile da modificare.
à molto piÚ efficace assegnare a ogni strumento il compito per cui è adatto:
Regex
â riconoscimento del formato
datetime
â validazione del calendario
Pandas
â gestione della colonna
database
â persistenza del dato
Questa è una lezione di progettazione software molto piÚ importante della singola regex.
Le tre lezioni dietro gli esercizi
1. Voto: difendersi dai falsi positivi
Nel primo esercizio il problema principale non era trovare una cifra.
Era evitare di scambiare:
15
per:
5
Il \b rappresenta quindi una forma di controllo della qualitĂ dell’estrazione.
In una pipeline ETL questo è fondamentale: un falso positivo può essere molto piÚ pericoloso di un valore mancante, perchÊ può entrare nel dataset senza generare un errore evidente.
2. SKU: tollerare la variabilitĂ dell’input
Nel secondo esercizio abbiamo separato:
ciò che può variare
-
:
spazio
#
da:
ciò che deve rimanere rigido
5 cifre
Questa è una strategia molto comune nel trattamento dei dati provenienti da utenti.
Non possiamo pretendere che l’input umano sia perfettamente standardizzato.
Possiamo però definire con precisione il formato del dato che vogliamo produrre.
3. Date: separare sintassi e semantica
Il terzo esercizio introduce il livello piĂš importante.
Una stringa può avere la forma:
31/02/2024
senza rappresentare una data reale.
La regex riconosce la sintassi.
datetime verifica la semantica.
Questo principio vale ben oltre le date.
Lo stesso approccio può essere applicato a:
- codici fiscali;
- numeri di telefono;
- codici prodotto;
- email;
- URL;
- identificativi;
- numeri di documento.
Una regex può dirci che un valore ha una forma plausibile.
Non necessariamente che quel valore sia corretto nel mondo reale.
Da regex a pipeline di Data Quality
A questo punto possiamo vedere i tre esercizi come una piccola pipeline:
TESTO GREZZO
â
âź
âââââââââââââââââ
â Regex â
â riconoscimentoâ
âââââââââŹââââââââ
âź
DATO ESTRATTO
â
âź
âââââââââââââââââ
â Validazione â
â semantica â
âââââââââŹââââââââ
âź
DATO NORMALIZZATO
â
âź
âââââââââââââââââ
â Pandas / DB â
â analisi â
âââââââââââââââââ
Questa è probabilmente la prospettiva piÚ interessante per chi lavora con Python e Data Science.
La regex non è il fine.
Ă un componente della pipeline.
Mini-quiz finale
Esercizio 1
PerchĂŠ:
re.search(...)
è piÚ adatto di:
re.match(...)
quando il voto può trovarsi in qualunque punto della recensione?
Esercizio 2
PerchĂŠ:
\b
alla fine dello SKU impedisce di estrarre le prime cinque cifre da:
SKU-9999999
?
Esercizio 3
PerchĂŠ questa stringa:
31/02/2024
può superare il controllo della regex ma deve essere rifiutata da datetime.date?
Domanda da Data Scientist
Se avessi un dataset con 1 milione di recensioni, preferiresti:
- applicare una regex riga per riga;
- normalizzare il testo prima dell’estrazione;
- costruire una pipeline composta da estrazione + validazione + logging degli errori?
Quale architettura sceglieresti e perchĂŠ?
Risposte al mini-quiz finale
Esercizio 1 â search() oppure match()?
La scelta corretta è:
re.search(...)
search() cerca il pattern in qualunque posizione della stringa.
Questo è esattamente ciò che ci serve quando analizziamo una recensione come:
"Prodotto molto buono, consegna rapida, 5 stelle!"
Il voto non si trova necessariamente all’inizio del testo.
Al contrario, re.match() verifica il match a partire dall’inizio della stringa.
Per esempio:
re.match(r"\b([1-5])\s*stelle\b", "5 stelle, prodotto ottimo")
può trovare il voto perchÊ la stringa inizia con 5 stelle.
Ma:
re.match(r"\b([1-5])\s*stelle\b",
"Prodotto ottimo, 5 stelle")
non trova nulla.
search(), invece, continua a cercare lungo tutta la stringa e trova il voto.
Regola pratica
Possiamo quindi ricordare:
match()
â il pattern deve iniziare dall'inizio della stringa
search()
â il pattern può comparire in qualunque posizione
In una pipeline di text mining questa distinzione è importante perchÊ scegliere il metodo sbagliato può produrre molti valori mancanti senza necessariamente generare un errore Python.
Esercizio 2 â PerchĂŠ \b protegge lo SKU?
Consideriamo:
SKU-9999999
Il nostro pattern richiede:
SKU + separatore + esattamente 5 cifre + \b
La parte:
(\d{5})
potrebbe inizialmente sembrare sufficiente.
Infatti le prime cinque cifre di:
9999999
sono effettivamente:
99999
Senza un controllo successivo, la regex potrebbe quindi estrarre una parte di un codice che in realtĂ non rispetta il formato previsto.
Il \b finale impedisce questo comportamento.
Dopo le prime cinque cifre c’è infatti un’altra cifra.
Una cifra è un carattere di parola (\w), quindi non c’è un confine di parola in quella posizione.
Il match viene quindi rifiutato.
PerchÊ è importante in un progetto reale?
Immagina che l’azienda abbia deciso che tutti gli SKU validi siano costituiti da cinque cifre.
Se accettassimo:
SKU-9999999
come:
99999
potremmo associare una recensione al prodotto sbagliato.
Questo è molto piÚ grave di un semplice errore di formattazione: è un problema di integrità del dato.
La regex, quindi, non sta soltanto estraendo informazioni.
Sta applicando una prima regola di data quality.
Esercizio 3 â PerchĂŠ 31/02/2024 supera la regex ma viene rifiutata da datetime.date?
La regex controlla principalmente la struttura della stringa.
Nel caso:
31/02/2024
trova:
giorno = 31
mese = 02
anno = 2024
La struttura è quindi compatibile con:
GG/MM/AAAA
Ma la regex non conosce il calendario.
Non sa che febbraio 2024 ha 29 giorni e che quindi il 31 febbraio non può esistere.
Quando passiamo i valori a:
date(2024, 2, 31)
datetime esegue invece una validazione semantica della data.
La combinazione non è valida e viene generato un:
ValueError
che il nostro codice intercetta:
try:
return date(anno, mese, giorno).isoformat()
except ValueError:
return None
Il risultato finale è quindi:
31/02/2024
â None
La lezione generale
Questo esempio introduce una distinzione fondamentale nel data cleaning:
VALIDAZIONE SINTATTICA
"La stringa ha una forma plausibile?"
â
VALIDAZIONE SEMANTICA
"Il valore rappresentato ha realmente senso?"
La regex è molto efficace nel primo compito.
Una libreria specializzata, come datetime, è piÚ adatta al secondo.
La domanda da Data Scientist
L’ultima domanda è volutamente piĂš aperta:
Se avessi un dataset con 1 milione di recensioni, preferiresti applicare una regex riga per riga, normalizzare il testo prima dell’estrazione oppure costruire una pipeline composta da estrazione + validazione + logging degli errori?
Non esiste una risposta unica valida per qualsiasi progetto, ma per un processo di produzione la terza opzione rappresenta l’approccio piĂš completo.
Una pipeline potrebbe essere organizzata cosĂŹ:
1. TESTO GREZZO
â
2. NORMALIZZAZIONE
â
3. ESTRAZIONE CON REGEX
â
4. VALIDAZIONE
â
5. NORMALIZZAZIONE DEL VALORE
â
6. LOG DEGLI ERRORI
â
7. DATASET PULITO
Per esempio:
" SKU-12345 "
â
normalizzazione
â
"SKU-12345"
â
regex
â
"12345"
â
validazione
â
SKU valido
Ma immaginiamo invece:
"SKU-9999999"
La regex può riconoscere che la struttura non rispetta il formato previsto.
In quel caso non dovremmo semplicemente eliminare la riga senza lasciare traccia.
Possiamo registrare l’anomalia:
record_id | campo | valore | errore
------------------------------------------------
18452 | sku | 9999999 | formato non valido
Questo è il ruolo del logging degli errori di data quality.
PerchÊ il logging è importante?
Con 10 recensioni possiamo leggere manualmente gli errori.
Con 1 milione di recensioni non possiamo farlo.
Il sistema deve quindi permetterci di sapere:
- quanti valori sono stati estratti;
- quanti sono stati rifiutati;
- quanti sono mancanti;
- quali pattern di errore sono piĂš frequenti;
- quali record devono essere eventualmente revisionati.
Per esempio:
Totale recensioni 1.000.000
SKU estratti 927.430
SKU mancanti 52.310
SKU non validi 20.260
Queste informazioni trasformano il semplice script regex in un vero processo di Data Quality Monitoring.
đ Regex, elaborazione del testo e pulizia dei dati con Python
Per approfondire l’uso delle espressioni regolari e capire come applicarle alla ricerca, estrazione e pulizia delle informazioni nei dataset, puoi consultare queste guide pratiche:
đ Espressioni regolari (Regex) in Python: esercizi pratici e soluzioni dettagliate
đ Guida pratica a Regex in Python: 3 esercizi reali su DNA, sentiment e sicurezza
đ Regex in Python: 6 esercizi pratici a difficoltĂ crescente
đ Pulizia dati con Python: dal dataset sporco all’analisi affidabile con Pandas





