CONTENTS

Explorateur MNIST Interactif

Dessinez des chiffres sur le canevas et observez une IA deviner ce que c'est !

Try drawing a digit on the canvas!

1v1 Least Squares (n/a ms)

Fully Connected Network (n/a ms)
Convolutional Network (n/a ms)

Dans cet article, nous passons en revue 3 modèles de base qui abordent le jeu de données MNIST de manières distinctes et comparons leurs propriétés, forces et faiblesses. Vous pouvez expérimenter avec chacun de ces modèles de manière interactive ci-dessus et visualiser leurs sorties dans les graphiques en barres.

La Tâche

Pour ceux qui ne sont pas familiers avec l’apprentissage automatique, convertir une image en un nombre peut sembler une tâche ardue. Cependant, cela devient plus facile si nous envisageons le problème de la manière suivante :

Une image en niveaux de gris est simplement une grille de luminosités de pixels, qui sont des valeurs réelles. C’est-à-dire que chaque image est un élément de l’ensemble , où sont respectivement la largeur et la hauteur de l’image. Donc, nous pouvons résoudre le problème si nous trouvons une fonction de .

Pour ce faire, nous construisons un modèle en utilisant nos images d’entraînement et leurs étiquettes .

Moindres Carrés

Cette méthode consiste à créer 45 applications linéaires de pour chaque paire unique sélectionnée parmi nos catégories 0..9, qui infère si une image appartient le plus probablement à la paire ou . Nous pouvons minimiser l’Erreur Quadradique Moyenne (MSE) en utilisant un peu d’algèbre linéaire. Premièrement, au lieu de traiter les images dans , nous pouvons les “aplatir” en .

Définissons les poids de comme , un vecteur de longueur . Pour obtenir la sortie du modèle, nous calculons

est un chiffre.

Nous voulons minimiser la MSE sur tous les échantillons

Pour ce faire, nous créons une nouvelle matrice , qui ne contient que les images appartenant à la classe ou ainsi qu’une colonne de pour le biais, et une matrice , qui contient de même uniquement les étiquettes dans , mais remplace par et par .

Maintenant, notre problème se réduit à

La solution est donnée par , où est la pseudo-inverse de la matrice (La preuve est laissée en exercice au lecteur 😁).

Une fois que nous avons pour toutes les paires (45 au total), nous pouvons représenter notre fonction désirée comme

def f(x):
    score = [0] * 10
    for i, j, f_ij in pair_functions:
        out_ij = f_ij(x)
        if out_ij > 0:
            score[i] += 1
            score[j] -= 1
        else:
            score[j] += 1
            score[i] -= 1
    return argmax(score)

Chacun des 45 modèles “vote” pour son ou son . Le tableau score est ce que vous voyez ci-dessus dans le graphique en barres.

Réseau Entièrement Connecté

Un Réseau Entièrement Connecté, ou FCN, est un modèle bien plus grand que le modèle des moindres carrés. Au lieu de projeter nos étiquettes sur le sous-espace principal des données, nous pouvons apprendre directement une correspondance de l’espace d’entrée vers l’espace de sortie.

Pour un réseau à une seule couche, nous supposons que peut être approximée par

est une fonction non linéaire. Il est possible d’apprendre la matrice de sorte que l’erreur (Entropie Croisée Catégorielle) soit minimisée dans le voisinage local via la descente de gradient. Dans la démonstration, nous utilisons un réseau à 2 couches qui transforme l’image en , puis ce résultat en . Ceci est représenté par

où nous devons apprendre les matrices et . Dans notre cas, et

convertit la sortie en une distribution de probabilité, qui est affichée ci-dessus dans les diagrammes en barres.

Réseau Convolutif

Une limitation des deux modèles ci-dessus est qu’ils ne perçoivent pas les caractéristiques visuelles comme le font les humains. Par exemple, un 1 écrit à la main est un peu importe où il a été tracé sur la toile. Cependant, puisque les modèles LS et FCN n’ont aucune notion d’espace ou de proximité, ils pointeront simplement vers la catégorie qui a le plus de chances d’avoir ces pixels exacts.

Ici, nous introduisons les convolutions. Les convolutions prennent une image et un noyau, parcourent l’image avec ce noyau, et produisent une image de sortie qui contient la somme pondérée des pixels de l’image et des valeurs du noyau.

Remarquez comment les convolutions encodent des données spatiales, ce que les réseaux simples ne font pas. Comme les pixels voisins sont généralement fortement corrélés entre eux, nous pouvons sous-échantillonner la sortie de la convolution avec un max pool et préserver la plupart des informations. Après avoir fait passer l’image à travers une série de noyaux (entraînés), nous obtenons un ensemble de matrices qui représentent la présence d’une caractéristique spatiale apprise. Enfin, nous pouvons aplatir ces matrices et les passer dans un FCN, qui peut désormais mapper des données spatiales en catégories.

La sortie de ce FCN (avec activation softmax) est montrée ci-dessus.

Comparaison des Modèles

Note : Les 3 dernières colonnes sont qualitatives et relatives les unes aux autres.

Modèle Nombre de Paramètres Temps d’Entraînement Temps d’Inférence Précision
Moindres Carrés Faible Rapide Faible
FCN Élevé Rapide Bonne
Réseau Convolutif Très Élevé Lent Excellente

Observations :

  • Le modèle des Moindres Carrés est très rapide mais a une faible capacité de généralisation
  • Les paramètres du CNN sont très efficaces à stocker
  • Relativement au temps d’inférence du CNN, les modèles MC et FCN sont très rapides

Exercices

Observez comment les modèles répondent à ces entrées :

  • Une toile vide
  • Un 1 au centre
  • Un 1 tout à gauche
  • Un 1 tout à droite
  • Un 0 avec une ligne/point au centre
  • Un 9, avec le haut légèrement déconnecté
  • Des chiffres légèrement tournés
  • Des chiffres très fins
  • Des chiffres très épais

Pouvez-vous trouver 2 entrées qui ne diffèrent que d’un pixel mais qui sont classées dans des catégories différentes ?

Détails d’implémentation

Les trois modèles fonctionnent directement dans votre navigateur en JavaScript natif ; aucun framework ou paquet externe n’a été utilisé.

Toile

La toile de est supportée par un tableau de nombres contenant la valeur alpha qui est affichée. Chaque fois qu’un pixel est mis à jour, l’ensemble est redessiné. Le seul autre détail intéressant est la fonction d’atténuation de la luminosité que j’ai utilisée :

const plateau = 0.3;
// dist est la distance^2 par rapport au centre
const alpha = Math.min(1 - dist / r2 + plateau, 1);
pixels[yc * 28 + xc] = Math.max(pixels[yc * 28 + xc], alpha);

J’ai d’abord essayé une atténuation de 1-dist/r2, mais cela estompait trop le centre. J’ai donc ajouté la variable plateau qui décale la fonction vers le haut, mais je l’ai limitée avec Math.min pour que alpha ne dépasse pas 1. Cela donne au pinceau un aspect plus naturel.

Moindres Carrés

J’ai obtenu les poids d’un projet que j’ai réalisé dans ECE 174 avec le Professeur Piya Pal. L’inférence se résume simplement à 45 produits scalaires et à un calcul de score.

function evalLSModel(digit, weights) {
    const scores = new Array(10).fill(0);
    for (const pairConfig of weights) {
        const [i, j, w] = pairConfig;
        // Produit scalaire vectoriel
        const result = vdot(digit, w);
        if (result > 0) {
            scores[i] += 1;
            scores[j] -= 1;
        } else {
            scores[j] += 1;
            scores[i] -= 1;
        }
    }
    return scores;
}

### Réseau Entièrement Connecté

Le principal travail lors de l'inférence d'un FCN est le produit matriciel, que j'ai implémenté de la manière standard.

```javascript
function matrixDot(matrix1, matrix2, rows1, cols1, rows2, cols2) {
    // Vérifier si les matrices peuvent être multipliées
    if (cols1 !== rows2) {
        console.error("Dimensions matricielles invalides pour le produit scalaire");
        return null;
    }

    // Initialiser la matrice résultat avec des zéros
    const result = new Array(rows1 * cols2).fill(0);

    // Effectuer le produit scalaire
    for (let i = 0; i < rows1; i++) {
        for (let j = 0; j < cols2; j++) {
            for (let k = 0; k < cols1; k++) {
                result[i * cols2 + j] +=
                    matrix1[i * cols1 + k] * matrix2[k * cols2 + j];
            }
        }
    }

    return result;
}

J’ai stocké les matrices dans un seul Array 1D pour une meilleure localité du cache et moins d’allocations sur le tas. Selon la formule ci-dessus, l’inférence consiste en 2 produits matriciels et 2 applications de fonction d’activation. Les appels push(1) servent à calculer le biais.

function evalNN(digit, weights) {
    const digitCopy = [...digit];
    digitCopy.push(1);
    // paramètres couche 1
    const [w1, [rows1, cols1]] = weights[0];
    const out1 = matrixDot(digitCopy, w1, 1, digitCopy.length, rows1, cols1).map(relu);
    const [w2, [rows2, cols2]] = weights[1];
    out1.push(1);
    const out2 = matrixDot(out1, w2, 1, out1.length, rows2, cols2);
    return softmax(out2);
}

### Réseau Convolutif

Le réseau convolutif ici est assez petit. En Pytorch, c'est

```python
nn.Sequential(
    nn.Conv2d(1, 32, kernel_size=3),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2, stride=2),
    nn.Conv2d(32, 64, kernel_size=3),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Dropout(0.5),
    nn.Linear(1600, 10),
    nn.Softmax(dim=1)
)

Pour l’inférence, il suffit de porter les passes forward en JavaScript. Conv2d (avec les canaux d’entrée/sortie) est donné par

function conv2d(
    nInChan,
    nOutChan,
    inputData,
    inputHeight,
    inputWidth,
    kernel,
    bias,
) {
    if (inputData.length !== inputHeight * inputWidth * nInChan) {
        console.error("Invalid input size");
        return;
    }
    if (kernel.length !== 3 * 3 * nInChan * nOutChan) {
        console.error("Invalid kernel size");
        return;
    }

    const kernelHeight = 3;
    const kernelWidth = 3;

    // Calculer les dimensions de sortie
    const outputHeight = inputHeight - kernelHeight + 1;
    const outputWidth = inputWidth - kernelWidth + 1;

    const output = new Array(nOutChan * outputHeight * outputWidth).fill(0);

    for (let i = 0; i < outputHeight; i++) {
        for (let j = 0; j < outputWidth; j++) {
            for (let outChan = 0; outChan < nOutChan; outChan++) {
                let sum = 0;
                // appliquer le filtre à un seul emplacement sur tous les canaux d'entrée
                for (let inChan = 0; inChan < nInChan; inChan++) {
                    for (let row = 0; row < 3; row++) {
                        for (let col = 0; col < 3; col++) {
                            const inI =
                                inChan * (inputHeight * inputWidth) +
                                (i + row) * inputWidth +
                                (j + col);

                            const kI =
                                outChan * (nInChan * 3 * 3) +
                                inChan * (3 * 3) +
                                row * 3 +
                                col;
                            sum += inputData[inI] * kernel[kI];
                        }
                    }
                }
                sum += bias[outChan];
                const outI =
                    outChan * (outputHeight * outputWidth) +
                    i * outputWidth +
                    j;
                output[outI] = sum;
            }
        }
    }
    return output;
}

Je sais que c’est moche. Je le mets juste ici pour référence. Attention pour le maxpool :

function maxPool2d(nInChannels, inputData, inputHeight, inputWidth) {
    if (inputData.length !== inputHeight * inputWidth * nInChannels) {
        console.error("maxpool2d: invalid input height/width");
        return;
    }
    const poolSize = 2;
    const stride = 2;
    const outputHeight = Math.floor((inputHeight - poolSize) / stride) + 1;
    const outputWidth = Math.floor((inputWidth - poolSize) / stride) + 1;
    const output = new Array(outputHeight * outputWidth * nInChannels).fill(0);

    for (let chan = 0; chan < nInChannels; chan++) {
        for (let i = 0; i < outputHeight; i++) {
            for (let j = 0; j < outputWidth; j++) {
                let m = 0;
                for (let row = 0; row < poolSize; row++) {
                    for (let col = 0; col < poolSize; col++) {
                        const ind =
                            chan * (inputHeight * inputWidth) +
                            (i * stride + row) * inputWidth +
                            (j * stride + col);
                        m = Math.max(m, inputData[ind]);
                    }
                }
                const outI =
                    chan * (outputHeight * outputWidth) + i * outputWidth + j;
                output[outI] = m;
            }
        }
    }
    return output;
}

Et oui, la seule raison pour laquelle je me tape cette abomination de code de calcul d’indices, c’est pour cette performance Blazing fast 🔥JavaScript🔥 Web App. Et enfin, voici la fonction qui lie le tout :

function evalConv(digit, weights) {
    const [
        [f1, fshape1], // poids du filtre de conv
        [b1, bshape1], // biais de conv
        [f2, fshape2],
        [b2, fbshape2],
        [w, wshape],   // poids de la couche dense
        [b, bshape],   // biais de la couche dense
    ] = weights;

    const x1 = conv2d(1, 32, digit, 28, 28, f1, b1).map(relu);
    const x2 = maxPool2d(32, x1, 26, 26);
    const x3 = conv2d(32, 64, x2, 13, 13, f2, b2).map(relu);
    const x4 = maxPool2d(64, x3, 11, 11);
    const x5 = matrixDot(w, x4, 10, 1600, 1600, 1);
    const x6 = vsum(x5, b);
    const out = softmax(x6);
    return out;
}

## Conclusion

J'espère que vous prendrez plaisir à tester l'application. Si vous avez des questions ou des retours, n'hésitez pas à laisser un commentaire ci-dessous.

✦ Aucune IA n'a été utilisée dans la conception, la recherche, la rédaction ou l'édition de cet article.