Proyecto Laennec — Sesión 2: convertir un sonido en números, y nuestro primer modelo
En la Sesión 1 escuchamos un latido normal y uno con murmullo, y nos quedamos con un problema pendiente: un modelo de scikit-learn no sabe qué hacer con un archivo de audio. Sabe hacer lo que ya le hemos pedido en todos los proyectos anteriores — comparar números en una tabla. Hoy resolvemos ese problema: convertimos cada sonido en un puñado de números que lo resumen bien, y entrenamos con ellos nuestro primer modelo.
El problema de fondo: cada latido dura algo distinto
Los archivos de este dataset duran entre 1 y 30 segundos, y cada segundo de audio son miles de números (la señal de sonido en sí, muestreada muchas veces por segundo). Si intentáramos usar la señal completa como en Taussig usamos los 187 instantes de un ECG, tendríamos un problema nuevo: dos latidos de duración distinta no tendrían la misma cantidad de números, y un modelo de scikit-learn necesita que todas las filas de la tabla tengan exactamente las mismas columnas.
La solución que vamos a usar hoy es la misma que emplean muchas herramientas reales de reconocimiento de voz y de sonido: en vez de guardar la señal completa, calculamos unos pocos números que resumen su timbre — cómo “suena” el sonido, independientemente de cuánto dure.
MFCC: resumir el timbre de un sonido en 13 números
La herramienta se llama MFCC (Mel-Frequency Cepstral Coefficients, coeficientes cepstrales en la escala de Mel). No hace falta entender la matemática que hay detrás para usarla bien: basta con saber qué problema resuelve. Un mismo sonido tiene una mezcla de frecuencias distinta según su origen — un silbido de murmullo entre los dos golpes de un latido “suena” de forma distinta, en términos de qué frecuencias predominan, que un “lub-dub” limpio. El MFCC captura precisamente esa mezcla de frecuencias, resumida en un puñado de coeficientes, y lo hace de una forma pensada para parecerse a cómo el oído humano distingue los sonidos — no es casualidad que la técnica lleve el nombre de la escala de Mel, diseñada a partir de percepción auditiva real.
librosa calcula un coeficiente MFCC por cada pequeño instante del audio, así que para un archivo de 5 segundos obtenemos muchos más valores que para uno de 1 segundo — el mismo problema del principio, solo que trasladado a los MFCC. Lo resolvemos con un paso más: nos quedamos con la media de cada coeficiente a lo largo de todo el archivo. Así, sea cual sea la duración, cada latido termina resumido siempre en la misma cantidad de números: 13.
import librosa
import numpy as np
def extraer_caracteristicas(ruta_archivo):
señal, frecuencia = librosa.load(ruta_archivo, sr=None)
mfcc = librosa.feature.mfcc(y=señal, sr=frecuencia, n_mfcc=13)
# mfcc tiene una fila por coeficiente y una columna por instante del audio;
# nos quedamos con la media de cada fila, así el resultado
# siempre tiene 13 números, dure lo que dure el archivo
return mfcc.mean(axis=1)
Es exactamente el mismo tipo de decisión que tomamos en Javierre con 4 genes o en Taussig con 187 instantes de un latido: elegir cuántos números van a representar cada caso, y qué representa cada uno. Aquí, 13 números son el “procesado ligero” del que hablamos al elegir este proyecto — la alternativa sería entrenar una red neuronal directamente sobre el audio en bruto, mucho más pesada de calcular y de explicar.

Reunir los archivos por clase, sin fiarnos del CSV
Como aprendimos (a las malas) en la Sesión 1, no podemos fiarnos de la columna fname de set_b.csv para encontrar los archivos reales. Seguimos usando el mismo truco que ya nos sacó del apuro: buscar directamente en la carpeta con glob, aprovechando que el propio nombre de cada archivo ya nos dice su clase.
import glob
archivos_normal = glob.glob(carpeta_datos + "set_b/normal_*.wav")
archivos_murmullo = glob.glob(carpeta_datos + "set_b/murmur_*.wav")
archivos_extrasistole = glob.glob(carpeta_datos + "set_b/extrastole_*.wav")
print(f"Normales: {len(archivos_normal)}")
print(f"Murmullos: {len(archivos_murmullo)}")
print(f"Extrasístoles: {len(archivos_extrasistole)}")
Fijaos en un detalle: el patrón "normal_*.wav" recoge tanto los archivos que empiezan por normal__ como los que empiezan por normal_noisynormal_ (una variante grabada con más ruido de fondo, pero de la misma clase) — el asterisco de glob no distingue esos dos casos, y aquí nos conviene que no lo haga: ambos son latidos normales de verdad. Lo mismo pasa con murmur_*.wav.
[COMPLETAR EN CLASE: comparad los tres números. ¿Cuántas veces más ejemplos normales hay que ejemplos de extrasístole? Guardad esta pregunta para dentro de un par de sesiones]
Construyendo la tabla de datos
Ahora sí, recorremos los tres grupos de archivos, extraemos las 13 características de cada uno con la función de antes, y las juntamos en una tabla igual que las de cualquier otro proyecto de la familia — solo que esta vez cada fila no representa un paciente ni una imagen, sino un latido grabado en audio.
import pandas as pd
grupos = {
"normal": archivos_normal,
"murmullo": archivos_murmullo,
"extrasistole": archivos_extrasistole,
}
filas = []
for clase, archivos in grupos.items():
for ruta in archivos:
caracteristicas = extraer_caracteristicas(ruta)
filas.append(list(caracteristicas) + [clase])
columnas = [f"mfcc_{i + 1}" for i in range(13)] + ["clase"]
datos = pd.DataFrame(filas, columns=columnas)
datos.head()
Esta celda tiene que abrir y procesar 461 archivos de audio uno a uno, así que puede tardar uno o dos minutos en terminar — nada que ver con el susto de la Sesión 5 de Taussig: aquí sí veréis la celda marcada como In [*] mientras trabaja, y es exactamente lo que tiene que pasar.
[COMPLETAR CON VUESTROS DATOS REALES: forma de la tabla resultante (datos.shape) = ___]

Separando entrenamiento y prueba: por qué esta vez lo hacemos nosotros
A diferencia de Taussig, donde Kaggle ya nos entregaba mitbih_train.csv y mitbih_test.csv separados, aquí solo tenemos una tabla, y hay que repartirla nosotros mismos — como ya hicimos en CUTLER y en Javierre.
from sklearn.model_selection import train_test_split
X = datos.drop(columns=["clase"])
y = datos["clase"]
X_entrenamiento, X_prueba, y_entrenamiento, y_prueba = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
Hay un detalle nuevo en esta línea que no habíamos necesitado antes: stratify=y. Con solo 46 extrasístoles en total, un reparto completamente al azar podría, por pura mala suerte, dejar muy pocas (o incluso ninguna) en el conjunto de prueba. stratify=y obliga a que la proporción de cada clase en X_entrenamiento y en X_prueba sea la misma que en el total — así nos aseguramos de que el conjunto de prueba sí incluye extrasístoles suficientes para que el examen del modelo signifique algo.
[COMPLETAR EN CLASE: sin stratify, ¿por qué podría un reparto al azar dejar el conjunto de prueba casi sin extrasístoles? Pensad en cuántas hay en total]
Nuestro primer modelo
Con la tabla ya repartida, entrenamos el mismo primer modelo con el que empezamos cada proyecto de la familia: un árbol de decisión.
from sklearn.tree import DecisionTreeClassifier
arbol = DecisionTreeClassifier(random_state=42)
arbol.fit(X_entrenamiento, y_entrenamiento)
exactitud = arbol.score(X_prueba, y_prueba)
print(f"Exactitud del árbol de decisión: {exactitud:.2%}")
[COMPLETAR CON VUESTROS DATOS REALES: exactitud del árbol = ___]

Antes de sacar conclusiones de ese único número, pensad en lo que ya aprendimos en Taussig: con tres clases tan desiguales (320 normales frente a solo 46 extrasístoles), una exactitud alta podría estar escondiendo que el árbol falla precisamente con la clase minoritaria. No lo vamos a comprobar hoy con una matriz de confusión — eso es, precisamente, lo que toca a continuación.
Próximo paso
Hemos convertido sonido en números y entrenado un primer modelo, algo que ningún proyecto anterior de la familia nos había pedido hacer. En la próxima sesión abriremos la caja de las predicciones con una matriz de confusión — esta vez de 3×3 — para comprobar si el árbol reconoce igual de bien las tres clases, o si el desequilibrio que hemos visto hoy le está pasando factura a la extrasístole.



