复现日志
阅读文章后初步构思复现内容:
首先采用IEMOCAP数据集作为搭建,后来的数据集还未下载,所以暂时先不考虑数据集接口的更换问题。
IEMOCAP数据集的结构如下所示:
共有5个session,其中共有10种情感,这里文中只用了4种:anger、hapiness、neutral和sadness,所以将其他没用的数据删掉,保留这四种情感即可。首先要进行的是分帧,即将所有的语音数据读取后分成窗长500ms,25%重叠的(0.25*500=125 overlapping)片段,供后续使用,片段不足500ms的部分进行补零。该数据集采样率为16000Hz,那么每个片段
这里考虑几个函数:
import os # 用于文件处理
import numpy as np
import math
from scipy.io.wavfile import read
def process(data, sp_r, win_len=500, overlap=0.25):
"""分割语段函数,输入为数据、采样率、窗长和overlap的百分比,输出每个数据的切割结果,放在一个数组当中"""
num = len(data) # 语句所有样本点数
win_num = int(sp_r * (win_len / 1000)) # 计算每个窗中的样本点数目
gap = math.floor(win_num * (1 - overlap)) # 非重叠部分的长度,需要跳过
start = 0 # 窗口起始点
result = [] # 结果列表
while start < num:
if start + win_num >= num: # 如果最后一个窗长超过了原长度
seg = np.zeros([win_num]) # 新建窗长大小全零数据
seg[:len(data[start:])] = data[start:] # 将尾部放入数组
else:
seg = data[start: start + win_num] # 取出窗口内样本点
result.append(seg)
start += gap # 后移gap大小
return np.array(result)
if __name__ == '__main__':
data_base = 'IEMOCAP' # 设置数据库
sample_rate = 16000 # 数据库对应采样率
target_name = 'IEMOCAP-preprocess' # 预处理好的npy文件存放位置
for dir_path, dir_names, filenames in os.walk(data_base): # walk是一个游走遍历器,用于遍历该路径下所有文件
if not os.path.exists(target_name + dir_path[len(data_base):]):
os.mkdir(target_name + dir_path[len(data_base):]) # 创建新的存放路径
for name in filenames: # 对于每一个文件(string类型)
path = os.path.join(dir_path, name) # 获得相对地址
wave_data = read(path)[1] # 读取音频到numpy数组当中
processed = process(wave_data, sample_rate) # 对当前语段进行切割处理并返回切割结果
new_dir = target_name + path[len(data_base): - (len(name) + 1)] # 新的保存路径
if not os.path.exists(new_dir):
os.mkdir(new_dir) # 创建新的存放路径
np.save(new_dir + '\\' + name[:-3] + 'npy', processed)
更改数据库只需要更改data_base、sample_rate和target_name三个参数即可。
上面经过预处理后,每个语段被分为若干大小相同的片段,我们而后需要对每个语段进行聚类以找到key segements,并将结果的npy文件保存在另外结构同源数据相同的文件夹中。需要注意的是,聚类需要一个参数K,即每个语段聚类类数,这个参数是通过一个阈值动态确定的,在每个语段中是不同的。
其中文献[32]给出了文中所使用方法Shot boundary detection的具体细节,文章连接:镜头边界检测,文中并没有具体说明是怎么计算距离的,所以这里暂时采用欧式距离矩阵计算两个片段间的距离。直接使用scipy中自带的cdist函数计算即可。根据文献[32]中的描述,阈值的设置公式如下,首先计算出所有相邻片段间的距离,对这些距离求均值,前面乘以一个权重作为阈值:
上面的权重自定义,一般 β \beta β取为5-10之间最佳,我考虑将 β \beta β作为外部参数,训练时调整参数寻优。
计算欧几里得距离时,我发现距离数值非常大,所以在计算前可以将所有数值按行进行z-score归一化,当然这里的归一化方法不是固定的,对结果的影响未知。但是归一化之后发现出错了,因为计算欧几里得距离时有可能算出inf或者nah,所以这里我去掉归一化了。
然后就是进行K-Means聚类了,可以单独有一个函数进行聚类,直接采用sklearn包的聚类算法,这里需要自定义距离度量,改成文中的RBF函数计算,具体自定义方法详见:sk-learn自定义距离度量。
聚类函数: 输入样本和聚类数,输出距离聚类中心最近的k个样本和标签情况(用来进行可视化),当然这里K=1没有意义,函数自己会将其归为一类。自带聚类函数中包含很多信息,有K个聚类中心和聚类结果标签。
K的取值显然是很重要的,有的时候会有6个片段只分为1类的情况,那只能选取一个片段训练,显然丢失了很多信息,这个K值选择的过程如果能优化一下,或者片段长度小一点,会不会好一些?
这里用sk-learn出现了一个警告信息:KMeans is known to have a memory leak on Windows with MKL, when there are less chunks than available threads. You can avoid it by setting the environment variable OMP_NUM_THREADS=1. 不知道是为什么
聚类这里还是有问题的,下面这个公式看不懂,没法完成自定义距离那里,暂时先放在这,采用欧氏距离的聚类,还有这里为什么不是把数据通过核函数直接映射到高维空间里呢?而是用核函数作为距离度量?
pyclustering包中其实是有聚类结果可视化的,如果后期有需要会加入可视化功能。
from scipy.spatial.distance import euclidean # 计算欧氏距离用
import os
import numpy as np
# from kmeans import clustering
from sklearn.cluster import KMeans
# def normalization(x):
# """归一化函数"""
# x_mean, x_std = np.mean(x, axis=0), np.std(x, axis=0)
# x = (x - x_mean) / x_std
# return x
def clustering(x, k):
"""聚类函数,输入样本和聚类数,输出距离聚类中心最近的k个样本"""
clf = KMeans(k) # 聚类器
r = clf.fit(x) # 进行聚类,其中包含很多信息
clustered = [] # 找出的关键片段
for c in r.cluster_centers_:
item = x[0] # 初始化距离聚类中心最近的那个片段
min_dist = np.Inf # 记录最小距离值
for i in x:
if euclidean(c, i) < min_dist: # 如果距离该中心的距离更短,更新结果
item = i
min_dist = euclidean(c, i)
clustered.append(item)
return clustered, r.labels_
if __name__ == '__main__':
origin_name = 'IEMOCAP-preprocess' # 上一步预处理好的npy文件存放文件夹
target_name = 'IEMOCAP-clustered' # 聚类好结果存放文件夹名称
thresh_weight = 1.5
for dir_path, dir_names, filenames in os.walk(origin_name): # 遍历数据文件夹下所有文件
if not os.path.exists(target_name + dir_path[len(origin_name):]):
os.mkdir(target_name + dir_path[len(origin_name):]) # 创建新的存放路径
for name in filenames: # 对于每一个文件(string类型)
K = 1 # 聚类类别参数
path = os.path.join(dir_path, name) # 获得相对地址
data = np.load(path) # 读取一条音频处理结果
# data = normalization(data) # 归一化数据
dists = [] # 存放所有相邻片段距离
for seg in range(data.shape[0] - 1): # 对于每一个片段来说(不包括最后一个)
dist = euclidean(data[seg], data[seg + 1]) # 计算相邻两个片段的欧氏距离
dists.append(dist) # 存入容器中
T = thresh_weight * np.mean(dists) # 计算阈值
for dis in dists: # 根据阈值确定K值
if dis > T:
K += 1
result, labels = clustering(data, K) # 进行聚类,返回K个样本和label信息(用于绘图)
new_dir = target_name + path[len(origin_name): - (len(name) + 1)] # 新的保存路径
if not os.path.exists(new_dir):
os.mkdir(new_dir) # 创建新的存放路径
np.save(new_dir + '\\' + name[:-3] + 'npy', result)
下一步需要将上面聚类后的结果转换为语谱图放入CNN-BiLSTM网络进行训练,这里单独写一个函数将特征提取结果保存在和源文件结构相同的文件夹当中,过程很简单,即读取对应文件夹及文件后进行处理保存。
这里很奇怪,一个语段分成segments之后,最后生成的语谱图形状是(片段数,时间,频率)一个三维图,直接输入到网络当中吗?显然片段数是不固定的,但是CNN的输入通道大小是确定的,这是怎么回事?只有一种可能,就是取数据的时候取得是(batch-size,片段数,时间,频率)这样大小,但是将前两维度合并,作为二维图输入网络
import os
import numpy as np
import scipy.signal as signal
"""运行顺序3:获取关键片段的频谱图作为特征并存储到文件中,结构同源"""
if __name__ == '__main__':
origin_name = 'IEMOCAP-clustered' # 传统艺能
target_name = 'IEMOCAP-feature' # 传统艺能
for dir_path, dir_names, filenames in os.walk(origin_name): # 遍历数据文件夹下所有文件
if not os.path.exists(target_name + dir_path[len(origin_name):]):
os.mkdir(target_name + dir_path[len(origin_name):]) # 创建新的存放路径
for name in filenames: # 对于每一个文件(string类型)
path = os.path.join(dir_path, name) # 获得相对地址
data = np.load(path) # 读取语段数据
result = [] # 存放所有片段STFT变换结果
for i in range(len(data)):
_, _, z = signal.stft(data[i]) # STFT
result.append(z)
result = np.array(result)
new_dir = target_name + path[len(origin_name): - (len(name) + 1)] # 新的保存路径
if not os.path.exists(new_dir):
os.mkdir(new_dir) # 创建新的存放路径
np.save(new_dir + '\\' + name[:-3] + 'npy', result)
上面提取好特征后,结构和原来的文件夹是一样的,不利于训练,我们需要的是每个speaker在一个文件夹中,因为训练时需要五折交叉验证。每个speaker文件夹包含两个文件夹:feature和label,训练时直接读取feature和label中的数据。
import numpy as np
import os
"""运行顺序4:重构文件夹形态,按照每一个speaker特征和标签进行划分,由于数据库本身特性,可能只适用于IEMOCAP,请勿改变任何文件夹名称"""
if __name__ == '__main__':
dic = {'ang':0, 'hap':1, 'neu':2, 'sad':3} # 每种情绪对应标签
origin_path = 'IEMOCAP-feature' # 特征所在文件夹
target_path = 'IEMOCAP-result' # 目标文件夹
if not os.path.exists(target_path):
os.mkdir(target_path) # 创建新的文件存放
speaker = 0 # speaker计数器
for i in range(1, 6): # 遍历session
cur_path = os.path.join(origin_path, 'Session{}'.format(i)) # 当前session的内容
features_M = [] # 存放男性特征
labels_M = [] # 存放男性标签
features_F = [] # 存放女性特征
labels_F = [] # 存放女性标签
for key in dic.keys(): # 取出每个文件夹
c_path = os.path.join(cur_path, key) # 当前情绪所在文件夹
for dir_path, dir_names, filenames in os.walk(c_path): # 遍历当前情绪下所有文件
for name in filenames:
data = np.load(os.path.join(dir_path, name)) # 读取该文件
if name[-8] == 'F': # 如果该文件是女性
for d in data:
features_F.append(d) # 添加特征
labels_F.append(dic[key]) # 添加标签
else:
for d in data:
features_M.append(d) # 添加特征
labels_M.append(dic[key]) # 添加标签
# 创建第一个说话人文件夹,第一个人是男的
speaker += 1
save_path = os.path.join(target_path, 'speaker{}'.format(speaker))
if not os.path.exists(save_path):
os.mkdir(save_path)
np.save(os.path.join(save_path, 'features.npy'), np.array(features_M))
np.save(os.path.join(save_path, 'labels.npy'), np.array(labels_M))
# 创建第二个说话人文件夹,第二个人是女的
speaker += 1
save_path = os.path.join(target_path, 'speaker{}'.format(speaker))
if not os.path.exists(save_path):
os.mkdir(save_path)
np.save(os.path.join(save_path, 'features.npy'), np.array(features_F))
np.save(os.path.join(save_path, 'labels.npy'), np.array(labels_F))
由上面的结构图可以看出,实际上网络只有两个部分,预训练的Resnet101和双向LSTM,而且经过查找资料发现ResNet的结构没有改变,可以直接调用预训练模型,最后一层的输出需要改变一下,改成softmax分类,类别数是数据库的情绪种类数。这里没有新建类直接调用的pytorch自带的模型进行训练,因为这两个模型没有自定义的成分。
这里比较坑的一点是文章好像没有给lstm的隐层数,我采用256作为隐藏层大小。
import torch
import torch.nn as nn
from torch.autograd import Variable
class LSTM_Model(nn.Module):
"""定义LSTM的模型部分,需要获取最后一步输出作为结果"""
def __init__(self, input_size, output_size, hidden_size, use_cuda=True):
super(LSTM_Model, self).__init__()
self.input_size = input_size # 输入特征大小
self.output_size = output_size # 输出大小
self.hidden_size = hidden_size # 隐藏层大小
self.use_cuda = use_cuda # 是否使用gpu训练
self.bi_lstm = nn.LSTM(input_size, hidden_size, num_layers=1, batch_first=True, bidirectional=True) # 双向LSTM
self.out_layer = nn.Linear(hidden_size * 2, output_size) # 输出层,大小为类别数
self.softmax = nn.LogSoftmax() # 输出概率
def forward(self, input, hidden):
x, hidden = self.bi_lstm(input, hidden) # 输入到LSTM中
x = self.out_layer(x[:, -1, :]) # 输出结果(取最后一步输出)
x = self.softmax(x) # 输出概率
return x, hidden
def init_hidden(self, batch_size):
"""初始化LSTM隐藏状态,每个batch调用一次"""
h0 = Variable(torch.zeros(2 * 1, batch_size, self.hidden_size))
c0 = Variable(torch.zeros(2 * 1, batch_size, self.hidden_size))
if self.use_cuda:
h0.cuda()
c0.cuda()
return h0, c0
训练采用五折交叉验证,由于不同数据库中speaker的数目是不同的,需要自动设置训练和验证、测试集的大小。这里需要能够训练模块,分别是speaker independent和speaker dependent。
主循环是5个turns,为五轮交叉验证,其中每轮需要划分出训练数据、验证数据和测试数据,五轮结束后以每轮测试结果平均值作为最终准确率、平均召回率和F1分数。
每轮中每5个epoch(总epoch暂时设置为100)做一次验证集上验证并保留验证集loss最低模型作为最优模型,训练结束后使用最优模型在测试集上测试并获得准确率,每轮需要输出的内容有:
这里为了方便,我将SI和SD的训练过程数据存储在文件里,格式为“IEMOCAP-SI-train//fold1”以此类推,共5个文件夹,每一个文件夹里是训练、验证和测试数据,生成代码如下:
import torch
import numpy as np
import os
if __name__ == '__main__':
"""运行顺序5:Speaker Independent 训练数据生成,将五折测试、训练数据存储在文件中,节省训练时间"""
root_path = 'IEMOCAP-result' # 特征所在文件夹
target_path = 'IEMOCAP-SI-train' # 目标存放文件夹
if not os.path.exists(target_path):
os.mkdir(target_path) # 创建新的存放路径
for t in range(5): # 进行5轮交叉训练验证
start = t * 2 # 选择游标
test_ind = start # 测试人下标
valid_ind = start + 1 # 验证人下标
# 测试数据
test_x = np.load(os.path.join(root_path, 'speaker{}'.format(test_ind + 1), 'features.npy'))
test_x = np.expand_dims(test_x, 1) # 拓展维度,作为一维图像
test_y = np.load(os.path.join(root_path, 'speaker{}'.format(test_ind + 1), 'labels.npy'))
# 验证数据
valid_x = np.load(os.path.join(root_path, 'speaker{}'.format(valid_ind + 1), 'features.npy'))
valid_x = np.expand_dims(valid_x, 1) # 拓展维度,作为一维图像
valid_y = np.load(os.path.join(root_path, 'speaker{}'.format(valid_ind + 1), 'labels.npy'))
# 训练数据
train_x = []
train_y = []
for person in range(10):
if person != test_ind or person != valid_ind:
train_x.extend([i for i in np.load(os.path.join(root_path, 'speaker{}'.format(person + 1),
'features.npy'))])
train_y.extend([i for i in np.load(os.path.join(root_path, 'speaker{}'.format(person + 1),
'labels.npy'))])
train_x = np.expand_dims(train_x, 1) # 拓展维度,作为一维图像
# 存储数据
new_path = os.path.join(target_path, 'fold{}'.format(t)) # 存储路径
if not os.path.exists(new_path):
os.mkdir(new_path) # 创建新的存放路径
np.save(os.path.join(new_path, 'train_x.npy'), np.array(train_x))
np.save(os.path.join(new_path, 'train_y.npy'), np.array(train_y))
np.save(os.path.join(new_path, 'test_x.npy'), test_x)
np.save(os.path.join(new_path, 'test_y.npy'), test_y)
np.save(os.path.join(new_path, 'valid_x.npy'), valid_x)
np.save(os.path.join(new_path, 'valid_y.npy'), valid_y)
接下来就是训练过程了,训练时数据可以直接从上面的文件夹中调用,节省了加载数据的时间。
这里会遇到一个问题,就是预训练模型的输入是[64, 3, 7, 7]的,而我们的数据是[batch-size, 1, 257, 63]的,需要修改预训练模型第一层卷积的结构使得能够塞入数据得到正确的结果。
在检查过程中我遇到了一个严重的问题,loss不下降,经过检查数据和loss定义的部分都是正确的,那么问题可能出在模型上,我对文章的理解可能出现了错误,LSTM的输入在程序里是[batch, 1000, 1],也就是时间步长是1,每个时间步的特征长度是1,学不到东西。为了验证是不是模型出了问题,在训练步中我把第2和3维度进行交换,发现loss可以下降了,可以确定是lstm输入的部分出现了问题,但是时间步长为1的LSTM基本相当于全连接层,所以合理猜测,下面图应该是这个意思:
每个语音对应几个聚类后的segment,这些segement转换为spectrogram,那么训练数据的形状应该为(batch, seg_num, 257, 63),但是这样每个语段的segment大小就不一样了,没法输入到CNN,因为CNN要求的通道大小要一致。要么就是,一个一个塞到CNN中,得到结果之后再合在一起放入LSTM……
【我卡住了】……