1. 简介
在此 Codelab 中,您将构建一个音频识别网络,并使用该网络通过发出声音来控制浏览器中的滑块。您将使用 TensorFlow.js,后者是一款适用于 JavaScript 的功能强大且灵活的机器学习库。
首先,您将加载并运行一个可以识别 20 个语音指令的预训练模型。然后,您将使用麦克风构建并训练一个简单的神经网络,该网络可以识别您的声音并使滑块向左或向右移动。
此 Codelab 不涵盖音频识别模型背后的理论。如果您想了解相关信息,请查看此教程。
我们还创建了一个术语表,其中包含您在此 Codelab 中会遇到的机器学习术语。
学习内容
- 如何加载预训练的语音指令识别模型
- 如何使用麦克风进行实时预测
- 如何使用浏览器麦克风训练和使用自定义音频识别模型
下面我们开始步入正题
2. 要求
要完成本 Codelab,您需要:
- 最新版的 Chrome 或其他新型浏览器。
- 文本编辑器(可以在机器的本地运行,也可以通过 Codepen 或 Glitch 等工具在 web 中运行)。
- 了解 HTML、CSS、JavaScript 和 Chrome 开发者工具(或您的首选浏览器开发者工具)。
- 大致了解神经网络的概念。如果您需要了解简介或回顾内容,请考虑观看这部由 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> 用于导入预训练的 Speech Commands 模型。<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 上启动简单的 Web 服务器,请执行以下操作:
python -m SimpleHTTPServer
下载模型可能需要一些时间,请耐心等待。模型加载后,您应该会在页面顶部看到一个字词。该模型经过训练,可以识别 0 到 9 的数字以及“向左”“向右”“是”“否”等一些其他命令。
说出其中一个字词。它是否能正确识别您的字词?您可以调整 probabilityThreshold,控制模型触发的频率。0.75 表示模型在听到某个字词时,如果确信度超过 75%,就会触发。
如需详细了解语音指令模型及其 API,请参阅 GitHub 上的 README.md。
6. 收集数据
为了增加趣味性,我们使用短促的声音(而不是完整的字词)来控制滑块!
您将训练一个模型来识别 3 个不同的命令:“向左”“向右”和“噪声”,这些命令将使滑块向左或向右移动。识别“噪声”(无需执行任何操作)在语音检测中至关重要,因为我们希望滑块仅在发出正确的声音时做出反应,而不是在正常说话和四处走动时做出反应。
- 首先,我们需要收集数据。通过在
<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>
- 将以下内容添加到
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);
}
- 从
app()中移除predictWord():
async function app() {
recognizer = speechCommands.create('BROWSER_FFT');
await recognizer.ensureModelLoaded();
// predictWord() no longer called.
}
细分
此代码起初可能会让您感到不知所措,下面我们来详细介绍一下。
我们在界面中添加了三个按钮,分别标记为“Left”“Right”和“Noise”,对应于我们希望模型识别的三个命令。按这些按钮会调用我们新添加的 collect() 函数,该函数会为我们的模型创建训练示例。
collect() 将 label 与 recognizer.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 上启动简单的 Web 服务器,请执行以下操作:
python -m SimpleHTTPServer
如需为每个指令收集示例,请在按住每个按钮 3-4 秒的同时,反复(或持续)发出一致的声音。您应为每个标签收集约 150 个示例。例如,我们可以用打响指表示“左”,用吹口哨表示“右”,用静音和说话交替表示“噪音”。
随着您收集的示例越来越多,页面上显示的计数器应该会增加。您也可以在控制台中对 examples 变量调用 console.log() 来检查数据。此时的目标是测试数据收集流程。稍后,您将在测试整个应用时重新收集数据。
8. 训练模型
- 在 index.html 的正文中,紧跟在“噪音”按钮之后添加一个“火车”按钮:
<br/><br/>
<button id="train" onclick="train()">Train</button>
- 将以下内容添加到 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;
}
- 在应用加载时调用
buildModel():
async function app() {
recognizer = speechCommands.create('BROWSER_FFT');
await recognizer.ensureModelLoaded();
// Add this line.
buildModel();
}
此时,如果您刷新应用,会看到一个新的“训练”按钮。您可以重新收集数据并点击“训练”来测试训练,也可以等到第 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 的批次大小(一次处理 16 个示例)对数据进行 10 次(周期),并在界面中显示当前准确率:
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. 实时更新滑块
现在,我们可以训练模型了,接下来添加代码以进行实时预测并移动滑块。在 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”作为轴参数传递,因为我们希望计算最后一个维度(即 numClasses)的 argMax。
更新滑块
如果标签为 0(“左侧”),moveSlider() 会减小滑块的值;如果标签为 1(“右侧”),则会增大滑块的值;如果标签为 2(“噪声”),则会忽略。
处置张量
为了清理 GPU 内存,我们需要手动对输出张量调用 tf.dispose()。手动 tf.dispose() 的替代方法是将函数调用封装在 tf.tidy() 中,但此方法无法与异步函数搭配使用。
tf.dispose([input, probs, predLabel]);
10. 测试最终应用
在浏览器中打开 index.html,然后像上一部分一样,使用与 3 个命令对应的 3 个按钮来收集数据。请务必在收集数据时按住每个按钮 3-4 秒。
收集完示例后,按“训练”按钮。这会开始训练模型,您应该会看到模型的准确率超过 90%。如果模型性能不佳,请尝试收集更多数据。
训练完成后,按“聆听”按钮,即可通过麦克风进行预测并控制滑块!
如需查看更多教程,请访问 http://js.tensorflow.org/。