• La Tâche
  • Moindres Carrés
  • Réseau Entièrement Connecté
  • Réseau Convolutif
  • Comparaison des Modèles
  • Exercices
  • Détails d’Implémentation
    • Canevas
    • Moindres Carrés
    • Réseau Entièrement Connecté
    • Réseau Convolutif
  • Accueil
  • Articles
  • Notes
  • Livres
  • Auteur
🇺🇸 en 🇨🇳 zh 🇮🇳 ml

Nathaniel Thomas

Explorateur MNIST Interactif

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

20 février 2024

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ère distincte et comparons leurs propriétés, forces et faiblesses. Vous pouvez interagir avec chacun de ces modèles ci-dessus et voir leurs sorties dans les graphiques à 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 considérons 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. Autrement dit, chaque image est un élément de l’ensemble Rw×h, où w,h sont respectivement la largeur et la hauteur de l’image. Ainsi, nous pouvons résoudre le problème si nous trouvons une fonction f de Rw×h→{0,1,…,8,9}.

Pour ce faire, nous construisons un modèle en utilisant nos images d’entraînement x(n) et les étiquettes y(n).

Moindres Carrés

Cette méthode consiste à créer 45 applications linéaires de Rwh→R pour chaque paire unique (i,j) sélectionnée parmi nos catégories 0..9, qui infère si une image appartient probablement à la paire i ou j. Nous pouvons minimiser l’Erreur Quadratique Moyenne (MSE) en utilisant un peu d’algèbre linéaire. D’abord, au lieu de traiter des images dans Rw×h, nous pouvons les “aplatir” en Rwh≡Rn.

Définissons les poids de (i,j) comme wij​, un vecteur de longueur n+1. Pour obtenir la sortie du modèle, nous calculons

y^​ij​=k=1∑n​wij,k​xk​+wij,n+1​

où x est un chiffre.

Nous voulons minimiser la MSE sur tous les m échantillons

Lij​​=n1​i=1∑m​(y^​ij(m)​−yij(m)​)2=n1​i=1∑n​([xij⊤​​1​]wij​−yij(n)​)2​

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

Maintenant, notre problème a été réduit à

wij​min​∣∣Xij​wij​−yij​∣∣22​

La solution à cela est donnée par wij​=Xij†​yij​, où X† est la pseudo-inverse de la matrice X (La preuve est laissée comme exercice au lecteur 😁).

Une fois que nous avons wij​ pour toutes les paires i,j (45 au total), nous pouvons représenter notre fonction désirée f 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 i ou j. Le tableau score est ce que vous voyez ci-dessus dans le graphique à barres.

Réseau Entièrement Connecté

Un Réseau Entièrement Connecté, ou FCN, est un modèle beaucoup 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 directement apprendre une application de l’espace d’entrée à l’espace de sortie.

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

f(x)=g(Ax)

où g est une fonction non linéaire. Il est possible d’apprendre la matrice A de sorte que l’erreur (Entropie Croisée Catégorique) soit minimisée dans le voisinage local par descente de gradient. Dans la démo, nous utilisons un réseau à 2 couches qui mappe l’image à R128, et le résultat de cela à R10. Ceci est représenté par

f(x)=h(B(g(Ax)))

où nous devons apprendre les matrices B∈R10×128 et A∈R128×n. Dans notre cas, g(x)=max(0,x) et

h(z)i​=∑j=110​ezj​ezi​​

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

Réseau Convolutif

Une limitation des deux modèles ci-dessus est qu’ils ne voient pas les caractéristiques visuelles comme les humains. Par exemple, un 1 manuscrit est un 1 peu importe où il a été dessiné sur le canevas. Cependant, puisque les modèles LS et FCN n’ont pas de 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, font passer le noyau à travers l’image, 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 que les réseaux simples ne font pas. Comme les pixels proches 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 passé l’image à travers plusieurs noyaux (entraînés), nous obtenons un ensemble de matrices qui représentent l’existence d’une caractéristique spatiale apprise. Enfin, nous pouvons aplatir et passer ces matrices dans un FCN, qui peut maintenant mapper des données spatiales en catégories.

La sortie de ce FCN (avec activation softmax) est affiché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 35,325 Faible Rapide Faible
FCN 101,760 Élevé Rapide Bonne
Réseau Convolutif 34,826 Très Élevé Lent Excellente

Observations :

  • Le modèle des moindres carrés est très rapide mais a une faible capacité à généraliser
  • Les paramètres du CNN sont très efficaces à stocker
  • Par rapport au temps d’inférence du CNN, LS et FCN sont très rapides

Exercices

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

  • Canevas vide
  • Un 1 au centre
  • Un 1 à l’extrême gauche
  • Un 1 à l’extrême droite
  • Un 0 avec une ligne/point au centre
  • Un 9, avec le haut légèrement déconnecté
  • Chiffres légèrement tournés
  • Chiffres très fins
  • Chiffres très épais

Pouvez-vous trouver 2 entrées qui ont une différence d’un pixel et qui mappent à des catégories différentes ?

Détails d’Implémentation

Les trois modèles fonctionnent dans votre navigateur en JavaScript pur ; aucun framework ou package n’a été utilisé.

Canevas

Le canevas de 28×28 est soutenu par un tableau de nombres qui contiennent la valeur alpha affichée. Chaque fois qu’un pixel est mis à jour, tout est redessiné. Le seul autre détail intéressant est la fonction de décroissance de luminosité que j’ai utilisée :

const plateau = 0.3;
// dist est la distance^2 du 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 décroissance de 1-dist/r2 mais cela atténuait trop le centre. J’ai donc ajouté la variable plateau qui décale la fonction vers le haut mais l’a 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 fait dans ECE 174 avec le professeur Piya Pal. L’inférence est simplement 45 produits scalaires et un 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 travail principal avec l’inférence FCN est le produit matriciel, que j’ai implémenté de manière standard.

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

    // Initialiser la matrice de 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 tableau 1D Array pour une meilleure localité de cache et moins d’allocations de tas. Comme indiqué dans 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 de la 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

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, nous devons simplement porter les passes avant en JavaScript. Conv2d (avec 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("Taille d'entrée invalide");
        return;
    }
    if (kernel.length !== 3 * 3 * nInChan * nOutChan) {
        console.error("Taille de noyau invalide");
        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 maxpool :

function maxPool2d(nInChannels, inputData, inputHeight, inputWidth) {
    if (inputData.length !== inputHeight * inputWidth * nInChannels) {
        console.error("maxpool2d: hauteur/largeur d'entrée invalide");
        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;
}

←
Optimisation des performances pas si anodine en Python
Un bot expert pour 2048
→

back to top