19 2.1K 1

QuartzNet-15x5 CTC para português

Como ajustei o QuartzNet-15x5 CTC em 26 horas do Common Voice 9.0 para reconhecimento de fala em português.

Ilustração de um modelo de aprendizado de máquina

QuartzNet-15x5 CTC para português: do áudio ao texto

A oferta então limitada de ASR aberto para português motivou este projeto. Ajustei o stt_en_quartznet15x5, criado pela NVIDIA e pré-treinado em fala inglesa, com 26 horas do Mozilla Common Voice 9.0 em português. Publiquei checkpoint .nemo, notebook, demonstração e métricas; a principal contribuição é uma base compacta e ajustável para reconhecimento de fala [1][2].

No GitHub, o projeto stt_pt_quartznet15x5_ctc_small inclui Readme.md com instruções de uso por ASRModel.from_pretrained, transcrição de áudio único e lote, amostra de inferência e limites explícitos para discurso técnico. Esse documento também vincula a dependência no NeMo, os scripts de avaliação e o pré-processamento com manifest antes da métrica [1].

A motivação é prática: disponibilizar uma base aberta, relativamente compacta e ajustável para português, quando modelos de ASR ainda eram grandes e a oferta pública para o idioma era limitada. QuartzNet troca as convoluções densas do Jasper por convoluções 1D separáveis em tempo e canal. O artigo original apresenta o 15x5 com 18,9 milhões de parâmetros, contra 333 milhões do Jasper DR 10x5 comparado no trabalho, preservando uma pilha puramente convolucional adequada a paralelismo [4][5]. “Small”, portanto, é relativo à família e à época, não uma promessa de execução instantânea em qualquer dispositivo.

PropriedadeValor verificável
Entrada do modeloáudio mono, 16.000 Hz
Saídaletras a-z minúsculas e espaço, sem pontuação ou acentos
Dados usados pelo projeto26 horas do Common Voice 9.0 em português
ArquiteturaEncDecCTCModel, QuartzNet-15x5, 64 filtros Mel
Versão gravada no checkpointNeMo 1.9.0
Resultado publicadoWER 49,17%; CER 18,59% no split de teste indicado no cartão
Licença declarada no cartãoCC BY 4.0

Dados e preparação do áudio

O Common Voice é construído por contribuições voluntárias de frases lidas e validações comunitárias [8]. A ficha da versão cv-corpus-9.0-2022-04-27 registra, para português, 134,68 horas totais, 115,65 horas validadas, 115.489 clipes e 2.453 participantes. Os splits oficiais continham 17.559 clipes de treino, 8.528 de desenvolvimento e 8.539 de teste [7]. As 26 horas declaradas pelo projeto são, portanto, apenas a parcela efetivamente usada no ajuste, não todo o corpus português validado.

No notebook, baixei train.tsv, dev.tsv e test.tsv, converti o áudio para WAV mono de 16 kHz e produzi manifestos JSON Lines. Cada linha contém audio_filepath, duration em segundos e text, formato esperado pelo NeMo [1][3]. Depois de obter e revisar o script de preparação indicado no projeto, use esta execução:

python scripts/get_commonvoice_data.py \
  --data_root "datasets/pt/" \
  --manifest_dir="manifests/pt" \
  --sample_rate=16000 \
  --n_channels=1 \
  --version="cv-corpus-9.0-2022-04-27" \
  --language="pt" \
  --log \
  --files_to_process train.tsv dev.tsv test.tsv

No cartão, escrevi “16000 KHz”; o valor correto, registrado como sample_rate: 16000, é 16 kHz, ou 16.000 amostras por segundo. O MP3 de exemplo que publiquei é mono, dura aproximadamente 3,636 s e está codificado a 32 kHz; isso pode ser verificado com ffprobe. Para remover dependência de decodificação e tornar a entrada inequívoca, converta-o antes da inferência:

wget -O common_voice_pt_25555332.mp3 \
  "https://github.com/DominguesM/stt_pt_quartznet15x5_ctc_small/raw/main/audios/common_voice_pt_25555332.mp3"
 
ffprobe -v error -show_entries stream=sample_rate,channels \
  -show_entries format=duration -of json common_voice_pt_25555332.mp3
 
ffmpeg -i common_voice_pt_25555332.mp3 -ac 1 -ar 16000 \
  -c:a pcm_s16le common_voice_pt_25555332.wav

Converti o texto para minúsculas e removi pontuação como vírgula, ponto, hífen, dois-pontos, ponto e vírgula, aspas e interrogação. O vocabulário final tem 27 símbolos: as 26 letras latinas não acentuadas e espaço. Isso reduz a ambiguidade para poucos dados, mas também torna impossível emitir diretamente á, ç, maiúsculas ou pontuação.

Renderizando diagrama...

QuartzNet-15x5 por dentro

Jasper organiza o codificador como B x R: B blocos residuais, cada um com R sub-blocos convolucionais [5]. QuartzNet mantém a organização, batch normalization, ReLU e conexões residuais, mas fatora a convolução temporal densa em duas etapas [4]:

  1. uma convolução depthwise percorre o tempo separadamente em cada canal;
  2. uma convolução pointwise de kernel 1 mistura os canais.

Para kernel temporal de tamanho K, C_in canais de entrada e C_out de saída, ignorando vieses, a economia é:

Pdensa=KCinCout,Psep=KCin+CinCout,economia=1PsepPdensa.P_{densa}=K C_{in}C_{out},\qquad P_{sep}=K C_{in}+C_{in}C_{out},\qquad \text{economia}=1-\frac{P_{sep}}{P_{densa}}.

Em um módulo com K=33 e C_in=C_out=256, presente na configuração, isso passa de 2.162.688 para 73.984 pesos, redução de aproximadamente 96,58% naquele módulo. O valor não significa a mesma redução no modelo inteiro: projeções residuais, normalizações e convoluções finais continuam existindo.

Configurei um prólogo com kernel 33, 256 canais e stride 2; depois, 15 blocos, cada um repetindo cinco módulos. Os grupos de kernels/canais são 33/256, 39/256, 51/512, 63/512 e 75/512, cada grupo repetido três vezes. No epílogo, usei kernel 87 com dilatação 2 e 512 canais, seguido de projeção para 1.024 canais e decodificador com vocabulário de 27 símbolos, além do blank CTC. O preprocessor calcula 64 filtros Mel em janelas de 20 ms, avanço de 10 ms, janela Hann e FFT de 512 pontos [2].

Renderizando diagrama...

O alinhamento CTC

Áudio e transcrição não chegam alinhados quadro a quadro. A Connectionist Temporal Classification (CTC) introduz o símbolo vazio (blank) e soma a probabilidade de todos os caminhos temporais que, depois de remover blanks e colapsar repetições, geram a transcrição y [6]. Se B é essa operação de colapso e pi um caminho de comprimento T:

p(yx)=πB1(y)t=1Tp(πtx),LCTC=logp(yx).p(y\mid x)=\sum_{\pi\in\mathcal{B}^{-1}(y)} \prod_{t=1}^{T}p(\pi_t\mid x),\qquad \mathcal{L}_{CTC}=-\log p(y\mid x).

Assim, caminhos como c blank a a blank s a podem colapsar para casa. Na inferência padrão que publiquei, usei decodificação gulosa, sem modelo de linguagem externo. Isso simplifica o sistema, mas não corrige palavras acusticamente plausíveis com contexto linguístico.

Ambiente, configuração e treinamento

Para reprodução histórica, use o lançamento nomeado NeMo 1.9.0 empregado pelo notebook, em vez de assumir compatibilidade silenciosa com a API atual:

python -m pip install wget unidecode "matplotlib>=3.3.2"
python -m pip install \
  "git+https://github.com/NVIDIA/NeMo.git@r1.9.0#egg=nemo_toolkit[all]"

Treinei com batch 32 e validei com batch 8; usei 8 workers, trim_silence=true, duração máxima de 16,7 s, dropout 0, SpecCutout com 5 máscaras retangulares (rect_freq=50, rect_time=120), NovoGrad com taxa 0.01, betas [0.95, 0.5], weight decay 0.001, cosine annealing, warmup de 5% e taxa mínima 1e-5 [1][2]. Mantive o codificador descongelado.

O núcleo da configuração no notebook é:

import copy
import nemo.collections.asr as nemo_asr
from omegaconf import open_dict
 
model = nemo_asr.models.ASRModel.from_pretrained(
    "stt_en_quartznet15x5", map_location="cpu"
)
model.change_vocabulary(new_vocabulary=list(train_dev_set))
model.encoder.unfreeze()
cfg = copy.deepcopy(model.cfg)
 
with open_dict(cfg):
    cfg.train_ds.manifest_filepath = (
        f"{train_manifest_cleaned},{dev_manifest_cleaned}"
    )
    cfg.train_ds.batch_size = 32
    cfg.train_ds.num_workers = 8
    cfg.train_ds.normalize_transcripts = False
    cfg.train_ds.trim_silence = True
    cfg.validation_ds.manifest_filepath = test_manifest_cleaned
    cfg.validation_ds.batch_size = 8
    cfg.validation_ds.num_workers = 8
    cfg.validation_ds.normalize_transcripts = False
    cfg.validation_ds.trim_silence = True
 
model.setup_training_data(cfg.train_ds)
model.setup_multiple_validation_data(cfg.validation_ds)

Usei um dispositivo, 50 épocas, acumulação de gradiente 1, logs a cada 5 passos e validação a cada 10 épocas; trainer.fit(model) executou o ajuste. Registrei 11 horas para 50 épocas no Google Colab, mas não preservei o modelo exato da GPU, a semente, um manifesto dos dados ou as versões completas do ambiente. Por isso, não apresento esse tempo como benchmark universal.

Há uma ressalva metodológica: o notebook concatena treino e desenvolvimento e usa o split de teste como validação, inclusive monitorando val_wer para salvar o melhor modelo. Logo, o teste participa da seleção do checkpoint. Uma reprodução rigorosa deve reservar o teste até o final, usar dev para seleção e publicar também um resultado nesse protocolo corrigido; isso não permite chamar o novo número de reprodução direta dos 49,17%.

Avaliação: WER, CER e RTF

Para referência r e hipótese h, a distância de Levenshtein conta substituições S, deleções D e inserções I. Com N_w palavras e N_c caracteres na referência:

WER=Sw+Dw+IwNw,CER=Sc+Dc+IcNc.WER=\frac{S_w+D_w+I_w}{N_w},\qquad CER=\frac{S_c+D_c+I_c}{N_c}.

No cartão, publiquei WER 49,17% e CER 18,59% no teste processado do Common Voice português [2]. Em termos intuitivos, a primeira razão representa cerca de 49 operações de edição por 100 palavras de referência; não significa que exatamente 50,83% das palavras estejam corretas, pois inserções podem levar WER acima de 100%. O CER menor mostra que muitas hipóteses permanecem próximas na grafia, mas isso não basta para aplicações que dependam de palavras exatas.

A avaliação publicada foi executada com NeMo 1.9.0:

wget -P scripts/ \
  "https://raw.githubusercontent.com/NVIDIA/NeMo/v1.9.0/examples/asr/speech_to_text_eval.py"
wget -P scripts/ \
  "https://raw.githubusercontent.com/NVIDIA/NeMo/v1.9.0/examples/asr/transcribe_speech.py"
 
python scripts/speech_to_text_eval.py \
  pretrained_name="dominguesm/stt_pt_quartznet15x5_ctc_small" \
  dataset_manifest="manifests/pt/commonvoice_test_manifest_processed.json" \
  output_filename="evaluation_transcripts.json" \
  batch_size=32 amp=true use_cer=false

Para CER, repita com use_cer=true, mantendo checkpoint, manifesto e normalização idênticos. Não compare WER de referências pontuadas/acentuadas com hipóteses sem esses símbolos sem antes aplicar a mesma normalização aos dois lados.

O real-time factor mede velocidade, não qualidade:

RTF=tprocessamentotaudio.RTF=\frac{t_{processamento}}{t_{audio}}.

RTF < 1 é mais rápido que tempo real. Não publiquei RTF; atribuir um valor sem medição esconderia diferenças de hardware, batch, aquecimento e I/O. Meça excluindo o carregamento do modelo:

import time
import soundfile as sf
import torch
import nemo.collections.asr as nemo_asr
 
files = ["common_voice_pt_25555332.wav"]
model = nemo_asr.models.ASRModel.from_pretrained(
    "dominguesm/stt_pt_quartznet15x5_ctc_small"
)
duration = sum(sf.info(path).duration for path in files)
 
if torch.cuda.is_available():
    torch.cuda.synchronize()
start = time.perf_counter()
texts = model.transcribe(files)
if torch.cuda.is_available():
    torch.cuda.synchronize()
elapsed = time.perf_counter() - start
 
print(texts)
print({"seconds": elapsed, "audio_seconds": duration, "rtf": elapsed / duration})

Inferência local e em Jetson

A menor inferência possível é a mesma do cartão:

import nemo.collections.asr as nemo_asr
 
asr_model = nemo_asr.models.ASRModel.from_pretrained(
    "dominguesm/stt_pt_quartznet15x5_ctc_small"
)
print(asr_model.transcribe(["common_voice_pt_25555332.wav"]))

Para um diretório, use o script versionado já baixado:

python scripts/transcribe_speech.py \
  pretrained_name="dominguesm/stt_pt_quartznet15x5_ctc_small" \
  audio_dir="/caminho/para/wavs"

Em CPU local, comece com um arquivo e monitore RAM e RTF; lotes maiores aumentam padding e memória. Em GPU desktop, amp=true é apropriado no comando de avaliação publicado, mas valide se as transcrições permanecem equivalentes. Em Jetson, não instale uma wheel PyTorch genérica esperando CUDA: a NVIDIA exige uma wheel compatível com a versão do JetPack e fornece uma matriz específica [9]. NeMo 1.9.0 é antigo e suas dependências podem não ter wheels AArch64 compatíveis com JetPack moderno. A estratégia segura é fixar uma imagem/ambiente funcional, usar batch 1, medir temperatura e consumo, e comparar texto, WER e RTF contra a execução de referência. Exportar ou quantizar pode ajudar, mas é um novo artefato e requer teste de paridade; não publiquei no repositório ONNX, TensorRT nem métricas Jetson.

Reprodutibilidade, limitações e ética

Uma reprodução auditável deve registrar a edição Common Voice Corpus 9.0 de 27 de abril de 2022, o lançamento NeMo 1.9.0, a data de acesso ao modelo, os manifestos usados, o ambiente Python/CUDA/NeMo, o modelo da GPU, a semente, o protocolo de normalização e os resultados por split. Preserve os manifestos JSONL, o checkpoint .nemo e o model_config.yaml em armazenamento controlado. Não misture a configuração atual do NeMo com a histórica sem relatar a migração [3].

As limitações técnicas são substanciais. Vinte e seis horas são poucas para cobrir variedades do português, fala espontânea, ruído, microfones, velocidades, nomes e jargão. O modelo não restaura acentos nem pontuação, não faz diarização, timestamps, detecção de idioma ou streaming garantido. WER 49,17% impede uso autônomo em legendas críticas, saúde, justiça, crédito, segurança ou acessibilidade sem revisão humana.

A ficha do Common Voice 9.0 também evidencia representatividade desigual: entre os metadados portugueses, 74% foram marcados como masculinos, 4% femininos, 2% “outro” e 20% sem gênero; o campo de sotaque ficou 100% sem informação [7]. Metadados incompletos não me permitem concluir viés, mas impedem demonstrar equidade. Antes de produção, avalie por região, gênero, idade, ambiente acústico e deficiência de fala com consentimento e amostras suficientes. Não use a transcrição para inferir origem, identidade, saúde ou confiabilidade de uma pessoa. Informe erros, minimize retenção de voz, proteja gravações como dado pessoal e ofereça contestação humana.

Conclusão

Com o ajuste do QuartzNet, publiquei uma base compacta de ASR em português, acompanhada por notebook, checkpoint e métricas, e registrei o desempenho obtido com 26 horas do Common Voice 9.0. Aprendi que ampliação de dados e avaliação representativa são tão importantes quanto a arquitetura.

Referências

  1. DominguesM, repositório, notebook e áudio de stt_pt_quartznet15x5_ctc_small, 2022.
  2. Domingues, M. Cartão e checkpoint stt_pt_quartznet15x5_ctc_small, 2022.
  3. NVIDIA. NeMo ASR: configuração e preparação de datasets.
  4. Kriman, S. et al. QuartzNet: Deep Automatic Speech Recognition with 1D Time-Channel Separable Convolutions, ICASSP, 2020.
  5. Li, J. et al. Jasper: An End-to-End Convolutional Neural Acoustic Model, Interspeech, 2019.
  6. Graves, A. et al. Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks, ICML, 2006.
  7. Mozilla Common Voice. Ficha estatística do Corpus 9.0, corte de 27 de abril de 2022.
  8. Ardila, R. et al. Common Voice: A Massively-Multilingual Speech Corpus, LREC, 2020.
  9. NVIDIA. Installing PyTorch for Jetson Platform.

BibTeX

@misc{domingues2022quartznet15x5smallportuguese,
  author       = {Maicon Domingues},
  title        = {Fine-tuned QuartzNet-15x5 CTC small model for speech recognition in Portuguese},
  year         = {2022},
  howpublished = {\url{https://huggingface.co/dominguesm/stt_pt_quartznet15x5_ctc_small}}
}
 
@inproceedings{kriman2020quartznet,
  author    = {Kriman, Samuel and Beliaev, Stanislav and Ginsburg, Boris and Huang, Jocelyn and Kuchaiev, Oleksii and Lavrukhin, Vitaly and Leary, Ryan and Li, Jason and Zhang, Yang},
  title     = {QuartzNet: Deep Automatic Speech Recognition with 1D Time-Channel Separable Convolutions},
  booktitle = {ICASSP},
  year      = {2020},
  doi       = {10.1109/ICASSP40776.2020.9053889}
}
 
@inproceedings{li2019jasper,
  author    = {Li, Jason and Lavrukhin, Vitaly and Ginsburg, Boris and Leary, Ryan and Kuchaiev, Oleksii and Cohen, Jonathan M. and Nguyen, Huyen and Gadde, Ravi Teja},
  title     = {Jasper: An End-to-End Convolutional Neural Acoustic Model},
  booktitle = {Interspeech},
  year      = {2019},
  doi       = {10.21437/Interspeech.2019-1819}
}
 
@inproceedings{graves2006ctc,
  author    = {Graves, Alex and Fern\'{a}ndez, Santiago and Gomez, Faustino and Schmidhuber, J\"{u}rgen},
  title     = {Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks},
  booktitle = {Proceedings of the 23rd International Conference on Machine Learning},
  pages     = {369--376},
  year      = {2006}
}
 
@inproceedings{ardila2020commonvoice,
  author    = {Ardila, Rosana and Branson, Megan and Davis, Kelly and Henretty, Michael and Kohler, Michael and Meyer, Josh and Morais, Reuben and Saunders, Lindsay and Tyers, Francis M. and Weber, Gregor},
  title     = {Common Voice: A Massively-Multilingual Speech Corpus},
  booktitle = {Proceedings of LREC 2020},
  pages     = {4218--4222},
  year      = {2020}
}