Suite au visionnage de cette vidéo de 3Blue1Brown qui explique parfaitement le fonctionnement d’un réseau neuronal, j’ai voulu tenter l’expérience avec un système tout simple qui reconnaît les chiffres de 0 à 9 (faut bien commencer quelque part). Et quel meilleur langage que PHP pour se lancer dans cette grande aventure ?

Aujourd’hui on ne verra pas les concepts de base, les explications du pourquoi et du comment qui ont déjà été traités maintes et maintes fois par diverses personnes bien plus pédagogues. On va se concentrer sur la création de nos couches de neurones, sur leur fonctionnement et … C’est tout ! On verra l’entraînement et les résultats une prochaine fois.

La théorie

Commençons juste par mettre à plat ce qu’on doit créer.

Nos images seront simplement des carrés de 28*28px de côté avec des niveaux de gris (de blanc à noir). On va donc passer ça par deux couches de neurones pour arriver à une sortie numérique (de 0 à 9). Visuellement ça donne grossièrement ça :

Pour notre première couche de neurone on va donc prendre un pixel, multiplier ça par le poids de notre neurone (est-ce que ce pixel est important ou pas) en y ajoutant un biais (qui sera calculé plus tard) et on va retourner une valeur entre 0 et 1. Le coup de la valeur entre 0 et 1 c’est un concept qui commence à se faire vieux (on fait mieux depuis) qui en gros définit juste si notre neurone est allumé ou éteint.

Ce que va faire notre première couche c’est repérer des « patterns » depuis les pixels. Ce qui est pratique dans notre cas de figure c’est que notre neurone étant juste allumé ou éteint, ça donne soit « j’ai repéré un pattern que je connais » soit « j’ai rien ». Ensuite on va faire pareil mais en plus gros avec notre seconde couche: chaque neurone vérifiera si cette association de pattern correspond à quelque chose qu’il connaît ou non.

Et j’ai un peu triché mais la sortie ne sera pas « c’est un 9 » mais « je pense à 70% que c’est un 9, 20% que c’est un 2, … » avec une somme totale des probabilités à 1.

Allez, on code !

Ok bon déjà désolé on ne va pas créer de neuron.php même si ça aurait été stylé. On n’aura pas vraiment besoin de traiter les données individuellement, on va traiter directement par couche. Donc créons un petit neuronlayer.php et créons notre class ! On fera en sorte de lui passer 3 paramètres : les inputs à traiter, les weights de chaque neurones et les biais de chaque neurones:

<?php

class NeuronLayer
{
	public function __construct(
		readonly private array $inputs,
		readonly private array $weights,
		readonly private array $biases
	)
	{ }
}

Maintenant on va déjà réfléchir à ce qu’on veut, puis on reviendra vers notre NeuronLayer. On va avoir un système qui va traiter les images et renvoyer un tableau de 284 float représentant le niveau de gris. On fera déjà les tests avec un array à 3 entrées. Ensuite avec les entrées de poids et les biais qu’on aura pré-défini, on voudra pouvoir générer un layer et lui faire calculer sa partie.

Donc avant de finir notre NeuronLayer, construisons d’abord notre main.php :

<?php
require('./neuronlayer.php');

$inputs  = [0.5, 0.8, 0.2];
$weights = [
    [0.4, -0.6, 0.9],
    [0.2, -0.1, -0.8],
];
$biases = [0.1, 0.3];

$layer = new NeuronLayer($inputs, $weights, $biases);
$resultLayer1 = $layer->process();

Ici on a donc 3 entrées qui seront traitées par deux neurones. Chaque neurone a 3 poids qui lui sont associés (un poids par entrée dans le tableau $inputs) et 1 biais qui lui est associé.

Encore une fois : les valeurs sont arbitraires ici, on veut juste faire fonctionner le système.

Notre première couche de neurones

Encore une fois sans rentrer dans les détails (regardez la vidéo de 3Blue1Brown !), on va commencer par faire une somme pondérée de chaque pixel et chaque poids, en ajoutant le biais. Ensuite pour avoir notre fameuse valeur entre 0 et 1, on va tout simplement utiliser une function dite « sigmoide ». En une image, ça ressemble à ça :

Donc comme on reçoit les poids de chaque neurones dans un array, on va commencer par boucler dessus, et on garde la clé parce qu’on s’en servira pour récupérer le bon biais :

public function process(): array
{
	$neurons = [];
	foreach ($this->weights as $key => $weight) {
		// On fera le traitement ici
	}

	return $neurons;
}

Maintenant donc on va :

  • initialiser une variable $z avec notre biais (valeur de départ)
  • boucler sur chaque poids dans la variable $weight
  • ajouter à $z le produit de notre poids et de l’input ayant le même index
  • stocker dans notre variable $neurons le résultat du sigmoid de $z
// Dans notre premier foreach
$z = $this->biases[$key];
foreach ($weight as $k => $datum) {
	$z += $datum * $this->inputs[$k];
}
$neurons[] = $this->sigmoid($z);

Et il nous reste juste à créer notre fonction sigmoid:

public function sigmoid(float $n): float
{
	return 1 / (1 + exp(-$n));
}

Maintenant si vous exécutez votre code, vous obtenez… Un array avec deux floats ! Incroyable ! Vous avez créé vos premiers neurones !

On en remet une couche ?

Oui je sais, c’est tristounet. Actuellement on n’a rien produit d’intéressant. D’ailleurs ça sera aussi le cas à la fin de cet article, mais promis on ne fait pas ça pour rien !

Pour obtenir un résultat un tantinet plus réjouissant, il va falloir brancher notre seconde couche de neurones ! Mais elle va réagir un poil différemment. Au lieu de retourner un chiffre entre 0 et 1, on va vouloir avoir notre pourcentage de certitude. Donc pour ça on va devoir créer une fonction softmax qui remplacera notre sigmoid pour cette couche là. La fonction ressemble à ça :

Alors on va avoir certaines limites liées à PHP parce que à la base j’étais parti sur ceci :

public function softmax(array $z): array
{
    $sum = array_reduce($z, [$this, 'sumExp'], 0);
    $data = [];
    foreach ($z as $val) {
        $data[] = exp($val) / $sum;
    }
    return $data;
}
private function sumExp(float $carry, float $item): float
{
    $carry += exp($item);
    return $carry;
}

Et ça marche pas mal, MAIS. Si une des valeurs de $z est trop grande, ça finit par produire un INF qui ne peut donc pas être stocké dans un float. Du coup comme on est sur de l’exponentiel, on peut se permettre de soustraire la plus grande valeur du tableau à chaque traitement avant de passer ça en exponentiel et techniquement ça devrait nous sortir les mêmes résultats. Donc au final j’ai fini avec ceci :

  • On stocke le max
  • On fait un reduce mais en soustrayant le max à chaque passage
  • On calcule le résultat de la division de l’exponentielle (moins le max si vous avez suivi) divisé par la somme
public function softmax(array $z): array
{
	$max = max($z);
	$sum = array_reduce($z,  function($carry, $item) use ($max) {
	    return $carry + exp($item - $max);
	}, 0);

	$data = [];
	foreach ($z as $val) {
		$data[] = (exp($val - $max)) / $sum;
	}

	return $data;
}

Maintenant il ne nous reste plus qu’à pouvoir préciser si on active le sigmoid ou le softmax en fonction de la couche dans laquelle on est. On va faire ça un peu sale pour le moment, encore une fois on veut juste que ça tourne. Donc ajoutons à notre __construct:

readonly private string $activation

Et comme le sigmoid doit être stocké dans l’array, mais le softmax doit s’executer SUR l’array. Donc on fait en sorte de distinguer les deux cas de figure :

public function process(): array
{
	$neurons = [];
	foreach ($this->weights as $key => $weight) {
		$z = $this->biases[$key];
		foreach ($weight as $k => $datum) {
			$z += $datum * $this->inputs[$k];
		}
		$neurons[] = $this->activation === "sigmoid" ? $this->sigmoid($z) : $z;
	}

	return $this->activation === "sigmoid" ? $neurons : $this->softmax($neurons);
}

Petit coup de clean sur l’appel aux layers

On va quand même faire plus propre que de stocker des arrays partout dans notre main.php. On va donc créer une class NeuralNetwork auquel on passera un array de configs pour nos neurones. Ensuite on pourra appeler sa fonction « predict » avec les inputs de notre choix et il traitera ça dans son coin.

<?php

class NeuralNetwork
{
    public function __construct(
        readonly private array $layersConfig
    ) { }

    public function predict(array $inputs): array
    {
        // On mettra la logique ici
        return $inputs;
    }
}

Et donc pour le traitement de la fonction predict, on vient simplement mettre une variable d’état pour les inputs actuels (qui changera à chaque passage de la boucle avec le nouveau résultat), puis on boucle sur la config pour activer chaque layer un à un:

$currentInputs = $inputs;
foreach ($this->layersConfig as $config) {
    $layer = new NeuronLayer(
        $currentInputs,
        $config['weights'],
        $config['biases'],
        $config['activation']
    );
    $currentInputs = $layer->process();
}
return $currentInputs;

Et voilà ! On peut tester si tout se branche bien ensemble en modifiant notre main.php comme suit :

<?php
require('./NeuronLayer.php');
require('./NeuralNetwork.php');

$inputs  = [0.5, 0.8, 0.2];

$network = new NeuralNetwork([
	[
		"weights" => [[0.4, -0.6, 0.9],[0.2, -0.1, -0.8]],
		"biases" => [0.1, 0.3],
		"activation" => "sigmoid"
	],
	[
		"weights" => [[0.3, 0.2],[0.9, 0.9]],
		"biases" => [0.3, 0.5],
		"activation" => "softmax"
	],
]);

var_dump($network->predict($inputs));

Et on a notre réseau fonctionnel. Il ne reste plus qu’à l’entraîner pour avoir les bon weights et les bons bias, puis tester ça en conditions réelles. Ce qu’on fera dans le prochain article !