面向沙特阿拉伯方言微调 NVIDIA Nemotron ASR 模型,并可迁移至其他语言
自动语音识别必须能应对人们实际的说话方式,而不仅仅是预训练数据中占主导地位的语言和风格。地方方言和本地录音条件往往代表性不足,因此在通用基准上表现良好的多语言模型,实际部署时仍可能不尽如人意。
沙特阿拉伯方言就是一个典型例子。模型也许能识别现代标准阿拉伯语或英语,却在处理 Najdi、Hijazi 方言或本地录音条件时表现不佳。只针对目标方言做微调虽然能提升效果,但会削弱其他语言的表现。
NVIDIA Nemotron 3.5 ASR 支持跨 40 种语言区域的多语言流式转录,其中阿拉伯语已可直接转录,但针对具体部署场景的方言和录音条件,微调仍然很有价值。
本文将介绍如何借助 NVIDIA NeMo framework 和 ASR fine-tuning recipe 对其进行适配:整理低资源语料、构建加权回放混合数据、通过高效批处理进行微调,并在独立数据集上评估转录质量。
适用场景
当你有足够的标注语音来特化一个 ASR 模型、但又不足以从头训练一个模型时,这条流水线就派上用场了:方言适配、特定领域转录、以及必须保留既有语言能力的部署场景。
其中各项技术解决的是不同的问题:
- 最小化数据整理:剔除不可用的标注和明显的对齐失败,同时不丢弃稀缺的、难度高的语音数据。
- 回放混合:穿插少量之前学过的数据,以缓解灾难性遗忘。
- 部分解冻编码器:限制参与更新的参数数量,比全量微调更省、更快,代价是精度略有损失。
- 长度分桶:减少填充(padding),让流式编码器的训练变得切实可行。
- Beam search 与更大的注意力上下文:无需重新训练即可提升离线精度,代价是延迟和算力增加。
这些并不是放之四海皆准的默认做法。Replay 只能保护数据所覆盖的内容;部分解冻(partial unfreezing)在数据构成变化后需要重新调优。而且这套流程的成效也不能推广为对所有阿拉伯语方言或部署环境都成立的证据。
微调实操
前置条件
- NVIDIA NeMo、PyTorch、Python、OmegaConf
- 本教程使用 SADA 2022 和 FLEURS 数据集
- 需要具备 Python、模型微调、WER 和 CER 的基本知识
- 12,000 步的基线实验使用了 2 块 GPU,具体型号与配置:NVIDIA RTX PRO 6000 Blackwell Workstation Edition
1. 整理目标语料库,但不要把问题数据一并过滤掉
首先选定你要部署的方言。在初始的 SADA 实验中,我们选的是 Najdi 和 Hijazi:
SAUDI_DIALECTS = {"najdi", "hijazi"}
seen = set()
for item in manifest:
dialect = str(item.get("speaker_dialect", "")).lower().strip()
seen.add(dialect)
if dialect not in SAUDI_DIALECTS:
continue
keep(item)
missing = SAUDI_DIALECTS - seen
assert not missing, f"never matched: {missing}; observed {sorted(seen)}"
接下来,移除模型学不会的标注文本,以及很可能存在错位的音频片段。
SADA 用 ghiyru wadih(含义为“不清晰”)标记听不清的语音;由于模型无法输出这类标注,每一次出现都会带来无法避免的错误。在下面的代码中,这些标记以 Unicode 转义形式呈现以保证从左到右显示,实际内容是 غيرواضح 和 غير واضح。代码还包含其他几项检查。
MIN_DURATION, MAX_DURATION = 0.5, 30.0 # 剔除过短或过长的音频片段
MIN_CHAR_RATE, MAX_CHAR_RATE = 1.5, 35.0 # 剔除转录与音频不对齐的样本
ANNOTATION_MARKERS = ['\u063a\u064a\u0631\u0648\u0627\u0636\u062d',
'\u063a\u064a\u0631 \u0648\u0627\u0636\u062d']
for row in manifest:
txt = normalize_arabic(row['text'])
rate = len(txt) / row['duration']
if not txt or txt.lower() == 'nan': continue # 字符串形式的 NaN
if any(m in row['text'] for m in ANNOTATION_MARKERS): continue # 是标注而非语音
if not (MIN_DURATION <= row['duration'] <= MAX_DURATION): continue
if not (MIN_CHAR_RATE <= rate <= MAX_CHAR_RATE): continue
keep(row)
def normalize_arabic(t): # 目标方言 + FLEURS 阿拉伯语
t = re.sub(r'[\u064b-\u0670]', '', t) # 去除音标符号
t = re.sub(r'[\u0623\u0625\u0622\u0627]', '\u0627', t) # 统一 alef 变体
t = t.replace('\u0649', '\u064a').replace('\u0629', '\u0647') # 统一 alef maqsura、taa marbuta
t = re.sub(r'[^\u0600-\u06ff\w\s]', '', t) # 去除标点
return ' '.join(t.split())
经过这些检查,125,490 条语音中保留了 103,559 条,共 133.7 小时,占初始数据集的 82.5%。清洗的目标是剔除结构性有问题的样本,而不是仅仅因为基础模型在重口音或嘈杂语音上表现不佳就把它们扔掉。如果使用自动质量评分,请先查看评分的分布情况——SADA 项目就发现,UTMOS 默认阈值 3.0 几乎会拒绝所有样本。
作为第二步,更完整的 SADA 数据清洗流程使用了 NVIDIA NeMo Curator 来规范音频格式并剔除严重劣化的片段。MonoConversionStage 将输入转换为单声道,UTMOSFilterStage 和 SIGMOSFilterStage 则对感知质量和背景噪声进行评分。我们没有直接采用它们的默认阈值,而是先以仅评分模式运行这些阶段,查看分数分布后,再针对该语料库设置阈值:UTMOS ≥ 1.25,SIGMOS 噪声 ≥ 1.5,SIGMOS 总体 ≥ 1.5。这套阈值放行了约 85% 的时长合格样本,保留了大量通用阈值会误杀的、有难度但可用的方言语音。
2. 先用单个数据集起步,密切观察模型行为
面对新语言或新领域时,最好从你能看懂结果的最简单实验开始:选一个有代表性的目标数据集,做一次常规的全量微调,再用一个固定的评测集。目的是观察模型的表现,验证训练流程是否可行,并建立一个基线,方便后续改动时对比衡量。
在我们的实验中,SADA 就是第一个数据集。模型是 Cache-Aware FastConformer-RNNT,带 prompt 式多语言流式识别(strip_lang_tags、target_lang: ar-AR),它的行为和普通的英文流式模型不同,需要在 manifest 中显式指定语言条件。
你的起始语料、训练时长和硬件配置可能都不一样。下面的配置改动记录了这个实验在遇到优化、显存和分布式训练问题时如何逐步调整,属于排障示例,而非推荐默认值。未列出的超参数(包括 weight decay、gradient clipping、混合精度、按句子数计算的有效 batch size 等)均沿用 NeMo 框架默认值,本次实验未做调优。
| 领域 | 配置改动 |
|---|---|
| 优化 | 学习率:默认 → 1e-4 → 2e-5;warmup:10,000 → 100 → 50 步;优化器:AdamW(NeMo 默认);调度器:Noam;d_model=1024 |
| 数据加载 | Batch 时长:400 → 300 → 200 秒,以避免 out-of-memory 报错;is_tarred=false;worker 数:训练 4、验证 2;bucket 和 shuffle buffer:1,000 |
| 验证 | Batch size 为 8;每个 epoch 验证一次 |
| Checkpoint 与解码 | 保留最好的三个 checkpoint;使用 greedy decoding |
在最初仅用 SADA 的实验中,我们用验证集来监控微调进度。预训练模型在验证集上的 WER 为 49.5%,在整个训练语料上为 59%。这一差距说明训练数据的音频噪声更大、方言变化更丰富。
验证集基线(49.5%)是我们全程关注的核心指标。首轮训练 10 个 epoch 后,WER 降至 47.8%;再训 10 个 epoch 仍是 47.8%;更长的 v4 继续训练阶段降到了 46.7%。到第 45 个 epoch 后,验证集 WER 就不再下降了。提升有限、曲线明显趋平,这说明沿用同样的全量微调方案很难带来实质性改善。
3. 真正有效的三件事:收窄目标、加入回放数据流、按时长分桶组 batch
收窄目标,数据只做最低限度的清洗
我们不再指望模型同时改善 11 种方言,而是只用 Najdi 和 Hijazi 这两种我们实际要用的方言来训练,并且只做了最低限度的数据清洗(见上文第 1 点),最终从 125,490 条语音中保留了 103,559 条,占 82.5%:
回放数据流
只拿沙特语音做微调,会把模型在预训练阶段学到的能力覆盖掉。应对方法是回放:混入一小部分以前学过的数据,让模型在学习新任务的同时,也继续练习老任务。
我们用了 10% 的 FLEURS 数据,按英文 7%、阿拉伯文 3% 的比例与 90% 的沙特语音混合。注意要通过配置声明这些比例,而不是把文件拼接在一起——因为滑动窗口式的 shuffle 可能直到训练后期才触及拼在大 manifest 末尾的数据,拼接出来的回放集在训练的大部分时间里等于不存在。
from omegaconf import OmegaConf
mix = OmegaConf.create([
{"type": "nemo", "manifest_filepath": "sada_train.jsonl", "weight": 0.90},
{"type": "nemo", "manifest_filepath": "fleurs_en.jsonl", "weight": 0.07},
{"type": "nemo", "manifest_filepath": "fleurs_ar.jsonl", "weight": 0.03},
])
OmegaConf.save(mix, "input_cfg.yaml")
cfg.train_ds.manifest_filepath = None
cfg.train_ds.input_cfg = "input_cfg.yaml"
7% 的英文数据就够了。FLEURS 英文留存集的表现还略有提升,从 11.04% 降到 10.42%(见下文表 2),同时模型也在专注学习阿拉伯方言语音。
按时长分桶组 batch
第二处改动没那么显眼,但同样关键。
按时长分桶是把长度相近的语音放进同一个 batch:
cfg.train_ds.use_bucketing = True cfg.train_ds.num_buckets = 30 cfg.train_ds.batch_size = None cfg.train_ds.batch_duration = 400.0 cfg.train_ds.quadratic_duration = 15.0
注意,单设置 num_buckets 没有任何作用;use_bucketing 默认为 False,所以只配置桶数而不开启这个开关,看似配置好了,实际上是个静默的空操作。
结果
这三处改动合在一起,在两块 GPU 上跑了 12,000 步、约 4.5 小时:
| 测试集 | 之前 | 之后 |
|---|---|---|
| SADA Najdi + Hijazi WER | 55.05% | 29.96% |
| SADA Najdi + Hijazi CER | 31.63% | 12.18% |
| 完整 SADA WER | 58.84% | 35.61% |
| 完整 SADA CER | 35.40% | 15.97% |
| FLEURS 英语 WER | 11.04% | 10.42% |
| FLEURS 英语 CER | 6.47% | 4.53% |
| FLEURS 阿拉伯语 WER | 12.67% | 11.41% |
| FLEURS 阿拉伯语 CER | 5.55% | 3.97% |
专项优化并没有牺牲我们舍弃的那些方言。模型在目标方向提升了 25 个百分点,整体提升了 23 个百分点,英语成绩还同时有所改善。看起来这已经是个完整的结果了。所有数据均用 NeMo 评估脚本测得。
4. 适配深度:更新 encoder 的多少层
模型有 24 层 encoder。全量微调会更新全部层;冻结 encoder 则能保留原状,但会限制声学层面的适配。折中方案是只解冻顶部 N 层、其余保持不动,同时始终训练 decoder、joint network 和 prompt embeddings。
for parameter in model.parameters():
parameter.requires_grad = False
for name, parameter in model.named_parameters():
if any(part in name for part in ("decoder", "joint", "prompt")):
parameter.requires_grad = True
for layer in model.encoder.layers[-8:]:
for parameter in layer.parameters():
parameter.requires_grad = True
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"trainable: {trainable/1e6:.1f}M / {total/1e6:.1f}M")
在记录的最佳 top-8 方案中,可训练参数为 2.304 亿,冻结参数为 4.076 亿。我们对比了两种深度,其余设置完全不变:
| 更新的 encoder 层数 | SADA WER | SADA CER |
|---|---|---|
| pretrained 基线 | 55.05% | 31.63% |
| top 6 | 33.42% | 14.10% |
| top 8 | 32.32% | 13.53% |
| 全部 24 层 | 29.96% | 12.18% |
可训练容量越大,效果越好。对于 134 小时的目标语音数据来说,直接更新整个 encoder 是合理的,部分冻结相比全量微调要损失 2.4 个百分点。
这是针对这个数据量的结论,并非普适规律。冻结本质上是一种节省开销的手段——当语料比我们的更少、或显存是瓶颈时,它才是正确选择。
5. 无需重训练的提升:更大的上下文与 beam search 解码
训练只是系统的一半。在花钱再跑一轮训练之前,先看看推理阶段能榨出多少性能。
这个 checkpoint 提供了几种 attention 上下文大小,无需重训练即可切换。第二个数字表示 encoder 在输出结果之前可以看多少未来的帧:[56, 3] 是流式默认值,[56, 13] 是支持的最大值。切换到最大前瞻上下文后,WER 无需重训练就降低了 1.31 个绝对百分点,代价是增加约 800 毫秒延迟。
| 配置 | WER | CER | 相对 Greedy |
|---|---|---|---|
Greedy,[56, 3] | 29.96% | 12.18% | +0.00 |
Greedy,[56, 13] | 28.65% | 11.39% | -1.31 |
MALSD beam-4,[56, 3] | 28.81% | 11.48% | -1.15 |
MALSD beam-8,[56, 3] | 28.62% | 11.40% | -1.34 |
MALSD beam-8,[56, 13] | 27.25% | 10.63% | -2.71 |
主要的代价是延迟增加,而非重新训练的成本。lookahead 帧从 3 帧增加到 13 帧,意味着每次输出前要多缓冲约 800 毫秒。对于批量转写场景——比如通话录音归档、媒体文件、会议记录——这能在不重新训练的前提下提升准确率,额外的缓冲延迟也可以接受。但用于实时字幕可能就不合适了。正确的设置取决于部署场景,而不是准确率数字。
Beam-search 的表现与解码器配置密切相关。在后续的参数扫描中,NeMo 的批处理 malsd_batch 策略以较小代价带来了可观的提升:beam 4 以 0.59 倍 greedy 的运行时间把 SADA WER 从 29.96% 降到 28.81%,beam 8 则以 0.64 倍达到 28.62%。在我们的测试中,beam 4 是速度与准确率平衡最好的选择。我们没有评估 MAES 或 NGPU-LM 融合,所以结论仅限于 MALSD。
无论用哪种策略,都建议设置一个参数:strip_lang_tags=True。否则区域标签会作为字面文本被输出,并在每条语音上被计为一次插入错误。
| Attention Context | Chunk 大小(延迟) | 适用场景 |
|---|---|---|
[56, 0] | 80ms(超低) | 超低延迟语音 Agent |
[56, 1] | 160ms(低) | 交互式语音 Agent、对话式 AI |
[56, 3] | 320ms(均衡) | 对话式 AI、实时字幕 |
[56, 6] | 560ms(中等) | 在合理延迟下追求高准确率 |
[56, 13] | 1.12s(高) | 追求最高准确率,可接受高延迟 |
将这套流程应用于其他语言
整体流程不变:整理数据、混合回放(replay)数据、选择微调深度、按时长分桶、在独立的目标测试集和回归测试集上评估。不同语言需要调整的是语料元数据、转写规范、文本归一化、tokenizer 覆盖范围、文字系统处理方式,以及能力评估指标。
换其他语言时,可以先从一份小的已审核清单入手,用基础 tokenizer 测试人名、数字、外来词和混合文字的句子。把阿拉伯语的归一化器替换为目标文字适用的版本(保留有意义的区分),并端到端地验证 Unicode 归一化和编码。
对于没有空格分词的语言,WER 指标可能产生误导,建议改用字符级、token 级或词素级的度量方式,并说明其局限性。回放数据应覆盖你需要保留的能力,而不是只选方便获取的高资源语音,并且尽可能使用真实数据而非合成数据。
用说话人分离扩展工作流
微调 ASR 改进的是“转写了什么”,而说话人分离(diarization)补充的是“谁在什么时候说的”。在多人音频中,分离模型会识别语音片段并分配一致的说话人标签。把这些标签和时间戳与微调后的 Nemotron 3.5 ASR 输出结合,就能生成带说话人归属的转写文本,区分每位参与者的发言,适用于会议、访谈、呼叫中心、课堂等多种多人场景。
这套工作流还能帮助语音数据提供商准备多人语料库:自动生成说话人轮次时间戳和匿名说话人标签,供人工审核,之后再将数据用于 ASR 微调或评估。
刚发布的 NVIDIA Nemotron 3 Diarization 把这一工作流从方言感知转写扩展到了最多支持 8 位说话人的归属转写。这是一个开放模型,可以接入你现有的任何 ASR 系统。分离模型本身不做语音转写,它的说话人边界和标签会与 ASR 时间戳对齐,最终生成结构化的转写文本。更多架构细节、基准测试和上手指南,请参阅 Hugging Face 博客。
下一步
促成这篇教程的那次讨论,提出了一个比能力本身更重要的问题:如何让方言适配真正有用——既能提升目标方言的表现,又不丢失已有技能,还要把训练资源花在真正能改变部署系统效果的地方。这些实验给出了一个务实的答案:轻度整理数据,按权重混入回放数据,补充恰好能体现目标行为的样本,并根据数据量尽可能多地更新 encoder。之后再优化解码,并在独立的数据集上评估每一项能力。
开始使用
微调 notebook:
https://github.com/nvidia-riva/tutorials/blob/main/asr-finetune-nemotron-3.5-asr-streaming-prompt.ipynb
微调 skill:
https://github.com/NVIDIA/skills/tree/main/skills/nemotron-asr-finetune