Perché è stato creato Tométo Tomato
Introduzione
Chiunque lavori con i dati molto spesso si imbatte in colonne che dovrebbero rispettare una codifica, un valore standard, una lista di valori noti, ma presentano invece errori di battitura, spazi in eccesso, caratteri speciali al posto di caratteri accentati, maiuscole/minuscole non coerenti, ecc..
Qui sotto ad esempio dei nomi ci città italiane, riportati in modo errato:
| city | tipo di errore |
|---|---|
| Cefalu’ | bisognerebbe usare la ù e non u' |
| Reggio Calabria | Il nome corretto è Reggio di Calabria |
| RODENGO-SAIANO | bisognerebbe usare Rodengo Saiano, senza - e non tutto maiuscolo |
Se volessi associare a queste città il codice1 che Istat - l’istituto nazionale di statistica - assegna a ciascuna città, non potrei farlo con una semplice operazione di join, perché i nomi non corrispondono esattamente. Associare un codice a ciascun comune è operazione molto importante perché mi consente di unire dati provenienti da fonti diverse, che altrimenti non potrei confrontare, ma anche di generare ad esempio automaticamente mappe, perché i software spesso usano proprio questi codici per identificare i comuni.
Chi fa didattica sui dati di solito infatti dice (urlando): CODES, NOT LABELS!!
I codici correlati alle unità amministrative italiane sono pubblici e liberamente accessibili in CC-BY-4.0 su diverse sezioni del sito Istat. Uno di questi è il SITUAS, in cui c’è la pagina con l’“Elenco dei codici e delle denominazioni delle unità territoriali”, scaricabili in formato CSV e JSON.
| city | region | istat_city_code |
|---|---|---|
| Cefalù | Sicilia | 082027 |
| Reggio di Calabria | Calabria | 080063 |
| Rodengo Saiano | Lombardia | 017163 |
Se provassi a fare un join tra i dati errati e questi dati ufficiali, non otterrei alcun risultato.
👉 Tométo Tomato è stato creato per risolvere questo problema: consente di fare comodamente join non basati su corrispondenze esatte, ma su corrispondenze “simili”, e arricchire, modificare, correggere la propria brutta tabella di input, confrontandola con una tabella di riferimento.
File di esempio
Queste sono le nostre due tabelle di esempio, disponibili come input.csv e ref_sample.csv in modo che sia possibile scaricarle e provare a eseguire gli esempi.
Utilizzare SQL
Fare il JOIN
Il primo test è quello di lanciare una semplice query SQL di join, per vedere cosa succede a partire dai nostri dati di esempio.
| city | region |
|---|---|
| Cefalu’ | Sicilia |
| Reggio Calabria | CALABRIA |
| RODENGO-SAIANO | Lombardia |
| city | region | city_code |
|---|---|---|
| Cefalù | Sicilia | 082027 |
| Reggio di Calabria | Calabria | 080063 |
| Rodengo Saiano | Lombardia | 017164 |
La query può essere quella di sotto. È impostata come un LEFT JOIN, in modo da mostrare tutte le righe della tabella di sinistra, anche se non trovano corrispondenza nella tabella di destra.
SELECT
i.*,
r.city_code
FROM read_csv_auto('input.csv') AS i
LEFT JOIN read_csv_auto('ref_sample.csv') AS r
ON i.city = r.city AND i.region = r.regionNota: l’uso di read_csv_auto nella query di sopra è una funzionalità di DuckDB che consente di leggere direttamente file CSV senza doverli importare prima in una tabella. In questo modo è possibile fare esperimenti veloci senza dover creare tabelle temporanee.
In output otteniamo un pessimo risultato: nessuna delle righe della tabella di sinistra trova corrispondenza nella tabella di destra, e quindi il campo city_code risulta sempre NULL.
| city | region | istat_city_code |
|---|---|---|
| Cefalu’ | Sicilia | NULL |
| Reggio Calabria | CALABRIA | NULL |
| RODENGO-SAIANO | Lombardia | NULL |
Fare il JOIN “fuzzy”
Un JOIN “fuzzy”, sfumato, è quello che consente di trovare corrispondenze anche quando i valori non sono esattamente uguali, ma “simili”. Ad esempio, potremmo voler considerare come corrispondenti i nomi di città che differiscono per un solo carattere, o che hanno una distanza di Levenshtein (numero di operazioni necessarie per trasformare una stringa in un’altra) inferiore a una certa soglia.
Potremmo riscrivere la query di join precedente in questo modo, usando la funzione levenshtein di DuckDB per il campo city in modo da considererare come corrispondenti i nomi di città che differiscono per al massimo 2 caratteri:
SELECT
i.city AS input_city,
i.region AS input_region,
r.city AS ref_city,
r.region AS ref_region,
r.city_code,
levenshtein(i.city, r.city) AS levenshtein_distance
FROM read_csv_auto('input.csv') AS i
JOIN read_csv_auto('ref_sample.csv') AS r
ON i.region = r.region
AND levenshtein(i.city, r.city) <= 2;| input_city | input_region | ref_city | ref_region | city_code | levenshtein_distance |
|---|---|---|---|---|---|
| Cefalu’ | Sicilia | Cefalù | Sicilia | 082027 | 2 |
L’unica città di cui c’è un risulato è soltanto Cefalu', perché la distanza di Levenshtein tra Cefalu' e Cefalù è 2: sostituzione di u' con ù e rimozione di '.
Se aumentiamo la soglia a 10, non cambia nulla, perché ad esempio il Comune di Rodengo Saiano è scritto in maiuscolo e con il trattino e la distanza tra RODENGO-SAIANO e Rodengo Saiano è pari a 12:
SELECT levenshtein('RODENGO-SAIANO', 'Rodengo Saiano') AS distance;
12Se si imposta la soglia a 15 ne otteniamo in ogni caso soltanto 2, perché il JOIN del campo region non trova corrispondenza tra CALABRIA e Calabria. Quindi dovremmo usare una funzione di distanza tra stringhe anche per il campo region:
SELECT
i.city AS input_city,
i.region AS input_region,
r.city AS ref_city,
r.region AS ref_region,
r.city_code,
levenshtein(i.city, r.city) AS levenshtein_distance
FROM read_csv_auto('input.csv') AS i
JOIN read_csv_auto('ref_sample.csv') AS r
ON levenshtein(i.city, r.city) <= 15
AND levenshtein(LOWER(i.region), LOWER(r.region)) < 10Ma è l’output non è proprio quello che ci aspettiamo, non 3 righe in totale (una per ogni Comune), ma ben 8 righe:
| input_city | input_region | ref_city | ref_region | city_code | levenshtein_distance |
|---|---|---|---|---|---|
| Cefalu’ | Sicilia | Cefalù | Sicilia | 082027 | 2 |
| Cefalu’ | Sicilia | Reggio di Calabria | Calabria | 080063 | 15 |
| Cefalu’ | Sicilia | Rodengo Saiano | Lombardia | 017163 | 12 |
| Reggio Calabria | CALABRIA | Cefalù | Sicilia | 082027 | 12 |
| Reggio Calabria | CALABRIA | Reggio di Calabria | Calabria | 080063 | 3 |
| Reggio Calabria | CALABRIA | Rodengo Saiano | Lombardia | 017163 | 10 |
| RODENGO-SAIANO | Lombardia | Cefalù | Sicilia | 082027 | 14 |
| RODENGO-SAIANO | Lombardia | Rodengo Saiano | Lombardia | 017163 | 12 |
Quando si esegue un JOIN esatto, l’obiettivo è trovare una singola e chiara corrispondenza per ogni riga. Nel mondo del “fuzzy matching”, le regole cambiano. Abbassando le nostre pretese con soglie di distanza permissive, non stiamo più chiedendo al database “trova l’unica corrispondenza giusta”, ma piuttosto:
Per ogni riga del mio input, trovami tutte le righe nel file di riferimento che soddisfano questi criteri di somiglianza generici.
Se una riga di input è “vagamente simile” a più righe di riferimento, il database creerà una riga di output per ciascuna di queste coincidenze. Questo effetto di moltiplicazione è noto come prodotto cartesiano delle coincidenze.
Dopo aver generato le possibili corrispondenze, dovremmo filtrarle per tenere solo la migliore per ogni record di partenza. Il processo può essere suddiviso in tre fasi:
- trovare tutte le possibili corrispondenze e calcolare le distanze
- assegnare un rango a ciascuna corrispondenza in base alla somma delle distanze
- selezionare solo la corrispondenza con il rango più alto (cioè la più simile)
-- Fase 1: trova tutti i candidati e calcola le distanze
WITH all_matches AS (
SELECT
i.city AS input_city,
i.region AS input_region,
r.city AS ref_city,
r.region AS ref_region,
r.city_code,
levenshtein(i.city, r.city) AS city_distance,
levenshtein(i.region, r.region) AS region_distance
FROM read_csv_auto('input.csv') AS i
JOIN read_csv_auto('ref_sample.csv') AS r
ON levenshtein(i.city, r.city) <= 15
AND levenshtein(i.region, r.region) < 10
),
-- Fase 2: assegna un rango ai candidati
ranked_matches AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY input_city, input_region
ORDER BY (city_distance + region_distance) ASC
) AS rn
FROM all_matches
)
-- Fase 3: prendi solo il miglior candidato (rn = 1)
SELECT
input_city,
input_region,
ref_city,
ref_region,
city_code,
city_distance,
region_distance
FROM ranked_matches
WHERE rn = 1;| input_city | input_region | ref_city | ref_region | city_code | city_distance | region_distance |
|---|---|---|---|---|---|---|
| Cefalu’ | Sicilia | Cefalù | Sicilia | 082027 | 2 | 0 |
| RODENGO-SAIANO | Lombardia | Rodengo Saiano | Lombardia | 017163 | 12 | 0 |
| Reggio Calabria | CALABRIA | Reggio di Calabria | Calabria | 080063 | 3 | 7 |
Una sintesi per ridurre le distanze
A seguira una tabellina di riepilogo, sull’esempio del nome del Comune di Forza·d'Agrò, scritto però in modo errato Forza··D’Agro·:
- c’è la
Dmaiuscola invece che minuscola; - ci sono due spazi in eccesso (uno tra
ForzaeD'Agroe uno alla fine); - non c’è la
oaccentata, ma unaonormale; - c’è un apostrofo tipografico
’, invece di uno dritto'.
Nota: qui sopra sono stati resi visibili gli spazi in eccesso con il carattere ·.
| descrizione | left | right | distanza | funzione aggiunta |
|---|---|---|---|---|
| Inizio | Forza··D’Agro· |
Forza·d'Agrò |
7 | |
| In minuscolo | forza··d’agro· |
forza·d'agrò |
6 | LOWER('value') |
| Rimossi spazi ridondanti | forza·d’agro |
forza·d'agrò |
5 | regexp_replace(trim(LOWER('value')), '\s+', ' ') |
| Rimossi accentati | forza·d’agro |
forza·d'agro |
3 | strip_accents(regexp_replace(trim(LOWER('value')), '\s+', ' ')) |
| Rimossi caratteri speciali | forza·dagro |
forza·dagro |
0 | regexp_replace(strip_accents(regexp_replace(trim(LOWER('value')), '\s+', ' ')), '[^a-zA-Z0-9 ]', '', 'g') |
👉 Quindi un caso molto brutto, pieno di errori, può essere normalizzato in modo da ridurre la distanza di Levenshtein a zero, e quindi trovare la corrispondenza esatta e Forza··D'Agro· è uguale a Forza·d'Agrò e quindi riesco a ricavarne il codice identificativo.
Note finali
In questo percorso abbiamo descritto alcuni elementi di base per misurare la “distanza” tra le stringhe e a normalizzare il testo per rendere il confronto più efficace. Abbiamo visto come gestire maiuscole/minuscole, spazi superflui, accenti e caratteri speciali, che sono gli elementi di base di ogni processo di pulizia dei dati.
Tuttavia, questo è solo l’inizio. Il mondo del “fuzzy matching” è molto più vasto e complesso. Per affrontare dataset più grandi e “disordinati”, si ricorre spesso a metodi più sofisticati, che vanno oltre il semplice conteggio delle modifiche:
- Algoritmi fonetici: Invece di guardare come sono scritte le parole, questi algoritmi le codificano in base a come suonano. Metodi come Metaphone o Soundex sono bravissimi a capire che “Smith” e “Smythe” sono probabilmente la stessa cosa.
- Modelli statistici (n-gram): Questi metodi scompongono le stringhe in piccole parti (es. coppie o triplette di caratteri) e ne confrontano la frequenza, risultando molto efficaci nel trovare somiglianze anche quando l’ordine delle parole è diverso.
Strumenti di pulizia dati come OpenRefine integrano decine di questi algoritmi avanzati, permettendo di raggruppare e correggere dati simili con grande efficacia.