实战-唤醒词识别

1,508 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第18天,点击查看活动详情

唤醒词识别

前言

本次实战主要是实现唤醒词识别(Speech Command Recognition),相应的数据集内容是一段段语音,就像小爱同学音箱,你需要通过念【小爱同学】,才能唤醒音箱,然后使用。本次实战主要就是通过音频数据使用 GRU 模型为主要模型,进行训练和学习,识别出对应的文字。

数据集下载地址:www.kaggle.com/competition…

文件说明

data目录存放原始数据与预处理后切分为训练集、验证机、测试集的数据
log目录训练过程中使用tensorboardX保存的指标数值,如损失、精确度等
model_save目录存放不同训练阶段的模型,最后找出个最优的用于测试集
config.py保存超参数
dataset_kws.pyBanknote数据类,用于训练时获取数据
inference.py挑选模型在测试集上运行
model.py算法模型
preprocess.py对原始数据进行预处理,划分为训练集、验证集、测试集
trainer.py模型训练代码
data目录存放数据集
utils.py工具类文件

数据集说明

数据集构成;

image.png

每个文件下都是该文件夹名称的一小段的录音,1s左右的时间。注意 _background_noise_ 该文件是背景噪声,我们也需要输入模型,也要识别出背景噪声,保证模型在接触背景噪声不会进行学习,尽可能的学习正确的语音信号。

模型构建

本次实战以 GRU 作为主要模型,并使用双向,多层结构作为前面识别模型构建,结果进行拼接,采用全连接层(分类器)对结果进行分类。

image.png

数据预处理

首先,我们的数据集唤醒词的语音时长较短,但是 _background_noise_ 该文件下的语音数据时长较长需要进行裁剪成 1s 长度一个个片段,方便后续使用和识别。

对背景噪声进行切分:以 200ms 为间隔时长,每次截取 1s 音频时长进行保存。

def chop_bgn():
    # 读取目的文件夹下的目的文件后缀
    waves_files = recursive_fetching(os.path.join(HP.data_root, "_background_noise_"), suffix=["wav", "WAV"])
    for wave_f in waves_files:
        file_name = os.path.split(wave_f)[-1] # 获取文件名
        sampling_rate, data = wf.read(wave_f) # 打开音频文件-> 采样率和音频数据
        data_len = data.shape[0] # 总共的采样点个数
        len_200ms = int(sampling_rate*200/1000) # 200 ms的采样点个数 
        count = round(data_len/len_200ms) # 总共取count个长度为1s的音频
 
        for i in range(count):
            segment = data[i*len_200ms: i*len_200ms+sampling_rate] # 获取采样区对应的数据
            ouput_file_name = "seg-%d-%s" % (i, file_name) # 存储文件地址
            wf.write(os.path.join(HP.data_root, 'bgn', ouput_file_name),sampling_rate, segment) # 将音频数据进行存储。

因为这次是一个分类问题,需要根据类别构建标签 json 文件。

# 构建一个类别到id的映射
cls_mapper = {
    "cls2id": {"bgn": 0, "down": 1, "go": 2, "left": 3, "off": 4, "on": 5, "right": 6, "stop": 7},
    "id2cls": {0: "bgn", 1: "down", 2: "go", 3: "left", 4: "off", 5: "on", 6: "right", 7: "stop"}
}
json.dump(cls_mapper, open(HP.cls_mapper_path, 'w')) # 保存成json

首先需要将所有的数据集里面数据全部读取出来,随机打乱,生成一个字典,键值为类别标签,对应值为数据地址的 List。

dataset = recursive_fetching(HP.data_root, suffix=['wav'])
dataset_num = len(dataset)
print("Total Items: %d" % dataset_num)
random.shuffle(dataset) # 随机打乱
dataset_dict = {}
for it in dataset:
    cls_name = os.path.split(os.path.split(it)[0])[-1] # ".../data/bgn/seg-11-xxxx.wav"
    cls_id = cls_mapper["cls2id"][cls_name]
    if cls_id not in dataset_dict:
        dataset_dict[cls_id] = [it]
    else:
        dataset_dict[cls_id].append(it)

接下来按 8:1:1 的比例去分配每一个类别的数据,放入不同的数据集 List 里面,再次随机打乱,保证数据集的随机性,将结果写入 txt 文件里面,格式可以自己定制(id|str_path

# 每个类别按照比例分到train/eval/test
train_ratio, eval_ratio, test_ratio = 0.8, 0.1, 0.1
train_set, eval_set, test_set = [], [], []
for _, set_list in dataset_dict.items():
    length = len(set_list)
    train_num, eval_num = int(length*train_ratio), int(length*eval_ratio)
    test_num = length - train_num - eval_num
    random.shuffle(set_list)
    train_set.extend(set_list[:train_num])
    eval_set.extend(set_list[train_num:train_num+eval_num])
    test_set.extend(set_list[train_num+eval_num:])
 
# 再次随机打乱
random.shuffle(train_set)
random.shuffle(eval_set)
random.shuffle(test_set)

with open(HP.metadata_train_path, 'w') as fw:
    for path in train_set:
        cls_name = os.path.split(os.path.split(path)[0])[-1]  
        cls_id = cls_mapper["cls2id"][cls_name]
        fw.write("%d|%s\n" % (cls_id, path))

数据集获取

首先构建一个数据集加载器,加载音频文件的 idmel 频谱特征。

class KWSDataset(torch.utils.data.Dataset):
    def __init__(self, metadata_path):
        self.dataset = load_meta(metadata_path)
 
    def __getitem__(self, idx):
        item = self.dataset[idx]
        cls_id, path = int(item[0]), item[1]
        mel = load_mel(path) # [data_point_dim, sequence_len] = [40, ?]
        # [x,x,x,x,x,0,0]
        # [x,x,x,x,x,x,x]
        return mel.to(HP.device), cls_id # cls_int
 
    def __len__(self):
        return len(self.dataset)

每个音频数据长度是不确定的,可能有的短,有的长,我们需要将这些数据定长,虽然 RNN 可以使用变长数据,但是对于同一个batch内要padding到一样长度,不同batch之间可以不一样。对于模型来讲都是处理批数据,不是一个一个进行学习训练,对于一批数据,需要保证长度一致。

def collate_fn(batch):
    # 每次batch获取item的mel数据的长度进行排序(降序)
    sorted_batch = sorted(batch, key=lambda b: b[0].size(1), reverse=True)
    # 获取每个mel数据,根据pad_sequence要求需要对数据进行转置
    mel_list = [item[0].transpose(0, 1) for item in sorted_batch]
    # 补齐
    mel_padded = pad_sequence(mel_list, batch_first=True)
    labels = torch.LongTensor([item[1] for item in sorted_batch]) # transfer labels to long tensor
    # mel的实际长度
    mel_lengths = torch.LongTensor([item.size(0) for item in mel_list])
    return mel_padded, mel_lengths, labels

构建模型

激活函数使用 Mish 函数。需要定义成一个类,这样子方便 torch 进行自动化使用,不需要我们手写详细的详细前向传播过程。

def mish(x): # [N, ....]
    return x*torch.tanh(F.softplus(x))
class Mish(nn.Module):
    def __init__(self):
        super(Mish, self).__init__()
 
    def forward(self, x):
        return mish(x)

按照图片进行构建模型。RNN 输出形状是(seq_len, batch, num_directions * hidden_size) ,如果是单向的,我们要用最后一部分的输出结果进入分类器中,所以进入分类器的channelhidden_size,如果是双向,则需要取两部分数据进入分类器,应该是hidden_size*2

在向 GRU 模型输入未填充的数据,需要利用 pack_padded_sequence 进行压缩。后面会将数据放入分类器,所以也需要对结果进行定长。

如果是双向 GRU 我们需要将承载这前向特征的数据与承载反向特征进行一次拼接送入,因为双向 RNN 每一个输出是由前向与后向输出拼接构成,分类器所需要的是最主要的特征部分。

class SpeechCommandModel(nn.Module):
    def __init__(self):
        super(SpeechCommandModel, self).__init__()
 
        self.rnn = nn.GRU(
            input_size=HP.data_point_channel, # 数据点的channel=mel 点数 = 40
            hidden_size=HP.rnn_hidden_dim, # rnn hidden layer dimension 隐藏层层数
            num_layers=HP.rnn_layer_num, # two layers rnn 多层RNN 
            bidirectional=HP.is_bidirection, # True default 双向
        )
        # ** output ** of shape `(seq_len, batch, num_directions * hidden_size)`
        fc_in_dim = 2 * HP.rnn_hidden_dim if HP.is_bidirection else HP.rnn_hidden_dim
        # 全连接层用于分类。
        self.fc = nn.Sequential(
            nn.Linear(fc_in_dim, 1024),
            Mish(),
            nn.Dropout(HP.fc_drop),
            nn.Linear(1024, 512),
            Mish(),
            nn.Dropout(HP.fc_drop),
            nn.Linear(512, HP.classes_num)
        )
 
    def forward(self, mel_input, mel_lengths):
        # mel_input: [batch, sequence_len, datapoint_dim], mel_lengths: [81,75, 45, ...]
        # 保证输入形状是[batch, sequence_len, datapoint_dim]
        mel_input = mel_input.permute(1, 0, 2) # mel_input: [sequence_len, batch, datapoint_dim]
        # 删除之前填充部分--压缩
        mel_packed = pack_padded_sequence(mel_input, mel_lengths)
        output_packed, hn = self.rnn(mel_packed)
        output, _ = pad_packed_sequence(output_packed) # [sequence_len, batch, rnn_hidden_dim*(?)]
        if HP.is_bidirection:
            forward_feature = output[-1, :, :HP.rnn_hidden_dim] # [batch, rnn_hidden]
            backward_feature = output[0, :, HP.rnn_hidden_dim:] # [batch, rnn_hidden]
            fc_in = torch.cat((forward_feature, backward_feature), dim=-1)
            cls_output = self.fc(fc_in)
        else:
            cls_output = self.fc(output[-1, :])
 
        return cls_output

模型训练

这一部分可以参考实战-假钞识别,这一块不会改变太多,很多东西都进行了封装过程,需要根据模型改变输入与输出部分。