Salta el contingut

Neteja: unitats i duplicats

Exercici cleaning-units-duplicates-annex.pdf · PDF

En un joc de crafting, dos objectes iguals s'apilen en una sola ranura. De tant en tant, però, dos objectes semblen idèntics i tot i això es neguen a apilar-se. La mateixa icona, el mateix nom, dues ranures. Alguna cosa que no pots veure els fa diferents.

Les dades fan això tot el temps. Un espai que no és un espai. Tres jocs que es diuen exactament igual i que no són el mateix joc. Un nombre que sembla «quanta gent té aquest joc» i no ho és. En aquesta pàgina aprens a trobar la diferència amagada abans de fusionar, separar o esborrar res.

Què aprendràs

  • Descobrir quin caràcter hi ha realment dins d'un text amb ASCII, i explicar què és un espai dur (non-breaking space)
  • Extreure un nombre d'un interval en text amb SPLIT_PART i REGEXP_REPLACE, i convertir-lo
  • Dir quina unitat té una columna, i per què un nombre sense la seva unitat és un tipus nou de brutícia
  • Distingir un duplicat real (la mateixa fila dues vegades) d'un duplicat aparent (el mateix nom, un altre joc)
  • Eliminar duplicats reals amb DISTINCT o ROW_NUMBER
  • Evitar la trampa: per què «una fila per nom de joc» esborra en silenci 77 files reals

Semblen idèntics, no s'apilen

L'inventari de brutícia va deixar dues línies per a avui: owners és text i conté un interval, i 74 noms de jocs apareixen més d'una vegada. Tots dos amaguen el mateix tipus de trampa, de manera que mantenim una sola analogia per a tota la pàgina: dos objectes que semblen iguals i no s'apilen.

Aquesta lliçó continua amb normalitzar: un tipus, un format i una unitat per columna, i una fila per cosa real.

flowchart LR
    subgraph A ["<b>Part 1</b>: la columna owners"]
        direction TB
        A1["'10,000,000 .. 20,000,000'<br>text, un interval"] --> A2["<b>owners_min</b><br>10000000, un nombre"]
    end
    subgraph B ["<b>Part 2</b>: noms repetits"]
        direction TB
        B1["74 noms apareixen<br>més d'una vegada"] --> B2["Duplicat real,<br>duplicat aparent<br>o zona grisa?"]
    end
    A --> B

L'espai que no és un espai

owners diu 10,000,000 .. 20,000,000. Volem el primer nombre. L'eina és SPLIT_PART(text, separator, n): talla un text en un separador i retorna la peça número n. El separador sembla evident: espai, dos punts, espai.

SELECT owners, SPLIT_PART(owners, ' .. ', 1) AS first_part
FROM games WHERE game = 'Half-Life 2';
owners first_part
10,000,000 .. 20,000,000 10,000,000 .. 20,000,000

No s'ha tallat res. first_part torna a ser el text sencer. Comprova quantes files contenen «espai, dos punts, espai»:

SELECT COUNT(*) FROM games WHERE owners LIKE '% .. %';   -- 0

Zero. Ni una fila. Els espais els veus a la pantalla, i la base de dades diu que no hi són.

Pren-li l'empremta

Un codi de caràcter és el nombre que un ordinador emmagatzema per a cada caràcter. La lletra, la xifra o l'espai que veus és només la manera com es dibuixa aquest nombre a la pantalla.

ASCII(character) mostra el codi. Agafa el caràcter que hi ha just abans dels punts i compara'l amb un espai escrit al teu teclat:

SELECT ASCII(SUBSTR(owners, POSITION('..' IN owners) - 1, 1)) AS mystery,
       ASCII(' ') AS normal_space
FROM games WHERE game = 'Half-Life 2';
mystery normal_space
160 32

El teu teclat escriu el caràcter 32. El fitxer conté el caràcter 160.

Un espai dur (non-breaking space) és un caràcter que sembla exactament un espai però diu a un programa «no trenquis la línia aquí». El seu codi és 160 (a l'estàndard Unicode, U+00A0; en una pàgina web s'escriu &nbsp;).

Aquesta taula es va recollir d'un lloc web, Steam Spy, i la decoració de la pàgina va venir amb les dades. Mateix aspecte, objecte diferent: no s'apilen, de manera que el separador mai no va coincidir.

Espai ordinari Espai dur
Codi 32 160
A la pantalla Un buit El mateix buit
S'escriu amb La barra espaiadora Normalment copiat d'una pàgina web o d'un document
TRIM el treu Sí No
Coincideix amb ' ' en una consulta Sí No

Un caràcter ocult és qualsevol caràcter que no pots distingir a ull: un espai dur, un tabulador, un salt de línia enganxat al final d'un valor.

Compte: TRIM no treu tots els espais

TRIM només treu l'espai ordinari, codi 32, i només als dos extrems. El codi 160 hi sobreviu intacte. Quan dos valors semblen iguals i la base de dades insisteix que són diferents, deixa de mirar la pantalla i demana els codis.

D'un interval en text a un nombre

Ara construeix el nombre, pas a pas. Cada fracàs t'ensenya una cosa.

Talla pel que et pots refiar. Els dos punts són caràcters ordinaris. Talla per aquí i mesura la peça:

SELECT SPLIT_PART(owners, '..', 1) AS part,
       LENGTH(SPLIT_PART(owners, '..', 1)) AS len,
       LENGTH('10,000,000') AS expected
FROM games WHERE game = 'Half-Life 2';
part len expected
10,000,000 11 10

Onze caràcters on en comptes deu. L'extra és l'espai dur, encara enganxat al final.

Falla a propòsit. Fes servir les eines de la lliçó anterior: TRIM l'espai, REPLACE les comes, converteix.

SELECT REPLACE(TRIM(SPLIT_PART(owners, '..', 1)), ',', '')::bigint FROM games;
ERROR:  invalid input syntax for type bigint: "10000000 "

Mira dins de les cometes, després de les xifres: encara hi ha un buit. TRIM no l'ha tret. Un error sorollós, per sort.

Queda't només amb les xifres. REGEXP_REPLACE(text, pattern, new, 'g') substitueix cada peça del text que coincideix amb un patró. El patró [^0-9] vol dir «qualsevol cosa que no sigui una xifra»: comes, espais de qualsevol classe i el caràcter estrany que aparegui l'any que ve.

SELECT REGEXP_REPLACE(SPLIT_PART(owners, '..', 1), '[^0-9]', '', 'g')::bigint AS owners_min,
       COUNT(*) AS n
FROM games GROUP BY owners_min ORDER BY owners_min;
owners_min n
0 17923
20000 3126
50000 1733
100000 1408
200000 1329
500000 546
1000000 325
2000000 211
5000000 50
10000000 30
20000000 3
50000000 3
100000000 1

Tretze valors, ordenats com a nombres, de 0 a 100,000,000. I la classificació que era un sense-sentit a l'auditoria ara té sentit: ordenat per owners_min de més a menys, Dota 2 és a dalt amb 100,000,000, seguit de tres jocs amb 50,000,000 (Counter-Strike: Global Offensive, Team Fortress 2 i PLAYERUNKNOWN'S BATTLEGROUNDS).

Peça de l'expressió Què fa
SPLIT_PART(owners, '..', 1) Es queda amb el que hi ha abans dels dos punts
REGEXP_REPLACE(..., '[^0-9]', '', 'g') Esborra tot el que no és una xifra
'g' «Global»: totes les coincidències, no només la primera
::bigint Converteix les xifres en un nombre enter gran

Compte: no t'oblidis del 'g'

Sense 'g', només s'elimina el primer caràcter que no és una xifra. La conversió falla llavors amb invalid input syntax for type bigint: "10000,000 ": una coma menys, l'altra i l'espai ocult encara hi són.

Un nombre necessita la seva unitat

Una unitat és el que un nombre compta o mesura: dòlars, minuts, persones, quilòmetres. Un nombre sense la seva unitat es pot llegir de diverses maneres, i almenys una és errònia.

Doncs quina és la unitat d'owners_min? Fa venir ganes de dir «persones que tenen el joc». No ho és.

Podries llegir Què és realment
«10,000,000 persones tenen Half-Life 2» «Com a mínim 10,000,000, segons una estimació»
«0 persones tenen aquest joc» «Entre 0 i 20,000»
Un recompte exacte El límit inferior d'un interval estimat

17,923 jocs tenen owners_min = 0. Això no vol dir que ningú no els hagi comprat. Vol dir «entre 0 i 20,000». Hem posat a la columna el nom owners_min, i no owners, justament per això: el nom porta la unitat.

El mateix val per a la resta de la taula. price és en dòlars dels EUA, no en euros. Res a la columna ho diu: només ho saps perquè la descripció del conjunt de dades ho indica. Apunta-ho.

Un error d'unitats que va costar una nau espacial

El 23 de setembre de 1999, la NASA va perdre el contacte amb el Mars Climate Orbiter quan arribava a Mart. La investigació en va trobar la causa en un fitxer de nombres. El programari del constructor, Lockheed Martin, informava de l'impuls dels propulsors en lliures-força segon (pound-force seconds). El programari de navegació del Jet Propulsion Laboratory de la NASA llegia aquests mateixos nombres com a newtons segon, la unitat mètrica. No faltava cap valor i cap consulta no va fallar. La nau, que havia costat uns 125 milions de dòlars, va entrar massa baixa i va ser destruïda.

Cada nombre era «correcte». Només la unitat era errònia, i ningú no l'havia escrit al costat del nombre.

Compte: nosaltres mateixos podríem haver fet aquesta brutícia

Netejar pot crear brutícia. Si haguéssim anomenat la nova columna owners i l'haguéssim posat en un gràfic titulat «Jugadors per joc», hauríem convertit un interval honest en un recompte fals. Posa a la columna el nom del que és, i digues-ho a l'informe.

Reals, aparents i grisos: tres tipus de «duplicat»

Ara els 74 noms que apareixen més d'una vegada. Esborrem les còpies? Abans de respondre, hi ha tres casos diferents.

Un duplicat real és una fila idèntica a una altra en totes les columnes: el mateix registre dues vegades. Un duplicat aparent només comparteix algunes columnes, com el nom, i en realitat és una cosa diferent.

Cas A games Què fer
Duplicat real Totes les columnes idèntiques: la mateixa fila dues vegades. Causa típica: un fitxer carregat dues vegades Eliminar les còpies. No es perd res
Duplicat aparent El mateix nom, diferent desenvolupador i data: Dark Matter són tres jocs diferents Conservar-los tots. Només s'assemblen
Zona grisa El mateix nom i desenvolupador, dues dates: Penumbra Overture, 2007 i 2009. Una reedició? Una decisió. La nostra: conservar-los i apuntar-ho

Dels 74 noms repetits, 32 tenen desenvolupadors diferents i 42 tenen el mateix desenvolupador amb dates diferents. Ni una sola parella comparteix alhora el nom i la data.

Té games duplicats reals?

SELECT DISTINCT * conserva una còpia de cada fila totalment idèntica. Compta abans i després:

SELECT COUNT(*) FROM games;                                  -- 26688
SELECT COUNT(*) FROM (SELECT DISTINCT * FROM games) AS t;    -- 26688

El mateix nombre. games no té duplicats reals. Els 74 noms repetits són tots duplicats aparents o zona grisa.

L'accident: un fitxer pujat dues vegades

Qui no ha premut mai Upload dues vegades? Ho pots simular sense tocar la taula. UNION ALL enganxa dos resultats un sota l'altre, còpies incloses:

WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games)
SELECT COUNT(*) FROM twice;                                  -- 53376

53,376 files: cada joc dues vegades. Ara DISTINCT es guanya el lloc:

WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games)
SELECT COUNT(*) FROM (SELECT DISTINCT * FROM twice) AS t;    -- 26688

Tornem a 26,688. Les còpies eren duplicats reals, de manera que eliminar-les no va fer perdre res.

Compte: DISTINCT depèn de les columnes que selecciones

DISTINCT només fusiona files idèntiques en totes les columnes seleccionades. SELECT DISTINCT * pregunta «es repeteix la fila sencera?». SELECT DISTINCT game pregunta «quins noms existeixen?» i retorna 26,611 files, fusionant els tres Dark Matter en un. Dues preguntes molt diferents amb la mateixa paraula clau.

Eliminar duplicats amb una clau

Una clau és la columna, o el grup de columnes, que diu «això és la mateixa cosa». Dues files amb la mateixa clau són el mateix objecte real.

De vegades les còpies no són perfectament idèntiques (una té un espai de més, una altra es va actualitzar més tard), i DISTINCT * no les atrapa. Llavors tries una clau i numeres les files dins de cada grup. ROW_NUMBER() fa la numeració: 1, 2, 3... tornant a començar a cada grup.

WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games),
numbered AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY game, release_date, developer ORDER BY number) AS rn
  FROM twice
)
SELECT rn, COUNT(*) FROM numbered GROUP BY rn ORDER BY rn;
rn count
1 26688
2 26688

PARTITION BY anomena la clau: nom, data i desenvolupador junts. Cada joc ha rebut un número de fila 1 i una còpia amb el número de fila 2. Queda't amb WHERE rn = 1 i les còpies han desaparegut.

flowchart TB
    Q{"La fila sencera és<br>idèntica a una altra?"}
    Q -- "Sí" --> T["<b>Duplicat real</b><br>elimina la còpia:<br>DISTINCT o rn = 1"]
    Q -- "No" --> K{"Coincideixen totes<br>les columnes de la clau?"}
    K -- "No: altre desenvolupador, altra data" --> A["<b>Duplicat aparent</b><br>una cosa diferent: conserva-la"]
    K -- "Algunes sí, d'altres no" --> G["<b>Zona grisa</b><br>decideix, i apunta-ho"]

La clau descuidada

Ara l'error que tothom té ganes de cometre: «un joc és el seu nom». Fes servir només PARTITION BY game, sobre la taula original:

WITH numbered AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY game ORDER BY number) AS rn
  FROM games
)
SELECT rn, COUNT(*) FROM numbered GROUP BY rn ORDER BY rn;
rn count
1 26611
2 75
3 2

Si només et quedes amb rn = 1, en queden 26,611 files. Aquesta «neteja» esborraria 77 files, i cap no és una còpia. Dos dels tres jocs Dark Matter hi són, i també el segon Penumbra Overture. Cap error. Cap avís. La taula simplement es fa més petita, i sembla més endreçada que abans.

Clau Files conservades Files perdudes Veredicte
La fila sencera (DISTINCT *) 26,688 0 Correcte: no hi havia duplicats reals
game, release_date, developer 26,688 0 Correcte: aquesta combinació és única a games
game sol 26,611 77 Incorrecte: esborra jocs reals que comparteixen nom

Com sé que la meva clau és correcta?

Demostra-ho. Agrupa per la clau i demana els grups amb més d'una fila: GROUP BY game, release_date, developer HAVING COUNT(*) > 1. A games no retorna res, de manera que la clau identifica cada fila. Si retorna files, mira-les abans de decidir res.

Prova-ho: owners_min i la caça de duplicats

Obre SQL → SQL Lab, base de dades Dades classe, esquema public. Executa una sentència cada vegada: selecciona-la i després Run.

Pas 1 — Troba el culpable. Executa el SPLIT_PART amb ' .. ', el recompte amb LIKE i la consulta amb ASCII. Hauries d'obtenir el text sencer, després 0, després 160 i 32.

Pas 2 — Construeix owners_min. Executa la consulta que mesura (11 contra 10), la consulta que falla a propòsit i la de REGEXP_REPLACE. Hauries de veure 13 files ordenades com a nombres.

Pas 3 — Afegeix-la a la consulta de neteja. Obre games_step1 a SQL → Saved Queries i fes clic al seu nom. Al bloc WITH, substitueix l'owners tal qual per l'expressió REGEXP_REPLACE AS owners_min, i canvia el nom del bloc a step2. Comprova-ho amb SELECT game, owners_min FROM step2 ORDER BY owners_min DESC LIMIT 4;. Dota 2 ha de ser a dalt.

Pas 4 — Desa-la amb un nom nou. Prem Save, canvia el nom a games_step2 i tria Save as new. Si triessis Update, sobreescriuries games_step1.

Pas 5 — Caça duplicats. Executa els dos recomptes amb DISTINCT *, les consultes twice i, finalment, la clau descuidada. Hauries de poder dir quantes files reals esborraria PARTITION BY game, i anomenar-ne una.

El separador ' .. ' m'ha coincidit. Per què?

Probablement has copiat el valor d'owners de la graella de resultats i l'has enganxat a la consulta. Els espais durs han vingut amb l'enganxada, de manera que el teu separador també conté el codi 160. Funciona, però ningú que llegeixi la teva consulta no pot veure per què. Tallar per '..' és la versió de la qual et pots refiar.

Idees clau

  • Cada caràcter s'emmagatzema com a codi de caràcter. Un espai dur (codi 160) sembla un espai (codi 32) i és un caràcter diferent: TRIM i ' ' no el toquen.
  • SPLIT_PART talla un text en un separador; REGEXP_REPLACE(..., '[^0-9]', '', 'g') deixa només les xifres; després converteixes.
  • Un nombre necessita la seva unitat. owners_min és el límit inferior d'una estimació, no un recompte: 0 vol dir «entre 0 i 20,000».
  • Un duplicat real és la fila sencera repetida. Un duplicat aparent només comparteix un nom. Entremig hi ha una zona grisa que necessita una decisió escrita.
  • games no té duplicats reals: DISTINCT * retorna les mateixes 26,688 files.
  • Una clau diu què fa que dues files siguin la mateixa cosa. Amb ROW_NUMBER() OVER (PARTITION BY key), les còpies són les files amb rn superior a 1.
  • Una clau descuidada esborra dades reals sense cap error: una fila per nom de joc perdria 77 files.

Comprovació ràpida

1. SPLIT_PART(owners, ' .. ', 1) retorna el text sencer, sense tallar. Per què?
  • Perquè SPLIT_PART no funciona amb columnes de text
  • Perquè els dos punts són caràcters especials en SQL
  • Perquè els espais al voltant dels punts són espais durs, codi 160, no l'espai ordinari que has escrit
  • Perquè la columna és buida per a aquest joc

El teu teclat escriu l'espai amb el codi de caràcter 32. El fitxer conté el codi 160, l'espai dur, que va venir de la pàgina web d'on es van recollir les dades. Es veuen igual i són caràcters diferents, de manera que el separador mai no coincideix.

2. Quina afirmació sobre TRIM és certa?
  • Treu tots els espais d'un text
  • Treu només els espais ordinaris dels dos extrems
  • Treu qualsevol caràcter que no sigui una xifra
  • Treu els espais durs però conserva els ordinaris

TRIM talla l'espai ordinari, codi 32, al principi i al final d'un text. Deixa en pau els espais del mig, i no reconeix el codi 160. Per descartar tot el que no sigui una xifra necessites REGEXP_REPLACE amb el patró [^0-9].

3. Què fa el 'g' a REGEXP_REPLACE(part, '[^0-9]', '', 'g')?
  • Fa que el resultat sigui un nombre
  • Ignora les lletres majúscules
  • Substitueix totes les coincidències, no només la primera
  • Agrupa el resultat com GROUP BY

'g' vol dir global. Sense ell només s'elimina el primer caràcter que no és una xifra, de manera que 10,000,000 es converteix en 10000,000 i la conversió a bigint falla amb un error.

4. Un joc té owners_min = 0. Què t'indica?
  • Que ningú no té el joc
  • Que el valor és absent
  • Que el joc té entre 0 i 20,000 propietaris, segons una estimació
  • Que el joc és gratuït

owners_min és el límit inferior d'un interval, i l'interval és una estimació. La seva unitat no és «persones que tenen el joc». Llegir-lo com un recompte exacte seria brutícia que hauríem creat nosaltres mateixos.

5. SELECT COUNT(*) FROM games i SELECT COUNT(*) FROM (SELECT DISTINCT * FROM games) AS t retornen tots dos 26,688. Què demostra això?
  • Que no es repeteix cap fila sencera: la taula no té duplicats reals
  • Que no es repeteix cap nom de joc
  • Que la columna number és una clau
  • Que DISTINCT no funciona amb taules grans

DISTINCT * només fusiona files idèntiques en totes les columnes. Recomptes iguals vol dir que no hi ha duplicats reals. No diu res dels noms: 74 noms encara són compartits per jocs diferents.

6. Dues files es diuen Penumbra Overture, totes dues de Frictional Games, una datada el 2007 i l'altra el 2009. Què és això?
  • Un duplicat real: esborra'n una
  • Un duplicat aparent: clarament dos jocs sense relació
  • Una zona grisa: necessita una decisió, i la decisió s'ha d'apuntar
  • Un valor atípic

El mateix nom i el mateix desenvolupador, però dues dates: potser una reedició, i les dades no ho diuen. No és un duplicat real (les files són diferents) i no és evidentment un joc diferent. A la taula de classe la decisió és conservar-los tots dos i deixar-ne constància.

7. Un company vol eliminar duplicats d'una taula d'alumnes conservant una fila per nom de pila. Tria les dues coses que li diries.
  • Primer comprova si es repeteix alguna fila sencera, amb DISTINCT *
  • Una fila per nom de pila és segur mentre la consulta no doni error
  • Un nom de pila no és una clau: persones diferents el comparteixen, de manera que s'esborrarien alumnes reals sense cap avís
  • Fes servir TRIM als noms i els duplicats desapareixeran

Aquesta és de nou la clau descuidada. A games, una fila per nom esborra 77 files reals i no apareix cap error. Una clau ha d'identificar una sola cosa real; demostra-ho amb un GROUP BY ... HAVING COUNT(*) > 1 que no retorni res.

Ara cada columna té un tipus, un format i una unitat que pots anomenar. Però 199 preus diferents i 13 valors de propietaris encara són difícils de llegir d'un cop d'ull. Tot seguit: Categorització, on els nombres es converteixen en etiquetes com Common, Rare i Epic.