Try drawing a digit on the canvas!
1v1 Least Squares (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 , où sont respectivement la largeur et la hauteur de l’image. Ainsi, 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 les é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 probablement à la paire ou . 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 , nous pouvons les “aplatir” en .
Définissons les poids de comme , un vecteur de longueur . Pour obtenir la sortie du modèle, nous calculons
où 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 a été réduit à
La solution à cela est donnée par , où est la pseudo-inverse de la matrice (La preuve est laissée comme 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
. 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 peut être approximé par
où est une fonction non linéaire. Il est possible d’apprendre la matrice 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 à , et le résultat de cela à . 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 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
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 | 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é à 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 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;
}