TensorFlow.js - التعرّف على الصوت باستخدام التعلّم القائم على النقل

1. مقدمة

في هذا الدرس التطبيقي حول الترميز، ستنشئ شبكة للتعرّف على الصوت وتستخدمها للتحكّم في شريط تمرير في المتصفّح من خلال إصدار أصوات. ستستخدم TensorFlow.js، وهي مكتبة قوية ومرنة لتعلُّم الآلة في JavaScript.

أولاً، عليك تحميل نموذج مدرَّب مسبقًا وتشغيله، وهو نموذج يمكنه التعرّف على 20 أمرًا صوتيًا. بعد ذلك، باستخدام الميكروفون، ستنشئ شبكة عصبية بسيطة وتدرّبها على التعرّف على الأصوات وتحريك شريط التمرير إلى اليمين أو اليسار.

لن يتناول هذا الدرس التطبيقي حول الترميز نظرية نماذج التعرّف على الصوت. إذا كنت مهتمًا بمعرفة المزيد، يمكنك الاطّلاع على هذا البرنامج التعليمي.

لقد أنشأنا أيضًا مسردًا لمصطلحات تعلُّم الآلة التي ستجدها في هذا الدرس التطبيقي حول الترميز.

أهداف الدورة التعليمية

  • كيفية تحميل نموذج مدرَّب مسبقًا للتعرّف على الأوامر الصوتية
  • كيفية إجراء توقّعات في الوقت الفعلي باستخدام الميكروفون
  • كيفية تدريب نموذج مخصّص للتعرّف على الصوت واستخدامه من خلال ميكروفون المتصفّح

لنبدأ الآن.

2. المتطلبات

لإكمال هذا الدرس التطبيقي حول الترميز، ستحتاج إلى:

  1. إصدار حديث من Chrome أو متصفّح حديث آخر
  2. أداة تعديل النصوص، سواء كانت تعمل على جهازك أو على الويب من خلال خدمة مثل Codepen أو Glitch
  3. معرفة HTML وCSS وJavaScript وأدوات مطوّري البرامج في Chrome (أو أدوات مطوّري البرامج في المتصفّحات المفضّلة لديك)
  4. فهم مفاهيمي رفيع المستوى للشبكات العصبية إذا كنت بحاجة إلى مقدّمة أو تنشيط للذاكرة، ننصحك بمشاهدة هذا الفيديو من قناة 3blue1brown أو هذا الفيديو حول التعلّم العميق في JavaScript من Ashi Krishnan.

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 وبعض الأوامر الإضافية، مثل "يسار" و"يمين" و"نعم" و"لا" وما إلى ذلك.

قُل إحدى هذه الكلمات. هل يتعرّف على الكلمة بشكل صحيح؟ العب بالقيمة probabilityThreshold التي تتحكّم في عدد المرات التي يتم فيها تشغيل النموذج، فمثلاً، يعني 0.75 أنّ النموذج سيتم تشغيله عندما يكون واثقًا بنسبة تزيد عن% 75 من أنّه يسمع كلمة معيّنة.

لمزيد من المعلومات حول نموذج "أوامر صوتية" وواجهة برمجة التطبيقات الخاصة به، يُرجى الاطّلاع على ملف README.md على GitHub.

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. إزالة predictWord() من app():
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // predictWord() no longer called.
}

التقسيم

قد تبدو هذه التعليمات البرمجية معقّدة في البداية، لذا دعنا نوضّحها.

أضفنا ثلاثة أزرار إلى واجهة المستخدم الخاصة بنا تحمل التصنيفات "يسار" و"يمين" و"ضوضاء"، وهي تتوافق مع الأوامر الثلاثة التي نريد أن يتعرّف عليها النموذج. يؤدي الضغط على هذه الأزرار إلى استدعاء الدالة collect() التي أضفناها حديثًا، والتي تنشئ أمثلة تدريبية لنموذجنا.

تربط collect() label بنتيجة recognizer.listen(). بما أنّ includeSpectrogram صحيحة,، فإنّ recognizer.listen() تعرض مخطط الطيف الخام (بيانات التردد) لمدة ثانية واحدة من الصوت، مقسّمة إلى 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);

أخيرًا، سيحتوي كل مثال تدريبي على حقلَين:

  • label****: 0 و1 و2 على التوالي لكل من "اليسار" و"اليمين" و "الضوضاء".
  • vals****: 696 رقمًا يتضمّن معلومات التردّد (الصورة الطيفية)

ونخزّن كل البيانات في المتغيّر examples:

examples.push({vals, label});

7. جمع بيانات الاختبار

افتح index.html في المتصفّح، وستظهر لك 3 أزرار تتوافق مع الأوامر الثلاثة. إذا كنت تعمل من ملف محلي، عليك بدء خادم ويب واستخدام http://localhost:port/ للوصول إلى الميكروفون.

لبدء خادم ويب بسيط على المنفذ 8000، اتّبِع الخطوات التالية:

python -m SimpleHTTPServer

لجمع أمثلة لكل أمر، أصدِر صوتًا ثابتًا بشكل متكرّر (أو مستمر) أثناء الضغط مع الاستمرار على كل زر لمدة 3 إلى 4 ثوانٍ. يجب جمع حوالي 150 مثالاً لكل تصنيف. على سبيل المثال، يمكننا استخدام فرقعة الأصابع للإشارة إلى "اليسار"، والصفير للإشارة إلى "اليمين"، والتبديل بين الصمت والكلام للإشارة إلى "الضوضاء".

مع جمع المزيد من الأمثلة، يجب أن يرتفع العداد المعروض على الصفحة. يمكنك أيضًا فحص البيانات من خلال استدعاء console.log() على المتغيّر examples في وحدة التحكّم. في هذه المرحلة، يكون الهدف هو اختبار عملية جمع البيانات. ستعيد جمع البيانات لاحقًا عند اختبار التطبيق بالكامل.

8. تدريب نموذج

  1. أضِف زر تدريب بعد زر ضوضاء مباشرةً في النص الأساسي في 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();
}

في هذه المرحلة، إذا أعَدت تحميل التطبيق، سيظهر لك زر جديد باسم "تدريب". يمكنك اختبار التدريب عن طريق إعادة جمع البيانات والنقر على "تدريب"، أو يمكنك الانتظار حتى الخطوة 10 لاختبار التدريب مع التوقّع.

التحليل

على مستوى عالٍ، نحن نفعل شيئين: buildModel() يحدّد بنية النموذج، وtrain() يدرّب النموذج باستخدام البيانات التي تم جمعها.

بنية النموذج

يتضمّن النموذج 4 طبقات: طبقة التفافية تعالج بيانات الصوت (الممثّلة كمخطّط طيفي)، وطبقة تجميع الحد الأقصى، وطبقة تسوية، وطبقة كثيفة يتم ربطها بالإجراءات الثلاثة:

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 لأنّه عدد حِزم الترددات اللازمة لالتقاط صوت الإنسان). في هذا الدرس العملي، نستخدم عيّنات مدتها 3 لقطات (عيّنات تبلغ مدتها 70 ملي ثانية تقريبًا) لأنّنا نصدر أصواتًا بدلاً من قول كلمات كاملة للتحكّم في شريط التمرير.

نجمّع النموذج لنجهّزه للتدريب:

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

نستخدم محسِّن Adam، وهو محسِّن شائع الاستخدام في التعلّم العميق، وcategoricalCrossEntropy لقياس الخسارة، وهي دالة الخسارة العادية المستخدَمة في التصنيف. باختصار، يقيس هذا المقياس مدى بُعد الاحتمالات المتوقّعة (احتمال واحد لكل فئة) عن احتمال أن تكون الفئة الصحيحة بنسبة% 100، وأن تكون جميع الفئات الأخرى بنسبة% 0. نوفّر أيضًا accuracy كمقياس يمكن تتبّعه، ما يتيح لنا معرفة النسبة المئوية للأمثلة التي يقدّم النموذج إجابات صحيحة عنها بعد كل حقبة من التدريب.

التدريب

يتم التدريب 10 مرات (فترات) على البيانات باستخدام حجم الدفعة يبلغ 16 (معالجة 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- تعديل شريط التمرير في الوقت الفعلي

بعد أن أصبح بإمكاننا تدريب النموذج، لنضِف الآن رمزًا برمجيًا لتقديم التوقّعات في الوقت الفعلي وتحريك شريط التمرير. أضِف هذا الرمز مباشرةً بعد الزر "تدريب" في الملف 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) هو Tensor بالشكل [1, numClasses] يمثّل توزيع الاحتمالات على عدد الفئات. بشكل أبسط، هذه مجرد مجموعة من درجات الثقة لكل فئة من فئات الإخراج المحتملة التي يبلغ مجموعها 1. يحتوي Tensor على بُعد خارجي يبلغ 1 لأنّ هذا هو حجم المجموعة (مثال واحد).

لتحويل توزيع الاحتمالات إلى عدد صحيح واحد يمثّل الفئة الأكثر احتمالاً، نستدعي probs.argMax(1) التي تعرض فهرس الفئة بأعلى احتمال. نمرّر القيمة "1" كمَعلمة المحور لأنّنا نريد حساب argMax على آخر بُعد، أي numClasses.

تعديل شريط التمرير

يؤدي الضغط على moveSlider() إلى خفض قيمة شريط التمرير إذا كان التصنيف 0 ("يسار")، وزيادتها إذا كان التصنيف 1 ("يمين")، وتجاهلها إذا كان التصنيف 2 ("ضوضاء").

التخلّص من الموترات

لتنظيف ذاكرة وحدة معالجة الرسومات، من المهم أن نستدعي tf.dispose() يدويًا على موترات الإخراج. البديل عن tf.dispose() اليدوي هو تضمين استدعاءات الدوال في tf.tidy()، ولكن لا يمكن استخدام ذلك مع الدوال غير المتزامنة.

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

10. اختبار التطبيق النهائي

افتح ملف index.html في المتصفّح واجمع البيانات كما فعلت في القسم السابق باستخدام الأزرار الثلاثة التي تتوافق مع الأوامر الثلاثة. تذكَّر الضغط مع الاستمرار على كل زر لمدة 3 إلى 4 ثوانٍ أثناء جمع البيانات.

بعد جمع الأمثلة، انقر على الزر "تدريب". سيؤدي ذلك إلى بدء تدريب النموذج، ومن المفترض أن تلاحظ أنّ دقة النموذج تتجاوز %90. إذا لم تحقّق أداءً جيدًا للنموذج، حاوِل جمع المزيد من البيانات.

بعد الانتهاء من التدريب، انقر على زر "الاستماع" لإنشاء توقّعات من الميكروفون والتحكّم في شريط التمرير.

يمكنك الاطّلاع على المزيد من الأدلة التعليمية على http://js.tensorflow.org/.