TensorFlow.js : Reconnaître des chiffres manuscrits à l'aide de réseaux de neurones convolutifs

Dans ce tutoriel, nous allons concevoir un modèle TensorFlow.js capable de reconnaître des chiffres manuscrits à l'aide d'un réseau de neurones convolutif. Nous entraînerons tout d'abord le classificateur en lui présentant des milliers d'images de chiffres manuscrits ainsi que leurs étiquettes. Nous évaluerons ensuite la justesse du classificateur à l'aide de données de test qu'il n'a jamais vues auparavant.

Dans la mesure où nous allons entraîner le modèle pour qu'il attribue une catégorie (le chiffre présent sur une image) à une image d'entrée, cette tâche est considérée comme une tâche de classification. Pour entraîner le modèle, nous lui fournirons de nombreux exemples d'entrées ainsi que leur résultat correct. Ce type de méthodologie est appelé apprentissage supervisé.

Objectifs du tutoriel

Vous allez créer une page Web qui exploite TensorFlow.js pour entraîner un modèle dans le navigateur. Le modèle devra déterminer le chiffre apparaissant dans une image en noir et blanc d'une taille donnée. Cette démarche inclut les étapes suivantes :

  • Chargement des données
  • Définition de l'architecture du modèle
  • Entraînement du modèle et surveillance des performances pendant l'entraînement
  • Évaluation du modèle entraîné via des prédictions

Points abordés

  • Syntaxe TensorFlow.js servant à créer des modèles convolutifs à l'aide de l'API Layers TensorFlow.js
  • Création de tâches de classification dans TensorFlow.js
  • Surveillance de l'entraînement dans le navigateur à l'aide de la bibliothèque tfjs-vis

Prérequis

Vous devez également maîtriser les éléments abordés dans notre premier tutoriel sur l'entraînement.

Créer une page HTML et inclure le code JavaScript

96914ff65fc3b74c.png Copiez le code suivant dans un fichier HTML appelé

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>TensorFlow.js Tutorial</title>

  <!-- Import TensorFlow.js -->
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@1.0.0/dist/tf.min.js"></script>
  <!-- Import tfjs-vis -->
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-vis@1.0.2/dist/tfjs-vis.umd.min.js"></script>

  <!-- Import the data file -->
  <script src="data.js" type="module"></script>

  <!-- Import the main script file -->
  <script src="script.js" type="module"></script>

</head>

<body>
</body>
</html>

Créer les fichiers JavaScript pour les données et le code

  1. Dans le même dossier que le fichier HTML ci-dessus, créez un fichier appelé data.js et copiez-y le contenu de ce lien.
  2. Dans le même dossier que pour l'étape 1, créez un fichier appelé script.js et ajoutez-y le code suivant.
console.log('Hello TensorFlow');

Tester les fichiers

Maintenant que vous disposez des fichiers HTML et JavaScript, testez-les. Ouvrez le fichier index.html dans votre navigateur et ouvrez la console d'outils de développement.

Si tout fonctionne correctement, vous devriez voir deux variables globales. tf est une référence à la bibliothèque TensorFlow.js, tandis que tfvis est une référence à la bibliothèque tfjs-vis.

Le message Hello TensorFlow** devrait s'afficher. Si c'est le cas, vous pouvez passer à l'étape suivante.

Dans ce tutoriel, vous allez entraîner un modèle pour lui apprendre à reconnaître des chiffres au sein d'images telles que les suivantes. Il s'agit d'images en noir et blanc de 28 x 28 pixels issues d'un ensemble de données appelé MNIST.

mnist 4 mnist 3 mnist 8

Nous vous fournissons le code permettant de charger ces images à partir d'un fichier de sprites spécial (environ 10 Mo) afin de nous concentrer sur la partie entraînement.

N'hésitez pas à étudier le fichier data.js pour comprendre comment les données sont chargées. Une fois ce tutoriel terminé, vous pouvez aussi créer votre propre approche de chargement des données.

Le code fourni contient une classe MnistData qui comporte deux méthodes publiques :

  • nextTrainBatch(batchSize) : renvoie un lot aléatoire d'images et d'étiquettes issues de l'ensemble d'entraînement.
  • nextTestBatch(batchSize) : renvoie un lot d'images et d'étiquettes issues de l'ensemble de test.

La classe MnistData se charge également du brassage et de la normalisation des données, qui constituent des étapes importantes.

Nous disposons de 65 000 images au total. Nous utiliserons jusqu'à 55 000 images pour entraîner le modèle et en conserverons 10 000 pour tester ses performances une fois l'entraînement terminé. Et tout cela, nous le ferons depuis le navigateur !

Chargeons maintenant les données et vérifions qu'elles ont bien été chargées.

96914ff65fc3b74c.png Ajoutez le code suivant à votre fichier script.js

import {MnistData} from './data.js';

async function showExamples(data) {
  // Create a container in the visor
  const surface =
    tfvis.visor().surface({ name: 'Input Data Examples', tab: 'Input Data'});

  // Get the examples
  const examples = data.nextTestBatch(20);
  const numExamples = examples.xs.shape[0];

  // Create a canvas element to render each example
  for (let i = 0; i < numExamples; i++) {
    const imageTensor = tf.tidy(() => {
      // Reshape the image to 28x28 px
      return examples.xs
        .slice([i, 0], [1, examples.xs.shape[1]])
        .reshape([28, 28, 1]);
    });

    const canvas = document.createElement('canvas');
    canvas.width = 28;
    canvas.height = 28;
    canvas.style = 'margin: 4px;';
    await tf.browser.toPixels(imageTensor, canvas);
    surface.drawArea.appendChild(canvas);

    imageTensor.dispose();
  }
}

async function run() {
  const data = new MnistData();
  await data.load();
  await showExamples(data);
}

document.addEventListener('DOMContentLoaded', run);

Actualisez la page. Au bout de quelques secondes, vous devriez voir sur la gauche un panneau indiquant un nombre d'images.

6dff857738b54eed.png

Nos données d'entrée se présentent ainsi.

6dff857738b54eed.png

Notre objectif est d'entraîner un modèle qui exploitera une image afin d'apprendre à prédire un score pour chacune des 10 classes auxquelles l'image peut appartenir (les chiffres 0 à 9).

Chaque image a pour dimensions 28 x 28 pixels et ne dispose que d'un canal de couleur, car elle est en noir et blanc. La forme de chaque image correspond donc à [28, 28, 1].

Gardez à l'esprit que nous souhaitons effectuer un mappage "un pour dix" (une image pour dix probabilités) et retenez la forme de chaque exemple d'entrée, car il s'agit d'un point important pour la section suivante.

Dans cette section, nous allons rédiger du code pour décrire l'architecture du modèle. L'expression "architecture du modèle" est une manière sophistiquée de dire "Quelles fonctions le modèle exécutera-t-il ?" ou "Quel algorithme le modèle utilisera-t-il pour calculer ses réponses ?".

Le machine learning consiste à définir une architecture (un algorithme) et à entraîner un modèle pour qu'il apprenne les paramètres de cet algorithme.

96914ff65fc3b74c.png Ajoutez la fonction suivante à votre fichier

script.js pour définir l'architecture du modèle

function getModel() {
  const model = tf.sequential();

  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const IMAGE_CHANNELS = 1;

  // In the first layer of our convolutional neural network we have
  // to specify the input shape. Then we specify some parameters for
  // the convolution operation that takes place in this layer.
  model.add(tf.layers.conv2d({
    inputShape: [IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS],
    kernelSize: 5,
    filters: 8,
    strides: 1,
    activation: 'relu',
    kernelInitializer: 'varianceScaling'
  }));

  // The MaxPooling layer acts as a sort of downsampling using max values
  // in a region instead of averaging.
  model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));

  // Repeat another conv2d + maxPooling stack.
  // Note that we have more filters in the convolution.
  model.add(tf.layers.conv2d({
    kernelSize: 5,
    filters: 16,
    strides: 1,
    activation: 'relu',
    kernelInitializer: 'varianceScaling'
  }));
  model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));

  // Now we flatten the output from the 2D filters into a 1D vector to prepare
  // it for input into our last layer. This is common practice when feeding
  // higher dimensional data to a final classification output layer.
  model.add(tf.layers.flatten());

  // Our last layer is a dense layer which has 10 output units, one for each
  // output class (i.e. 0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
  const NUM_OUTPUT_CLASSES = 10;
  model.add(tf.layers.dense({
    units: NUM_OUTPUT_CLASSES,
    kernelInitializer: 'varianceScaling',
    activation: 'softmax'
  }));

  // Choose an optimizer, loss function and accuracy metric,
  // then compile and return the model
  const optimizer = tf.train.adam();
  model.compile({
    optimizer: optimizer,
    loss: 'categoricalCrossentropy',
    metrics: ['accuracy'],
  });

  return model;
}

Examinons cette fonction de plus près.

Convolutions

model.add(tf.layers.conv2d({
  inputShape: [IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS],
  kernelSize: 5,
  filters: 8,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));

Nous utilisons ici un modèle séquentiel.

Nous exploitons également une couche conv2d au lieu d'une couche dense. Dans ce tutoriel, nous n'analyserons pas en détail le fonctionnement des convolutions. Si cela vous intéresse, voici quelques ressources qui expliquent l'opération sous-jacente :

Examinons chaque argument de l'objet de configuration pour conv2d :

  • inputShape : forme des données qui seront transmises à la première couche du modèle. Dans notre cas, les exemples MNIST sont des images en noir et blanc de 28 x 28 pixels. Comme le format canonique des données d'image est [row, column, depth], nous avons configuré ici une forme [28, 28, 1]. Cela correspond à 28 lignes et colonnes (pour le nombre de pixels dans chaque dimension) et à une profondeur de 1, car nos images ne possèdent qu'un seul canal de couleur. Notez que nous n'avons pas spécifié de taille de lot dans la forme d'entrée. En effet, comme les couches sont conçues pour être indépendantes des tailles de lot, nous pourrons transmettre un Tensor de n'importe quelle taille lors de l'inférence.
  • kernelSize : taille des fenêtres glissantes de filtres convolutifs à appliquer aux données d'entrée. Ici, nous avons défini kernelSize sur 5, ce qui correspond à une fenêtre convolutive carrée de 5 x 5.
  • filters : nombre de fenêtres de filtre ayant pour taille kernelSize à appliquer aux données d'entrée. Ici, nous appliquerons huit filtres aux données.
  • strides : "taille de pas" de la fenêtre glissante, c'est-à-dire le nombre de pixels duquel le filtre se décale chaque fois qu'il se déplace sur l'image. Ici, nous avons défini un pas de 1, ce qui signifie que le filtre se déplace d'un pixel à chaque pas sur l'image.
  • activation : fonction d'activation à appliquer aux données une fois la convolution terminée. Dans cet exemple, nous appliquons une fonction d'unité de rectification linéaire (ReLU), qui est une fonction d'activation très courante dans les modèles de ML.
  • kernelInitializer : méthode à utiliser pour l'initialisation aléatoire des pondérations du modèle. Il s'agit d'un élément très important pour la dynamique d'entraînement. Nous n'entrerons pas ici dans les détails de l'initialisation, mais VarianceScaling (utilisé ici) constitue généralement un bon choix d'initialiseur.

Aplatir la représentation des données

model.add(tf.layers.flatten());

Les images sont des données de grandes dimensions, et les opérations convolutives tendent à accroître la taille des données ingérées. Avant de transmettre des données à notre couche de classification finale, nous devons donc les aplatir en un long tableau. Comme les couches denses (telles que notre couche finale) n'acceptent que des Tensors tensor1d, il s'agit d'une étape courante dans la plupart des tâches de classification.

Calculer la distribution de probabilité finale

const NUM_OUTPUT_CLASSES = 10;
model.add(tf.layers.dense({
  units: NUM_OUTPUT_CLASSES,
  kernelInitializer: 'varianceScaling',
  activation: 'softmax'
}));

Nous allons utiliser une couche dense avec une activation softmax afin de calculer les distributions de probabilité pour les 10 classes possibles. La classe obtenant le score le plus élevé sera le chiffre prédit.

Choisir un optimiseur et une fonction de perte

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

Nous allons compiler le modèle en spécifiant un optimiseur, une fonction de perte ainsi que des métriques à surveiller.

Contrairement à notre premier tutoriel, nous utiliserons ici categoricalCrossentropy comme fonction de perte. Comme son nom l'indique, cette fonction est utilisée lorsque le résultat d'un modèle est une distribution de probabilité. La fonction categoricalCrossentropy mesure l'erreur entre la distribution de probabilité générée par la dernière couche de notre modèle et la distribution de probabilité fournie par notre étiquette réelle.

Par exemple, si notre chiffre représente réellement un 7, nous pouvons obtenir les résultats suivants :

Index

0

1

2

3

4

5

6

7

8

9

Étiquette réelle

0

0

0

0

0

0

0

1

0

0

Prédiction

0,1

0,01

0,01

0,01

0,20

0,01

0,01

0,60

0,03

0,02

L'entropie croisée catégorique génère un nombre unique qui indique le degré de similitude entre le vecteur de prédiction et le vecteur d'étiquette réelle.

La représentation de données employée ici pour les étiquettes est appelée encodage one-hot. Elle est couramment utilisée dans les problèmes de classification. Chaque classe est associée à une probabilité pour chaque exemple. Lorsque nous savons exactement à quel chiffre nous avons affaire, nous pouvons définir la probabilité associée à ce chiffre sur 1 et les autres probabilités sur 0. Consultez cette page pour en savoir plus sur l'encodage one-hot.

L'autre métrique que nous allons surveiller est accuracy, soit la justesse. Dans un problème de classification, il s'agit du pourcentage de prédictions correctes par rapport à toutes les prédictions.

96914ff65fc3b74c.png Copiez la fonction suivante dans votre fichier script.js

async function train(model, data) {
  const metrics = ['loss', 'val_loss', 'acc', 'val_acc'];
  const container = {
    name: 'Model Training', tab: 'Model', styles: { height: '1000px' }
  };
  const fitCallbacks = tfvis.show.fitCallbacks(container, metrics);

  const BATCH_SIZE = 512;
  const TRAIN_DATA_SIZE = 5500;
  const TEST_DATA_SIZE = 1000;

  const [trainXs, trainYs] = tf.tidy(() => {
    const d = data.nextTrainBatch(TRAIN_DATA_SIZE);
    return [
      d.xs.reshape([TRAIN_DATA_SIZE, 28, 28, 1]),
      d.labels
    ];
  });

  const [testXs, testYs] = tf.tidy(() => {
    const d = data.nextTestBatch(TEST_DATA_SIZE);
    return [
      d.xs.reshape([TEST_DATA_SIZE, 28, 28, 1]),
      d.labels
    ];
  });

  return model.fit(trainXs, trainYs, {
    batchSize: BATCH_SIZE,
    validationData: [testXs, testYs],
    epochs: 10,
    shuffle: true,
    callbacks: fitCallbacks
  });
}

96914ff65fc3b74c.png Ajoutez ensuite le code suivant à votre

fonction run.

const model = getModel();
tfvis.show.modelSummary({name: 'Model Architecture', tab: 'Model'}, model);

await train(model, data);

Actualisez la page. Au bout de quelques secondes, plusieurs graphiques illustrant la progression de l'entraînement devraient s'afficher.

a2c7628dc47d465.png

Examinons tout cela de plus près.

Surveiller les métriques

const metrics = ['loss', 'val_loss', 'acc', 'val_acc'];

Nous allons ici choisir quelles métriques surveiller. Nous surveillerons la perte et la justesse sur l'ensemble d'entraînement, ainsi que la perte et la justesse sur l'ensemble de validation (val_loss et val_acc, respectivement). Nous parlerons plus en détail de l'ensemble de validation dans la suite de ce tutoriel.

Préparer les données sous forme de Tensors

const BATCH_SIZE = 512;
const TRAIN_DATA_SIZE = 5500;
const TEST_DATA_SIZE = 1000;

const [trainXs, trainYs] = tf.tidy(() => {
  const d = data.nextTrainBatch(TRAIN_DATA_SIZE);
  return [
    d.xs.reshape([TRAIN_DATA_SIZE, 28, 28, 1]),
    d.labels
  ];
});

const [testXs, testYs] = tf.tidy(() => {
  const d = data.nextTestBatch(TEST_DATA_SIZE);
  return [
    d.xs.reshape([TEST_DATA_SIZE, 28, 28, 1]),
    d.labels
  ];
});

Ici, nous allons créer deux ensembles de données : un ensemble d'entraînement qui nous permettra d'entraîner le modèle, et un ensemble de validation qui nous servira à le tester à la fin de chaque époque. Notez que le modèle n'aura pas accès aux données de l'ensemble de validation pendant l'entraînement.

La classe de données que nous avons fournie facilite la récupération de Tensors à partir des données d'image. Nous allons toutefois remanier les Tensors et leur donner la forme [nombre_exemples, largeur_image, hauteur_image, canaux] attendue par le modèle avant de les lui transmettre. Pour chaque ensemble de données, nous disposons d'entrées (X) et d'étiquettes (Y).

return model.fit(trainXs, trainYs, {
  batchSize: BATCH_SIZE,
  validationData: [testXs, testYs],
  epochs: 10,
  shuffle: true,
  callbacks: fitCallbacks
});

Nous allons maintenant appeler model.fit pour démarrer la boucle d'entraînement. Nous transmettrons également une propriété validationData qui spécifie quelles données le modèle doit utiliser pour s'autotester après chaque époque (ces données ne seront pas utilisées pour l'entraînement).

Si le modèle est performant avec les données d'entraînement, mais obtient de mauvais résultats avec les données de validation, cela indique probablement un surapprentissage des données d'entraînement. Dans ce cas, le modèle ne parviendra probablement pas à généraliser efficacement une entrée qu'il n'a jamais vue auparavant.

La justesse de la validation fournit une estimation fiable des performances de notre modèle sur des données inconnues (tant que ces données ressemblent à celles de l'ensemble de validation). Toutefois, nous pourrions souhaiter obtenir un rapport plus détaillé des performances pour les différentes classes.

Plusieurs méthodes du fichier tfjs-vis peuvent nous servir dans cette situation.

96914ff65fc3b74c.png Ajoutez le code suivant au bas de votre fichier script.js

const classNames = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine'];

function doPrediction(model, data, testDataSize = 500) {
  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const testData = data.nextTestBatch(testDataSize);
  const testxs = testData.xs.reshape([testDataSize, IMAGE_WIDTH, IMAGE_HEIGHT, 1]);
  const labels = testData.labels.argMax(-1);
  const preds = model.predict(testxs).argMax(-1);

  testxs.dispose();
  return [preds, labels];
}

async function showAccuracy(model, data) {
  const [preds, labels] = doPrediction(model, data);
  const classAccuracy = await tfvis.metrics.perClassAccuracy(labels, preds);
  const container = {name: 'Accuracy', tab: 'Evaluation'};
  tfvis.show.perClassAccuracy(container, classAccuracy, classNames);

  labels.dispose();
}

async function showConfusion(model, data) {
  const [preds, labels] = doPrediction(model, data);
  const confusionMatrix = await tfvis.metrics.confusionMatrix(labels, preds);
  const container = {name: 'Confusion Matrix', tab: 'Evaluation'};
  tfvis.render.confusionMatrix(container, {values: confusionMatrix, tickLabels: classNames});

  labels.dispose();
}

Quelles opérations ce code effectue-t-il ?

  • Il génère une prédiction.
  • Il calcule les métriques de justesse.
  • Il affiche les métriques.

Intéressons-nous de plus près à chaque étape.

Générer des prédictions

function doPrediction(model, data, testDataSize = 500) {
  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const testData = data.nextTestBatch(testDataSize);
  const testxs = testData.xs.reshape([testDataSize, IMAGE_WIDTH, IMAGE_HEIGHT, 1]);
  const labels = testData.labels.argMax(-1);
  const preds = model.predict(testxs).argMax(-1);

  testxs.dispose();
  return [preds, labels];
}

Nous devons d'abord générer des prédictions. Ici, nous allons exploiter 500 images afin de prédire le chiffre qu'elles contiennent (vous pourrez augmenter ce nombre plus tard pour tester ce processus sur un ensemble d'images plus grand).

La fonction argmax nous fournit l'index de la classe ayant la probabilité la plus élevée. N'oubliez pas que le modèle génère une probabilité pour chaque classe. Nous allons ici déterminer la probabilité la plus élevée et l'attribuer en tant que prédiction.

Vous remarquerez également que nous pouvons effectuer des prédictions sur les 500 exemples à la fois. C'est là toute la puissance de la vectorisation dans TensorFlow.js.

Afficher la justesse par classe

async function showAccuracy() {
  const [preds, labels] = doPrediction();
  const classAccuracy = await tfvis.metrics.perClassAccuracy(labels, preds);
  const container = { name: 'Accuracy', tab: 'Evaluation' };
  tfvis.show.perClassAccuracy(container, classAccuracy, classNames);

  labels.dispose();
}

À l'aide d'un ensemble de prédictions et d'étiquettes, nous pouvons calculer la justesse pour chaque classe.

Afficher une matrice de confusion

async function showConfusion() {
  const [preds, labels] = doPrediction();
  const confusionMatrix = await tfvis.metrics.confusionMatrix(labels, preds);
  const container = { name: 'Confusion Matrix', tab: 'Evaluation' };
  tfvis.render.confusionMatrix(container, {values: confusionMatrix, tickLabels: classNames});

  labels.dispose();
}

Une matrice de confusion est semblable à la justesse par classe, mais elle va encore plus loin en affichant les schémas de classification erronée. Elle vous permet de savoir si le modèle fonctionne moins bien avec des paires de classes spécifiques.

Afficher l'évaluation

96914ff65fc3b74c.png Ajoutez le code suivant au bas de votre fonction d'exécution pour afficher l'évaluation

await showAccuracy(model, data);
await showConfusion(model, data);

Un écran semblable au suivant devrait s'afficher :

82458197bd5e7f52.png

Félicitations ! Vous avez entraîné un réseau de neurones convolutif !

Lorsque l'on prédit des catégories pour des données d'entrée, on parle de tâche de classification.

Les tâches de classification nécessitent une représentation appropriée des données pour les étiquettes.

  • L'encodage one-hot des catégories est un exemple de représentation courante des étiquettes.

Préparez vos données :

  • Il est utile de garder de côté des données que le modèle ne verra jamais pendant l'entraînement, mais qui vous aideront à l'évaluer par la suite. Ces données forment ce qu'on appelle l'ensemble de validation.

Créez et exécutez votre modèle :

  • Les modèles convolutifs ont prouvé leur efficacité pour les tâches basées sur des images.
  • Les problèmes de classification utilisent généralement l'entropie croisée catégorique pour leurs fonctions de perte.
  • Surveillez l'entraînement pour voir si la perte diminue et si la justesse augmente.

Évaluez le modèle :

  • Réfléchissez à une façon d'évaluer votre modèle une fois l'entraînement terminé. Cela vous permettra de constater ses performances sur le problème initial que vous vouliez résoudre.
  • La justesse par classe et les matrices de confusion par classe peuvent vous fournir un rapport plus précis des performances du modèle que la simple justesse globale.