Skip to content

Latest commit

 

History

History
746 lines (542 loc) · 36.4 KB

File metadata and controls

746 lines (542 loc) · 36.4 KB

十一、分类三——音乐类型分类

到目前为止,我们很幸运,每个训练数据实例都可以很容易地用特征值向量来描述。例如,在 Iris 数据集中,花由包含花的某些方面的长度和宽度值的向量表示。在基于文本的示例中,我们可以将文本转换为单词包表示,并手动创建自己的特征来捕捉文本的某些方面。

这一章会有所不同,当我们试图根据歌曲的流派来分类时。例如,我们如何表现一首三分钟长的歌曲?我们应该把它的 MP3 表现的个别位?可能不会,因为像文本一样对待它并创建一个类似于一包声音片段的东西肯定会非常复杂。不知何故,我们将不得不把一首歌转换成能充分描述它的价值矢量。

绘制我们的路线图

本章将向您展示我们如何在超出我们舒适范围的领域中提出一个合适的分类器。首先,我们将不得不使用基于声音的功能,这比我们目前使用的基于文本的功能要复杂得多。然后我们将学习如何处理比以前更多的课程。此外,我们将了解衡量分类绩效的新方法。

让我们假设一个场景,出于某种原因,我们在硬盘上发现了一堆随机命名的 MP3 文件,这些文件被假设包含音乐。我们的任务是根据音乐类型将它们分类到不同的文件夹中,如爵士乐、古典音乐、乡村音乐、流行音乐、摇滚音乐和金属音乐。

获取音乐数据

我们将使用 GTZAN 数据集,该数据集经常用于音乐流派分类任务的基准测试。它被组织成 10 个不同的流派,为了简单起见,我们将只使用其中的 6 个:古典、爵士、乡村、流行、摇滚和金属。数据集包含每个流派 100 首歌曲的前 30 秒。我们可以从http://opihi.cs.uvic.ca/sound/genres.tar.gz下载数据集。

我们可以直接用 Python 下载和提取它,这很好,尤其是如果你使用的是 Windows,它没有附带 tarball 解压程序。

在整个 Jupyter 笔记本中,我们将利用优秀的pathlib库,该库自 3.4 版本以来就是 Python 的一部分。它允许简单的路径和文件操作:

from pathlib import Path
DATA_DIR = "data"
if not Path(DATA_DIR).exists():
    os.mkdir(DATA_DIR)
import urllib.request
genre_fn = 'http://opihi.cs.uvic.ca/sound/genres.tar.gz'
# The division operator of Path instances is overloaded to behave 
# like os.path.join(), which makes it very convenient to use.
urllib.request.urlretrieve(genre_fn, Path(DATA_DIR) / 'gen-res.tar.gz')

现在我们已经下载了它,我们使用tarfile模块提取它:

import tarfile
cwd = os.getcwd()
os.chdir(DATA_DIR)

try:
    f = tarfile.open('genres.tar.gz', 'r:gz')
    try: 
        f.extractall()
    finally: 
        f.close()
finally:
    os.chdir(cwd)

转换为 WAV 格式

果然,如果我们想在我们的私人 MP3 收藏上测试我们的分类器,我们将无法提取太多的意义。这是因为 MP3 是一种有损音乐压缩格式,它会剪切掉人耳无法感知的部分。这很适合存储,因为有了 MP3,你可以在你的设备上存储 10 倍多的歌曲。然而,对于我们的努力来说,情况并不那么好。对于分类,我们使用 WAV 文件会更容易,因为它们可以被scipy.io.wavfile包直接读取。因此,如果我们想使用我们的分类器,我们必须转换我们的 MP3 文件。

如果附近没有转换工具,您可能想查看 SoX:http://sox.sourceforge.net。它声称是声音处理的瑞士军刀,我们同意这个大胆的说法。

然而,GTZAN 数据集附带的音乐文件不是 MP3 格式,而是 AU 格式,这意味着我们必须一个文件一个文件地转换它。以下片段是 Jupyter 笔记本中可能出现的一个巧妙技巧:它方便地允许我们运行系统命令,例如 Python 环境中的sox声音转换器。我们只需在命令行前面加上感叹号(!)并使用花括号传递 Python 表达式:

GENRE_DIR = Path(DATA_DIR) / 'genres' 
# You need to adapt the SOX_PATH accordingly on your system
SOX_PATH = r'C:\Program Files (x86)\sox-14-4-2'
for au_fn in Path(GENRE_DIR).glob('**/*.au'):
   print(au_fn)
    !"{SOX_PATH}/sox.exe" {au_fn} {au_fn.with_suffix('.wav')}

当然,所有这些都可以在普通的 Linux 或 Windows 外壳中完成,但是需要更多的外壳专业知识。

我们所有的音乐文件都是 WAV 格式的一个优点是它可以被 SciPy 工具包直接读取:

>>> sample_rate, X = scipy.io.wavfile.read(wave_filename)

X现在包含样本,sample_rate是获取样本的速率。让我们利用这些信息来浏览一些音乐文件,以了解数据是什么样子的。

看音乐

快速了解不同流派的歌曲“外观”的一个非常方便的方法是为一个流派中的一组歌曲绘制一个声谱图。声谱图是歌曲中出现的频率的视觉表示。它在 x 轴上显示指定时间间隔内 y 轴上频率的强度。在下面的声谱图中,这意味着在歌曲的特定时间窗口中,颜色越亮,频率越强。

Matplotlib 提供了方便的specgram()功能,为我们执行大部分的幕后计算和绘图:

>>> import scipy.io.wavfile
>>> from matplotlib.pyplot import specgram
>>> sample_rate, X = scipy.io.wavfile.read(wave_filename)
>>> print(sample_rate, X.shape)
22050, (661794,)
>>> specgram(X, Fs=sample_rate, xextent=(0,30), cmap='hot')

我们刚刚读入的 WAV 文件是以22050 Hz 的速率采样的,并且包含661794个样本。

如果我们现在为不同的 WAV 文件绘制这前 30 秒的声谱图,我们可以看到相同流派的歌曲之间存在共性,如下图所示:

只要看一眼图像,我们就能立即看到例如金属和古典歌曲之间的光谱差异。虽然金属歌曲在大部分频谱上一直具有高强度(它们充满活力!),古典歌曲呈现出更加多样化的格局。

应该可以训练出一个分类器,它至少能以足够高的准确度区分金属和古典歌曲。不过,乡村和摇滚等其他类型的组合可能会构成更大的挑战。这对我们来说似乎是一个真正的挑战,因为我们不仅需要辨别两个类别,还需要辨别六个类别。我们需要能够合理地区分它们。

将音乐分解成正弦波成分

我们的计划是从原始样本读数(之前存储在X中)中提取单个频率强度,并将其输入分类器。这些频率强度可以通过应用快速傅立叶变换 ( 快速傅立叶变换)来提取,快速傅立叶变换将波信号转换成其频率分量的系数。由于快速傅立叶变换背后的理论超出了本章的范围,让我们只看一个例子来了解它实现了什么。稍后,我们将把它当作一个黑盒特征提取器。

比如我们生成两个 WAV 文件,sine_a.wavsine_b.wav,分别包含 400 Hz 和 3000Hz 正弦波的声音。前面提到的瑞士军刀sox,是在命令行上实现这一点的一种方法(或者通过在前面加上感叹号直接从 Jupyter 获得):

sox --null -r 22050 sine_a.wav synth 0.2 sine 400
sox --null -r 22050 sine_b.wav synth 0.2 sine 3000

在下面的图表中,我们绘制了它们的前 0.008 秒。在下面的图像中,我们可以看到正弦波的快速傅立叶变换。毫不奇怪,我们在相应的正弦波下面看到400 Hz 和3000 Hz 的尖峰。

现在,让我们将两者混合,使 400 赫兹的声音只有 3000 赫兹声音的一半:

sox --combine mix --volume 1 sine_b.wav --volume 0.5 sine_a.wav 
sine_mix.wav

我们在组合声音的快速傅立叶变换图中看到两个尖峰,其中 3000 赫兹的尖峰几乎是 400 赫兹的两倍:

对于真正的音乐,我们很快会发现 FFT 看起来并不像前面的例子中那么漂亮:

使用快速傅立叶变换构建我们的第一个分类器

我们现在可以使用 FFT 创建歌曲的音乐指纹。如果我们对几首歌曲这样做,并手动分配它们相应的流派作为标签,我们就有了可以输入到第一个分类器中的训练数据。

增加实验灵活性

在我们深入分类器训练之前,让我们考虑一下实验敏捷性。虽然我们在快速傅立叶变换中有“快速”这个词,但它比我们基于文本的章节中的功能创建要慢得多。因为我们还处于实验阶段,我们可能会考虑如何加快整个特征创建过程。

当然,每次运行分类器时,每个文件的快速傅立叶变换的创建都是相同的。因此,我们可以缓存它并读取缓存的 FFT 表示,而不是完整的 WAV 文件。我们用create_fft()函数来做,该函数反过来使用scipy.fft()来创建快速傅立叶变换。为了简单(和速度!),让我们将本例中 FFT 组件的数量固定为前 1000 个。以我们目前的知识,我们不知道这些对于音乐类型分类是否是最重要的,只知道它们在前面的快速傅立叶变换例子中显示了最高的强度。如果我们以后想使用更多或更少的快速傅立叶变换组件,我们必须为每个声音文件重新创建快速傅立叶变换表示:

import numpy as np
import scipy

def create_fft(fn):
    sample_rate, X = scipy.io.wavfile.read(fn)

    fft_features = abs(scipy.fft(X)[:1000])
    np.save(Path(fn).with_suffix('.fft'), fft_features)

for wav_fn in Path(GENRE_DIR).glob('**/*.wav'):
    create_fft(wav_fn)  

我们使用 NumPy 的save()函数保存数据,该函数总是将.npy附加到文件名中。我们只需要为训练或预测所需的每个 WAV 文件做一次。

对应的 FFT 读取功能为read_fft():

def read_fft(genre_list, base_dir=GENRE_DIR):
    X = []
    y = []
    for label, genre in enumerate(genre_list):
        genre_dir = Path(base_dir) / genre
        for fn in genre_dir.glob("*.fft.npy"):
            fft_features = np.load(fn)

            X.append(fft_features[:1000])
            y.append(label)

    return np.array(X), np.array(y)

在我们的加扰music目录中,我们期待以下音乐流派:

GENRES = ["classical", "jazz", "country", "pop", "rock", "metal"]

训练分类器

让我们使用逻辑回归分类器,它已经在第 9 章分类二–情感分析中为我们提供了很好的服务:

from sklearn.linear_model.logistic import LogisticRegression
def create_model():
    return LogisticRegression()

仅提一个令人惊讶的方面:当第一次从二进制转换到多类分类时的准确率评估。在二进制分类问题中,我们知道 50%的准确率是最坏的情况,因为这可以通过随机猜测来实现。在多类设置中,50%已经很好了。以我们的六个流派为例,随机猜测的结果只有 16.7%(假设班级规模相等)。

完整的培训过程如下所示:

from collections import defaultdict
from sklearn.metrics import precision_recall_curve, roc_curve, \
                            confusion_matrix
from sklearn.metrics import auc
from sklearn.model_selection import ShuffleSplit

def train_model(clf_factory, X, Y):
    labels = np.unique(Y)

    cv = ShuffleSplit(n_splits=1, test_size=0.3, random_state=0)

    train_errors = []
    test_errors = []

    scores = []
    pr_scores = defaultdict(list)
    precisions = defaultdict(list)
    recalls = defaultdict(list)
    thresholds = defaultdict(list)

    roc_scores = defaultdict(list)
    tprs = defaultdict(list)
    fprs = defaultdict(list)

    clfs = [] # used to later get the median

    cms = []

    for train, test in cv:
        X_train, y_train = X[train], Y[train]
        X_test, y_test = X[test], Y[test]

        clf = clf_factory()
        clf.fit(X_train, y_train)
        clfs.append(clf)

        train_score = clf.score(X_train, y_train)
        test_score = clf.score(X_test, y_test)
        scores.append(test_score)

        train_errors.append(1 - train_score)
        test_errors.append(1 - test_score)

        y_pred = clf.predict(X_test)
        cm = confusion_matrix(y_test, y_pred) # will be explained soon
        cms.append(cm)

        for label in labels:
            y_label_test = np.asarray(y_test == label, dtype=int)
            proba = clf.predict_proba(X_test)
            proba_label = proba[:, label]

            precision, recall, pr_thresholds = preci-sion_recall_curve(
                y_label_test, proba_label)
            pr_scores[label].append(auc(recall, precision))
            precisions[label].append(precision)
            recalls[label].append(recall)
            thresholds[label].append(pr_thresholds)

            fpr, tpr, roc_thresholds = roc_curve(y_label_test, 
                                                        pro-ba_label)
            roc_scores[label].append(auc(fpr, tpr))
            tprs[label].append(tpr)
            fprs[label].append(fpr)

    all_pr_scores = np.asarray(pr_scores.values()).flatten()
    summary = (np.mean(scores), np.std(scores),
                 np.mean(all_pr_scores), np.std(all_pr_scores))
    print("%.3f\t%.3f\t%.3f\t%.3f\t" % summary)

    return np.mean(train_errors), np.mean(test_errors), np.asarray(cms)

整个训练调用如下:

X, Y = read_fft(GENRES)
train_avg, test_avg, cms = train_model(create_model, X, Y)

在多类问题中使用混淆矩阵来测量精确度

对于多类问题,我们不应该只对如何正确地对体裁进行分类感兴趣。我们还应该调查哪些体裁我们彼此混淆。这可以通过适当命名的混淆矩阵来完成,您可能已经注意到这是培训过程的一部分:

>>> cm = confusion_matrix(y_test, y_pred)

如果我们打印出混淆矩阵,我们会看到如下内容:

 [[26 1 2 0 0 2]
 [ 4 7 5 0 5 3]
 [ 1 2 14 2 8 3]
 [ 5 4 7 3 7 5]
 [ 0 0 10 2 10 12]
 [ 1 0 4 0 13 12]]

这是分类器为每个流派的测试集预测的标签分布。对角线代表正确的分类。因为我们有六个流派,所以我们有一个六乘六的矩阵。矩阵第一行表示,对于 31 首古典歌曲(第一行之和),预测26属于古典流派,1为爵士歌曲,2为乡村,2为金属。对角线显示了正确的分类。第一排,我们看到在*(26+1+2+2)= 31歌曲中,26被正确归类为古典,5被误分。这其实没那么糟。第二排更发人深省:24 首爵士歌曲中只有7被正确分类——也就是说,只有 29%。*

*当然,我们遵循前面章节中的训练/测试分割设置,因此我们实际上必须记录每个交叉验证文件夹的混淆矩阵。我们必须在稍后对其进行平均和标准化,这样我们就有了一个介于 0(总故障)和 1(所有分类正确)之间的范围。

图形可视化通常比 NumPy 数组更容易阅读。matplotlibmatshow()功能是我们的朋友:

from matplotlib import pylab as plt

def plot_confusion_matrix(cm, genre_list, name, title):
 plt.clf()
 plt.matshow(cm, fignum=False, cmap='Blues', vmin=0, vmax=1.0)
 ax = plt.axes()
 ax.set_xticks(range(len(genre_list)))
 ax.set_xticklabels(genre_list)
 ax.xaxis.set_ticks_position("bottom")
 ax.set_yticks(range(len(genre_list)))
 ax.set_yticklabels(genre_list)
 ax.tick_params(axis='both', which='both', bottom='off', left='off')
 plt.title(title)
 plt.colorbar()
 plt.grid(False)
 plt.show()
 plt.xlabel('Predicted class')
 plt.ylabel('True class')
 plt.grid(False)

创建混淆矩阵时,一定要选择一个颜色顺序合适的颜色映射图(matshow()cmap参数),这样就可以立即看到较浅或较深的颜色意味着什么。特别不推荐这类图形是彩虹色地图,例如,matplotlib实例默认 jet 甚至成对的彩色地图。

最终的图表如下所示:

对于一个完美的分类器,我们希望左上角到右下角有一个对角的深色方块,其余区域有浅色。在上图中,我们立即看到我们基于快速傅立叶变换的分类器远非完美。它只能正确预测古典歌曲(暗方)。例如,对于岩石,大多数时候它更喜欢标签金属。

显然,使用快速傅立叶变换为我们指出了正确的方向(古典流派没有那么糟糕),但不足以获得一个像样的分类器。当然,我们可以使用快速傅立叶变换组件的数量(固定为 1000)。但是在我们深入研究参数调整之前,我们应该先做研究。在那里,我们发现快速傅立叶变换确实是流派分类的一个不错的特征——它只是不够精炼。很快,我们将看到如何通过使用它的处理版本来提高我们的分类性能。

然而,在此之前,我们将学习另一种测量分类性能的方法。

使用接收器-操作器特性测量分类器性能的另一种方法

我们已经了解到,测量精度不足以真正评估分类器。相反,我们依靠精确-回忆(P/R)曲线来更深入地理解我们的分类器是如何工作的。

有一个 P/R 曲线的姊妹曲线,称为接收器-操作者-特征 ( ROC ),它测量分类器性能的相似方面,但提供了分类性能的另一个视图。关键的区别在于,P/R 曲线更适合于正类比负类有趣得多的任务,或者正例数比负例数少得多的任务。信息检索和欺诈检测是典型的应用领域。另一方面,ROC 曲线更好地描述了分类器的总体表现。

为了更好地理解差异,让我们考虑一下先前训练的分类器在正确分类乡村歌曲方面的性能,如下图所示:

在左边,我们看到了市盈率曲线。对于一个理想的分类器,我们会让曲线从左上角直接到右上角,然后到右下角,从而产生 1.0 的曲线下面积 ( AUC )。

右图描绘了相应的 ROC 曲线。它绘制了真阳性率 ( TPR )与假阳性率 ( FPR )的关系图。这里,理想的分类器会有一条从左下角到左上角,然后到右上角的曲线。随机分类器将是从左下角到右上角的直线,如虚线所示,其 AUC 为 0.5。因此,我们不能将市盈率曲线的 AUC 与 ROC 曲线的 AUC 进行比较。

与曲线无关,当在同一数据集上比较两个不同的分类器时,我们总是可以安全地假设一个分类器的 P/R 曲线的较高 AUC 也意味着相应 ROC 曲线的较高 AUC,反之亦然。因此,我们从不费心去产生两者。关于这一点的更多信息可以在 Davis 和 Goadrich 的非常有见地的论文精确-回忆和 ROC 曲线之间的关系中找到(ICML,2006)。

下表总结了市盈率和 ROC 曲线之间的差异:

| | x 轴 | y 轴 | | 损益 | | | | 皇家对空观察队 | | |

查看“ xy 轴的定义,我们看到 ROC 曲线 y 轴的 TPR 与 P/R 图 x 轴的 Recall 相同。

FPR 测量了被错误归类为阳性的真实阴性样本的比例,范围从完美情况下的0(无false阳性)到1(均为false阳性)。

接下来,让我们使用 ROC 曲线来衡量我们的分类器的性能,以获得更好的感觉。我们的多类问题的唯一挑战是 ROC 和 P/R 曲线都假设一个二元分类问题。因此,出于我们的目的,让我们为每个流派创建一个图表,显示分类器是如何执行一对一分类的:

from sklearn.metrics import roc_curve
y_pred = clf.predict(X_test)for label in labels:
    y_label_test = np.asarray(y_test==label, dtype=int)
    proba = clf.predict_proba(X_test)
    proba_label = proba[:, label] 

    # calculate false and true positive rates as well as the
    # ROC thresholds
    fpr, tpr, roc_thres = roc_curve(y_label_test, proba_label)

    # plot tpr over fpr ...

结果是以下六个 ROC 图(同样,完整的代码,请遵循随附的 Jupyter 笔记本)。正如我们已经发现的,我们的第一个版本的分类器只在古典歌曲上表现良好。然而,从单个 ROC 曲线来看,我们确实在其他大部分流派中表现不佳。只有爵士乐和乡村音乐提供了一些希望。其余流派显然不可用:

利用 mel 频率倒谱系数提高分类性能

我们已经了解到,快速傅立叶变换为我们指明了正确的方向,但其本身不足以最终得出一个分类器,成功地将我们的歌曲加扰目录组织成单个流派目录。我们需要一个更先进的版本。

在这一点上,我们必须做更多的研究。其他人过去可能也遇到过类似的挑战,并且已经找到了可能对我们有帮助的方法。事实上,甚至还有一个由国际音乐信息检索协会组织的年度音乐流派分类会议。显然,自动音乐流派分类 ( AMGC )是音乐信息检索的一个既定子领域。浏览一些 AMGC 论文,我们可以看到有一堆针对自动体裁分类的工作可能会帮助我们。

一种似乎在许多情况下成功应用的技术叫做 mel 频率倒频谱 ( MFC )系数。MFC 对声音的功率谱进行编码,功率谱是声音包含的每个频率的功率。它被计算为信号频谱对数的傅里叶变换。如果听起来太复杂,只需记住倒谱这个名字来源于谱,前四个字符颠倒即可。MFC 已成功应用于语音和说话人识别。让我们看看它是否也适用于我们。我们很幸运,因为其他人已经确切地需要这个,并且发布了它的实现作为python_speech_features模块的一部分。我们可以用pip轻松安装。之后,我们可以调用mfcc()函数,计算 MFC 系数,如下所示:

>>> from python_speech_features import mfcc

>>> fn = Path(GENRE_DIR) / 'jazz' / 'jazz.00000.wav'
>>> sample_rate, X = scipy.io.wavfile.read(fn)
>>> ceps = mfcc(X)
>>> print(ceps.shape)
 (4135, 13) 

ceps包含歌曲的每个4135帧的13系数(默认为mfcc()num_ceps参数)。获取所有的数据会让我们的分类器不堪重负。相反,我们可以做的是对所有帧的每个系数取平均值。假设每首歌的开头和结尾可能没有中间部分那么特定于流派,我们也忽略了第一个和最后 10%:

>>> num_ceps = ceps.shape[0]
>>> np.mean(ceps[int(num_ceps*0.1):int(num_ceps*0.9)], axis=0)
array([ 16.43787597, 7.44767565, -13.48062285, -7.49451887,
 -8.14466849, -4.79407047, -5.53101133, -5.42776074,
 -8.69278344, -6.41223865, -3.01527269, -2.75974429, -3.61836327])

果然,我们将使用的基准数据集只包含每首歌的前 30 秒,所以我们不需要剪掉最后 10%。我们无论如何都会这样做,这样我们的代码也可以在其他数据集上工作,这些数据集很可能不会被截断。

与我们使用 FFT 的工作类似,我们也希望缓存曾经生成的 MFCC 特征并读取它们,而不是每次训练分类器时都重新创建它们。

这将导致以下代码:

def create_ceps(fn):
    sample_rate, X = scipy.io.wavfile.read(fn)
    np.save(Path(fn).with_suffix('.ceps'), mfcc(X))

for wav_fn in Path(GENRE_DIR).glob('**/*.wav'):
    create_fft(wav_fn)
def read_ceps(genre_list, base_dir=GENRE_DIR):
    X = []
    y = []
    for label, genre in enumerate(genre_list):
        genre_dir = Path(base_dir) / genre
        for fn in genre_dir.glob("*.ceps.npy"):
            ceps = np.load(fn)
            num_ceps = len(ceps)
            X.append(np.mean(ceps[int(num_ceps / 10):int(num_ceps * 9 / 10)], axis=0))
            y.append(label)

    return np.array(X), np.array(y)

使用每首歌曲仅使用 13 个特征的分类器,我们得到了以下有希望的结果:

所有流派的分类性能都有所提高。古典和金属的 AUC 几乎都在 1.0。事实上,以下情节中的混乱矩阵现在看起来好多了。我们可以清楚地看到对角线,表明分类器在大多数情况下能够正确地对流派进行分类。这个分类器实际上非常适用于解决我们的初始任务:

如果我们想改进这一点,这个混淆矩阵会很快告诉我们应该关注什么:非对角线位置上的非白点。例如,我们有一个更黑暗的地方,我们错误地将摇滚歌曲贴上了爵士的标签,这种可能性很大。为了解决这个问题,我们可能需要更深入地研究歌曲,提取一些东西,比如鼓的模式和类似的特定流派的特征。然后,在浏览 ISMIR 论文的同时,我们还阅读了关于听觉滤波器组时间包络 ( AFTE )特征的文章,这些特征在某些情况下似乎优于 MFCC 特征。也许我们也应该看看他们?

好的一点是,只有配备了 ROC 曲线和混淆矩阵,我们才可以自由地在特征提取器方面引入其他专家的知识,而不必完全了解他们的内部工作方式。我们的测量工具总是会告诉我们什么时候方向是对的,什么时候该改变。当然,作为一个渴望学习的机器学习者,我们总会有一种感觉,在我们的特征提取器的黑匣子里,某个地方埋藏着一个令人兴奋的算法,就等着我们被理解。

使用张量流的音乐分类

我们可以用我们的特征输入张量流吗?当然可以!但是让我们试着利用这个机会实现另外两个目标:

  • 我们将使 TensforFlow 分类器的行为类似于sklearn分类器,以便在所有兼容函数中重用。
  • 即使神经网络可以提取任何特征,它们仍然需要被设计和训练来提取它们。在这个例子中,从原始声音文件开始,我们将向您展示,获得比倒谱系数更好的结果是不够的。

但是让我们切入正题,设置我们的超参数:

import tensorflow as tf
import numpy as np

n_epochs = 50
learning_rate = 0.01
batch_size = 128
step = 32
dropout_rate = 0.2

signal_size = 1000
signal_shape = [signal_size,1]

我们从 600 个样本开始,但为了向训练中添加更多数据,我们将把文件分成几个块:

def read_wav(genre_list, multiplicity=1, base_dir=GENRE_DIR):
    X = []
    y = []
    for label, genre in enumerate(genre_list):
        genre_dir = Path(base_dir) / genre
        for fn in genre_dir.glob("*.wav"):
            sample_rate, new_X = scipy.io.wavfile.read(fn)
            for i in range(multiplicity):
                X.append(new_X[i*signal_size:(i+1)*signal_size])
                y.append(label)

    return np.array(X).reshape((-1, signal_size, 1)), np.array(y)

从每个文件中,我们将获得 20 个较短的样本:

from sklearn.model_selection import train_test_split

X, Y = read_wav(GENRES, 20)
classes = len(GENRES)
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, 
                                     test_size=(1\. / 6.))

我们的网络将非常类似于图像分类网络。我们将使用 1D 层代替 2D 卷积层。我们还将添加一个获取池大小的参数。以前的网络使用小的 2D 池,我们可能不得不使用更大的池。

我们还将使用脱落层。由于样本不多,我们不得不避免网络泛化不良。这将帮助我们实现这一目标:

class CNN():
    def __init__(
            self,
            signal_shape=[1000,1],
            dim_W1=64,
            dim_W2=32,
            dim_W3=16,
            classes=6,
            kernel_size=5,
            pool_size=16
            ):

        self.signal_shape = signal_shape

        self.dim_W1 = dim_W1
        self.dim_W2 = dim_W2
        self.dim_W3 = dim_W3
        self.classes = classes
        self.kernel_size = kernel_size
        self.pool_size = pool_size

    def build_model(self):
        image = tf.placeholder(tf.float32, [None]+self.signal_shape, name="signal")
        Y = tf.placeholder(tf.int64, [None], name="label")

        probabilities = self.discriminate(image, training)
        cost = tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(labels=Y, 
                logits=probabilities))
        accuracy = tf.reduce_mean(tf.cast(tf.equal(tf.argmax(probabilities, ax-is=1), 
                        Y), tf.float32), name="accuracy")

        return image, Y, cost, accuracy, probabilities

在这里,我们重用我们在第 8 章人工神经网络和深度学习中看到的sparse_softmax_cross_entropy_with_logits cost-helper函数。提醒一下,它将整数目标与图层进行比较,以便具有最大值的节点与该目标匹配:

    def create_conv1d(self, input, filters, kernel_size, name):
        layer = tf.layers.conv1d(
                    inputs=input,
                    filters=filters,
                    kernel_size=kernel_size,
                    activation=tf.nn.leaky_relu,
                    name="Conv1d_" + name,
                    padding="same")
        return layer

    def create_maxpool(self, input, name):
        layer = tf.layers.max_pooling1d(
                    inputs=input,
                    pool_size=[self.pool_size],
                    strides=self.pool_size,
                    name="MaxPool_" + name)
        return layer

    def create_dropout(self, input, name, is_training):
        layer = tf.layers.dropout(
                    inputs=input,
                    rate=dropout_rate,
                    name="DropOut_" + name,
                    training=is_training)
        return layer

    def create_dense(self, input, units, name):
        layer = tf.layers.dense(
                inputs=input,
                units=units,
                name="Dense" + name,
                )
        layer = tf.layers.batch_normalization(
                inputs=layer,
                momentum=0,
                epsilon=1e-8,
                training=True,
                name="BatchNorm_" + name,
        )
        layer = tf.nn.leaky_relu(layer, name="LeakyRELU_" + name)
        return layer

    def discriminate(self, signal, training):
        h1 = self.create_conv1d(signal, self.dim_W3, self.kernel_size, "Layer1")
        h1 = self.create_maxpool(h1, "Layer1")

        h2 = self.create_conv1d(h1, self.dim_W2, self.kernel_size, "Layer2")
        h2 = self.create_maxpool(h2, "Layer2")
        h2 = tf.reshape(h2, (-1, self.dim_W2 * h2.shape[1]))

        h3 = self.create_dense(h2, self.dim_W1, "Layer3")
        h3 = self.create_dropout(h3, "Layer3", training)

        h4 = self.create_dense(h3, self.classes, "Layer4")
        return h4

如前所述,可以将我们的网络封装在一个sklearn对象BaseEstimator中。这些估计器有一组从构造函数中提取的参数,正如我们在这里看到的:

from sklearn.base import BaseEstimator

class Classifier(BaseEstimator):
    def __init__(self,
            signal_shape=[1000,1],
            dim_W1=64,
            dim_W2=32,
            dim_W3=16,
            classes=6,
            kernel_size=5,
            pool_size=16):
        self.signal_shape=signal_shape
        self.dim_W1=dim_W1
        self.dim_W2=dim_W2
        self.dim_W3=dim_W3
        self.classes=classes
        self.kernel_size=kernel_size
        self.pool_size=pool_size

fit方法是创建和训练模型的方法。这里我们还保存了网络和变量的状态:

    def fit(self, X, y):
        tf.reset_default_graph()

        print("Fitting (W1=%i) (W2=%i) (W3=%i) (kernel=%i) (pool=%i)"
              % (self.dim_W1, self.dim_W2, self.dim_W3, self.kernel_size, self.pool_size))

        cnn_model = CNN(
                signal_shape=self.signal_shape,
                dim_W1=self.dim_W1,
                dim_W2=self.dim_W2,
                dim_W3=self.dim_W3,
                classes=self.classes,
                kernel_size=self.kernel_size,
                pool_size=self.pool_size
                )

        signal_tf, Y_tf, cost_tf, accuracy_tf, output_tf = cnn_model.build_model()
        train_step = tf.train.AdamOptimizer(learning_rate, be-ta1=0.5).minimize(cost_tf)

        saver = tf.train.Saver()

        with tf.Session() as sess:
            sess.run(tf.global_variables_initializer())
            for epoch in range(n_epochs):
                permut = np.random.permutation(len(X_train))
                for j in range(0, len(X_train), batch_size):
                    batch = permut[j:j+batch_size]
                    Xs = X_train[batch]
                    Ys = Y_train[batch]

                    sess.run(train_step,
                            feed_dict={
                                Y_tf: Ys,
                                signal_tf: Xs
                                })
            saver.save(sess, './classifier')
        return self

然后在predict方法中恢复它们,我们将使用训练好的网络对新数据进行分类:

    def predict(self, X):
        tf.reset_default_graph()
        new_saver = tf.train.import_meta_graph("classifier.meta") 
        with tf.Session() as sess: 
            new_saver.restore(sess, tf.train.latest_checkpoint('./'))

            graph = tf.get_default_graph()
            training_tf = graph.get_tensor_by_name('is_training:0')
            signal_tf = graph.get_tensor_by_name('signal:0')
            output_tf = graph.get_tensor_by_name('LeakyRELU_Layer4/Maximum:0')

            predict = sess.run(output_tf,
                            feed_dict={
                                training_tf: False,
                                signal_tf: X
                                })
            return np.argmax(predict, axis=1)

我们还没有在这个估算器中创建一个score方法,但是我们可以使用sklearn API 从predict方法中创建一个。

现在我们将使用这个估计量,并进行网格搜索,以找到一组合适的超参数。由于我们没有很多样本,我们将在每个卷积层以及密集层上仅使用少数几个单元。我们还想提取更好的过滤器。毕竟,声音通常需要更大的过滤器来提取有意义的特征:

from sklearn.model_selection import GridSearchCV
from sklearn.metrics import accuracy_score, make_scorer

param_grid = {
    "dim_W1": [4, 8, 16],
    "dim_W2": [4, 8, 16],
    "dim_W3": [4, 8, 16],
    "kernel_size":[7, 11, 15],
    "pool_size":[8, 12, 16],
}

cv = GridSearchCV(Classifier(), param_grid, scor-ing=make_scorer(accuracy_score), cv=6)
cv.fit(X, Y)
print(cv.best_params_)

{'dim_W1': 4, 'dim_W2': 4, 'dim_W3': 16, 'kernel_size': 15, 'pool_size': 12}

现在我们已经花了几个小时找到了一组足够的超参数,我们可以用它来检查混淆矩阵:

clf = Classifier(**cv.best_params_)
clf.fit(X_train, Y_train)

Y_train_predict = clf.predict(X_train)
Y_test_predict = clf.predict(X_test)

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(Y_train, Y_train_predict)
plot_confusion_matrix(cm / np.sum(cm, axis=0), GENRES, "CNN",
    "Confusion matrix of a CNN based classifier (train)")
cm = confusion_matrix(Y_test, Y_test_predict)
plot_confusion_matrix(cm / np.sum(cm, axis=0), GENRES, "CNN",
    "Confusion matrix of a CNN based classifier (test)")

请参考以下图表:

即使有最好的参数,这也表明了我们之前所说的:你也需要有足够的特性。你可以用电脑来训练一个更好的网络,但是你也需要更多的数据。

在倒频谱特征上尝试此网络(或具有不同层配置的网络)。能达到更好的分类吗?它能匹配我们之前创建的最佳LogisticRegression分类器吗?来看看吧!

摘要

在这一章中,当我们构建音乐类型分类器时,我们走出了舒适区。由于对音乐理论了解不深,一开始我们没有训练出一个分类器,用 FFT 以合理的精度预测歌曲的音乐流派。但是,然后,我们创建了一个使用 MFC 特性显示真正可用性能的分类器。

在这两种情况下,我们使用的特征,我们只了解知道如何和在哪里把它们放在我们的分类器设置。第一次失败,第二次成功。它们之间的区别在于,在第二种情况下,我们依赖于该领域专家创建的功能。

这是完全可以的。如果我们主要对结果感兴趣,我们有时只需要走捷径——我们只需要确保这些捷径来自特定领域的专家。因为我们已经学会了如何在这个新的多类分类问题中正确衡量性能,所以我们自信地走了这些捷径。

第 12 章计算机视觉中,将看一下图像处理、特征表示、CNN 和 GAN。*