随着互联网、智能硬件的普及,智能音箱和语音助手已经深入人们的日常生活,家居场景下的语音识别技术已成为企业和研究机构竞相追逐的关键技术。
目前,由北京智源人工智能研究院、爱数智慧、biendata 共同发布的“智源 MagicSpeechNet 家庭场景语音数据集挑战赛” (2019 年 12 月 — 2020年 3 月)正在火热进行中,总奖金为 10 万元。参赛者需要使用比赛提供的真实家庭环境中的双人对话音频和文本数据,训练并优化语音识别(ASR)模型。比赛和数据复制下方链接查看,或点击“阅读原文”,欢迎所有感兴趣的读者参赛。
为便于选手熟悉和上手赛题,biendata 邀请 zc 选手(主要研究方向机器学习,语音翻译,语音识别)从数据处理、模型选择、提升方向等方面进行深入分析,希望可以抛砖引玉,为陷入瓶颈的选手提供灵感和思路,共同探索 ASR 实际应用场景中可行的解决方案。
比赛地址:
https://www.biendata.com/competition/magicdata/
Baseline地址:
https://biendata.com/models/category/4264/L_notebook/
1
Baseline 概述
zc 选手所采用模型结构基于语音识别中常见的 CTC 算法(Connectionist temporal classification),其中使用 CNN 对 mel-spectogram 进行特征抽取进行特征抽取,使用 RNN(本文选择的是 BiLSTM,如果是 Streaming ASR,则考虑 GRU 或者 LC-LSTM 等 )+ DNN 对序列每个 units 所对应的 label进行预测 label 进行预测,使用 CTC Loss 进行模型优化。该模型预估得分为 0.45。zc 认为使用 Data Augmentation、LM Fusion、Larger Model 等策略有助于进一步提示模型性能。
注:本 baseline 数据处理及模型训练部分参考《自动化所博士生田正坤分享端到端 Baseline》一文。
https://www.biendata.com/models/category/4162/L_notebook/
2
Baseline 详情
1. 赛题简介
本次比赛的任务为日常家庭环境中的对话语音识别。所使用数据集为智源 MagicSpeechNet 家庭场景中文语音数据集,其中的语言材料来自数十段真实环境中的双人对话。每段对话基于多种平台进行录制,并已完全转录和标注。参赛者需要使用比赛提供的数据训练并优化模型,提升模型在家庭环境的对话语音识别效果。
2. 数据处理
2.1 音频处理
切分音频,整理 metadata。
import os
import json
data_rootdir = './Magicdata' # 指定解压后数据的根目录
audiodir = os.path.join(data_rootdir, 'audio')
trans_dir = os.path.join(data_rootdir, 'transcription')
# 音频切分
def segment_wav(src_wav, tgt_wav, start_time, end_time):
span = end_time - start_time
cmds = 'sox %s %s trim %f %f' % (src_wav, tgt_wav, start_time, span)
os.system(cmds)
# 将时间格式转化为秒为单位
def time2sec(t):
h,m,s = t.strip().split(":")
return float(h) * 3600 + float(m) * 60 + float(s)
# 读取json文件内容
def load_json(json_file):
with open(json_file, 'r', encoding="utf-8") as f:
lines = f.readlines()
json_str = ''.join(lines).replace('\n', '').replace(' ', '').replace(',}', '}')
return json.loads(json_str)
# 训练集和开发集数据处理
for name in ['train', 'dev']:
save_dir = os.path.join('./data', name, 'wav')
if not os.path.exists(save_dir):
os.makedirs(save_dir)
seg_wav_list = []
sub_audio_dir = os.path.join(audiodir, name)
for wav in os.listdir(sub_audio_dir):
if wav[0] == '.':
continue # 跳过隐藏文件
if name == 'dev':
parts = wav.split('_')
jf = '_'.join(parts[:-1])+'.json'
suffix = parts[-1]
else:
jf = wav[:-4]+'.json'
utt_list = load_json(os.path.join(trans_dir, name, jf))
for i in range(len(utt_list)):
utt_info = utt_list[i]
session_id = utt_info['session_id']
if name == 'dev':
tgt_id = session_id + '_' + str(i) + '_' + suffix
else:
tgt_id = session_id + '_' + str(i) + '.wav'
# 句子切分
start_time = time2sec(utt_info['start_time']['original'])
end_time = time2sec(utt_info['end_time']['original'])
src_wav = os.path.join(sub_audio_dir, wav)
tgt_wav = os.path.join(save_dir, tgt_id)
segment_wav(src_wav, tgt_wav, start_time, end_time)
seg_wav_list.append((tgt_id, tgt_wav, utt_info['words']))
with open(os.path.join('./data', name, 'wav.scp'), 'w') as ww:
with open(os.path.join('./data', name, 'transcrpts.txt'), 'w', encoding='utf-8') as tw:
for uttid, wavdir, text in seg_wav_list:
ww.write(uttid+' '+wavdir+'\n')
tw.write(uttid+' '+text+'\n')
print('prepare %s dataset done!' % name)
# 测试集数据处理
save_dir = os.path.join('./data', 'test', 'wav')
if not os.path.exists(save_dir):
os.makedirs(save_dir)
seg_wav_list = []
sub_audio_dir = os.path.join(audiodir, 'test')
for wav in os.listdir(sub_audio_dir):
if wav[0] == '.' or 'IOS' not in wav:
continue # 跳过隐藏文件和非IOS的音频文件
jf = '_'.join(wav.split('_')[:-1])+'.json'
utt_list = load_json(os.path.join(trans_dir, 'test_no_ref_noise', jf))
for i in range(len(utt_list)):
utt_info = utt_list[i]
session_id = utt_info['session_id']
uttid = utt_info['uttid']
if 'words' in utt_info: continue # 如果句子已经标注,则跳过
# 句子切分
start_time = time2sec(utt_info['start_time'])
end_time = time2sec(utt_info['end_time'])
tgt_id = uttid + '.wav'
src_wav = os.path.join(sub_audio_dir, wav)
tgt_wav = os.path.join(save_dir, tgt_id)
segment_wav(src_wav, tgt_wav, start_time, end_time)
seg_wav_list.append((uttid, tgt_wav))
with open(os.path.join('./data', 'test', 'wav.scp'), 'w') as ww:
for uttid, wavdir in seg_wav_list:
ww.write(uttid+' '+wavdir+'\n')
print('prepare test dataset done!')
2.2 文本处理
对文本数据进行归一化处理,其中包括大写字母都转化为小写字母,过滤掉标点符号和无意义的句子。
3. 系统构建
3.1 实验环境
实验在 Linux 系统上进行,要求具备以下软件和硬件环境。
至少具备一个 GPU
python >= 3.6
pytorch >= 1.2.0
torchaudio >= 0.3.0
import torch
from torch import nn
import torch.nn.functional as F
import math
3.2 数据处理与加载
3.2.1 词表生成
根据训练集文本生成词表,并加入起始标记
import os
vocab_dict = {}
for name in ['train', 'dev']:
with open(os.path.join('./data', name, 'text'), 'r', encoding='utf-8') as fr:
for line in fr:
chars = line.strip().split()[1:]
for c in chars:
if c in vocab_dict:
vocab_dict[c] += 1
else:
vocab_dict[c] = 1
vocab_list = sorted(vocab_dict.items(), key=lambda x: x[1], reverse=True)
vocab = {'': 0, '': 1, '': 2, '': 3}
for i in range(len(vocab_list)):
c = vocab_list[i][0]
vocab[c] = i + 4
print('There are %d units in Vocabulary!' % len(vocab))
with open(os.path.join('./data', 'vocab'), 'w', encoding='utf-8') as fw:
for c, id in vocab.items():
fw.write(c+' '+ str(id) +'\n')
3.2.2 构建特征提取与加载模块
source 端:提取语音数据的 MFCC 特征作为输入
target 端:对文本数据用抽取的 vocab 进行编码
import os
import torch
import numpy as np
import torchaudio as ta
from torch.utils.data import Dataset, DataLoader
PAD = 0
BOS = 1
EOS = 2
UNK = 3
class AudioDataset(Dataset):
def __init__(self, wav_list, text_list=None, unit2idx=None, num_mel_bins=80):
self.num_mel_bins=num_mel_bins
self.unit2idx = unit2idx
self.file_list = []
for wavscpfile in wav_list:
with open(wavscpfile, 'r', encoding='utf-8') as wr:
for line in wr:
uttid, path = line.strip().split()
self.file_list.append([uttid, path])
if text_list is not None:
self.targets_dict = {}
for textfile in text_list:
with open(textfile, 'r', encoding='utf-8') as tr:
for line in tr:
parts = line.strip().split()
uttid = parts[0]
label = []
for c in parts[1:]:
label.append(self.unit2idx[c] if c in self.unit2idx else self.unit2idx[''])
self.targets_dict[uttid] = label
self.file_list = self.filter(self.file_list) # 过滤掉没有标注的句子
assert len(self.file_list) == len(self.targets_dict)
else:
self.targets_dict = None
self.lengths = len(self.file_list)
def __getitem__(self, index):
uttid, path = self.file_list[index]
wavform, _ = ta.load_wav(path) # 加载wav文件
feature = ta.compliance.kaldi.fbank(wavform, num_mel_bins=self.num_mel_bins) # 计算fbank特征
# 特征归一化
mean = torch.mean(feature)
std = torch.std(feature)
feature = (feature - mean) / std
if self.targets_dict is not None:
targets = self.targets_dict[uttid]
return uttid, feature, targets
else:
return uttid, feature
def filter(self, feat_list):
new_list = []
for (uttid, path) in feat_list:
if uttid not in self.targets_dict: continue
new_list.append([uttid, path])
return new_list
def __len__(self):
return self.lengths
@property
def idx2char(self):
return {i: c for (c, i) in self.unit2idx.items()}
# 收集函数,将同一个批内的特征填充到同样的长度,并在文本中加上起始标记和结束标记
def collate_fn(batch):
uttids = [data[0] for data in batch]
features_length = [data[1].shape[0] for data in batch]
max_feat_length = max(features_length)
padded_features = []
if len(batch[0]) == 3:
targets_length = [len(data[2]) for data in batch]
max_text_length = max(targets_length)
padded_targets = []
for parts in batch:
feat = parts[1]
feat_len = feat.shape[0]
padded_features.append(np.pad(feat, ((
0, max_feat_length-feat_len), (0, 0)), mode='constant', constant_values=0.0))
if len(batch[0]) == 3:
target = parts[2]
text_len = len(target)
padded_targets.append(
[BOS] + target + [EOS] + [PAD] * (max_text_length - text_len))
if len(batch[0]) == 3:
return uttids, torch.FloatTensor(padded_features), torch.LongTensor(padded_targets)
else:
return uttids, torch.FloatTensor(padded_features)
3.3 模型结构
本文所用结构基于语音识别中常见的 CTC 算法(Connectionist temporal classification)。
使用 CNN 对 mel-spectogram 进行特征抽取;
使用 RNN(本文选择的是 BiLSTM,如果是 Streaming ASR,则考虑 GRU 或者 LC-LSTM 等)+ DNN 对序列每个 units 所对应的 label 进行预测;
使用 CTC Loss 进行模型优化。
class BatchRNN(nn.Module):
def __init__(self, input_size, hidden_size, rnn_type=nn.LSTM,
bidirectional=False, batch_norm=True, dropout = 0.1):
super(BatchRNN, self).__init__()
self.input_size = input_size
self.hidden_size = hidden_size
self.bidirectional = bidirectional
self.batch_norm = SequenceWise(nn.BatchNorm1d(input_size)) if batch_norm else None
self.rnn = rnn_type(input_size=input_size, hidden_size=hidden_size,
bidirectional=bidirectional, dropout = dropout, bias=False)
def forward(self, x):
if self.batch_norm is not None:
x = self.batch_norm(x)
x, _ = self.rnn(x)
self.rnn.flatten_parameters()
return x
class SequenceWise(nn.Module):
def __init__(self, module):
super(SequenceWise, self).__init__()
self.module = module
def forward(self, x):
try:
x, batch_size_len = x.data, x.batch_sizes
# x.data sum(x_len) * num_features
x = self.module(x)
x = nn.utils.rnn.PackedSequence(x, batch_size_len)
except:
t, n = x.size(0), x.size(1)
x = x.view(t*n, -1)
# x sum(x_len) * num_features
x = self.module(x)
x = x.view(t, n, -1)
return x
class InferenceBatchLogSoftmax(nn.Module):
def forward(self, x):
# [seq_len, bs, num_class]
if not self.training:
seq_len = x.size()[0]
return torch.stack([F.log_softmax(x[i]) for i in range(seq_len)], 0)
else:
x = F.log_softmax(x)
return x
class CTC_Model(nn.Module):
def __init__(self, rnn_input_size=80, rnn_hidden_size=256, rnn_layers=4,
rnn_type=nn.LSTM, bidirectional=True,
batch_norm=True, num_class=3864, drop_out=0.1):
super(CTC_Model, self).__init__()
self.rnn_input_size = rnn_input_size
self.rnn_hidden_size = rnn_hidden_size
self.rnn_layers = rnn_layers
self.rnn_type = rnn_type
self.num_class = num_class
self.num_directions = 2 if bidirectional else 1
self._drop_out = drop_out
self.name = 'CTC_Model'
self.conv = nn.Sequential( # 抽取features时的压缩尺度,可以调整
nn.Conv2d(1, 16, kernel_size=(11, 3), stride=(2, 2)),
nn.BatchNorm2d(16),
nn.ReLU(),
nn.Conv2d(16, 16, kernel_size=(3, 3), stride=(2, 2)),
nn.BatchNorm2d(16),
nn.ReLU(),
)
rnn_input_size = int(math.floor(rnn_input_size-3)/2+1)
rnn_input_size = int(math.floor(rnn_input_size-3)/2+1)
rnn_input_size *= 16
rnns = []
rnn = BatchRNN(input_size=rnn_input_size, hidden_size=rnn_hidden_size,
rnn_type=rnn_type, bidirectional=bidirectional,
batch_norm=False)
rnns.append(('0', rnn))
for i in range(rnn_layers-1):
rnn = BatchRNN(input_size=self.num_directions*rnn_hidden_size,
hidden_size=rnn_hidden_size, rnn_type=rnn_type,
bidirectional=bidirectional, dropout = drop_out, batch_norm = batch_norm)
rnns.append(('%d' % (i+1), rnn))
self.rnns = nn.Sequential(OrderedDict(rnns))
if batch_norm :
fc = nn.Sequential(nn.BatchNorm1d(self.num_directions*rnn_hidden_size),
nn.Linear(self.num_directions*rnn_hidden_size, num_class+1, bias=False))
else:
fc = nn.Linear(self.num_directions*rnn_hidden_size, num_class+1, bias=False)
self.fc = SequenceWise(fc)
self.inference_log_softmax = InferenceBatchLogSoftmax()
def forward(self, x):
x = torch.unsqueeze(x, dim=1) # x: [bs, 1, seq_len, 80]
x = self.conv(x) # [bs, 16, seq_len/4, 19]
x = x.transpose(2, 3).contiguous()
sizes = x.size()
x = x.view(sizes[0], sizes[1]*sizes[2], sizes[3]) # [bs, 304, seq_len/4]
x = x.transpose(1, 2).transpose(0, 1).contiguous() # [seq_len/4, bs, 16*19]
x = self.rnns(x) # [seq_len/4, 16, 512]
x = self.fc(x) # [seq_len/4, 16, num_class]
x = self.inference_log_softmax(x) # [seq_len/4, 16, num_class]
return x
3.4 训练过程与模型保存
# 获取input的seqlen
def get_input_len(inputs):
# inputs [bs, seq_len, 80]
x = torch.sum(inputs.abs(), dim=2) # [bs, seq_len]
x = x.ne(torch.zeros(inputs.shape[:-1], dtype=torch.int64))
x = torch.sum(x, dim=1)
x = ((x - 11) / 2 + 1 - 3) / 2 + 1
return x
# 获取target的seqlen
def get_target_len(targets):
# targets [bs, text_len]
x = targets.ne(torch.zeros(targets.shape, dtype=torch.int64))
x = torch.sum(x, dim=1)
return x
total_epochs = 60 # 模型迭代次数
batch_size = 16 # 指定批大小
warmup_steps = 12000 # 热身步数
lr_factor = 1.0 # 学习率因子
accu_grads_steps = 8 # 梯度累计步数
num_mel_bins = 80
rnn_hidden_size = 256
rnn_layers = 4
rnn_type = nn.LSTM
bidirectional = True
batch_norm = True
drop_out = 0.1
# 加载词表
unit2idx = {}
with open('./data/vocab', 'r', encoding='utf-8') as fr:
for line in fr:
unit, idx = line.strip().split()
unit2idx[unit] = int(idx)
vocab_size = len(unit2idx) # 输出词表大小
print("[info] vocab size is", vocab_size)
# 模型定义
model = CTC_Model(rnn_input_size=num_mel_bins, rnn_hidden_size=rnn_hidden_size, rnn_layers=rnn_layers,
rnn_type=rnn_type, bidirectional=bidirectional, batch_norm=batch_norm,
num_class=vocab_size, drop_out=drop_out)
if torch.cuda.is_available():
model.cuda() # 将模型加载到GPU中
train_wav_list = ['./data/train/wav.scp', './data/dev/wav.scp']
train_text_list = ['./data/train/text', './data/dev/text']
dataset = AudioDataset(train_wav_list, train_text_list, unit2idx=unit2idx, num_mel_bins=num_mel_bins)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=batch_size,
shuffle=True, num_workers=2, pin_memory=False,
collate_fn=collate_fn)
# 定义优化器以及学习率更新函数
def get_learning_rate(step):
return lr_factor * rnn_hidden_size ** (-0.5) * min(step ** (-0.5), step * warmup_steps ** (-1.5))
lr = get_learning_rate(step=1)
optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.98), eps=1e-9)
if not os.path.exists('./model'): os.makedirs('./model')
global_step = 1
step_loss = 0
ctc_loss = nn.CTCLoss(blank=vocab_size-1, reduction='mean')
print('Begin to Train...')
for epoch in range(total_epochs):
print('***** epoch: %d *****'% epoch)
for step, (_, inputs, targets) in enumerate(dataloader):
# 将输入加载到GPU中
if torch.cuda.is_available():
inputs = inputs.cuda() # [bs, seq_len, 80]
targets = targets.cuda() # [bs, txt_len]
input_sizes = get_input_len(inputs)
target_sizes = get_target_len(targets)
out = model(inputs)
loss = ctc_loss(out, targets, input_sizes, target_sizes)
loss /= batch_size
loss.backward()
step_loss += loss.item()
if (step+1) % accu_grads_steps == 0:
# 梯度裁剪
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
optimizer.zero_grad()
optimizer.step()
if global_step % 10 == 0:
print('-Training-Epoch-%d, Global Step:%d, lr:%.8f, Loss:%.5f' % \
(epoch, global_step, lr, step_loss / accu_grads_steps))
global_step += 1
step_loss = 0
# 学习率更新
lr = get_learning_rate(global_step)
for param_group in optimizer.param_groups:
param_group['lr'] = lr
# 模型保存
checkpoint = model.state_dict()
torch.save(checkpoint, os.path.join('./model', 'model.epoch.%d.pt' % epoch))
print('Done!')
4. 改进方法
Data Augmentation
LM Fusion
Larger Model
3
比赛时间
竞赛分为初赛与复赛两阶段,初赛已于 2019 年 12 月 23 日开启,biendata 平台同步发布训练集、开发集、测试集,并开放初赛提交。2020 年 3 月 20 日,初赛报名和组队时间截止。每日提交存在次数限制,请感兴趣的选手尽量选择提前参赛,以获得更多验证提交次数和优化模型的机会。
4
参赛方式
点击阅读原文链接或扫描下图中的二维码直达赛事页面,注册网站-下载数据,即可参赛。
biendata 是知名的国际性大数据竞赛平台,面向全球在校学生、科研人员、企业以及自由职业者开放,期待对人工智能感兴趣的小伙伴能在平台上众多比赛中大展身手,在思维与技术的交流碰撞中激发创新和突破。
友情提示,因涉及到数据下载,强烈建议大家登录 PC 页面报名参加。
5
比赛数据
“智源 MagicSpeechNet 家庭场景中文语音数据集”是当前业界稀缺的优质家居环境语音数据,其中包含数百小时的真实家庭环境中的双人对话,每段对话基于多种平台进行录制,并已完全转录和标注。
比赛数据分为训练集、开发集和测试集三部分,测试集数据为需要识别的音频文件,每段音频分为安卓平台、iOS 平台,录音笔录制的三个文件。为便于选手分割每段音频,比赛提供了标明起始和结束时间点信息的 json 文件,选手需使用模型识别音频中的对话,并根据 json 中对应的 uttid 提交相应的文本。
相较于国内外同类多通道语音识别比赛,本比赛数据在数量、场景、声音特性等方面具有以下优势。
1. 大量的对话数据国内的语音识别比赛基本使用朗读类型的语音数据,而本比赛使用的数据为真实的对话数据。数据为完全真实场景的对话,说话人以放松和无脚本的方式,围绕所选主题自由对话。相比基于对话数据的国际同类比赛,在数据量方面仍旧具有极大的优势。同时,合理的说话人语音交叠更真实地体现日常家庭场景下的语音识别难度。
2. 场景真实多样本数据集采集于 3 个真实的家庭场景,说话人以放松和无脚本的方式,围绕所选主题自由对话。不同的采集环境丰富了数据的多样性,同时增强了比赛的难度。
3. 近讲与多平台远讲数据结合每段对话有 5 个通道的同步录音,包括 3 个远讲通道和 2 个近讲通道。远讲通道分别由多个型号的安卓手机,苹果手机和录音笔录制,充分体现多平台录音数据的特性;近讲数据使用高保真麦克风录制,与说话人的嘴保持 10 cm 的距离。
4. 丰富均衡的声音特性本数据集拥有丰富均衡的声音特性。录制本数据集的说话人来自中国大陆不同地域,存在一定的普通话口音。同时,说话人选自不同年龄段,性别均衡。
6
智源算法大赛
2019 年 9 月,智源人工智能算法大赛正式启动。本次比赛由北京智源人工智能研究院主办,清华大学、北京大学、中科院计算所、旷视、知乎、博世、爱数智慧、国家天文台、晶泰等协办,总奖金超过 100 万元,旨在以全球领先的科研数据集与算法竞赛为平台,选拔培育人工智能创新人才。
研究院副院长刘江也表示:“我们希望不拘一格来支持人工智能真正的标志性突破,即使是本科生,如果真的是好苗子,我们也一定支持。”而人工智能大赛就是发现有潜力的年轻学者的重要途径。
本次智源人工智能算法大赛有两个重要的目的,一是通过发布数据集和数据竞赛的方式,推动基础研究的进展。特别是可以让计算机领域的学者参与到其它学科的基础科学研究中。二是可以通过比赛筛选、锻炼相关领域的人才。智源算法大赛已发布全部的 10 个数据集,目前仍有 5 个比赛(奖金 50 万)尚未结束。
7
正在角逐的比赛
智源小分子化合物性质预测挑战赛
https://www.biendata.com/competition/molecule/
智源杯天文数据算法挑战赛
https://www.biendata.com/competition/astrodata2019/
智源-INSPEC 工业大数据质量预测赛
https://www.biendata.com/competition/bosch/
智源-MagicSpeechNet 家庭场景中文语音数据集挑战赛
https://www.biendata.com/competition/magicdata/
智源-高能对撞粒子分类挑战赛
https://www.biendata.com/competition/jet/
????
现在,在「知乎」也能找到我们了
进入知乎首页搜索「PaperWeekly」
点击「关注」订阅我们的专栏吧
关于PaperWeekly
PaperWeekly 是一个推荐、解读、讨论、报道人工智能前沿论文成果的学术平台。如果你研究或从事 AI 领域,欢迎在公众号后台点击「交流群」,小助手将把你带入 PaperWeekly 的交流群里。