Misión 4. Caza lo roto

▶ Abrir en Colab ▶ Ejecutar en Binder

Colab abre en segundos. Binder, la primera vez, construye el entorno en la nube (10-20 min) y luego queda en caché.

Misión 4. Encargo: «encuentra las canciones rotas».
Victoria: el catálogo limpio, y las canciones rotas a la vista.
Insignia en juego: cazador de atípicos.

El encargo

En la Misión 3 le hiciste al catálogo de música una pregunta y te contestó con nombre y cifra. Fue una gran victoria… pero te callé un detalle incómodo: te fiaste de que los números eran buenos. Y casi nunca lo son. Los datos del mundo real llegan sucios: con huecos donde un rasgo no se pudo medir, con la misma canción repetida dos veces porque un fichero se cargó dos veces, con un valor imposible que ninguna música pudo producir —un tempo de cero, como si una canción no tuviera ritmo alguno—, y con valores tan extremos que no sabes si son una pista rarísima de verdad o un fallo del análisis. Si calculas la media de un género sin mirar todo eso, tu respuesta será falsa aunque el código sea perfecto. Un programa impecable sobre datos rotos es una trampa con cara de rigor.

Por eso hay una frase que se repite en el oficio: preparar los datos se lleva la mayor parte del tiempo de un analista —hay quien dice que el 80 %—. La cifra exacta da igual; su valor está en la idea que hay detrás, y la dejó escrita @dasu2003exploratory: limpiar no es el trámite aburrido antes del análisis; limpiar es análisis. Cada decisión que tomas aquí —qué es un duplicado, si un hueco se rellena o se respeta, qué valor es físicamente imposible— cambia el resultado tanto como el modelo más sofisticado que le pongas después.

Para que veas que no exagero, imagina que un análisis averiado escribe un 9999 en el tempo de una pista —su manera de decir «no encuentro el ritmo»— y tú no lo cazas. Calculas el tempo medio del reggaeton y te sale altísimo; publicas «¡el reggaeton es rapidísimo!»; el periódico del instituto lo recoge; y todo era un número inventado por una máquina con hipo. No fallaste al programar la media —la media estaba perfecta—: fallaste al no mirar los datos antes. Ese es el peligro de los datos sucios: no te dan un error rojo que te avise, te dan una respuesta tranquila y falsa. Y la única defensa es cazarlos tú primero.

Aquí está tu encargo, y es de detective: encuentra las canciones rotas. No a ojo —son miles de pistas—, sino con la máquina, cazándolas por tipo y dejando un parte de cada una: duración cero, tempo imposible, popularity o rasgos fuera de rango. Cuando termines tendrás dos cosas: el catálogo limpio, listo para analizar sin mentiras, y un parte de averías que dice cuántos fallos había y de qué clase. Con eso te ganarás la insignia cazador de atípicos —la cuarta de la campaña—.

El mapa de la misión.

No vamos a lanzar una orden mágica que «lo limpie todo»: esa orden no existe, y quien te la venda te está engañando. Vamos a cazar los fallos de uno en uno, entendiendo cada uno antes de tocarlo. Este es el recorrido:

  1. Cargar el catálogo real y romperlo un poco a propósito, para saber exactamente qué cazamos (como un campo de tiro con dianas contadas).

  2. Los huecos: lo que no está. Encontrarlos, contarlos y decidir si se tapan.

  3. Los duplicados: lo que está dos veces. La clave que define «la misma pista».

  4. Los imposibles: lo que no pudo pasar. El rango físico y el tempo cero.

  5. Los atípicos: lo que se aleja demasiado. Las vallas de Tukey, explicadas sin álgebra.

  6. Decidir: marcar, investigar o tirar. El criterio propone; el dominio dispone.

  7. Juntarlo todo en una cadena de limpieza y un parte de averías, y superar al jefe de parte.

Una regla de oro nueva para esta misión, hermana del «predice antes de ejecutar» de la Misión 1: cuenta antes y después. Cada vez que limpies algo, mira cuántas filas entraban y cuántas salen. Un limpiador que no sabe cuánto tiró no está limpiando: está borrando a ciegas. Vamos allá.

La herramienta

Recuerda tu herramienta: la tabla

Antes de cazar, un repaso relámpago de la herramienta que dominaste en la Misión 3: la tabla, el DataFrame de pandas. Es una hoja de cálculo viva, con filas (cada pista) y columnas con nombre. De todo lo que aprendiste, hoy te apoyas en cuatro gestos —tenlos a mano—:

  • Elegir una columna: df["tempo"] te da esa columna entera.

  • Filtrar con una máscara: una condición como df["tempo"] > 180 produce una columna de True/False, y df[df["tempo"] > 180] se queda solo con las filas donde es True. Este es el gesto del cazador: una condición que señala lo que buscas.

  • Contar: .sum() sobre una columna de verdaderos los cuenta (cada True vale 1); len(df) cuenta filas y df.shape da (filas, columnas).

  • Agrupar: df.groupby("track_genre") parte la tabla por género para resumir cada uno por separado, y value_counts() cuenta cuántas veces sale cada valor.

Con esos cuatro —elegir, filtrar, contar, agrupar— se caza cualquier avería. Toda la misión es, en el fondo, inventar la condición que delata cada tipo de fallo y luego contar y enseñar lo que atrapa. Empecemos por traer la presa.

El catálogo, y cómo romperlo a propósito

En la Misión 2 descargaste el catálogo de música y en la Misión 3 lo interrogaste. Es un fichero real —musica.csv—, con las canciones más populares de cada género y sus rasgos de audio. Lo cargamos:

import numpy as np
import pandas as pd

cat = pd.read_csv("musica.csv")
print(cat.shape)
print(cat[["track_name", "track_genre", "popularity", "tempo"]].head())
(3351, 10)
             track_name track_genre  popularity    tempo
0               Hold On    acoustic          82  119.949
1             I'm Yours    acoustic          80  150.960
2                  Pano    acoustic          75  174.839
3  Give Me Your Forever    acoustic          74   99.905
4                 Lucky    acoustic          74  130.088

Una tabla —un DataFrame de pandas, tu herramienta de la Misión 3— con 3351 filas (una por pista) y diez columnas: el nombre, los artistas, el género (hay 114), la popularity de 0 a 100, y rasgos de audio como danceability, energy, valence, tempo (las pulsaciones por minuto) y duration_ms. Este catálogo real es bastante aseado: no le falta ninguna casilla. Pero esconde alguna canción rota de verdad —ya la cazaremos—. Y como para aprender a cazar conviene saber cuántas presas hay, vamos a hacer lo mismo que un artificiero que entrena con bombas de mentira contadas: meterle al catálogo unos cuantos fallos a propósito, con la máquina, para poder comprobar al final si los cazamos todos.

Todo arranca con una semilla. Ya la usaste: numpy sabe elegir filas al azar pero siempre las mismas si le das la misma semilla. La semilla 42 hará que tu catálogo saboteado salga idéntico al mío —esa es la reproducibilidad de la Misión 1, ahora al servicio de romper datos a mano—:

rng = np.random.default_rng(42)
sucio = cat.copy()
validos = cat.index[cat["tempo"] > 0].to_numpy()
huecos = rng.choice(validos, size=8, replace=False)
sucio.loc[huecos, "tempo"] = np.nan            # 8 huecos en el tempo

copias = sucio.iloc[[1, 4]]                     # 2 copias exactas
conflicto = sucio.iloc[[0]].assign(tempo=95.0)  # misma clave, otro tempo
sucio = pd.concat([sucio, copias, conflicto], ignore_index=True)
print(sucio.shape)
(3354, 10)

Acabamos de sabotear el catálogo. Fíjate en el reparto de averías, porque no todas las ponemos nosotros:

  • 8 huecos: en ocho pistas ponemos np.nan (Not a Number, «no es un número»), la marca de pandas para «aquí no hay dato». Es un tempo que no se pudo medir. Los ponemos nosotros, así que sabemos que hay 8.

  • 2 copias + 1 conflicto: repetimos dos pistas tal cual y añadimos una tercera con la misma canción, artista y género pero otro tempo. Por eso la tabla pasa de 3351 a 3354 filas. También los ponemos nosotros: 3 filas de más.

  • Lo que ya estaba: el catálogo real trae, de fábrica, unas pocas canciones rotas (tempo cero) y unas cuantas rapidísimas. Esas no las inventamos: las cazaremos y las contaremos.

Es una mezcla bonita: de los números finales, unos los pusimos a mano (y sabemos cuántos) y otros los sacaremos del catálogo de verdad. El resto de la misión consiste en redescubrirlos todos con la máquina y comprobar al final que las cuentas cuadran. Si tu cazador encuentra 8 huecos, funciona.

NotaPara saber más: por qué los datos vienen sucios

No es mala suerte ni descuido: es la naturaleza del dato. El sonido de una canción se analiza con un programa que a veces se equivoca o no encuentra el ritmo. El fichero se exporta con la coma decimal de un país y se lee con el punto de otro. Alguien concatena dos descargas que se solapan. Un mismo tema se cataloga en varios géneros. Y el nombre de un género se teclea hoy con mayúscula y mañana con tilde. Cada eslabón entre «la música real» y «tu tabla» puede introducir un fallo. Por eso limpiar no es castigo por hacer las cosas mal: es el peaje normal de trabajar con el mundo, que es tozudo y no cabe entero en una casilla.

Los huecos: lo que no está

El primer fallo, y el más estudiado, es la ausencia: la casilla vacía. En pandas se llama NaN, y tiene una virtud enorme —es honesto—: en lugar de inventarse un cero o un número cualquiera, la máquina dice «aquí no sé». Cazarlos es fácil, porque pandas tiene una orden que pregunta casilla por casilla «¿estás vacía?»: isna (de is NA, «es ausente»).

print("Huecos:", sucio["tempo"].isna().sum())
print(sucio[sucio["tempo"].isna()][["track_genre", "tempo"]])
Huecos: 8
     track_genre  tempo
287       brazil    NaN
298    breakbeat    NaN
1448   hardstyle    NaN
1467   hardstyle    NaN
2188         mpb    NaN
2334       piano    NaN
2586      reggae    NaN
2873   sertanejo    NaN

Ocho, los ocho que pusimos. Dos órdenes y ya los tienes localizados con su género —una es Your Song de Elton John (piano), otra La Bachata de Manuel Turizo (reggae)—. La primera línea, sucio["tempo"].isna().sum(), es un truco que usarás mil veces: isna() da una columna de verdaderos y falsos (True donde hay hueco), y sumar True como si fuera 1 cuenta los huecos. La segunda usa esa misma columna de verdaderos como filtro —lo aprendiste en la Misión 3— para enseñar solo las filas rotas. Contar y enseñar: los dos gestos del cazador.

Pero encontrar los huecos es media caza. La otra media es preguntarse dónde caen, porque eso te dice si son inocentes o traicioneros. Si se reparten por muchos géneros al azar, huelen a fallo tonto de exportación; si se apilan todos en el mismo género, huelen a algo peor —todo un estilo que no se llegó a analizar—. Vamos a mirarlo agrupando por género, con el value_counts de la Misión 3:

faltan = sucio[sucio["tempo"].isna()]
print(faltan["track_genre"].value_counts())
track_genre
hardstyle    2
brazil       1
breakbeat    1
mpb          1
piano        1
reggae       1
sertanejo    1
Name: count, dtype: int64

Repartidos: caen en siete géneros distintos, ninguno acaparador. Un reparto así es compatible con la explicación más inocente —fallos sueltos de exportación—, y con datos así de dispersos taparlos es defendible. Cuidado: la tabla sola no lo demuestra; solo lo sugiere. El porqué de verdad de un hueco no está en los datos, está en conocer cómo se hizo el catálogo. Pero ya tienes una pista, y las pistas se anotan.

NotaPara saber más: la familia de los huecos

Cuatro órdenes de pandas giran alrededor de los ausentes, y conviene no confundirlas. isna() pregunta «¿está vacía?» y devuelve True en los huecos; notna() es su contraria, True donde hay dato. dropna() tira las filas con huecos, y fillna(x) los rellena con x. Con estas cuatro se hace casi todo: contar huecos (isna().sum()), quedarte con lo lleno (df[df["tempo"].notna()]), tirarlos (dropna) o taparlos (fillna). Y un detalle que sorprende: un hueco ni siquiera es igual a sí mismo —np.nan == np.nan da False—, y por eso hay que preguntar con isna y no con == np.nan, que nunca acierta.

NotaPara saber más: las tres clases de hueco (MCAR, MAR, MNAR)

Los estadísticos distinguen tres motivos por los que falta un dato, y el motivo decide qué puedes hacer con él [@littlerubin2019missing]. MCAR (completamente al azar): el hueco no depende de nada, como un fallo de exportación que borra un tempo cualquiera; golpea cualquier género por igual. Es el caso amable —taparlo no engaña—. MAR (al azar, condicionado): el hueco depende de algo que ves, como un género que no llegó a analizarse; sus huecos se apilan en ese género. MNAR (no al azar): el hueco depende del propio valor que no ves —una pista sin ritmo, como el ruido blanco, a la que el análisis no le encuentra tempo—, y entonces el hueco aparece justo en las canciones cuyo tempo no existe. La trampa: los tres se ven igual en la tabla; distinguirlos es una hipótesis sobre el mundo, no un cálculo. La idea la fundó @rubin1976inference y tú, por ahora, solo necesitas quedarte con la moraleja: antes de tapar un hueco, pregúntate por qué falta.

NotaPara saber más: un hueco se contagia

¿Por qué molesta tanto un hueco? Porque NaN es pegajoso: en cuanto entra en una cuenta a pelo, la contamina.

a = pd.Series([120.0, np.nan, 140.0])
print("Suma:", a.sum())
print("Media:", a.mean())
print("120 + NaN =", 120 + np.nan)
Suma: 260.0
Media: 130.0
120 + NaN = nan

Fíjate en el matiz salvador: pandas es listo, y sus resúmenes (sum, mean) se saltan los huecos por su cuenta —la suma da 260 y la media 130, como si el hueco no estuviera—. Pero la aritmética cruda no perdona: 120 + np.nan es nan, y un solo nan basta para envenenar toda una columna de cálculos. Por eso conviene decidir qué hacer con los huecos antes de operar, y no confiar en que siempre se esfumen solos.

Tapar o no tapar un hueco

Tienes tres caminos con un hueco, y ninguno es «el bueno» siempre: depende. El primero es tirar la fila —si son pocos y repartidos, no pierdes casi nada—. El segundo es taparlo (los mayores dicen imputar). El más fácil de tapar es con un número que represente a la pista normal: la mediana, ese valor del medio que ya viste en la Misión 3 y que, a diferencia de la media, no se deja arrastrar por los extremos.

mediana = sucio["tempo"].median()
print("Mediana global:", round(mediana, 1))
tapado = sucio["tempo"].fillna(mediana)
print("Huecos tras tapar:", tapado.isna().sum())
Mediana global: 121.1
Huecos tras tapar: 0

fillna («rellena los NA») cambia cada hueco por el valor que le pases —aquí, 121,1 BPM— y los ocho huecos desaparecen. Rápido y honrado… pero un poco tonto: pone 121,1 tanto en un hueco de un tema de hardstyle (que ronda los 150 BPM) como en uno de balada (mucho más lento). Aplasta la identidad de cada género.

Hay una manera más lista: usar la mediana del propio género. Como la música no tiene un orden en el tiempo —no hay «la pista anterior» ni «la siguiente»—, el mejor vecino de una canción es su género. Un tema de hardstyle se parece más a otros de hardstyle que al catálogo entero:

med_gen = sucio.groupby("track_genre")["tempo"].median()
print("global:", round(mediana, 1),
      "hardstyle:", round(med_gen["hardstyle"], 1))
por_gen = sucio.groupby("track_genre")["tempo"].transform(
    lambda s: s.fillna(s.median()))
print("Huecos tras tapar por genero:", por_gen.isna().sum())
global: 121.1 hardstyle: 150.0
Huecos tras tapar por genero: 0

La mediana global (121,1) es lenta para el hardstyle, que va a 150. Taparlo con la global mentiría; taparlo con la mediana de su género (150,0) se parece mucho más a la verdad. El groupby(...) .transform(...) hace justo eso, género a género, de una pasada. Para un rasgo que depende tanto del estilo, es el relleno más honrado que tienes sin ir a buscar la canción real.

Y aquí llega el tercer camino, el más importante y el menos obvio: no taparlo. A veces el hueco no es un accidente, sino la señal de que ese valor no existe. Un ruido blanco para dormir no tiene ritmo; el análisis no le puede poner tempo, y decir «no sé» es lo correcto. Rellenar ese hueco con 121,1 sería inventar un pulso que la pista no tiene. Por eso la regla es: primero el motivo, después el método. Si el hueco es un fallo tonto y repartido, tápalo o tíralo sin drama; si señala que el rasgo no aplica a esa pista, respétalo. En esta misión, como verás, elegimos tirar los huecos, porque son pocos y lo que queremos es un tempo medio limpio; pero otro análisis podría exigir conservarlos. La técnica es la misma; la decisión, tuya.

NotaPara saber más: ¿por qué la mediana y no la media?

Para tapar un hueco solemos preferir la mediana —el valor que queda justo en el medio al ordenar— antes que la media. El motivo es el mismo que hace robustas a las vallas de Tukey: la media se deja arrastrar por los extremos y la mediana no. Si en un género hay unas pistas rapidísimas y un valor roto que se te escapó, la media sube y taparías los huecos con un número inflado; la mediana, que solo mira quién está en el centro, ni se entera. Un buen relleno debe parecerse a una pista normal, y «normal» lo describe mejor la del medio que el promedio de todas. Regla práctica: para tapar, mediana; y mejor aún la mediana de un grupo parecido (aquí, el género).

Cuando el número llega disfrazado de texto

Hay un fallo silencioso, y de los peores, que no se ve mirando los valores sino su tipo. En el catálogo, pandas supo que tempo era un número; pero si lo hubieras leído de otra fuente, una sola casilla rara puede hacer que toda la columna aterrice como texto, y sobre texto no hay media que valga ni rango físico que comprobar —como texto, hasta el orden miente: "9" es «mayor» que "10"—. Recuerda cómo se le pregunta a pandas el tipo de cada columna:

print(cat.dtypes)
track_name       object
artists          object
track_genre      object
popularity        int64
danceability    float64
energy          float64
valence         float64
tempo           float64
duration_ms       int64
explicit           bool
dtype: object

float64 es «número con decimales» (perfecto para el tempo), int64 es «entero», bool es verdadero/falso (la columna explicit) y object suele significar «texto». Si tempo apareciera como object, tendrías un problema. Imagina esta columna recién leída, con una coma decimal a la europea y un «no disponible» de un análisis despistado:

crudo = pd.Series(["119.9", "120,5", "n/d", "98.0"])
num = pd.to_numeric(crudo, errors="coerce")
print(num.tolist())
sospechosos = num.isna() & crudo.notna()
print("No convertibles:", crudo[sospechosos].tolist())
[119.9, nan, nan, 98.0]
No convertibles: ['120,5', 'n/d']

pd.to_numeric con errors="coerce" («fuerza») convierte a número lo que puede y, lo que no, lo vuelve NaN en vez de estallar. Cómodo… pero tramposo si no miras qué convirtió mal: aquí ha tirado dos valores, y solo uno lo merecía. El "120,5" no es basura, es un buen tempo con coma decimal, y se repara, no se tira; el "n/d" sí es un ausente de verdad. Repararlo es cambiar la coma por punto antes de convertir:

reparado = crudo.str.replace(",", ".", regex=False)
num = pd.to_numeric(reparado, errors="coerce")
print(num.tolist())
print("Ausentes de verdad:", int(num.isna().sum()))
[119.9, 120.5, nan, 98.0]
Ausentes de verdad: 1

Ahora el 120,5 se salva y solo queda un ausente, el «n/d», honradamente marcado como NaN. Coaccionar sin mirar habría tirado un dato legítimo; la regla es repara lo reparable y solo entonces convierte. Con la duración la trampa es aún peor, porque la conversión equivocada no da NaN sino un número plausible y falso: si una pista viene como "3:45" (minutos y segundos) y la lees como número, no vale 3,45 ni salta ningún error; hay que pasar los minutos y segundos a milisegundos a mano. Por eso, con duraciones y fechas, se dice siempre el formato.

Etiquetas que no cuadran

El mismo fallo, en las columnas de texto: una errata en una etiqueta crea un grupo fantasma. Mira qué pasa si en la columna del género alguien escribe el mismo estilo de tres maneras —con mayúscula, con tilde, sin nada—:

gen = pd.Series(["reggaeton", "Reggaeton", "reggaeton",
                 "reggaeton", "pop"])
print(gen.value_counts())
reggaeton    3
Reggaeton    1
pop          1
Name: count, dtype: int64

A ojo, «reggaeton» y «Reggaeton» son lo mismo; para la máquina son dos etiquetas distintas, y ese "Reggaeton" formaría su propio grupito de uno en cualquier recuento, falseando los totales por género. En el catálogo real, por suerte, los 114 géneros vienen todos en minúscula y sin sorpresas; pero en cuanto mezcles fuentes aparecen estas variantes (y con tildes es peor todavía). Se caza contando las etiquetas —un valor que aparece una sola vez y se parece sospechosamente a otro es un candidato— y se repara unificándolas (pasar todo a minúscula, quitar tildes). La moraleja se repite: los errores que la máquina no ve, porque para ella son datos válidos, son los más peligrosos.

Los duplicados: lo que está dos veces

Segundo fallo. Los duplicados nacen de la fontanería: un fichero cargado dos veces, dos descargas que se solapan, una tabla pegada consigo misma. Antes de buscarlos hay que responder una pregunta que parece de listillo y es la clave de todo: ¿duplicado de qué? Dos filas son «la misma pista» si coinciden en lo que la identifica de verdad: la canción, el artista y el género. A esa combinación se le llama clave natural —la huella dactilar de cada pista—. Dos filas con la misma huella son la misma pista, tengan el mismo tempo o no.

CLAVE = ["track_name", "artists", "track_genre"]
print("Filas identicas:", sucio.duplicated().sum())
print("Misma clave:", sucio.duplicated(subset=CLAVE).sum())
Filas identicas: 2
Misma clave: 3

duplicated() a secas marca las filas idénticas de punta a punta, y ve dos: las dos copias exactas. Pero con subset=CLAVE compara solo la huella, y encuentra tres. ¿De dónde sale la tercera? De la fila conflictiva: misma canción, artista y género que otra, pero distinto tempo. Es el duplicado peligroso, el que no se ve si solo buscas filas calcadas. Mirémoslos todos con keep=False, que marca todas las apariciones, no solo las repetidas:

print(sucio[sucio.duplicated(subset=CLAVE, keep=False)]
      [["track_name", "track_genre", "tempo"]])
     track_name track_genre    tempo
0       Hold On    acoustic  119.949
1     I'm Yours    acoustic  150.960
4         Lucky    acoustic  130.088
3351  I'm Yours    acoustic  150.960
3352      Lucky    acoustic  130.088
3353    Hold On    acoustic   95.000

Léelo por parejas de misma huella. Las filas 1 y 3351 son gemelas (I’m Yours, 150,96 las dos): borrar una es limpieza sin riesgo. Las 4 y 3352, igual (Lucky). Pero las filas 0 y 3353 (Hold On) discrepan: para la misma canción dicen 119,9 y 95,0 de tempo. Una pista no tiene dos tempos, así que una sobra —y cuál sobra no lo decide pandas, lo decides tú con un criterio—. Para quitarlos está drop_duplicates:

sin_dup = sucio.drop_duplicates(subset=CLAVE)
print(sucio.shape, "->", sin_dup.shape)
(3354, 10) -> (3351, 10)

De 3354 a 3351: cayeron las tres de más. Por defecto, drop_dup­licates se queda con la primera aparición de cada huella —por eso conserva el 119,9 (la original) y tira el 95,0 (el conflicto que añadimos después)—. Quedarse con la primera o con la última es razonable solo si el orden significa algo; si no sabes cuál de las dos es la buena, lo honrado es documentar el conflicto, no elegir a cara o cruz y disimular. Tienes tres formas de decidir, y conviene ver cuánto quita cada una:

print("first:", len(sucio.drop_duplicates(subset=CLAVE)))
print("last :", len(sucio.drop_duplicates(subset=CLAVE, keep="last")))
print("False:", len(sucio.drop_duplicates(subset=CLAVE, keep=False)))
first: 3351
last : 3351
False: 3348

keep="first" y keep="last" dejan una de cada pareja (3351 filas): la diferencia es cuál del conflicto sobrevive, el 119,9 o el 95,0. Pero keep=False es el más severo: tira todas las filas que tengan alguna copia —las seis—, y por eso quedan 3348. ¿Cuándo se usa el severo? Cuando un conflicto te da tan mala espina que prefieres quedarte sin ninguna de las dos versiones antes que arriesgarte con la equivocada. Descartar las dos y dejar constancia es, muchas veces, más honesto que elegir a ciegas.

NotaPara saber más: una clave mal elegida miente

La clave la dicta el problema, no pandas, y equivocarse cuesta caro. Si la haces demasiado corta —por ejemplo, solo ["track_name", "artists"], olvidando el género— pandas creería que muchas pistas son duplicados y borraría canciones buenas. Míralo en el catálogo real:

corta = cat.duplicated(subset=["track_name", "artists"]).sum()
print("Falsos duplicados con clave corta:", corta)
print(cat[cat["track_name"] == "You're Gonna Go Far, Kid"]
      [["artists", "track_genre"]].to_string(index=False))
Falsos duplicados con clave corta: 626
      artists track_genre
The Offspring    alt-rock
The Offspring alternative
The Offspring       metal
The Offspring        punk
The Offspring   punk-rock
The Offspring         ska

¡626! Una misma canción popular se cataloga legítimamente en varios géneros —You’re Gonna Go Far, Kid de The Offspring está en seis—, y con la clave corta pandas las tomaría por copias y borraría 626 filas buenas. Con el género dentro de la clave, esas seis son seis pistas distintas, no un duplicado. Pensar la clave es pensar qué es «una observación» en tus datos.

Los imposibles: lo que no pudo pasar

Tercer fallo, y el más fácil de juzgar, porque no hace falta estadística: basta la física de la música. El tempo de una canción con ritmo no puede ser cero —no existe música sin pulso—, ni puede ser 300, porque ninguna música humana llega a esa velocidad. El dominio fija un rango físico —aquí, un tempo mayor que 0 y hasta 250 BPM— y todo lo que se salga de él no es un dato raro: es un error, y se descarta sin remordimiento.

imposibles = sucio[(sucio["tempo"] <= 0) | (sucio["tempo"] > 250)]
print("Imposibles:", len(imposibles))
print(imposibles[["track_genre", "tempo", "duration_ms"]])
Imposibles: 3
     track_genre  tempo  duration_ms
2759     romance    0.0       122206
2968       sleep    0.0        90228
2970       sleep    0.0        63000

Ahí están tus canciones rotas, y son reales —no las metimos nosotros, estaban en el catálogo de verdad—. Tres pistas con tempo cero: dos del género sleep (Clean White Noise… y Deep Sleep Recovery Noise), que son ruido blanco para dormir —audio sin ritmo, así que el análisis escribió 0 con toda la honradez—, y un viejo remaster. El filtro combina dos condiciones con | («o»): tempo menor o igual que 0 o mayor que 250. Cualquiera de las dos delata un imposible. Ojo a un disfraz muy común de este fallo: los centinelas. Muchos programas, cuando no saben qué medir, no escriben un hueco honrado sino un número absurdo pactado —9999, o \(-1\)— que un ojo despistado tomaría por una canción vertiginosa. Un 9999 en el tempo no es una pista rapidísima: es la máquina diciendo «error» con un número. Lo bueno es que el mismo filtro los caza, sin cambiar una línea:

con = sucio.copy()
con.loc[0, "tempo"] = 9999.0                # un centinela colado
imp = con[(con["tempo"] <= 0) | (con["tempo"] > 250)]
print("Imposibles:", len(imp))
Imposibles: 4

Cuatro: las tres rotas de fábrica y el 9999 colado, cazados por la misma red. La misma idea vale para los demás rasgos —la popularity tiene que estar entre 0 y 100, la duration_ms ha de ser mayor que cero, y danceability, energy o valence viven entre 0 y 1—: cada columna tiene su rango físico, y todo lo que se salga es basura. Por eso el rango se comprueba siempre, y por eso los imposibles se cazan antes que los atípicos: no tiene sentido preguntarse si un tempo de 0 es «estadísticamente raro»; es que no existe.

Los atípicos: lo que se aleja demasiado

Y llegamos al fallo más sutil y más bonito de cazar. Un valor atípico (en inglés, outlier) es una medición sospechosamente alejada del resto. La palabra clave es sospechosamente: un atípico no es necesariamente un error. Un tempo de 205 BPM puede ser una canción de drum and bass o de power metal —rápida de verdad— o un fallo del análisis. El criterio estadístico no dice cuál de las dos: solo señala candidatos, y luego un humano investiga.

¿Cómo decide la máquina qué es «alejado»? Con una idea de John Tukey tan simple que se dibuja. Ordena todos los tempos de menor a mayor y parte el montón en cuatro trozos iguales. Los dos cortes de los lados se llaman cuartiles: \(Q_1\) deja por debajo a la cuarta parte más lenta, y \(Q_3\) deja por debajo a las tres cuartas partes. Entre los dos está la mitad central de tus datos —la gente «normal» de la fiesta—, y la anchura de esa mitad, \(Q_3 - Q_1\), se llama rango intercuartílico (IQR). Ahora viene el truco: planta dos vallas, una a cada lado, a una distancia de IQR y medio de los cuartiles. Lo que cae fuera de las vallas es el candidato a atípico. Es como decir «normal es parecerse al montón del medio; quien se aleja más de una valla, que se explique».

valido = (sucio.drop_duplicates(subset=CLAVE)
          .dropna(subset=["tempo"])
          .query("0 < tempo <= 250"))
q1 = valido["tempo"].quantile(0.25)
q3 = valido["tempo"].quantile(0.75)
iqr = q3 - q1
valla_baja = round(q1 - 1.5 * iqr, 1)
valla_alta = round(q3 + 1.5 * iqr, 1)
print("Q1:", round(q1, 1), "Q3:", round(q3, 1), "IQR:", round(iqr, 1))
print("Vallas:", valla_baja, "a", valla_alta)
Q1: 100.0 Q3: 139.9 IQR: 39.9
Vallas: 40.1 a 199.8

Fíjate en el orden: antes de buscar atípicos hemos quitado duplicados, huecos e imposibles —no queremos que el tempo 0 ensucie los cuartiles—. La mitad central de la música va de 100 a 139,9 BPM (el pulso típico del pop y el dance), así que el IQR es 39,9. Las vallas caen en 40,1 (la baja) y 199,8 (la alta). Y aquí una sorpresa que enseña: por abajo no cazaremos nada. La música más lenta del catálogo sigue por encima de 40 BPM, así que ninguna pista baja de la valla baja. La forma de los datos te acaba de contar algo del mundo. Ahora cazamos:

fuera = (valido["tempo"] < valla_baja) | (valido["tempo"] > valla_alta)
print("Atipicos:", fuera.sum())
print(valido[fuera].sort_values("tempo", ascending=False, kind="stable")
      .head(6)[["track_name", "track_genre", "tempo"]])
Atipicos: 24
                    track_name track_genre    tempo
3305              Wind Brought     turkish  209.557
1700                Bubble Gum   indie-pop  207.970
1435          93 'Til Infinity    hardcore  206.247
1894          93 'Til Infinity        jazz  206.247
1742                  Center X     iranian  206.139
1551  Last Date - Instrumental  honky-tonk  205.400

Veinticuatro candidatos, todos rapidísimos (por arriba, de 199,8 a 209,6 BPM) —enseñamos los seis más veloces—. Y mira qué importante: no son errores. Son canciones rápidas de verdad, repartidas por géneros muy distintos; entre las veinticuatro hay power metal de DragonForce, temas de drum and bass y hasta canciones infantiles de CoComelon a 200 BPM. Las vallas no las conocían y aun así las han señalado, sin marcar de rebote ninguna pista normal. A diferencia del tempo 0 —imposible, se tira—, estas son música real: atípicas pero posibles. El criterio propone; el humano dispone.

NotaPara saber más: la fórmula de las vallas (y su prima, la z)

Lo que dibujamos tiene una fórmula de una línea. Con los cuartiles \(Q_1\) y \(Q_3\) y el rango intercuartílico \(\text{IQR}=Q_3-Q_1\), las vallas de Tukey son: \[\text{valla baja}=Q_1-1{,}5\cdot\text{IQR}, \qquad \text{valla alta}=Q_3+1{,}5\cdot\text{IQR}.\] El \(1{,}5\) no es una ley del universo: es una heurística para explorar. Con \(3\cdot\text{IQR}\) en vez de \(1{,}5\) marcarías solo los casos extremos [@tukey1977eda]. Existe otra receta escolar, la puntuación \(z\): mira a cuántas desviaciones típicas de la media está cada valor y marca lo que pase de 3. Tiene una trampa elegante —la media y la desviación se calculan con los atípicos dentro, así que una cola larga infla la desviación y acaba camuflando a los extremos—; por eso muchos prefieren versiones robustas basadas en la mediana [@iglewicz1993outliers]. Recuérdalo: distintos umbrales dan distintas respuestas. Los umbrales no descubren atípicos; los definen.

Por curiosidad, comparemos las vallas de Tukey con la puntuación \(z\) sobre este mismo catálogo, a ver si se ponen de acuerdo:

z = (valido["tempo"] - valido["tempo"].mean()) / valido["tempo"].std()
print("Tukey:", fuera.sum(), " z>3:", (z.abs() > 3).sum())
Tukey: 24  z>3: 2

No coinciden. Tukey señala 24 y la \(z\) solo 2. ¿Por qué? Porque los tempos altos forman una cola larga, y esa cola infla la desviación típica; la \(z\), que se mide en desviaciones, se vuelve perezosa y solo pilla los dos casos más extremos (208 y 209,6). Es justo la fragilidad que anunciaba la caja: los valores gordos, al entrar en el cálculo, se camuflan a sí mismos. Cuando los atípicos son pocos y aislados, los dos métodos coinciden; aquí, con una cola larga de tempos altos, discrepan —y saber por qué la \(z\) es más frágil te separa de quien aplica una fórmula sin entenderla—.

El orden importa: primero los imposibles

¿Te acuerdas de que calculamos las vallas después de tirar el tempo 0? No fue por manía de orden: si un imposible se cuela en el paso de los atípicos, puede romperlo. Míralo. Metemos un centinela 9999 entre los tempos y comparamos qué le pasa a la valla de Tukey (hecha de cuartiles) frente al viejo truco de «la media más tres desviaciones»:

con = pd.concat([valido["tempo"], pd.Series([9999.0])], ignore_index=True)
q1, q3 = con.quantile([0.25, 0.75])
iqr = q3 - q1
print("Tukey valla alta:", round(q3 + 1.5 * iqr, 1))
media, desv = con.mean(), con.std()
print("Corte media + 3 desv:", round(media + 3 * desv, 1))
Tukey valla alta: 199.8
Corte media + 3 desv: 644.5

La valla de Tukey no se inmuta: sigue en 199,8, porque los cuartiles no se dejan impresionar por un solo monstruo —son el valor del medio, y da igual que el de la punta sea 209 o 9999—. En cambio el corte de la media se dispara a 644,5. ¿Qué significa eso? Que con ese corte, tu drum and bass real de 174 BPM pasaría por normalísimo, porque el 9999 ha inflado la vara de medir para todos los demás y ha borrado del mapa a las pistas rápidas de verdad. La moraleja son dos reglas que ya intuías y ahora ves con números: caza los imposibles antes que los atípicos —para que no envenenen el cálculo— y, cuando puedas, mide con cuartiles, que aguantan el golpe, y no con la media, que se deja arrastrar.

Dibuja las vallas

Todavía no sabemos pintar de verdad —eso es la Misión 5—, pero, como en la Misión 1, podemos dibujar con caracteres y ver de un vistazo la forma de los tempos. Repartimos las pistas en franjas de 20 BPM y hacemos una barra por franja (cuantas más canciones, más larga), marcando dónde cae la valla alta:

t = valido["tempo"]
print("valla alta =", valla_alta, "BPM  (marcada con <==)")
for lo in range(40, 220, 20):
    c = int(((t >= lo) & (t < lo + 20)).sum())
    barra = "#" * round(c / 25)
    marca = " <== valla" if lo <= valla_alta < lo + 20 else ""
    print(f"{lo:3d}-{lo + 20:3d} {barra}{c:>5d}{marca}")
valla alta = 199.8 BPM  (marcada con <==)
 40- 60    10
 60- 80 ########  208
 80-100 #########################  627
100-120 ##############################  749
120-140 #####################################  931
140-160 #################  436
160-180 ############  290
180-200 ###   72 <== valla
200-220 #   17

Se ve la joroba enorme en 120–140 (931 canciones) y una cola que se adelgaza hacia la derecha hasta las pistas rapidísimas; la valla (199,8) cae en la última franja poblada. Y aquí un matiz que enseña: entre lo normal y lo rápido no hay un abismo. Los tempos bajan poquito a poco desde 209 hasta la valla y siguen. El atípico no es un salto, es el final de una cola larga. Eso te recuerda algo: la valla es una línea que eliges, no un precipicio natural —por eso el umbral define los atípicos, no los descubre—.

NotaPara saber más: esto se llama diagrama de caja

Lo que has dibujado a mano —una mitad central «normal», unas vallas a los lados y unos puntos sueltos más allá— tiene nombre y es uno de los gráficos más usados en estadística: el diagrama de caja (en inglés, box plot), inventado por el mismísimo Tukey [@tukey1977eda]. La caja marca del primer al tercer cuartil, una raya dentro señala la mediana, unos «bigotes» llegan hasta las vallas y los atípicos aparecen como puntitos fuera. En la Misión 5, cuando aprendas a pintar de verdad, lo harás en condiciones con una sola orden, y podrás poner la cajita de cada género al lado de las demás para ver de un vistazo cuál va más rápido. Hoy lo has entendido por dentro; pronto lo tendrás bonito.

Marcar, investigar o tirar

Ya los tienes cazados. ¿Y ahora qué? Aquí está la decisión más importante de la misión, y la más fácil de hacer mal: un atípico no se borra por ser raro. Tienes tres opciones, de la más suave a la más agresiva:

  • Marcar: añadir una columna que diga «esta es sospechosa», sin tocar el valor. Es la opción por defecto y casi siempre la correcta: conservas el dato y avisas.

  • Investigar: volver a la fuente. ¿Es un género que de verdad va rápido, como el drum and bass? ¿O falló el análisis de esa pista? Es trabajo de detective, y es el que de verdad resuelve el caso.

  • Tirar: reservado a los imposibles físicos —el tempo 0, el 9999—, no a los atípicos posibles. Borrar una canción rápida de verdad porque «va muy deprisa» sería destruir parte del catálogo.

Nuestras 24 pistas rápidas son valores posibles, así que lo honrado es marcarlas, no borrarlas:

limpio = valido.copy()
limpio["atipico"] = fuera.values
print("Marcados:", limpio["atipico"].sum())
Marcados: 24

Ahora tu tabla lleva una columna atipico de verdaderos y falsos: las 24 pistas rápidas quedan señaladas con True, y el dato sigue ahí, intacto, por si resulta ser oro. Esta es la diferencia entre un analista honesto y una máquina de borrar: el criterio estadístico propone candidatos, pero el dominio —la música, el sentido común, la fuente— dispone qué se hace con cada uno. Marcar es afirmar «lo he visto y decido conservarlo»; es lo contrario de barrer bajo la alfombra.

NotaPara saber más: recortar en vez de borrar (winsorizar)

Entre «dejar el atípico tal cual» y «borrarlo» hay un punto medio con nombre raro: winsorizar. Consiste en no tirar el valor extremo, sino rebajarlo hasta la valla —un tempo de 209 con la valla en 199,8 se convierte en 199,8—, de modo que sigue contando como «alto» pero deja de dominar la media él solo. Es útil cuando sospechas que el valor es medio real y medio avería, y no quieres ni fiarte del todo ni perderlo. Como todo en esta misión, es una decisión que hay que poder defender: recortar cambia el dato, así que se hace con la conciencia de que se hace —y se apunta en la bitácora—, nunca a escondidas.

La victoria

Tienes las cuatro cazas por separado: huecos, duplicados, imposibles y atípicos. La victoria de hoy es juntarlas en una sola cadena que deje el catálogo limpio de una pasada, y en un parte de averías que diga, con números, qué había roto. Empecemos por la cadena, y hagámosla paso a paso, contando las filas después de cada eslabón —la regla de oro de la misión: cuenta antes y después—. Así ves la factura de cada limpieza por separado:

paso0 = sucio
paso1 = paso0.drop_duplicates(subset=CLAVE)   # fuera los duplicados
paso2 = paso1.dropna(subset=["tempo"])        # fuera los huecos
paso3 = paso2.query("0 < tempo <= 250")       # fuera los imposibles
print("bruto      ", paso0.shape)
print("sin dup    ", paso1.shape)
print("sin huecos ", paso2.shape)
print("sin imposib", paso3.shape)
bruto       (3354, 10)
sin dup     (3351, 10)
sin huecos  (3343, 10)
sin imposib (3340, 10)

Se lee como un descenso con cada baja anotada: de 3354 caen 3 duplicados (quedan 3351), luego 8 huecos (quedan 3343), luego 3 imposibles (quedan 3340). Cada baja tiene nombre y motivo —eso es limpiar con honestidad, y no «me quedé con 3340 no sé por qué»—. Y como cada paso devuelve una tabla, se pueden encadenar en una sola receta, un eslabón por línea, que hace lo mismo de una pasada:

limpio = (sucio
          .drop_duplicates(subset=CLAVE)   # fuera los duplicados
          .dropna(subset=["tempo"])        # fuera los huecos
          .query("0 < tempo <= 250"))      # fuera los imposibles
print(sucio.shape, "->", limpio.shape)
(3354, 10) -> (3340, 10)

Mismo resultado, 3354 a 3340, en tres líneas que se leen de arriba abajo. Fíjate en que las 24 pistas rápidas siguen dentro: son valores posibles, las marcamos pero no las tiramos. La cadena echa fuera lo roto; lo solo sospechoso se conserva señalado. Y una comprobación que nunca sobra —¿cuánto del catálogo sobrevivió limpio?— es la cobertura:

print("Cobertura:", round(100 * len(limpio) / len(cat), 1), "%")
Cobertura: 99.7 %

De las 3351 pistas del catálogo, conservamos 3340 limpias: un 99,7 % de cobertura. Ese número también es un dato del informe —si hubiera salido un 60 %, más que una media te tocaría explicar por qué falta tanta música—.

Ahora el parte. Un buen parte de averías es lo que entregarías a quien confía en tus datos: cuántos fallos de cada tipo, y cuánto quedó limpio. Lo empaquetamos en una función que sirve para cualquier catálogo con una columna tempo, no solo para este:

def parte_de_averias(df, clave):
    limpio = (df.drop_duplicates(subset=clave)
              .dropna(subset=["tempo"])
              .query("0 < tempo <= 250"))
    q1 = limpio["tempo"].quantile(0.25)
    q3 = limpio["tempo"].quantile(0.75)
    iqr = q3 - q1
    valla_baja = q1 - 1.5 * iqr
    valla_alta = q3 + 1.5 * iqr
    fuera = (limpio["tempo"] < valla_baja) | (limpio["tempo"] > valla_alta)
    return {
        "filas_brutas": len(df),
        "huecos": int(df["tempo"].isna().sum()),
        "duplicados": int(df.duplicated(subset=clave).sum()),
        "imposibles": int(((df["tempo"] <= 0)
                           | (df["tempo"] > 250)).sum()),
        "atipicos": int(fuera.sum()),
        "filas_limpias": len(limpio),
    }


parte = parte_de_averias(sucio, CLAVE)
for clave, valor in parte.items():
    print(clave, "->", valor)
filas_brutas -> 3354
huecos -> 8
duplicados -> 3
imposibles -> 3
atipicos -> 24
filas_limpias -> 3340

Ahí está tu victoria, en seis cifras. De 3354 filas brutas: 8 huecos, 3 duplicados, 3 imposibles (las canciones rotas) y 24 atípicos (las rápidas), y 3340 pistas limpias para trabajar. De los seis números, dos los pusimos nosotros (los 8 huecos y los 3 duplicados) y cuatro los sacó del catálogo real. Le has dado a la máquina un catálogo saboteado y te ha devuelto un diagnóstico completo —limpio y con los fallos a la vista, que era justo el encargo—. Y como todo salió de la función, mañana se lo pasas a otro catálogo o a los 114 000 temas del volumen adulto, y hará el mismo trabajo sin que cambies una línea.

El parte, de un vistazo

Un parte con números está muy bien, pero se cuenta mejor con una imagen. Como en la Misión 1, una barra por tipo de avería —cuantos más fallos, más larga— lo deja claro de un golpe:

for tipo in ["huecos", "duplicados", "imposibles", "atipicos"]:
    print(f"{tipo:11s} {'#' * parte[tipo]} {parte[tipo]}")
huecos      ######## 8
duplicados  ### 3
imposibles  ### 3
atipicos    ######################## 24

De un vistazo: las rápidas fueron la «avería» más numerosa (24, aunque no sean un error) y los huecos, la siguiente (8). Un gráfico así, aunque sea de almohadillas, es lo que pegarías al principio de un informe para que quien lo lea sepa, antes de mirar ni un número del análisis, qué tan sucios venían los datos —y, por tanto, cuánto puede fiarse de lo que viene después—.

¿Lo lograste?

Toca comprobarlo, que un analista no cree: verifica. Como en la Misión 1, el guardián es assert: si todo cuadra, calla y te felicita; si algo falla, te para y te da la pista. Y lo bonito es que sabemos las respuestas: dos las pusimos nosotros (8 y 3) y las otras tres las cazamos en el catálogo real (3, 24 y 3340).

assert parte["huecos"] == 8, "Revisa los huecos."
assert parte["duplicados"] == 3, "Revisa los duplicados."
assert parte["imposibles"] == 3, "Revisa el rango de tempo."
assert parte["atipicos"] == 24, "Revisa las vallas de Tukey."
assert parte["filas_limpias"] == 3340, "Revisa la cadena."
print("Lo lograste. Insignia: cazador de atipicos.")
Lo lograste. Insignia: cazador de atipicos.

Si ves ese mensaje, enhorabuena de verdad: has cazado las cuatro clases de avería, has dejado el catálogo limpio con las cuentas cuadradas y te has ganado la insignia cazador de atípicos. Si en cambio salta un «Revisa…», no es un fracaso —es el guardián señalándote exactamente qué caza se te escapó—. Vuelve a ese fallo, míralo con calma y prueba otra vez.

La bitácora del analista

Abre tu bitácora —la que empezaste en la Misión 1— y añade la entrada de hoy. Un cazador de datos apunta siempre qué encontró y qué decidió, porque esas decisiones son las que tendrá que defender. Para la Misión 4, anota al menos:

  • El parte: cuántos fallos de cada tipo (8 huecos, 3 duplicados, 3 imposibles, 24 atípicos) y cuántas filas limpias quedaron (3340).

  • Una decisión que tomaste y su porqué: por ejemplo, «tiré los huecos porque eran pocos y repartidos» o «marqué las pistas rápidas en vez de borrarlas porque son música posible».

  • La pista de las canciones rotas: dos de las tres de tempo 0 son ruido blanco del género sleep. ¿Por qué tendría sentido que un ruido blanco no tenga tempo?

  • Una duda que te llevas —la que sea—. Las dudas anotadas son las que luego se resuelven.

Escribir lo que decidiste es la mejor forma de descubrir las decisiones que tomaste sin darte cuenta.

Sube de nivel: un parte por género

Un parte global está bien, pero un analista fino mira dónde se concentran las cosas. Repartamos las pistas rápidas por género con el value_counts de la Misión 3, a ver si alguna familia de música las acapara.

Retomamos la tabla marcada —la cadena de la victoria devolvió una tabla nueva, sin la columna atipico, así que la volvemos a poner—:

limpio = valido.copy()
limpio["atipico"] = fuera.values
picos = limpio[limpio["atipico"]]
print(picos["track_genre"].value_counts().head(6))
track_genre
children      5
dancehall     2
hardcore      2
guitar        2
honky-tonk    2
classical     1
Name: count, dtype: int64

Curioso: quien más pistas rápidas aporta es children, la música infantil (5). ¿Un error? No: las canciones de CoComelon van a doscientas pulsaciones por minuto, para que los niños salten. El resto se reparte por muchos géneros. Cuando un atípico tiene explicación de dominio —la música infantil es rápida a propósito— deja de ser sospechoso y pasa a ser un rasgo del estilo. Y eso apunta a algo grande que veremos enseguida: quizá el tempo no habría que juzgarlo con una sola valla para todos, sino género a género.

Lo que se juega en una cifra

¿Para qué tanto esfuerzo? Para que un número en el que confías no mienta. El tempo medio de nuestro catálogo limpio es 121,7 BPM. Mira lo que le pasa a esa media si un solo centinela —un único 9999 sin cazar— se cuela en la tabla:

media_limpia = round(limpio["tempo"].mean(), 1)
con_error = pd.concat([limpio["tempo"], pd.Series([9999.0])],
                      ignore_index=True)
media_rota = round(con_error.mean(), 1)
print("Media limpia:", media_limpia)
print("Con un 9999 sin cazar:", media_rota)
Media limpia: 121.7
Con un 9999 sin cazar: 124.7

En el catálogo entero (3340 pistas), un 9999 sube la media de 121,7 a 124,7 —tres BPM diluidos entre miles—. Parece poco… hasta que calculas la media de un solo género, que tiene muchas menos canciones. Mira el reggaeton, con sus 30 pistas:

regg = limpio[limpio["track_genre"] == "reggaeton"]["tempo"]
con = pd.concat([regg, pd.Series([9999.0])], ignore_index=True)
print("Reggaeton limpio:", round(regg.mean(), 1))
print("Con un 9999:", round(con.mean(), 1))
Reggaeton limpio: 124.4
Con un 9999: 442.9

El mismo valor roto, entre 30 pistas en vez de miles, dispara la media de 124,4 a 442,9 BPM —un disparate que ninguna música alcanza—. Ahí está, en una cifra, todo lo que te jugabas: cuanto más pequeño es el grupo, más veneno hace un solo valor sin cazar. Si ese 9999 hubiera pasado, tu informe habría declarado que el reggaeton corre a 443 BPM. Por eso el imposible físico se caza siempre, aunque parezca que «casi nunca hay». Basta uno.

El catálogo ya limpio habla

Y ahora, la recompensa de tanto limpiar: hacerle preguntas al catálogo y fiarte de la respuesta. Con las 3340 pistas limpias retomamos preguntas al estilo de la Misión 1 —¿cuántas pasan de cierta velocidad?, ¿cuál es la más rápida?— sabiendo que ningún fallo las envenena:

rapidas = (limpio["tempo"] > 180).sum()
print("Canciones sobre 180 BPM:", rapidas, "de", len(limpio))
mas_rapida = limpio.loc[limpio["tempo"].idxmax()]
print("La mas rapida:")
print(mas_rapida[["track_name", "artists", "track_genre",
                  "tempo", "atipico"]])
Canciones sobre 180 BPM: 89 de 3340
La mas rapida:
track_name      Wind Brought
artists        Evgeny Grinko
track_genre          turkish
tempo                209.557
atipico                 True
Name: 3305, dtype: object

Ochenta y nueve de 3340 pistas pasan de 180 BPM, y la más rápida es Wind Brought, de Evgeny Grinko (turkish), con 209,6. Fíjate en la última línea de la ficha: atipico True. Ese tempo sigue en la tabla y lleva su etiqueta de sospechoso, que es justo el premio de haber marcado en vez de tirar: si la hubieras borrado por «rara», tu «más rápida» sería una respuesta más cómoda y menos verdadera. idxmax da la posición del valor más alto y .loc saca su fila entera: la ficha completa de la pista más veloz, con la columna atipico que le pusimos incluida.

El género más rápido

Y con el groupby de la Misión 3, ahora sobre datos limpios, podemos resumir cada género en su tempo medio y encontrar los extremos:

medias = limpio.groupby("track_genre")["tempo"].mean().round(1)
print("Mas rapido:", medias.idxmax(), "->", medias.max())
print("Mas lento :", medias.idxmin(), "->", medias.min())
Mas rapido: drum-and-bass -> 167.4
Mas lento : sleep -> 80.1

El género más rápido es el drum and bass (167,4 BPM de media, y casi todo clavado en 174); el más lento, sleep (80,1) —música para dormir, ruido blanco, ¡el mismo género donde estaban dos de nuestras canciones rotas de tempo 0!—. Todo cuadra: el sleep es audio lento o sin ritmo. Es el mismo groupby de la Misión 3, ahora contándote la personalidad de cada estilo.

¿Y si fueran dos géneros?

Una última caza fina, y la lección grande de la misión: las vallas hay que calcularlas por género, nunca mezclando. Un género rapidísimo y otro lento tienen velocidades normales distintas, así que lo que en uno es rutina, en el otro es un escándalo. No hace falta fabricar nada: el catálogo ya trae 114 géneros. Comparemos dos con caracteres muy distintos y saquemos las vallas de cada uno con un groupby, como en la Misión 3:

dos = limpio[limpio["track_genre"].isin(["drum-and-bass", "classical"])]
for genero, g in dos.groupby("track_genre"):
    q1, q3 = g["tempo"].quantile([0.25, 0.75])
    iqr = q3 - q1
    vb, va = round(q1 - 1.5 * iqr, 1), round(q3 + 1.5 * iqr, 1)
    print(f"{genero:14s} vallas {vb} a {va}")
classical      vallas -18.8 a 239.4
drum-and-bass  vallas 171.6 a 175.5

Mira qué distintas. El classical tiene vallas anchísimas (\(-18{,}8\) a 239,4): hay piezas lentas y piezas rápidas, cabe casi todo. El drum and bass, en cambio, tiene vallas finísimas (171,6 a 175,5): todo el género va a unos 174 BPM. Un tema de 140 BPM sería de lo más normal en classical y un atípico de manual en drum and bass. Y aquí lo que la valla global se perdía: dentro del propio drum and bass hay pistas «lentas» para el género que la valla de todo el catálogo (40,1 a 199,8) jamás señalaría:

dnb = limpio[limpio["track_genre"] == "drum-and-bass"]["tempo"]
q1, q3 = dnb.quantile([0.25, 0.75])
vb = q1 - 1.5 * (q3 - q1)
print("Atipicos internos del dnb:", (dnb < vb).sum())
print(sorted(round(x, 1) for x in dnb[dnb < vb]))
Atipicos internos del dnb: 5
[86.0, 116.7, 140.0, 153.7, 171.1]

Cinco pistas que, para el drum and bass, van lentas —la más lenta, 86 BPM, cuando sus compañeras corren a 174—. La valla global (40,1) las tomaría por normalísimas, porque 86 BPM es un tempo corriente en el catálogo entero. Si hubieras calculado una sola valla para todo, habrías dado por normales las rarezas de cada género y por raras las canciones corrientes de los géneros rápidos —mezclar peras y manzanas y extrañarte de la ensalada—. Cada grupo se juzga con su propia vara. Es el mismo groupby de la Misión 3, ahora al servicio de cazar con justicia.

Recortar las rápidas, con cuidado

En la caja de antes conociste el recorte (winsorizar): en vez de tirar las pistas rápidas, rebajarlas hasta la valla. Vamos a hacerlo de verdad y a medir su efecto, con clip, que «pinza» los valores para que no pasen de un tope:

recortado = limpio["tempo"].clip(upper=199.8)
print("Media con rapidas:", round(limpio["tempo"].mean(), 1))
print("Media winsorizada:", round(recortado.mean(), 1))
print("Valores recortados:", (limpio["tempo"] > 199.8).sum())
Media con rapidas: 121.7
Media winsorizada: 121.7
Valores recortados: 24

clip(upper=199.8) baja las 24 rápidas a 199,8 y deja el resto igual; la media casi ni se mueve (121,7). Poca cosa, porque son solo 24 entre 3340 y ya estaban cerca de la valla —pero fíjate en lo que no ha pasado—: no hemos borrado nada, seguimos teniendo 3340 pistas. Ahora bien, ¿deberías winsorizar aquí? Seguramente no: nuestras pistas rápidas son música real, y aplastarlas escondería justo la variedad del catálogo. El recorte es una herramienta para cuando dudas —medio real, medio avería—, no un botón que se pulsa por costumbre. Como todo hoy: la técnica es fácil; saber cuándo usarla es el oficio.

El jefe de parte: el cazador universal

Hora del jefe de parte, el miniproyecto que cierra la misión. Tu parte_de_averias ya es una herramienta seria; el jefe te pide demostrar que sirve para cualquier catálogo, no solo para el que rompiste tú. Le vamos a dar el mismo catálogo saboteado de otra forma —con otras averías, en otras cantidades— y comprobar que la caza sigue cuadrando. Esta vez metemos 5 huecos, 2 tempos imposibles y 4 duplicados:

rng2 = np.random.default_rng(7)
sucio2 = cat.copy()
validos2 = cat.index[cat["tempo"] > 0].to_numpy()
sucio2.loc[rng2.choice(validos2, size=5, replace=False),
           "tempo"] = np.nan                    # 5 huecos
sucio2.loc[[50, 400], "tempo"] = [300.0, 280.0]  # 2 tempos imposibles
extra = sucio2.iloc[[11, 12, 13, 14]]            # 4 duplicados
sucio2 = pd.concat([sucio2, extra], ignore_index=True)

parte2 = parte_de_averias(sucio2, CLAVE)
for clave, valor in parte2.items():
    print(clave, "->", valor)
filas_brutas -> 3355
huecos -> 5
duplicados -> 4
imposibles -> 5
atipicos -> 24
filas_limpias -> 3341

La misma función, sin tocar una coma, ha diagnosticado un catálogo distinto. Cuenta los 5 huecos y los 4 duplicados que pusimos, y reporta 5 imposibles: los 2 tempos absurdos (300 y 280 BPM) que metimos más las 3 canciones rotas de tempo 0 que el catálogo ya traía de fábrica. Eso es lo que separa un truco de una herramienta: un truco sirve para el ejemplo del libro; una herramienta sirve para el problema que te encuentres. Tu cazador ya es de los segundos.

¿Lo lograste? (nivel jefe)

El jefe también se comprueba. Esta celda verifica de una vez que el cazador contó bien las averías del segundo catálogo —las que pusimos (5 huecos, 4 duplicados) y los 5 imposibles totales—:

assert parte2["huecos"] == 5, "El cazador falla con los huecos."
assert parte2["imposibles"] == 5, "El cazador falla con el rango."
assert parte2["duplicados"] == 4, "El cazador falla con la clave."
print("Cazador validado. Sirve para cualquier catalogo.")
Cazador validado. Sirve para cualquier catalogo.

Tres guardianes, un solo mensaje de victoria. Si callan, tu cazador es sólido: no lo crees porque «funcionó una vez», lo sabes porque lo comprobaste con un caso nuevo. Ese es, en miniatura, el oficio entero —y la razón por la que un analista duerme tranquilo—.

Retos: elige tu nivel

Ahora te toca a ti. Los retos suben de dificultad: \(\star\) es imprescindible, \(\star\star\) te reta y \(\star\star\star\) es de jefe. Haz al menos todas las de una estrella; sube hasta donde te apetezca. En todas, la regla nueva de la misión: cuenta antes y después.

  • Otra semilla, otro sabotaje. Cambia la semilla 42 por tu número favorito y vuelve a romper el catálogo. Antes de ejecutar, apuesta: ¿seguirá teniendo 3354 filas? ¿Caerán los huecos en otras pistas? Ejecuta y comprueba.

  • Cuenta huecos tú. Sin mirar el parte, escribe la línea que cuenta los huecos de sucio y comprueba que da 8. Pista: isna y sum.

  • Un imposible más. Mete un centinela 9999 en el tempo de una pista cualquiera con sucio.loc[...] = 9999 y comprueba que el filtro de imposibles ahora caza cuatro en vez de tres.

  • La valla baja. Explica en tu bitácora, con tus palabras, por qué la valla baja (40,1) no atrapa ninguna canción y qué significa eso para los atípicos por debajo.

  • ★★ Tapar en vez de tirar. Repite la cadena de limpieza pero, en lugar de dropna, tapa los huecos con la mediana de cada género (groupby \(+\) transform \(+\) fillna). ¿Cuántas filas limpias quedan ahora? ¿Por qué es distinto?

  • ★★ Mueve la valla. Cambia el \(1{,}5\) de las vallas por un \(3\) (las vallas «de casos extremos»). Antes de ejecutar, apuesta: ¿saldrán menos de 24 atípicos? Comprueba.

  • ★★ Quién gana el conflicto. En el duplicado conflictivo de Hold On (119,9 frente a 95,0), usa drop_duplicates(subset=CLAVE, keep="last") y mira qué tempo sobrevive ahora. Explica cuándo tendría sentido quedarse con el último.

  • ★★ El tempo cero con lupa. Saca las tres canciones de tempo 0. Dos son ruido blanco del género sleep. ¿Es un error o una medición honesta que un ruido no tenga ritmo? Escríbelo.

  • ★★ Marca, no borres. Añade a la tabla limpia una columna imposible y otra atipico de verdaderos y falsos, sin tirar ninguna fila. Cuenta cuántos True hay en cada una.

  • ★★★ El jefe: cazador de dos rasgos. Amplía parte_de_averias para que cace también los imposibles de duration_ms (otro rango físico: mayor que 0 y, digamos, menor que quince minutos). Pista: el rango de cada columna es distinto.

  • ★★★ El jefe: por género. Calcula las vallas de Tukey por género con groupby para tres géneros a tu gusto. ¿Salen las mismas vallas para los tres? ¿Por qué importa no mezclarlos?

  • ★★★ El jefe final en miniatura. Escribe una función catalogo_limpio(df) que devuelva la tabla limpia con una columna atipico marcada (sin borrar las rápidas) y una celda assert que compruebe que no quedan huecos ni imposibles, pero sí las atípicas marcadas.

¿Te has atascado? En el apéndice C tienes, para cada reto, primero una pista y solo después la solución. Pero el pacto es este: entra ahí después de haberlo intentado de verdad.

NotaPara saber más: el contrato de datos

Cuando un proyecto se pone serio, la caza deja de ser manual: se escribe un contrato de datos —una lista de reglas que los datos deben cumplir— y una máquina lo comprueba en cada carga. «La columna tempo es un número entre 0 y 250», «la popularity va de 0 a 100», «la duration_ms es mayor que cero», «no hay dos filas con la misma clave (track_name, artists, track_genre)». Si un dato rompe el contrato, salta una alarma antes de que ensucie ningún análisis. En Python, bibliotecas como pandera dejan escribir ese contrato en pocas líneas. Es la versión adulta de lo que has hecho hoy a mano: en vez de cazar los fallos uno a uno, declaras cómo son los datos buenos y dejas que la máquina vigile la puerta.

Tu caja de herramientas

Mira todo lo que sabes cazar ahora y hace unas páginas no sabías. Pega esta chuleta en la bitácora y vuelve a ella cuando dudes.

  • Huecos: df["col"].isna() (¿vacía?), .isna().sum() (cuántos), df[df["col"].isna()] (cuáles).

  • Tapar huecos: fillna(valor) (con un número), groupby(...).transform(...) (con la mediana del género). O dropna para tirarlos.

  • Duplicados: define la CLAVE natural, duplicated(subset=CLAVE) (cuenta), drop_duplicates(subset=CLAVE) (quita). keep=False marca todas las apariciones.

  • Imposibles: rango físico con una máscara, df[(df["col"] <= 0) | (df["col"] > alto)], o filtrar con .query("0 < col <= 250").

  • Atípicos (Tukey): q1, q3 = col.quantile([0.25, 0.75]), iqr = q3 - q1, vallas en q1 - 1.5*iqr y q3 + 1.5*iqr.

  • Marcar sin borrar: df["atipico"] = (col < baja) | (col > alta).

  • Cadena de limpieza: encadena .drop_duplicates(...).dropna(...).query(...) y cuenta antes y después.

  • Comprobar: assert parte["huecos"] == 8.

No memorices la lista: úsala. La memoria viene sola de tanto cazar.

Cuando quieras ir en serio

Todo esto lo has hecho a mano, cazando fallo por fallo, y para aprender es perfecto —ahora entiendes lo que ocurre por dentro—. En un proyecto grande, el oficio va un paso más allá, y el puente hacia ese mundo es el volumen adulto del que nace este libro. Allí verás que la limpieza se automatiza con contratos de datos que vigilan cada carga; que la decisión de imputar se toma con más cuidado —la imputación múltiple rellena un hueco varias veces para no esconder la incertidumbre del relleno—; que hay recetas de atípicos más robustas que la \(z\) escolar, basadas en la mediana [@leys2013mad]; y que el catálogo completo —114 000 pistas— trae sus propias trampas: géneros mezclados, rasgos en otras escalas, mediciones que el análisis ya marcó como dudosas. No lo necesitas para las próximas misiones. Pero está ahí, y ahora tienes la intuición para entenderlo.

Hay una idea de esta misión que no caduca nunca, y es la que de verdad te llevas: limpiar no es aplicar una receta, es tomar decisiones que puedes defender. Cualquiera aprende en diez minutos a teclear dropna y drop_duplicates; lo difícil —y lo valioso— es saber qué es un duplicado en tus datos, por qué falta un hueco antes de taparlo, qué valor es imposible según la física del problema y cuándo una pista rápida es una avería o una canción de verdad. Esas preguntas no las contesta pandas: las contestas tú, con conocimiento del asunto. Por eso un buen cazador de datos no es el que más órdenes se sabe, sino el que, ante cada baja de su cadena de limpieza, puede mirar a los ojos y decir por qué la tiró. Guarda tu bitácora: el día que alguien dude de uno de tus números, esas notas serán tu defensa.

Misión 4 completada. Insignia cazador de atípicos, desbloqueada. Has aprendido por qué los datos vienen sucios, has cazado las cuatro clases de avería —huecos, duplicados, imposibles y atípicos—, has dejado el catálogo limpio con las cuentas cuadradas y has fabricado un cazador que sirve para cualquier tabla. En la Misión 5 dejaremos de contar y empezaremos a pintar: convertiremos este catálogo limpio en figuras y en el sonido de cada género. Los datos ya están sanos; ahora vamos a hacerlos hermosos.