← Ruta Perico
Etapa 08

Cuando la herramienta miente en silencio

1ª categoría Publicada Auditoría
08

Una entrevista de 117 minutos. La transcripción: 1.737 palabras. Y al abrir el fichero, casi todas eran la misma:

out/VIDEO_ID.txt
¡Suscríbete al canal! ¡Suscríbete al canal! ¡Suscríbete al canal!
¡Suscríbete al canal! ¡Suscríbete al canal! ¡Suscríbete al canal!
¡Suscríbete al canal! ¡Suscríbete al canal! ¡Suscríbete al canal!

Eso es una alucinación: texto que el modelo se inventa y presenta con la misma seguridad que el resto. No es ruido aleatorio, y entender de dónde sale explica todo lo demás. Whisper se entrenó, entre otras cosas, con subtítulos de YouTube, así que lleva grabadísimas las coletillas que aparecen en millones de vídeos. Cuando le toca un tramo sin voz —música, silencio, aplausos— no devuelve nada vacío: devuelve lo más probable que ha visto en ese hueco. Y lo más probable, en YouTube, es «¡Suscríbete al canal!».

Otras que salieron en este corpus: La Iglesia de Jesucristo de los Santos de los Últimos Días, Buenas tardes., Música Música Música.

Ahora lo importante: ninguna de esas transcripciones dio error. El fichero existía, pesaba lo que tenía que pesar, el español estaba bien escrito y bien acentuado, y todo lo que venía después en el pipeline las aceptó encantado. Un programa que revienta te para y te enseña dónde. Esto no para nada.

Detector 1: la línea más repetida

La primera comprobación fue la obvia: contar cuál es la línea que más se repite y marcar el fichero si se lleva más del 30% del texto.

audit_corpus.py — primera versión
MAX_REPEATED_SHARE = 0.30

lines = [line.strip() for line in text.split("\n") if line.strip()]
line, count = collections.Counter(lines).most_common(1)[0]
share = count * len(line.split()) / len(text.split())

looping = share > MAX_REPEATED_SHARE

Encontró 7 vídeos. Y también un falso positivo, del tipo que se repite en cualquier detector con umbral.

El falso positivo del primer día

Esa versión marcó como bucle varias transcripciones de subtítulos automáticos. El motivo es tonto: esas llegan en una sola línea, sin ningún salto. Su línea más repetida es el texto entero — el 100% — y aparece exactamente una vez.

El arreglo es exigir que la frase se repita de verdad:

audit_corpus.py
MIN_REPEATS = 3

looping = share > MAX_REPEATED_SHARE and count >= MIN_REPEATS

Detector 2: n-gramas, no líneas

Siete parecían pocos para el destrozo que se veía abriendo ficheros a mano. Y lo eran, por una razón que solo aparece mirando el texto crudo: la misma frase alucinada vuelve con los saltos de línea en sitios distintos.

la misma frase, tres líneas distintas
...ofrece un trato personal. Banco NatWest
ofrece un trato personal. Banco NatWest ofrece
un trato personal. Banco NatWest ofrece un trato...

Para un contador de líneas exactas esas son tres líneas diferentes, y ninguna se repite. Estábamos contando el formato, no el contenido.

La solución es un n-grama: una ventana de n palabras consecutivas que se desliza por el texto de una en una. Con seis palabras sobre un texto de mil salen 995 ventanas — «Banco NatWest ofrece un trato personal», «NatWest ofrece un trato personal. Banco», y así hasta el final. A la ventana le da igual dónde cayeron los saltos de línea, porque trabaja sobre la lista de palabras.

audit_corpus.py
NGRAM_SIZE = 6


def repeated_phrase_share(text, size=NGRAM_SIZE):
    """Share of the text taken up by the single most repeated word sequence.

    Comparing whole lines is too brittle: the same hallucinated phrase comes
    back with the line breaks in different places, so exact line matching sees
    each occurrence as a different line and reports nothing. Sliding a window
    over the words instead does not care where the newlines fell.
    """
    words = text.split()
    if len(words) < size:
        return 0.0, 0, ""
    grams = [" ".join(words[i:i + size]) for i in range(len(words) - size + 1)]
    phrase, count = collections.Counter(grams).most_common(1)[0]
    # count * size overstates the span when occurrences overlap, so cap at 1.0
    return min(count * size / len(words), 1.0), count, phrase

Seis palabras es el compromiso: con tres, el habla normal repite ventanas por casualidad; con doce, un bucle con una palabra distinta en medio se escapa.

Esta versión encontró cuatro vídeos más, y entre ellos el más grande del corpus, con 12.125 palabras. Pero el que de verdad asusta es este:

«Y se encuentran a 50 segundos de Pello y de Perico.»

Repetida 199 veces dentro del vídeo más grande del corpus

Léela otra vez. Es español correcto, con la puntuación en su sitio, hablando exactamente del tema del vídeo y nombrando a ciclistas que salen en él. Ningún corrector ortográfico, ningún filtro de puntuación y ninguna comprobación de vocabulario la habría marcado nunca. Lo único que la delata es que aparece 199 veces.

Dos hipótesis razonables, las dos falsas

Con los ficheros localizados quedaba explicar por qué. La primera sospecha fue la más cómoda: la descarga del audio se había cortado, o YouTube nos había frenado a mitad — rate limiting, que es cuando un servidor te va estrangulando las peticiones porque le estás pidiendo mucho. Se mide, no se supone:

terminal
ffprobe -v error -show_entries format=duration \
        -of default=noprint_wrappers=1:nokey=1 wav/VIDEO_ID.wav
# 7035.700000

7.035,7 segundos frente a los 7.036 que anunciaba el vídeo. El audio estaba entero.

Segunda sospecha: el fichero estaba completo pero mudo, o tan bajo que Whisper no tenía nada que oír. También se mide:

terminal
ffmpeg -i wav/VIDEO_ID.wav -af volumedetect -f null - 2>&1 | grep volume
# mean_volume: -20.3 dB
# max_volume:   -0.4 dB

-20,3 dB de media es un nivel de conversación normal y corriente. Tampoco era eso.

La tercera prueba cerró el caso: se cortaron tres minutos del centro de ese mismo fichero y se transcribieron sueltos. Salió texto perfecto. O sea que el fichero estaba completo, el audio estaba bien, y la herramienta transcribía ese mismo audio correctamente cuando se le daba un trozo pequeño.

El problema no estaba en los datos. Estaba en cómo se llamaba a la herramienta.

Diagnosticar es medir, no adivinar

Las dos hipótesis eran razonables y las dos eran falsas. Descartarlas costó un comando cada una. Adivinar habría costado relanzar el lote entero para ver si mejoraba, y seis horas después seguiríamos sin saber por qué.

La causa: el contexto que se arrastra

whisper.cpp no procesa el audio de golpe: lo parte en trozos de 30 segundos. Y para que las frases no queden cortadas de mala manera en las costuras, le pasa a cada trozo lo que decodificó en el anterior como contexto.

Ahí está la trampa. Si un trozo cae sobre música, silencio o un anuncio, el modelo suelta su coletilla aprendida. Esa coletilla entra como contexto del trozo siguiente, que ahora tiene un motivo más para repetirla. Se repite, se vuelve a pasar hacia delante, y el bucle se alimenta a sí mismo hasta comerse lo que queda de fichero. Por eso una entrevista de 117 minutos acaba en 1.737 palabras: no es que faltara audio, es que el modelo dejó de escucharlo.

El arreglo es una bandera:

transcribe.sh
whisper-cli -m $MODEL -l es -np -nt -mc 0 \
            -f "wav/$id.wav" -otxt -of "out/$id"

-mc 0 es max context 0: no arrastres nada de un trozo al siguiente. Se pierde algo de coherencia justo en las costuras — una frase partida se reconstruye peor — y a cambio un bucle ya no puede propagarse más allá de sus 30 segundos.

Los mismos quince minutos de audio, antes y después. Sin la bandera:

«¡Suscríbete al canal! ¡Suscríbete al canal! ¡Suscríbete al canal!…»

El bucle se ha comido el fichero

Y con -mc 0, sobre exactamente el mismo audio:

«…ganar la Vuelta a España en tierra en Segovia fue un revulsivo.»

Con max context 0, el mismo tramo transcrito de verdad

Cuánto se había llevado por delante

Con el detector afinado ya se podía medir el destrozo. Repartiendo las 82 transcripciones de Whisper según cuánto texto ocupa su frase más repetida:

RepeticiónVídeosPalabras
Menos del 5% — limpio54141.136
Entre el 5% y el 30% — contaminado, y no marcado1552.451
Más del 30% — marcado por la auditoría1313.516

El umbral del 30% había cazado 13 vídeos con 13.516 palabras. Por debajo pasaban otros 15 con 52.451 palabras contaminadas: casi cuatro veces más texto malo del que el detector estaba reportando, escondido en ficheros que por lo demás eran largos y buenos.

Cuando una comprobación encuentra algo, pregunta qué no está encontrando

Cada versión del detector encontró más que la anterior: líneas exactas → n-gramas → bajar el umbral. Y en ningún momento la versión anterior daba un número equivocado. Daba la parte visible.

Un detector con umbral te dice cuántos casos lo superan. Nunca te dice cuántos hay. Para eso hay que mirar la distribución entera, que es lo que hace la tabla de arriba y cuesta lo mismo que contar los que pasan.

Resultado: 46 vídeos a retranscribir con -mc 0.

Muestrear no es medir

El fallo de fondo no fue técnico.

Cuando se validó la calidad de las transcripciones en la etapa 5, se hizo leyendo trozos del medio de los ficheros. Y por el medio estaban perfectos. Tiene sentido: el bucle arranca en un tramo sin voz, y los tramos sin voz están en los extremos — la cabecera, la despedida, el corte de publicidad — o en los silencios largos. La muestra decía «esto está bien», y la muestra tenía razón sobre lo que había leído.

Es el mismo error de la etapa 3, cuando se estimó el tamaño del corpus a partir de un solo podcast en vez de contarlo y el número salió entre tres y diez veces optimista. Dos veces el mismo patrón: una muestra responde por sí misma, no por el conjunto. Cuando lo que buscas se concentra en un sitio concreto — los extremos, los ficheros grandes, los raros — muestrear por el medio es garantizar que no lo encuentras.

Una verificación sin verificar es otra afirmación más

Con los 46 vídeos identificados quedaba comprobar que todos estaban en la cola de retranscripción. Un bucle de shell de tres líneas recorrió la lista y contestó que sí, que estaban todos cubiertos.

No lo estaban: faltaba uno. El bucle se comía un error a mitad y llegaba al final imprimiendo «todo cubierto» igualmente — el mismo fallo silencioso que estábamos persiguiendo, ahora dentro del código que lo perseguía. Reescrita la comprobación en Python, comparando los dos conjuntos e imprimiendo los dos totales, apareció el que faltaba.

Epílogo: Whisper sobre música

Semanas después se reintentaron los cuatro vídeos que habían fallado al descargarse en el primer lote. Esta vez bajaron sin problema… y ninguno tenía habla. Eran música de fondo sobre imágenes de carrera. Esto fue lo que escribió Whisper:

La Iglesia de Jesucristo de los Santos de los Últimos Días ¡Suscríbete al canal! ¡Suscríbete al canal! ¡Suscríbete al canal!

Whisper, sobre un vídeo de música de la Lieja-Bastoña-Lieja de 1989

Son alucinaciones conocidas. El modelo aprendió de subtítulos de YouTube, y cuando no oye a nadie hablar escribe lo que suele aparecer en los subtítulos de un vídeo sin habla. El detector de bucles de esta etapa paró tres de los cuatro. El cuarto, 96 palabras de padres animando en una carrera infantil, habría pasado todos los controles: no tiene bucle, pero tampoco es Perico. Son 11 palabras por minuto en un vídeo de ocho minutos, cuando alguien hablando dice unas 150. El control que falta es ese, la densidad de habla, y queda apuntado.

Lo que se lleva uno de esta etapa

  1. Un fallo silencioso es peor que uno ruidoso. Aquí no hubo excepción, ni código de salida distinto de cero, ni fichero corrupto. Hubo español correcto, del tamaño correcto, en el sitio correcto. Lo único que lo encontró fue una comprobación escrita a propósito para buscarlo.
  2. Muestrear no es medir. Leer trozos del medio dijo que todo estaba bien durante semanas, y no mentía: es que no miraba donde estaba el problema.
  3. Cuando una comprobación encuentra algo, pregunta qué no está encontrando. Cada versión del detector encontró más. El primer número no era falso, era el borde de lo visible.
  4. Diagnostica midiendo. Dos hipótesis plausibles, las dos falsas, un comando para descartar cada una.
  5. Verifica también el verificador. Una comprobación que solo sabe decir «todo bien» no ha comprobado nada.

Coste de la auditoría: un script y una tarde. Coste de no haberla escrito: 65.967 palabras de corpus envenenado camino de los embeddings, con la particularidad de que ninguna etapa posterior las habría rechazado.