【复现日志】Clustering-Based Speech Emotion Recognition by Incorporating Learned Features and Deep BiLSTM

Clustering-Based Speech Emotion Recognition by Incorporating Learned Features and Deep BiLSTM

复现日志


2021/11/22

阅读文章后初步构思复现内容:

  1. 数据集的下载和管理(注意speaker dependent和speaker independent数据集处理方式是不同的,需要设置一个超参获取数据信息,并存储到numpy数组当中)
  • speaker dependent是将所有文件混合后,随机选取80%数据作为测试和20%数据作为验证和测试。
  • speaker dependent是按照speaker进行五折交叉验证,80%的人用来训练,20%的人用来验证和测试。
    初步考虑将所有文件按照speaker划分文件夹,但是经过观察之后发现对于IEMOCAP数据集来说,session中的男女数据是混杂在一起的,带有“M”和“F”字样,考虑后续获得原始数据后,在训练前转化为语谱图阶段再进行划分,划分后每个session文件夹包含两个speaker,命名为M和F,每个性别文件夹内是两个npy文件,命名为data和label,分别存放聚类后的片段和标签。
  1. 将数据分为片段,窗长为500ms,有25%交叠,每个句子分别处理,存储到numpy数组当中作为处理后的数据,数据的形状大致为(样本数,片段数目,每个片段的数据长度)。
  2. 将上面处理好的数据进行聚类,距离度量使用RBF而不是欧拉距离矩阵,首先确定K值,阈值threshold动态计算[32],具体操作方式看一下该文章,K值确定后,进行K-Means聚类,将每个语段中距离每类最近的一个片段作为key segment,将所有key segment按照speaker存储到npy文件当中备用(上述为特征提取部分,参数一旦改变需要重新运行上面的程序),聚类结果最好做一个可视化,只展示部分数据(考虑4*4窗格)【这里先不加,后面有需要的话,可以添加可视化功能】。
  3. 构建后面的训练模型,CNN与LSTM部分是在一起训练的,可以同时写在一个模型里,但是CNN部分是预训练的,而且是一部分网络,可以在训练之前初始化参数时把预训练参数赋值好,然后LSTM进行空的初始化。这里注意双向LSTM的写法,是两层LSTM,可参考网络资料。LSTM最后一个时间步长的输出加log softmax进行结果预测,这里不要忘记对阈值确定的系数 β \beta β进行寻优。
  4. 训练过程参数初步考虑:
  • epoch = 100
  • learning rate = 0.001
  • optimizer = Adam
  • loss = 对数交叉熵
  • equipment = RTX 3060 GPU
    五折交叉验证需要根据数据集不同进行调整,同样需要设置if选择超参数决定
  1. 最后的结果需要speaker dependent和speaker independent分别的准确率、召回率、F1分数共2 * 3 * 3 (18个)数据,有必要可以加上WA,还有二者对应的混淆矩阵3 * 2(6个),这里只用IEMOCAP的话,需要SD、SI的三个数据3*2共6个数据和两个混淆矩阵。

数据预处理 2021/11/24

首先采用IEMOCAP数据集作为搭建,后来的数据集还未下载,所以暂时先不考虑数据集接口的更换问题。
IEMOCAP数据集的结构如下所示:
在这里插入图片描述
共有5个session,其中共有10种情感,这里文中只用了4种:anger、hapiness、neutral和sadness,所以将其他没用的数据删掉,保留这四种情感即可。首先要进行的是分帧,即将所有的语音数据读取后分成窗长500ms,25%重叠的(0.25*500=125 overlapping)片段,供后续使用,片段不足500ms的部分进行补零。该数据集采样率为16000Hz,那么每个片段
这里考虑几个函数:

  • 音频读取函数:输入文件路径,输出音频读取的numpy数组,每个数是每个采样点的值;(经过查找可以直接使用scipy中的read函数读取到numpy数组当中)
  • 片段切割函数:输入为数据、采样率、窗长和overlap的百分比,输出每个数据的切割结果,放在一个数组当中;
  • 主函数:按文件夹读取音频,切割后将音频存入对应新的文件夹中,最后得到的numpy文件的文件夹组织要和上面一样。
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三个参数即可。


聚类 2021/11/25

上面经过预处理后,每个语段被分为若干大小相同的片段,我们而后需要对每个语段进行聚类以找到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)


生成语谱图 2021/11/26

下一步需要将上面聚类后的结果转换为语谱图放入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)

重构特征 2021/11/26

上面提取好特征后,结构和原来的文件夹是一样的,不利于训练,我们需要的是每个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))

网络搭建与训练数据生成 2021/11/29

在这里插入图片描述
由上面的结构图可以看出,实际上网络只有两个部分,预训练的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最低模型作为最优模型,训练结束后使用最优模型在测试集上测试并获得准确率,每轮需要输出的内容有:

  • 验证EPOCH、验证集上的LOSS、UA\WA\召回率\F1分数(必要时加上最优模型存储提示)
  • 最优模型对应的EPOCH(完成)
  • 测试集平均UA、WA、召回率、F1分数
  • 每折训练集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)

在这里插入图片描述
接下来就是训练过程了,训练时数据可以直接从上面的文件夹中调用,节省了加载数据的时间。


训练与结果展示 2021/11/30

这里会遇到一个问题,就是预训练模型的输入是[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……
【我卡住了】……

  • 6
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值