TensorFlow.js - 使用迁移学习进行音频识别

1. 简介

在此 Codelab 中,您将构建一个音频识别网络,并使用该网络通过发出声音来控制浏览器中的滑块。您将使用 TensorFlow.js,这是一个功能强大且灵活的 JavaScript 机器学习库。

首先,您将加载并运行一个预训练模型,该模型可以识别 20 个语音指令。然后使用麦克风构建和训练一个简单的神经网络,该网络可识别您的声音,并让滑块向左或向右移动。

此 Codelab 涵盖音频识别模型背后的理论。如果您想了解相关信息,请查看此教程

我们还创建了一个术语表,其中包含您在此 Codelab 中找到的机器学习术语。

学习内容

  • 如何加载预训练的语音指令识别模型
  • 如何使用麦克风进行实时预测
  • 如何使用浏览器麦克风训练和使用自定义音频识别模型

下面我们开始步入正题

2. 要求

要完成此 Codelab,您需要:

  1. 最新版本的 Chrome 或其他现代浏览器。
  2. 文本编辑器(可以在机器本地运行,也可以通过 CodepenGlitch 等工具在网络上运行)。
  3. 了解 HTML、CSS、JavaScript 和 Chrome 开发者工具(或您的首选浏览器开发者工具)。
  4. 大致了解神经网络的概念。如果您需要了解简介或回顾相关知识,请观看这个由 3blue1brown 制作的视频或这个由 Ashi Krishnan 制作的有关使用 JavaScript 进行深度学习的视频

3. 加载 TensorFlow.js 和音频模型

在编辑器中打开 index.html 并添加以下内容:

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/speech-commands"></script>
  </head>
  <body>
    <div id="console"></div>
    <script src="index.js"></script>
  </body>
</html>

第一个 <script> 标记导入 TensorFlow.js 库,第二个 <script> 导入预训练的语音指令模型<div id="console"> 标记将用于显示模型的输出。

4. 实时预测

接下来,在代码编辑器中打开/创建 index.js 文件,在其中添加以下代码:

let recognizer;

function predictWord() {
 // Array of words that the recognizer is trained to recognize.
 const words = recognizer.wordLabels();
 recognizer.listen(({scores}) => {
   // Turn scores into a list of (score,word) pairs.
   scores = Array.from(scores).map((s, i) => ({score: s, word: words[i]}));
   // Find the most probable word.
   scores.sort((s1, s2) => s2.score - s1.score);
   document.querySelector('#console').textContent = scores[0].word;
 }, {probabilityThreshold: 0.75});
}

async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 predictWord();
}

app();

5. 测试预测

确保您的设备有麦克风。请注意,此功能也可以在手机上使用!要运行该网页,请在浏览器中打开 index.html。如果您正在使用本地文件访问麦克风,则必须启动网络服务器并使用 http://localhost:port/

要在端口 8000 上启动一个简单的网络服务器,请执行以下操作:

python -m SimpleHTTPServer

下载模型可能需要一些时间,所以请耐心等待。模型加载后,您应该会在页面顶部看到一个字词。该模型经过训练,能够识别数字 0 到 9,以及一些其他命令,如“left”“right”“yes”“no”等。

请说出其中一个字词。它能不能准确识别你说的话?不妨试试 probabilityThreshold,它控制模型的触发频率。0.75 表示模型在听到给定字词的置信度超过 75% 时会触发。

如需详细了解 Speech Commands 模型及其 API,请参阅 GitHub 上的 README.md

6. 收集数据

为了有趣,我们可以使用短音(而不是整个字词)来控制滑块!

您将训练模型来识别 3 个不同的命令:“左”“右”和“噪音”这会使滑块向左或向右移动识别“噪音”(无需操作)在语音检测中至关重要,因为我们希望滑块仅在我们发出正确的声音时做出反应,而不是在我们通常说话和四处移动时做出反应。

  1. 首先,我们需要收集数据。将以下代码添加到 <body> 标记的 <div id="console"> 前面,即可为应用添加一个简单的界面:
<button id="left" onmousedown="collect(0)" onmouseup="collect(null)">Left</button>
<button id="right" onmousedown="collect(1)" onmouseup="collect(null)">Right</button>
<button id="noise" onmousedown="collect(2)" onmouseup="collect(null)">Noise</button>
  1. 将以下内容添加到 index.js
// One frame is ~23ms of audio.
const NUM_FRAMES = 3;
let examples = [];

function collect(label) {
 if (recognizer.isListening()) {
   return recognizer.stopListening();
 }
 if (label == null) {
   return;
 }
 recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
   let vals = normalize(data.subarray(-frameSize * NUM_FRAMES));
   examples.push({vals, label});
   document.querySelector('#console').textContent =
       `${examples.length} examples collected`;
 }, {
   overlapFactor: 0.999,
   includeSpectrogram: true,
   invokeCallbackOnNoiseAndUnknown: true
 });
}

function normalize(x) {
 const mean = -100;
 const std = 10;
 return x.map(x => (x - mean) / std);
}
  1. app() 中移除 predictWord()
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // predictWord() no longer called.
}

细分

这段代码一开始可能会让人无所适从,我们来详细了解一下吧。

我们在界面中添加了三个标记为“Left”“Right”和“Noise”的按钮,分别对应于我们希望模型识别的三个命令。按下这些按钮会调用我们新添加的 collect() 函数,该函数会为模型创建训练示例。

collect() 会将 labelrecognizer.listen() 的输出相关联。由于 includeSpectrogram 为 true, recognizer.listen() 会给出 1 秒音频的原始声谱图(频率数据),分 43 帧,因此每帧的音频时长约为 23 毫秒:

recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
...
}, {includeSpectrogram: true});

由于我们想使用较短的声音而不是文字来控制滑块,因此我们仅考虑最后 3 帧(约 70 毫秒):

let vals = normalize(data.subarray(-frameSize * NUM_FRAMES));

为了避免数值问题,我们将数据归一化,使平均值为 0,标准差为 1。在这种情况下,声谱图值通常是 -100 左右的较大负值,偏差为 10:

const mean = -100;
const std = 10;
return x.map(x => (x - mean) / std);

最后,每个训练样本都包含 2 个字段:

  • label****:0、1 和 2 分别表示“左”和“右”和“噪音”。
  • vals****:696 个数字,包含频率信息(声谱图)

并将所有数据存储在 examples 变量中:

examples.push({vals, label});

7. 测试数据收集

在浏览器中打开 index.html,您应该会看到与 3 个命令相对应的 3 个按钮。如果您通过本地文件访问麦克风,则必须启动网络服务器并使用 http://localhost:port/

要在端口 8000 上启动一个简单的网络服务器,请执行以下操作:

python -m SimpleHTTPServer

如需收集每条指令的示例,请在按住每个按钮 3-4 秒的同时(或连续)发出一致的声音。您应为每个标签收集大约 150 个样本。例如,我们可以用手指打响“左”手势,用口哨吹出“右耳”,还可以在静音和讲话之间切换“噪音”。

随着您收集更多样本,页面上显示的计数器应该也会变大。您还可以随时通过在控制台中的 examples 变量上调用 console.log() 来检查数据。此阶段的目标是测试数据收集流程。之后,您会在测试整个应用时重新收集数据。

8. 训练模型

  1. 添加“火车”“Noise”按钮之后index.html: 中正文的按钮
<br/><br/>
<button id="train" onclick="train()">Train</button>
  1. 将以下内容添加到 index.js 中的现有代码中:
const INPUT_SHAPE = [NUM_FRAMES, 232, 1];
let model;

async function train() {
 toggleButtons(false);
 const ys = tf.oneHot(examples.map(e => e.label), 3);
 const xsShape = [examples.length, ...INPUT_SHAPE];
 const xs = tf.tensor(flatten(examples.map(e => e.vals)), xsShape);

 await model.fit(xs, ys, {
   batchSize: 16,
   epochs: 10,
   callbacks: {
     onEpochEnd: (epoch, logs) => {
       document.querySelector('#console').textContent =
           `Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
     }
   }
 });
 tf.dispose([xs, ys]);
 toggleButtons(true);
}

function buildModel() {
 model = tf.sequential();
 model.add(tf.layers.depthwiseConv2d({
   depthMultiplier: 8,
   kernelSize: [NUM_FRAMES, 3],
   activation: 'relu',
   inputShape: INPUT_SHAPE
 }));
 model.add(tf.layers.maxPooling2d({poolSize: [1, 2], strides: [2, 2]}));
 model.add(tf.layers.flatten());
 model.add(tf.layers.dense({units: 3, activation: 'softmax'}));
 const optimizer = tf.train.adam(0.01);
 model.compile({
   optimizer,
   loss: 'categoricalCrossentropy',
   metrics: ['accuracy']
 });
}

function toggleButtons(enable) {
 document.querySelectorAll('button').forEach(b => b.disabled = !enable);
}

function flatten(tensors) {
 const size = tensors[0].length;
 const result = new Float32Array(tensors.length * size);
 tensors.forEach((arr, i) => result.set(arr, i * size));
 return result;
}
  1. 当应用加载时,调用 buildModel()
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // Add this line.
 buildModel();
}

此时,如果您刷新应用,会看到新的“Train”按钮。您可以通过重新收集数据并点击“训练”来测试训练,也可以等到第 10 步再测试训练和预测。

分解

概括来讲,我们要做两件事:buildModel() 定义模型架构,train() 使用收集的数据训练模型。

模型架构

该模型有 4 个层:用于处理音频数据的卷积层(表示为声谱图)、一个最大池层、一个扁平化层,以及一个映射到以下 3 个操作的密集层:

model = tf.sequential();
 model.add(tf.layers.depthwiseConv2d({
   depthMultiplier: 8,
   kernelSize: [NUM_FRAMES, 3],
   activation: 'relu',
   inputShape: INPUT_SHAPE
 }));
 model.add(tf.layers.maxPooling2d({poolSize: [1, 2], strides: [2, 2]}));
 model.add(tf.layers.flatten());
 model.add(tf.layers.dense({units: 3, activation: 'softmax'}));

模型的输入形状是 [NUM_FRAMES, 232, 1],其中每帧是 23 毫秒的音频,包含 232 个对应于不同频率的数字(选择了 232 个,因为这是捕获人类语音所需的频率范围量)。在此 Codelab 中,我们将使用时长为 3 帧的样本(样本时长约为 70 毫秒),因为我们通过发出声音来控制滑块,而不是读出整个字词。

我们编译模型,使其准备好进行训练:

const optimizer = tf.train.adam(0.01);
 model.compile({
   optimizer,
   loss: 'categoricalCrossentropy',
   metrics: ['accuracy']
 });

我们使用 Adam 优化器(深度学习中常用的优化器)和 categoricalCrossEntropy(用于分类的标准损失函数)处理损失函数。简而言之,它会测量预测的概率(每个类别一个概率)与真实类别中的概率为 100%,所有其他类别中的概率为 0% 的差距。我们还提供 accuracy 作为监控的指标,这可提供每个训练周期后模型正确获取样本的百分比。

训练

使用批量大小为 16 的数据对数据进行 10 次(周期)训练(一次处理 16 个样本),并在界面中显示当前的准确率:

await model.fit(xs, ys, {
   batchSize: 16,
   epochs: 10,
   callbacks: {
     onEpochEnd: (epoch, logs) => {
       document.querySelector('#console').textContent =
           `Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
     }
   }
 });

9. 实时更新滑块

现在我们可以训练模型了,让我们添加代码来实时进行预测并移动滑块。将以下代码添加到“Train”之后、index.html 中的按钮:

<br/><br/>
<button id="listen" onclick="listen()">Listen</button>
<input type="range" id="output" min="0" max="10" step="0.1">

以及 index.js 中的以下内容:

async function moveSlider(labelTensor) {
 const label = (await labelTensor.data())[0];
 document.getElementById('console').textContent = label;
 if (label == 2) {
   return;
 }
 let delta = 0.1;
 const prevValue = +document.getElementById('output').value;
 document.getElementById('output').value =
     prevValue + (label === 0 ? -delta : delta);
}

function listen() {
 if (recognizer.isListening()) {
   recognizer.stopListening();
   toggleButtons(true);
   document.getElementById('listen').textContent = 'Listen';
   return;
 }
 toggleButtons(false);
 document.getElementById('listen').textContent = 'Stop';
 document.getElementById('listen').disabled = false;

 recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
   const vals = normalize(data.subarray(-frameSize * NUM_FRAMES));
   const input = tf.tensor(vals, [1, ...INPUT_SHAPE]);
   const probs = model.predict(input);
   const predLabel = probs.argMax(1);
   await moveSlider(predLabel);
   tf.dispose([input, probs, predLabel]);
 }, {
   overlapFactor: 0.999,
   includeSpectrogram: true,
   invokeCallbackOnNoiseAndUnknown: true
 });
}

分解

实时预测

listen() 会监听麦克风并进行实时预测。该代码与 collect() 方法非常相似,该方法会将原始声谱图归一化并删除最后 NUM_FRAMES 帧以外的所有帧。唯一的区别在于,我们还会调用经过训练的模型,以获取预测结果:

const probs = model.predict(input);
const predLabel = probs.argMax(1);
await moveSlider(predLabel);

model.predict(input) 的输出是形状为 [1, numClasses] 的张量,表示类别数量的概率分布。简而言之,这只是每个可能输出类别的一组置信度,总和为 1。张量的外维度为 1,因为这是批次(单个样本)的大小。

为了将概率分布转换为表示最可能类别的单个整数,我们调用 probs.argMax(1) 以返回概率最高的类别索引。我们将一个“1”作为轴参数,因为我们想要计算最后一个维度 numClassesargMax

更新滑块

moveSlider():如果标签为 0(“左”),则减小滑块的值;如果标签为 1(“右”),则减小滑块的值;如果标签为 2(“噪声”),则忽略该值。

处置张量

要清理 GPU 内存,我们必须对输出张量手动调用 tf.dispose()。手动 tf.dispose() 的替代方案是将函数调用封装在 tf.tidy() 中,但这不能用于异步函数。

   tf.dispose([input, probs, predLabel]);

10. 测试最终应用

在浏览器中打开 index.html,并按照与上一部分相同的操作,使用对应于 3 个命令的 3 个按钮收集数据。收集数据时,请记得按住每个按钮 3-4 秒。

收集样本后,按训练按钮。这将开始训练模型,您应该会看到模型的准确率超过 90%。如果模型性能不佳,请尝试收集更多数据。

训练完成后,按 Listen 按钮,使用麦克风进行预测并控制滑块!

如需查看更多教程,请访问 http://js.tensorflow.org/。