为低资源ASR精调MMS适配器模型

Adapt MMS adapter model for low-resource ASR fine-tuning.

新 (06/2023):本博客受到“Fine-tuning XLS-R on Multi-Lingual ASR”的强烈启发,可以看作是其改进版本。

Wav2Vec2是一个用于自动语音识别(ASR)的预训练模型,由Alexei Baevski、Michael Auli和Alex Conneau于2020年9月发布。在Wav2Vec2在最流行的英语ASR数据集LibriSpeech上展示出强大性能后,Facebook AI提出了两个多语言版本的Wav2Vec2,称为XLSR和XLM-R,能够识别高达128种语言的语音。XLSR代表跨语言语音表示,指的是该模型学习的对多种语言有用的语音表示能力。

Meta AI最近发布的大规模多语言语音(MMS)由Vineel Pratap、Andros Tjandra、Bowen Shi等人开发,将多语言语音表征提升到了一个新水平。超过1100种口头语言可以通过发布的各种语言识别、语音识别和文本到语音检查点进行识别、转录和生成。

在本博客中,我们展示了MMS的Adapter训练在仅经过10-20分钟的微调后实现了惊人的低词错误率。

对于资源稀缺的语言,我们强烈推荐使用MMS的Adapter训练,而不是像“Fine-tuning XLS-R on Multi-Lingual ASR”中那样对整个模型进行微调。

在我们的实验中,MMS的Adapter训练在内存效率、鲁棒性和低资源语言的性能方面都更好。对于资源丰富的语言,与使用Adapter层相比,整个检查点的微调仍然具有优势。

保护世界语言多样性

根据https://www.ethnologue.com/的数据,约3000种或40%的“活”语言正因为越来越少的母语使用者而濒临灭绝。在日益全球化的世界中,这一趋势只会继续。

MMS能够转录许多濒危语言,如Ari或Kaivi。将来,MMS可以通过帮助剩余的说话人创建书面记录并用他们的母语进行交流,起到保持语言活力的重要作用。

为了适应1000多种不同的词汇,MMS使用Adapter——一种只训练了模型权重的一小部分的训练方法。

Adapter层起到语言桥梁的作用,使模型能够在解读其他语言时利用一种语言的知识。

MMS的微调

MMS的无监督检查点在超过1400种语言中,预训练了超过50万小时的音频,模型参数范围从3亿到10亿之间。

您可以在🤗 Hub上找到仅预训练检查点的300万参数(300M)和10亿参数(1B)的模型大小:

  • mms-300m
  • mms-1b

注意:如果您想对基本模型进行微调,可以按照“Fine-tuning XLS-R on Multi-Lingual ASR”中所示的方式进行。

类似于BERT的遮蔽语言建模目标,MMS通过在自监督预训练期间随机遮蔽特征向量,然后将其传递给变换器网络,来学习上下文化的语音表示。

对于ASR,预训练的MMS-1B检查点在1000多种语言上以有监督的方式进行了进一步微调,并使用了一个联合词汇输出层。最后,丢弃了联合词汇输出层,而保留了特定语言的Adapter层。每个Adapter层只包含约250万个权重,由每个注意力块的小型线性投影层和特定语言的词汇输出层组成。

发布了针对语音识别(ASR)进行微调的三个MMS检查点。它们分别包括102、1107和1162个适配器权重(每种语言一个):

  • mms-1b-fl102
  • mms-1b-l1107
  • mms-1b-all

您可以看到基本模型被保存为一个model.safetensors文件,但是除此之外,这些存储库还存储了许多适配器权重,例如法语的adapter.fra.safetensors

Hugging Face文档非常好地解释了如何使用这些检查点进行推断,因此在这篇博文中,我们将重点介绍如何基于任何发布的ASR检查点高效训练高性能适配器模型。

训练自适应权重

在机器学习中,适配器是一种在保持原始模型参数不变的同时微调预训练模型的方法。它们通过在模型的预先存在的层之间插入小的可训练模块,称为适配器层,来将模型适应特定任务,而无需进行大规模的重新训练。

适配器在语音识别中,特别是说话人识别中,有着悠久的历史。在说话人识别中,适配器已被有效地用于调整预先存在的模型以识别个体说话人的特异性,正如Gales和Woodland(1996)以及Miao等人(2014)的工作所强调的那样。这种方法不仅大大减少了与训练完整模型相比的计算要求,而且还允许更好、更灵活的说话人特定调整。

MMS中的工作利用了这种适配器在不同语言的语音识别中的想法。少量适配器权重被微调以抓住每种目标语言的独特语音和语法特征。因此,MMS使得一个单一的大型基础模型(例如,mms-1b-all检查点)和1000多个小适配器层(对于mms-1b-all,每个适配器层有2.5M个权重)能够理解和转录多种语言。这极大地降低了为每种语言开发不同模型的计算需求。

太棒了!现在我们已经了解了动机和理论,让我们来看看如何为mms-1b-all微调适配器权重🔥

笔记本设置

如同在之前的“在多语言ASR上微调XLS-R”博文中所做的那样,我们在只包含约4小时有效训练数据的低资源ASR数据集上对模型进行微调。

与Wav2Vec2或XLS-R一样,MMS使用连续时间分类(CTC)进行微调,CTC是一种用于训练序列到序列问题的神经网络的算法,例如ASR和手写识别。

关于CTC算法的更多细节,我强烈推荐阅读Awni Hannun撰写的精彩博文“带有CTC的序列建模(2017年)”。

在我们开始之前,让我们安装datasetstransformers。此外,我们需要torchaudio来加载音频文件,以及jiwer来使用单词错误率(WER)指标评估我们的微调模型1 {}^1 1 。

%%capture
!pip install --upgrade pip 
!pip install datasets
!pip install evaluate
!pip install git+https://github.com/huggingface/transformers.git
!pip install jiwer
!pip install accelerate

我们强烈建议在训练过程中直接将训练检查点上传到🤗 Hub上。Hub存储库内置了版本控制,因此您可以确保在训练过程中不会丢失任何模型检查点。

为此,您需要从Hugging Face网站(如果尚未注册,请在此处注册!)存储您的身份验证令牌

from huggingface_hub import notebook_login

notebook_login()

准备数据、分词器和特征提取器

ASR模型将语音转录为文本,这意味着我们需要一个特征提取器来处理语音信号,将其转换为模型的输入格式,例如特征向量,还需要一个分词器来处理模型的输出格式,将其转换为文本。

在🤗 Transformers中,MMS模型配备了一个名为Wav2Vec2FeatureExtractor的特征提取器和一个名为Wav2Vec2CTCTokenizer的分词器。

让我们首先创建一个分词器,将预测的输出类解码为输出转录。

创建Wav2Vec2CTCTokenizer

经过微调的MMS模型,如mms-1b-all,已经有一个与模型检查点配套的分词器。然而,由于我们想要在特定低资源数据和特定语言上对模型进行微调,建议完全删除分词器和词汇表输出层,仅基于训练数据本身创建新的分词器和词汇表。

像Wav2Vec2一样经过微调的CTC模型通过首先将音频输入处理为一系列处理过的上下文表示,然后使用最终的词汇表输出层将每个上下文表示分类到代表转录的字符中,从而一次前向传递就可以转录一个音频文件。

该层的输出大小对应于词汇表中的标记数,我们将从用于微调的标记数据集中提取出来。因此,首先,我们将看一下所选择的Common Voice数据集,并根据转录定义一个词汇表。

对于本笔记本,我们将使用Common Voice的6.1土耳其语数据集。土耳其语对应的语言代码是"tr"

很好,现在我们可以使用🤗 Datasets的简单API来下载数据。数据集名称是"mozilla-foundation/common_voice_6_1",配置名称对应语言代码,在我们的案例中是"tr"

注意:在能够下载数据集之前,您必须登录到您的Hugging Face帐户,进入数据集存储库页面,然后点击“同意并访问存储库”

Common Voice有许多不同的拆分,包括invalidated,它指的是未被评为“足够清洁”以被视为有用的数据。在这个笔记本中,我们只使用"train""validation""test"这几个拆分。

由于土耳其语数据集非常小,我们将把验证和训练数据合并成一个训练数据集,只使用测试数据进行验证。

from datasets import load_dataset, load_metric, Audio

common_voice_train = load_dataset("mozilla-foundation/common_voice_6_1", "tr", split="train+validation", use_auth_token=True)
common_voice_test = load_dataset("mozilla-foundation/common_voice_6_1", "tr", split="test", use_auth_token=True)

许多ASR数据集仅为每个音频数组('audio')和文件('path')提供目标文本('sentence')。Common Voice实际上为每个音频文件提供了更多信息,例如'accent'等。为了使笔记本尽可能通用,我们只考虑用于微调的转录文本。

common_voice_train = common_voice_train.remove_columns(["accent", "age", "client_id", "down_votes", "gender", "locale", "segment", "up_votes"])
common_voice_test = common_voice_test.remove_columns(["accent", "age", "client_id", "down_votes", "gender", "locale", "segment", "up_votes"])

让我们编写一个显示数据集的一些随机样本的简短函数,并运行几次以对转录有所了解。

from datasets import ClassLabel
import random
import pandas as pd
from IPython.display import display, HTML

def show_random_elements(dataset, num_examples=10):
    assert num_examples <= len(dataset), "无法选择比数据集中元素更多的元素。"
    picks = []
    for _ in range(num_examples):
        pick = random.randint(0, len(dataset)-1)
        while pick in picks:
            pick = random.randint(0, len(dataset)-1)
        picks.append(pick)

    df = pd.DataFrame(dataset[picks])
    display(HTML(df.to_html()))

show_random_elements(common_voice_train.remove_columns(["path", "audio"]), num_examples=10)

Oylar teker teker elle sayılacak.
Son olaylar endişe seviyesini yükseltti.
Tek bir kart hepsinin kapılarını açıyor.
Blogcular da tam bundan bahsetmek istiyor.
Bu Aralık iki bin onda oldu.
Fiyatın altmış altı milyon avro olduğu bildirildi.
Ardından da silahlı çatışmalar çıktı.
"Romanya'da kurumlar gelir vergisi oranı yüzde on altı."
Bu konuda neden bu kadar az şey söylendiğini açıklayabilir misiniz?

好的!转录看起来相当干净。翻译这些转录的句子后,似乎语言更符合书面文本而不是嘈杂的对话。考虑到Common Voice是一个众包的朗读语音语料库,这是有道理的。

我们可以看到转录中包含一些特殊字符,如,.?!;:。没有语言模型,很难将语音块分类到这些特殊字符,因为它们实际上并不对应于一个特定的声音单位。例如,字母"s"有一个相对清晰的声音,而特殊字符"."没有。此外,为了理解语音信号的含义,通常不需要在转录中包含特殊字符。

让我们简单地删除所有对单词含义没有贡献且无法通过声学声音来表示的字符,并对文本进行规范化。

import re
chars_to_remove_regex = '[\,\?\.\!\-\;\:\"\“\%\‘\”\�\']'

def remove_special_characters(batch):
    batch["sentence"] = re.sub(chars_to_remove_regex, '', batch["sentence"]).lower()
    return batch

common_voice_train = common_voice_train.map(remove_special_characters)
common_voice_test = common_voice_test.map(remove_special_characters)

让我们再次查看处理后的文本标签。

show_random_elements(common_voice_train.remove_columns(["path","audio"]))

i̇kinci tur müzakereler eylül ayında başlayacak
jani ve babası bu düşüncelerinde yalnız değil
onurun gözlerindeki büyü
bandiç oyların yüzde kırk sekiz virgül elli dördünü topladı
bu imkansız
bu konu açık değildir
cinayet kamuoyunu şiddetle sarstı
kentin sokakları iki metre su altında kaldı
muhalefet partileri hükümete karşı ciddi bir mücadele ortaya koyabiliyorlar mı
festivale tüm dünyadan elli film katılıyor

很好!这看起来更好了。我们已经从转录中删除了大多数特殊字符,并将它们规范化为小写。

在最终处理之前,与目标语言的母语用户咨询是否可以进一步简化文本总是有利的。对于这篇博文,Merve很好心地快速查看了一下,并指出“带帽子”的字符 – 如â – 在土耳其不再使用,可以用它们的“不带帽子”的等价物来替代,例如a

这意味着我们应该将像"yargı sistemi hâlâ sağlıksız"这样的句子替换为"yargı sistemi hala sağlıksız"

让我们再写一个简短的映射函数,进一步简化文本标签。记住 – 文本标签越简单,模型学习预测这些标签就越容易。

def replace_hatted_characters(batch):
    batch["sentence"] = re.sub('[â]', 'a', batch["sentence"])
    batch["sentence"] = re.sub('[î]', 'i', batch["sentence"])
    batch["sentence"] = re.sub('[ô]', 'o', batch["sentence"])
    batch["sentence"] = re.sub('[û]', 'u', batch["sentence"])
    return batch

common_voice_train = common_voice_train.map(replace_hatted_characters)
common_voice_test = common_voice_test.map(replace_hatted_characters)

在CTC中,将语音块分类为字母是常见的做法,所以我们也将在这里这样做。让我们提取训练和测试数据中的所有不同字母,并从这组字母构建我们的词汇表。

我们编写一个映射函数,将所有转录连接成一个长转录,然后将字符串转换为字符集。重要的是向map(...)函数传递参数batched=True,这样映射函数就可以一次访问所有转录。

def extract_all_chars(batch):
  all_text = " ".join(batch["sentence"])
  vocab = list(set(all_text))
  return {"vocab": [vocab], "all_text": [all_text]}

vocab_train = common_voice_train.map(extract_all_chars, batched=True, batch_size=-1, keep_in_memory=True, remove_columns=common_voice_train.column_names)
vocab_test = common_voice_test.map(extract_all_chars, batched=True, batch_size=-1, keep_in_memory=True, remove_columns=common_voice_test.column_names)

现在,我们创建训练数据集和测试数据集中所有不同字母的并集,并将结果列表转换为一个枚举字典。

vocab_list = list(set(vocab_train["vocab"][0]) | set(vocab_test["vocab"][0]))

vocab_dict = {v: k for k, v in enumerate(sorted(vocab_list))}
vocab_dict

    {' ': 0,
     'a': 1,
     'b': 2,
     'c': 3,
     'd': 4,
     'e': 5,
     'f': 6,
     'g': 7,
     'h': 8,
     'i': 9,
     'j': 10,
     'k': 11,
     'l': 12,
     'm': 13,
     'n': 14,
     'o': 15,
     'p': 16,
     'q': 17,
     'r': 18,
     's': 19,
     't': 20,
     'u': 21,
     'v': 22,
     'w': 23,
     'x': 24,
     'y': 25,
     'z': 26,
     'ç': 27,
     'ë': 28,
     'ö': 29,
     'ü': 30,
     'ğ': 31,
     'ı': 32,
     'ş': 33,
     '̇': 34}

很棒,我们可以看到字母表中的所有字母都出现在数据集中(这并不令人惊讶),并且我们还提取了特殊字符""'。请注意,我们没有排除这些特殊字符,因为模型需要学习预测何时一个单词结束,否则预测结果将始终是一个连续的字母序列,这样就无法区分单词。

在训练模型之前,我们应始终记住预处理是非常重要的一步。例如,如果我们忘记对数据进行归一化,我们不希望模型区分aA。字母aA的区别并不取决于字母的“声音”,而更多取决于语法规则,例如在句子开头使用大写字母。因此,消除大写和非大写字母之间的区别是明智的,这样模型在学习转录语音时会更容易。

为了更清楚地表明" "有自己的令牌类别,我们给它一个更明显的字符|。此外,我们还添加了一个“未知”令牌,以便模型可以处理Common Voice训练集中未遇到的字符。

vocab_dict["|"] = vocab_dict[" "]
del vocab_dict[" "]

最后,我们还添加了一个填充令牌,对应于CTC的“空白令牌”。“空白令牌”是CTC算法的核心组成部分。有关更多信息,请参阅此处的“对齐”部分。

vocab_dict["[UNK]"] = len(vocab_dict)
vocab_dict["[PAD]"] = len(vocab_dict)
len(vocab_dict)

    37

很棒,现在我们的词汇表已完整,并包含37个令牌,这意味着我们将在预训练的MMS检查点上添加的线性层作为适配器权重的一部分,将具有37个输出维度。

由于单个MMS检查点可以为多种语言提供自定义权重,因此分词器也可以包含多个词汇表。因此,我们需要将我们的vocab_dict嵌套起来,以便将来可以向词汇表中添加更多语言。该字典应嵌套使用适配器权重的名称,并保存在tokenizer配置中的target_lang名称下。

让我们像原始的mms-1b-all检查点一样使用ISO-639-3语言代码。

target_lang = "tur"

让我们定义一个空字典,我们可以向其中添加刚刚创建的词汇表

new_vocab_dict = {target_lang: vocab_dict}

注意:如果您要使用此笔记本向现有模型存储库添加新的适配器层,请确保创建一个空的新词汇表字典,而是重新使用已经存在的字典。要做到这一点,您应该取消注释以下单元格,并将"patrickvonplaten/wav2vec2-large-mms-1b-turkish-colab"替换为您要向其添加适配器权重的模型存储库的ID。

# 从transformers库中导入Wav2Vec2CTCTokenizer类

# mms_adapter_repo = "patrickvonplaten/wav2vec2-large-mms-1b-turkish-colab"  # 确保将此路径替换为要添加新适配器权重的存储库
# tokenizer = Wav2Vec2CTCTokenizer.from_pretrained(mms_adapter_repo)
# new_vocab = tokenizer.vocab
# new_vocab[target_lang] = vocab_dict

现在,让我们将词汇表保存为json文件。

import json
with open('vocab.json', 'w') as vocab_file:
    json.dump(new_vocab_dict, vocab_file)

最后一步,我们使用json文件将词汇表加载到Wav2Vec2CTCTokenizer类的实例中。

from transformers import Wav2Vec2CTCTokenizer

tokenizer = Wav2Vec2CTCTokenizer.from_pretrained("./", unk_token="[UNK]", pad_token="[PAD]", word_delimiter_token="|", target_lang=target_lang)

如果想要重用刚刚创建的tokenizer与此笔记本的微调模型,强烈建议将tokenizer上传到🤗 Hub。让我们称将要上传文件的存储库为"wav2vec2-large-mms-1b-turkish-colab"

repo_name = "wav2vec2-large-mms-1b-turkish-colab"

并将tokenizer上传到🤗 Hub。

tokenizer.push_to_hub(repo_name)

    CommitInfo(commit_url='https://huggingface.co/patrickvonplaten/wav2vec2-large-mms-1b-turkish-colab/commit/48cccbfd6059aa6ce655e9d94b8358ba39536cb7', commit_message='Upload tokenizer', commit_description='', oid='48cccbfd6059aa6ce655e9d94b8358ba39536cb7', pr_url=None, pr_revision=None, pr_num=None)

很好,您可以在https://huggingface.co/<your-username>/wav2vec2-large-mms-1b-tr-colab下看到刚刚创建的存储库

创建Wav2Vec2FeatureExtractor

语音是连续的信号,为了让计算机处理,首先必须将其离散化,通常称为采样。采样率在此过程中起着重要作用,它定义了每秒测量语音信号的数据点数量。因此,使用更高的采样率进行采样可以更好地逼近真实的语音信号,但也需要更多的值。

预训练的检查点期望其输入数据的采样率与其训练所用的数据的采样率基本相同。两个不同采样率的相同语音信号具有非常不同的分布,例如:采样率加倍会导致数据点数量增加一倍。因此,在微调ASR模型的预训练检查点之前,必须验证用于预训练模型的数据的采样率与用于微调模型的数据集的采样率是否匹配。

实例化Wav2Vec2FeatureExtractor对象需要以下参数:

  • feature_size:语音模型以特征向量序列作为输入。虽然此序列的长度显然会有所变化,但特征大小不应改变。在Wav2Vec2的情况下,特征大小为1,因为模型是在原始语音信号2 {}^2 2上进行训练的。
  • sampling_rate:模型训练的采样率。
  • padding_value:对于批量推理,需要使用特定值对较短的输入进行填充。
  • do_normalize:是否将输入进行零均值单位方差归一化。通常情况下,归一化输入可以提高语音模型的性能。
  • return_attention_mask:模型是否应使用注意力掩码进行批量推理。一般来说,XLS-R模型检查点应始终使用注意力掩码。
from transformers import Wav2Vec2FeatureExtractor

feature_extractor = Wav2Vec2FeatureExtractor(feature_size=1, sampling_rate=16000, padding_value=0.0, do_normalize=True, return_attention_mask=True)

太棒了,MMS的特征提取流程已经完全定义好了!

为了提高用户友好性,特征提取器和分词器被封装到一个名为Wav2Vec2Processor的类中,这样我们只需要一个modelprocessor对象。

from transformers import Wav2Vec2Processor

processor = Wav2Vec2Processor(feature_extractor=feature_extractor, tokenizer=tokenizer)

接下来,我们可以准备数据集。

预处理数据

到目前为止,我们还没有查看语音信号的实际值,只看了转录文本。除了sentence外,我们的数据集还包括两个列名pathaudiopath表示音频文件的绝对路径,audio表示已加载的音频数据。MMS期望输入是一个1维数组,采样率为16 kHz。这意味着音频文件需要被加载和重新采样。

谢天谢地,当列名为audio时,datasets会自动完成这个过程。让我们试一试。

common_voice_train[0]["audio"]

    {'path': '/root/.cache/huggingface/datasets/downloads/extracted/71ba9bd154da9d8c769b736301417178729d2b87b9e00cda59f6450f742ed778/cv-corpus-6.1-2020-12-11/tr/clips/common_voice_tr_17346025.mp3',
     'array': array([ 0.00000000e+00, -2.98378618e-13, -1.59835903e-13, ...,
            -2.01663317e-12, -1.87991593e-12, -1.17969588e-12]),
     'sampling_rate': 48000}

从上面的示例中,我们可以看到音频数据以48kHz的采样率加载,而模型期望的是16kHz的采样率,正如我们所见。我们可以使用cast_column将音频特征设置为正确的采样率:

common_voice_train = common_voice_train.cast_column("audio", Audio(sampling_rate=16_000))
common_voice_test = common_voice_test.cast_column("audio", Audio(sampling_rate=16_000))

让我们再次查看"audio"

common_voice_train[0]["audio"]

{'path': '/root/.cache/huggingface/datasets/downloads/extracted/71ba9bd154da9d8c769b736301417178729d2b87b9e00cda59f6450f742ed778/cv-corpus-6.1-2020-12-11/tr/clips/common_voice_tr_17346025.mp3',
 'array': array([ 9.09494702e-13, -6.13908924e-12, -1.09139364e-11, ...,
         1.81898940e-12,  4.54747351e-13,  3.63797881e-12]),
 'sampling_rate': 16000}

看起来这个操作似乎起作用了!让我们通过打印语音输入的形状、转录文本及对应的采样率来最后检查数据是否准备好。

rand_int = random.randint(0, len(common_voice_train)-1)

print("目标文本:", common_voice_train[rand_int]["sentence"])
print("输入数组形状:", common_voice_train[rand_int]["audio"]["array"].shape)
print("采样率:", common_voice_train[rand_int]["audio"]["sampling_rate"])

    目标文本: bağış anlaşması bir ağustosta imzalandı
    输入数组形状: (70656,)
    采样率: 16000

太好了!一切看起来都很好 – 数据是一个1维数组,采样率始终对应于16kHz,目标文本已经归一化。

最后,我们可以利用Wav2Vec2Processor处理数据,使其符合训练所需的Wav2Vec2ForCTC的格式。为此,让我们使用数据集的map(...)函数。

首先,我们加载和重新采样音频数据,只需调用batch["audio"]即可。其次,我们从加载的音频文件中提取input_values。在我们的例子中,Wav2Vec2Processor只是对数据进行归一化处理。然而,对于其他语音模型,这一步骤可能包括更复杂的特征提取,例如Log-Mel特征提取。第三,我们将转录文本编码为标签ID。

注意:这个映射函数是如何使用Wav2Vec2Processor类的好例子。在“正常”上下文中,调用processor(...)会重定向到Wav2Vec2FeatureExtractor的调用方法。然而,当将处理器包装到as_target_processor上下文中时,同样的方法会重定向到Wav2Vec2CTCTokenizer的调用方法。更多信息请查看文档。

def prepare_dataset(batch):
    audio = batch["audio"]

    # batched output is "un-batched"
    batch["input_values"] = processor(audio["array"], sampling_rate=audio["sampling_rate"]).input_values[0]
    batch["input_length"] = len(batch["input_values"])

    batch["labels"] = processor(text=batch["sentence"]).input_ids
    return batch

让我们将数据准备函数应用到所有示例中。

common_voice_train = common_voice_train.map(prepare_dataset, remove_columns=common_voice_train.column_names)
common_voice_test = common_voice_test.map(prepare_dataset, remove_columns=common_voice_test.column_names)

注意datasets会自动处理音频加载和重新采样。如果您希望实现自己定制的数据加载/采样,可以直接使用"path"列,并忽略"audio"列。

太棒了,现在我们准备开始训练!

训练

数据已经处理好了,我们现在可以开始设置训练流程。我们将使用🤗的Trainer,需要做以下几个步骤:

  • 定义一个数据合并器。与大多数NLP模型不同,MMS的输入长度要比输出长度长得多。例如,输入长度为50000的样本的输出长度最多不超过100。由于输入尺寸较大,动态填充训练批次效率更高,意味着所有训练样本应该只填充到其批次中最长的样本,而不是整个数据集中最长的样本。因此,微调MMS需要一个特殊的填充数据合并器,我们将在下面定义它。

  • 评估指标。在训练过程中,模型应该根据词错误率进行评估。我们需要相应地定义一个compute_metrics函数。

  • 加载预训练的检查点。我们需要加载一个预训练的检查点,并正确配置它以进行训练。

  • 定义训练配置。

在微调模型之后,我们将在测试数据上进行正确的评估,并验证它是否确实学会了正确的语音转录。

设置Trainer

让我们从定义数据合并器开始。数据合并器的代码是从这个示例中复制过来的。

不详细介绍了,与普通的数据合并器相比,这个数据合并器将input_valueslabels区分对待,因此在它们上面分别应用两个单独的填充函数(再次利用MMS处理器的上下文管理器)。这是必要的,因为在语音识别中,输入和输出是不同的模态,因此不能使用相同的填充函数处理它们。与普通的数据合并器类似,标签中的填充标记为-100,这样在计算损失时就不会将这些标记考虑在内。

import torch

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union

@dataclass
class DataCollatorCTCWithPadding:
    """
    动态填充输入的数据整理器。
    参数:
        processor (:class:`~transformers.Wav2Vec2Processor`)
            用于处理数据的处理器。
        padding (:obj:`bool`, :obj:`str` 或 :class:`~transformers.tokenization_utils_base.PaddingStrategy`, `可选`, 默认为 :obj:`True`):
            选择一种策略来填充返回的序列(根据模型的填充方向和填充索引),可选的策略有:
            * :obj:`True` 或 :obj:`'longest'`:填充到批处理中最长的序列(如果只提供了单个序列,则不进行填充)。
            * :obj:`'max_length'`:根据参数 :obj:`max_length` 或模型的最大可接受输入长度进行填充。
            * :obj:`False` 或 :obj:`'do_not_pad'`(默认):无填充(即可以输出具有不同长度序列的批处理)。
    """

    processor: Wav2Vec2Processor
    padding: Union[bool, str] = True

    def __call__(self, features: List[Dict[str, Union[List[int], torch.Tensor]]]) -> Dict[str, torch.Tensor]:
        # 将输入和标签拆分,因为它们必须具有不同的长度并且需要不同的填充方法
        input_features = [{"input_values": feature["input_values"]} for feature in features]
        label_features = [{"input_ids": feature["labels"]} for feature in features]

        batch = self.processor.pad(
            input_features,
            padding=self.padding,
            return_tensors="pt",
        )

        labels_batch = self.processor.pad(
            labels=label_features,
            padding=self.padding,
            return_tensors="pt",
        )

        # 将填充替换为-100以正确忽略损失
        labels = labels_batch["input_ids"].masked_fill(labels_batch.attention_mask.ne(1), -100)

        batch["labels"] = labels

        return batch

data_collator = DataCollatorCTCWithPadding(processor=processor, padding=True)

接下来,定义评估指标。如前所述,ASR 中主要的指标是词错误率(WER),因此我们也将在本笔记本中使用它。

from evaluate import load

wer_metric = load("wer")

模型将返回一个 logit 向量序列:y 1 , … , y m \mathbf{y}_1, \ldots, \mathbf{y}_m y 1 ​ , … , y m ​ ,其中 y 1 = f θ ( x 1 , … , x n ) [ 0 ] \mathbf{y}_1 = f_{\theta}(x_1, \ldots, x_n)[0] y 1 ​ = f θ ​ ( x 1 ​ , … , x n ​ ) [ 0 ] ,且 n > > m n >> m n > > m 。

一个 logit 向量 y 1 \mathbf{y}_1 y 1 ​ 包含了我们之前定义的词汇表中每个单词的对数几率,因此 len ( y i ) = \text{len}(\mathbf{y}_i) = len ( y i ​ ) = config.vocab_size 。我们对模型最可能的预测感兴趣,因此取 logits 的 argmax(...) 值。此外,我们将编码的标签转换回原始字符串,将 -100 替换为 pad_token_id 并解码 id,同时确保连续的令牌在 CTC 样式中分组 1 {}^1 1 。

def compute_metrics(pred):
    pred_logits = pred.predictions
    pred_ids = np.argmax(pred_logits, axis=-1)

    pred.label_ids[pred.label_ids == -100] = processor.tokenizer.pad_token_id

    pred_str = processor.batch_decode(pred_ids)
    # 在计算指标时,我们不希望将令牌分组在一起
    label_str = processor.batch_decode(pred.label_ids, group_tokens=False)

    wer = wer_metric.compute(predictions=pred_str, references=label_str)

    return {"wer": wer}

现在,我们可以加载预训练的 mms-1b-all 检查点。tokenizer 的 pad_token_id 必须定义模型的 pad_token_id ,或者在 Wav2Vec2ForCTC 的情况下,还要定义 CTC 的空白符号 2 {}^2 2 。

由于我们只训练了一小部分的权重,该模型不容易过拟合。因此,我们确保禁用了所有的dropout层。

注意:当使用这个笔记本在Common Voice的其他语言上训练MMS时,这些超参数设置可能效果不好。请根据您的使用情况进行调整。

from transformers import Wav2Vec2ForCTC

model = Wav2Vec2ForCTC.from_pretrained(
    "facebook/mms-1b-all",
    attention_dropout=0.0,
    hidden_dropout=0.0,
    feat_proj_dropout=0.0,
    layerdrop=0.0,
    ctc_loss_reduction="mean",
    pad_token_id=processor.tokenizer.pad_token_id,
    vocab_size=len(processor.tokenizer),
    ignore_mismatched_sizes=True,
)

    Wav2Vec2ForCTC的一些权重没有从facebook/mms-1b-all的模型检查点中初始化,因为形状不匹配:
    - lm_head.bias:在检查点中的形状为torch.Size([154]),在实例化的模型中为torch.Size([39])
    - lm_head.weight:在检查点中的形状为torch.Size([154, 1280]),在实例化的模型中为torch.Size([39, 1280])
    你应该训练这个模型以便能够用它进行预测和推断。

注意:预计有一些权重是新初始化的。这些权重对应于新初始化的词汇表输出层。

现在,我们希望只训练适配器权重,而将模型的其余部分保持冻结。

首先,我们使用方便的init_adapter_layers方法重新初始化所有适配器权重。也可以选择不重新初始化适配器权重并继续微调,但在这种情况下,应该在训练之前使用load_adapter(...)方法加载适配器权重。然而,通常情况下,词汇表仍然不会很好地匹配自定义训练数据,所以通常最好的方法是重新初始化所有适配器层,以便它们可以轻松进行微调。

model.init_adapter_layers()

接下来,我们冻结所有权重,但是适配器层除外。

model.freeze_base_model()

adapter_weights = model._get_adapters()
for param in adapter_weights.values():
    param.requires_grad = True

最后,我们定义与训练相关的所有参数。对于一些参数的更多解释:

  • group_by_length通过将输入长度相似的训练样本分组到一个批次中,使训练更加高效。这可以通过大大减少通过模型传递的无用填充标记的总数来显著加快训练时间。
  • learning_rate选择为1e-3,这是使用Adam进行训练的常见默认值。其他学习率可能同样有效。

有关其他参数的更多解释,请参阅文档。为了节省GPU内存,我们启用了PyTorch的梯度检查点,并将损失减少设置为”mean”。MMS适配器微调非常快速地收敛到非常好的性能,因此即使对于只有4小时的数据集,我们也只训练4个时期。在训练过程中,每200个训练步骤,将异步上传一个检查点到hub。这样即使您的模型仍在训练中,您也可以玩弄演示小部件。

注意:如果不想将模型检查点上传到hub,请将push_to_hub=False

from transformers import TrainingArguments

training_args = TrainingArguments(
  output_dir=repo_name,
  group_by_length=True,
  per_device_train_batch_size=32,
  evaluation_strategy="steps",
  num_train_epochs=4,
  gradient_checkpointing=True,
  fp16=True,
  save_steps=200,
  eval_steps=100,
  logging_steps=100,
  learning_rate=1e-3,
  warmup_steps=100,
  save_total_limit=2,
  push_to_hub=True,
)

现在,所有实例都可以传递给Trainer,我们准备开始训练!

from transformers import Trainer

trainer = Trainer(
    model=model,
    data_collator=data_collator,
    args=training_args,
    compute_metrics=compute_metrics,
    train_dataset=common_voice_train,
    eval_dataset=common_voice_test,
    tokenizer=processor.feature_extractor,
)

1 {}^1 1 为了使模型不依赖于说话者的语速,在CTC中,连续的相同标记被简单地组合成一个标记。然而,在解码时,编码的标签不应该被组合,因为它们不对应于模型的预测标记,这就是为什么必须传递group_tokens=False参数的原因。如果不传递这个参数,像"hello"这样的单词会被错误地编码,并解码为"helo"。2 {}^2 2 空白标记允许模型预测一个单词,比如"hello",通过强制它在两个”l”之间插入空白标记。我们模型对"hello"的CTC兼容预测将是[PAD] [PAD] "h" "e" "e" "l" "l" [PAD] "l" "o" "o" [PAD]

训练

训练时间应该不到30分钟,具体取决于所使用的GPU。

trainer.train()

训练损失和验证集的词错误率(WER)都很好地下降。

我们可以看到,仅仅对mms-1b-all的适配器层进行100步的微调就可以明显优于已经显示在这里的整个xls-r-300m检查点的微调。

从官方论文和这个快速比较可以清楚地看出,mms-1b-all具有更高的知识转移能力,适用于资源稀缺的语言,并且应该优先选择它而不是xls-r-300m。此外,训练也更节省内存,因为只有一小部分层被训练。

适配器的权重将作为模型检查点的一部分上传,但我们还要确保将它们单独保存,以便可以轻松地加载和卸载它们。

让我们将所有的适配器层保存到训练输出目录中,以便可以正确上传到Hub。

from safetensors.torch import save_file as safe_save_file
from transformers.models.wav2vec2.modeling_wav2vec2 import WAV2VEC2_ADAPTER_SAFE_FILE
import os

adapter_file = WAV2VEC2_ADAPTER_SAFE_FILE.format(target_lang)
adapter_file = os.path.join(training_args.output_dir, adapter_file)

safe_save_file(model._get_adapters(), adapter_file, metadata={"format": "pt"})

最后,你可以将训练结果上传到🤗 Hub。

trainer.push_to_hub()

适配器权重训练的一个主要优势是,基本模型(占总模型权重的约99%)保持不变,只需要共享一个小的2.5M适配器检查点即可使用训练好的检查点。

这使得训练额外的适配器层并将它们添加到你的存储库变得非常简单。

只需重新运行此脚本并将要训练的语言更改为其他语言(例如swe表示瑞典语)即可轻松实现。此外,你应该确保词汇表不会被完全覆盖,而是将新语言的词汇表附加到现有词汇表中,如上面注释掉的单元格中所述。

为了演示如何加载不同的适配器层,我还训练并上传了瑞典语的适配器层,语言代码为isoswe,你可以在这里看到

你可以像往常一样使用from_pretrained(...)来加载微调后的检查点,但你应该确保还添加了一个target_lang="<your-lang-code>"到该方法中,以便加载正确的适配器。你还应该为你的分词器正确设置目标语言。

让我们先看看如何加载土耳其语的检查点。

model_id = "patrickvonplaten/wav2vec2-large-mms-1b-turkish-colab"

model = Wav2Vec2ForCTC.from_pretrained(model_id, target_lang="tur").to("cuda")
processor = Wav2Vec2Processor.from_pretrained(model_id)

processor.tokenizer.set_target_lang("tur")

让我们检查模型是否能正确转录土耳其语

from datasets import Audio

common_voice_test_tr = load_dataset("mozilla-foundation/common_voice_6_1", "tr", data_dir="./cv-corpus-6.1-2020-12-11", split="test", use_auth_token=True)
common_voice_test_tr = common_voice_test_tr.cast_column("audio", Audio(sampling_rate=16_000))

让我们处理音频,运行前向传递并预测ids

input_dict = processor(common_voice_test_tr[0]["audio"]["array"], sampling_rate=16_000, return_tensors="pt", padding=True)

logits = model(input_dict.input_values.to("cuda")).logits

pred_ids = torch.argmax(logits, dim=-1)[0]

最后,我们可以解码示例。

print("预测结果:")
print(processor.decode(pred_ids))

print("\n参考结果:")
print(common_voice_test_tr[0]["sentence"].lower())

输出 :

    预测结果:
    pekçoğuda roman toplumundan geliyor

    参考结果:
    pek çoğu da roman toplumundan geliyor.

这看起来几乎完全正确,只是第一个单词应该添加两个空格。现在,通过调用model.load_adapter(...)并将分词器更改为瑞典语,将适配器更改为瑞典语也很简单。

model.load_adapter("swe")
processor.tokenizer.set_target_lang("swe")

我们再次从common voice加载瑞典语测试集

common_voice_test_swe = load_dataset("mozilla-foundation/common_voice_6_1", "sv-SE", data_dir="./cv-corpus-6.1-2020-12-11", split="test", use_auth_token=True)
common_voice_test_swe = common_voice_test_swe.cast_column("audio", Audio(sampling_rate=16_000))

并转录一个样本:

input_dict = processor(common_voice_test_swe[0]["audio"]["array"], sampling_rate=16_000, return_tensors="pt", padding=True)

logits = model(input_dict.input_values.to("cuda")).logits

pred_ids = torch.argmax(logits, dim=-1)[0]

print("预测结果:")
print(processor.decode(pred_ids))

print("\n参考结果:")
print(common_voice_test_swe[0]["sentence"].lower())

输出 :

    预测结果:
    jag lämnade grovjobbet åt honom

    参考结果:
    jag lämnade grovjobbet åt honom.

很棒,这看起来是完美的转录!

我们在这篇博文中展示了MMS适配器权重微调不仅在低资源语言上提供了最先进的性能,还显著加快了训练时间,并允许轻松构建一组定制的适配器权重。

相关帖子和附加链接如下:

  • 官方论文
  • 原始cobebase
  • 官方演示
  • Transformer文档
  • 相关XLS-R博文
  • Hub上的模型