DEV Community

Cover image for Come trovare i momenti migliori in una trascrizione con una finestra scorrevole in Python

Come trovare i momenti migliori in una trascrizione con una finestra scorrevole in Python

Quando una trascrizione ha i timestamp per parola, trovare possibili clip brevi in una registrazione lunga non richiede alcun modello. Una finestra di lunghezza fissa, avviata a ogni parola e valutata in base a quante parole ci stanno dentro, trova i tratti in cui si dice di più. L'editor video dell'app desktop di osFoundry offre esattamente questo con il pulsante Suggest clips, scritto in Rust (find_highlights in canvas_transcript.rs). Qui lo ricostruiamo in Python e lo eseguiamo su una vera intervista di 5:07, misurando cosa fa ciascuna regola, come la lunghezza della finestra cambia il risultato, quando la selezione «prima il migliore» smette di essere ottimale e come rendere lineare il calcolo dei punteggi.

L'input

La registrazione è un'intervista della NASA all'astronauta Christina Koch. La trascrizione di Whisper ha 1.007 parole in 306,97 secondi, in media 3,28 parole al secondo. In questa modalità Whisper restituisce le parole senza punteggiatura, e all'algoritmo servono solo i tempi:

{"words": [
  {"index": 0, "text": "And",   "start_ms": 0,   "end_ms": 160},
  {"index": 1, "text": "we'll", "start_ms": 160, "end_ms": 280},
  {"index": 2, "text": "get",   "start_ms": 280, "end_ms": 380},
  ...
], "duration_ms": 306970}
Enter fullscreen mode Exit fullscreen mode

Una finestra per parola

Ogni parola apre una finestra candidata. Una parola conta se finisce prima della fine della finestra, e la scansione si ferma alla prima parola che non rispetta la condizione:

def candidates(words, window_ms):
    out = []
    for i, w in enumerate(words):
        start = w["start_ms"]
        end = start + window_ms
        n = 0
        for x in words[i:]:
            if x["end_ms"] > end:
                break
            n += 1
        if n:
            out.append((start, end, n))
    return out
Enter fullscreen mode Exit fullscreen mode

Far partire le finestre dalle parole invece che da una griglia fissa (per esempio ogni secondo) significa che un candidato si apre sempre all'inizio di una parola, mai a metà. Il punteggio si divide per la lunghezza della finestra, non per il tempo effettivamente parlato, quindi le 44 finestre che iniziano negli ultimi 15 secondi della registrazione ottengono da sole un punteggio basso, senza casi speciali.

Per questa intervista si ottengono 1.007 candidati. La mediana contiene 49 parole, la migliore 67.

Prima il migliore, senza sovrapposizioni

I candidati vengono ordinati per punteggio, con i pareggi risolti in base alla posizione così che la selezione sia uguale a ogni esecuzione, e presi dall'alto finché non si sovrappongono a nulla di già scelto:

def find_highlights(words, window_ms=15_000, count=3):
    ranked = sorted(candidates(words, window_ms), key=lambda c: (-c[2], c[0]))
    chosen = []
    for c in ranked:
        if len(chosen) >= count:
            break
        if any(c[0] < k[1] and k[0] < c[1] for k in chosen):
            continue  # overlaps a better window
        chosen.append(c)
    return sorted(chosen)
Enter fullscreen mode Exit fullscreen mode

Con le impostazioni dell'app, 15 secondi e tre clip, la versione Python restituisce gli stessi tre intervalli mostrati dall'app:

clip parole parole/s
0:45.1 – 1:00.1 64 4,27
2:03.4 – 2:18.4 67 4,47
2:31.5 – 2:46.5 65 4,33

Gli stessi tre intervalli nel pannello della trascrizione dell'app dopo Suggest clips

Gran parte del lavoro lo fa la regola sulle sovrapposizioni. Senza di essa i primi tre sarebbero 2:03.4, 2:03.6 e 2:00.2, cioè tre volte lo stesso passaggio, tutti entro 3,4 secondi l'uno dall'altro. Un parlato fitto resta fitto per un po', quindi le finestre migliori si raggruppano: i primi 19 candidati iniziano tutti tra 1:59.7 e 2:04.4. La seconda e la terza scelta sono i candidati numero 20 e 21 della classifica, e per arrivarci sono state scartate 18 finestre con punteggio migliore. Le due a pari merito con 67 (2:03.4 e 2:03.6) spiegano perché conta risolvere i pareggi in base alla posizione.

La lunghezza della finestra cambia la risposta

La stessa trascrizione con altre lunghezze di finestra:

finestra scelte (inizio, parole/s)
10 s 0:51.2 (4,5), 2:08.5 (4,7), 3:15.3 (4,5)
15 s 0:45.1 (4,27), 2:03.4 (4,47), 2:31.5 (4,33)
30 s 0:25.1 (3,8), 1:48.5 (3,93), 2:31.5 (3,93)
60 s 0:00.0 (3,57), 1:23.7 (3,48), 2:31.0 (3,77)

Le finestre corte premiano una singola raffica veloce e producono picchi più alti. Quelle lunghe fanno la media su pause e domande e si avvicinano al ritmo complessivo di 3,28. Il tratto intorno a 2:31 resiste con ogni lunghezza da 15 a 60 secondi, un segnale piuttosto buono che si tratti di una risposta sostenuta e non di una sola frase veloce. Provare due lunghezze e cercare dove coincidono è un modo economico per ordinare i candidati in base a più di un numero.

Prima il migliore è ottimale?

Prendere per prima la finestra migliore è una scelta greedy. Per verificarla, la risposta esatta si ottiene con un piccolo programma dinamico sui candidati ordinati per inizio: per ogni candidato, o lo si salta, oppure lo si prende e si passa al primo candidato che inizia dopo la sua fine.

import bisect, functools

def best_total(words, window_ms, count):
    cands = sorted(candidates(words, window_ms))
    starts = [c[0] for c in cands]
    nxt = [bisect.bisect_left(starts, c[1]) for c in cands]

    @functools.lru_cache(None)
    def best(i, k):
        if k == 0 or i >= len(cands):
            return 0
        return max(best(i + 1, k), cands[i][2] + best(nxt[i], k - 1))

    return best(0, count)
Enter fullscreen mode Exit fullscreen mode

Totale delle parole nelle finestre scelte, greedy contro esatto:

finestra × clip prima il migliore esatto
15 s × 3 196 parole, 3 clip 196
15 s × 10 595 parole, 10 clip 595
30 s × 5 563 parole, 5 clip 565
30 s × 10 764 parole, 8 clip 989
60 s × 5 849 parole, 5 clip 991

Per una breve selezione di finestre corte, qui il greedy non perde nulla: a 15 secondi raggiunge il totale esatto con 3, 5 e 10 clip. Va in crisi quando le finestre iniziano a riempire la registrazione. Dieci finestre da 30 secondi stanno in 307 secondi, ma prima il migliore piazza le prime scelte dove si trovano i picchi, gli spazi rimasti tra di esse sono più corti di 30 secondi, e restituisce 8 clip dove la risposta esatta ne fa stare 10. Lo stesso problema costa 142 parole con 60 s × 5. La versione greedy è quindi lo strumento giusto per «dammi tre candidati» e quello sbagliato per «dividi questo talk in capitoli», dove la programmazione dinamica richiede solo poche righe in più.

Rendere lineare il calcolo dei punteggi

La nuova scansione in candidates legge ogni parola di ogni finestra, quindi il costo cresce con la lunghezza della finestra per il numero di parole. Dato che sia i tempi di inizio sia quelli di fine vanno solo in avanti, anche l'ultima parola della finestra va solo in avanti, e basta un solo puntatore:

def candidates_two_pointer(words, window_ms):
    out, j = [], 0
    for i, w in enumerate(words):
        j = max(j, i)
        end = w["start_ms"] + window_ms
        while j < len(words) and words[j]["end_ms"] <= end:
            j += 1
        if j > i:
            out.append((w["start_ms"], end, j - i))
    return out
Enter fullscreen mode Exit fullscreen mode

Su questa trascrizione le due versioni restituiscono candidati identici. Tempi misurati con Python 3.9 su un Apple M1, mediana di 7 esecuzioni, solo il calcolo dei punteggi; il caso da 1 ora è ottenuto ripetendo l'intervista 12 volte:

trascrizione parole nuova scansione due puntatori
5 min 1.007 2,9 ms 0,4 ms
1 h 12.084 190,5 ms 5,4 ms

Il ciclo Rust dell'app è la versione con nuova scansione. In Python, su un podcast lungo o con più lunghezze di finestra insieme, la versione a due puntatori è quella da tenere. Presuppone però che i tempi di fine siano in ordine: qui valeva per ogni parola, ma su trascrizioni unite da più fonti conviene verificarlo con un assert.

Cosa il punteggio non sa

Le parole al secondo misurano quanto si dice, non se vale la pena ascoltarlo. Il commento sopra find_highlights nel codice dell'app la definisce un'euristica e traccia il confine di proposito: un modello che sceglie i momenti migliori sarebbe un'altra funzione, che costa soldi e richiede una chiave, e potenziare questa di nascosto trasformerebbe un'operazione gratuita in un costo che l'utente non ha mai approvato. Il pannello dell'app dice la stessa cosa all'utente: «a starting point, not a verdict» (un punto di partenza, non un verdetto). In pratica, le tre finestre dicono da dove iniziare ad ascoltare, e i punti di taglio si scelgono ancora a orecchio.

Lo script completo, con tutte le misure qui sopra, è di circa 120 righe di Python con la sola libreria standard. Nell'app desktop di osFoundry la stessa funzione sta dietro al pulsante Suggest clips nel pannello della trascrizione dell'editor video, accanto a un passaggio di trascrizione che usa la tua chiave API cloud. L'app è gratuita dalla pagina di download di osFoundry.

Top comments (0)