Les réseaux de neurones sont aujourd'hui à la pointe de l'apprentissage automatique (ML), et Python est sans aucun doute le langage de programmation incontournable pour toute tâche de ML, que l'on ait ou non l'intention d'utiliser les réseaux de neurones pour la résoudre. Il existe une vaste gamme de bibliothèques Python disponibles qui couvrent tout le spectre des tâches de ML, telles que NumPy, Pandas, Keras, TensorFlow, PyTorch, etc. Ces bibliothèques s'appuient généralement sur des implémentations C ou C++ d'algorithmes et d'approches ML sous le capot, car Python est trop lent pour elles. Cependant, Python n’est pas le seul langage de programmation existant, et ce n’est pas celui que j’utilise dans mon travail quotidien.
Cet article n'est pas un guide sur la façon d'écrire quelque chose dans Swift ; il s'agit plutôt d'une réflexion sur l'état d'esprit actuel de nombreux développeurs qui considèrent Python comme un pont vers la solution ultime pour les bibliothèques ML qui résoudra tout problème ou tâche qu'ils rencontrent, quel que soit le langage qu'ils utilisent. Je parierais que la plupart des développeurs préfèrent investir leur temps dans la recherche de moyens d'intégrer les bibliothèques Python dans leur langage/environnement plutôt que d'envisager des solutions alternatives sans elles. Même si ce n’est pas mauvais en soi – la réutilisation a été un moteur important de progrès en informatique au cours des dernières décennies – j’ai commencé à sentir que de nombreux développeurs n’envisagent même plus de solutions alternatives. Cet état d'esprit devient encore plus ancré avec l'état actuel et les progrès des grands modèles linguistiques.
L'équilibre manque ; nous nous précipitons pour demander aux LLM de résoudre nos problèmes, d'obtenir du code Python, de le copier et de profiter de notre productivité avec une surcharge potentiellement importante due à des dépendances inutiles.
Explorons une approche alternative pour résoudre la tâche à accomplir en utilisant uniquement Swift, les mathématiques et aucun autre outil.
Lorsque les gens commencent à apprendre les réseaux de neurones, il existe deux exemples classiques de Hello World que vous pouvez trouver dans la plupart des didacticiels et des documents d'introduction. Le premier est une reconnaissance de chiffres manuscrits. La seconde est une classification des données. Je me concentrerai sur la seconde dans cet article, mais la solution que je vais présenter fonctionnera également pour la première.
Un très bon exemple visuel peut être trouvé dans TensorFlow Playground, où vous pouvez jouer avec différentes structures de réseaux neuronaux et observer visuellement dans quelle mesure le modèle résultant résout la tâche.
Vous pourriez vous demander quelle est la signification pratique de ces points sur une image avec des couleurs différentes ? Le fait est que c'est une représentation visuelle de certains ensembles de données. Vous pouvez présenter de nombreux types de données différents de manière exactement identique ou similaire, comme les groupes sociaux de personnes qui achètent des produits spécifiques ou leurs préférences musicales. Puisque je me concentre principalement sur le développement iOS mobile, je donnerai également un exemple d'une tâche réelle que je résolvais et qui peut être représentée visuellement de la même manière : trouver des fils électriques à l'intérieur des murs à l'aide d'un gyroscope et d'un magnétomètre sur un téléphone mobile. Dans cet exemple particulier, nous avons un ensemble de paramètres liés au fil trouvé et un autre ensemble de paramètres pour rien n'est à l'intérieur du mur.
Jetons un coup d'œil aux données que nous utiliserons.
Nous avons ici deux types de données : les points rouges et les points bleus. Comme je l'ai décrit ci-dessus, il peut s'agir d'une représentation visuelle de tout type de données classifiées. Par exemple, prenons la zone rouge comme celle où nous avons un signal du magnétomètre et du gyroscope dans les cas où nous avons un fil électrique dans le mur, et la zone bleue dans le cas contraire.
Nous pouvons voir que ces points sont regroupés d’une manière ou d’une autre et forment des sortes de formes rouges et bleues. La façon dont ces points ont été générés consiste à prendre des points aléatoires à partir de l'image suivante :
Nous utiliserons cette image comme modèle aléatoire pour notre processus d'entraînement en prenant des points aléatoires pour entraîner le modèle et d'autres points aléatoires pour tester notre modèle entraîné.
L'image originale mesure 300 x 300 pixels et contient 90 000 points (points). À des fins de formation, nous n'utiliserons que 0,2 % de ces points, soit moins de 100 points. Pour mieux comprendre les performances du modèle, nous sélectionnerons au hasard 3 000 points et tracerons des cercles autour d'eux sur l'image. Cette représentation visuelle nous fournira une idée plus complète des résultats. Nous pouvons également mesurer le pourcentage de précision pour vérifier l'efficacité du modèle.
Comment allons-nous faire un modèle? Si nous regardons ces deux images ensemble et essayons de simplifier notre tâche, nous découvrirons qu'il s'agit en fait de recréer l'image Origin à partir des données dont nous disposons (lot de points rouges et bleus). Et plus l'image que nous obtenons de notre modèle à celle d'origine sera proche, plus notre modèle fonctionnera avec précision. Nous pouvons également considérer nos données de test comme une sorte de version extrêmement compressée de notre image originale et avoir pour objectif de la décompresser.
Ce que nous allons faire, c'est transformer nos points en fonctions mathématiques qui seront représentées dans le code sous forme de tableaux ou de vecteurs (j'utiliserai le terme de vecteur ici dans le texte simplement parce qu'il se situe entre la fonction du monde mathématique et le tableau du développement logiciel). Ensuite, nous utiliserons ces vecteurs pour tester chaque point de test et identifier à quel vecteur il appartient le plus.
Pour transformer nos données, je vais essayer une transformation en cosinus discret (DCT). Je n'entrerai pas dans les explications mathématiques sur ce que c'est et comment cela fonctionne, car vous pouvez facilement trouver ces informations si vous le souhaitez. Cependant, je peux expliquer en termes simples comment cela peut nous aider et pourquoi c'est utile. Le DCT est utilisé dans de nombreux domaines, notamment dans la compression d'images (comme le format JPEG). Il transforme les données dans un format plus compact en ne conservant que les parties importantes de l'image tout en supprimant les détails sans importance. Si nous appliquons le DCT à notre image 300x300 contenant uniquement des points rouges, nous obtiendrons une matrice de valeurs 300x300 qui pourra être transformée en tableau (ou vecteur) en prenant chaque ligne séparément.
Écrivons enfin du code pour cela. Tout d’abord, nous devons créer un objet simple qui représentera notre point (point).
enum Category { case red case blue case none } struct Point: Hashable { let x: Int let y: Int let category: Category }
Vous remarquerez peut-être que nous avons une catégorie supplémentaire appelée none
. Nous allons en fait créer trois vecteurs à la fin : un pour les points red
, un deuxième pour les points blue
et le troisième pour tout ce qui est représenté par none
. Bien que nous puissions en avoir seulement deux, avoir un vecteur entraîné pour ni le rouge ni le bleu rendra les choses un peu plus simples.
Nous avons `Point` conforme au protocole Hashable
pour utiliser un Set
afin d'éviter d'avoir des points avec les mêmes coordonnées dans notre vecteur de test.
func randomPoints(from points: [Point], percentage: Double) -> [Point] { let count = Int(Double(points.count) * percentage) var result = Set<Point>() while result.count < count { let index = Int.random(in: 0 ..< points.count) result.insert(points[index]) } return Array<Point>(result) }
Nous pouvons maintenant l'utiliser pour prendre 0.2%
de points aléatoires de notre image d'origine pour les points rouges, bleus et aucun.
redTrainPoints = randomPoints(from: redPoints, percentage: 0.002) blueTrainPoints = randomPoints(from: bluePoints, percentage: 0.002) noneTrainPoints = randomPoints(from: nonePoints, percentage: 0.002)
Nous sommes prêts à transformer ces données de formation à l'aide de DCT. En voici une implémentation :
final class CosTransform { private var sqrtWidthFactorForZero: Double = 0 private var sqrtWidthFactorForNotZero: Double = 0 private var sqrtHeightFactorForZero: Double = 0 private var sqrtHeightFactorForNotZero: Double = 0 private let cosLimit: Int init(cosLimit: Int) { self.cosLimit = cosLimit } func discreteCosTransform(for points: [Point], width: Int, height: Int) -> [[Double]] { if sqrtWidthFactorForZero == 0 { prepareSupportData(width: width, height: height) } var result = Array(repeating: Array(repeating: Double(0), count: width), count: height) for y in 0..<height { for x in 0..<width { let cos = cosSum( points: points, width: width, height: height, x: x, y: y ) result[y][x] = cFactorHeight(index: y) * cFactorWidth(index: x) * cos } } return result } func shortArray(matrix: [[Double]]) -> [Double] { let height = matrix.count guard let width = matrix.first?.count else { return [] } var array: [Double] = [] for y in 0..<height { for x in 0..<width { if y + x <= cosLimit { array.append(matrix[y][x]) } } } return array } private func prepareSupportData(width: Int, height: Int) { sqrtWidthFactorForZero = Double(sqrt(1 / CGFloat(width))) sqrtWidthFactorForNotZero = Double(sqrt(2 / CGFloat(width))) sqrtHeightFactorForZero = Double(sqrt(1 / CGFloat(height))) sqrtHeightFactorForNotZero = Double(sqrt(2 / CGFloat(height))) } private func cFactorWidth(index: Int) -> Double { return index == 0 ? sqrtWidthFactorForZero : sqrtWidthFactorForNotZero } private func cFactorHeight(index: Int) -> Double { return index == 0 ? sqrtHeightFactorForZero : sqrtHeightFactorForNotZero } private func cosSum( points: [Point], width: Int, height: Int, x: Int, y: Int ) -> Double { var result: Double = 0 for point in points { result += cosItem(point.x, x, height) * cosItem(point.y, y, width) } return result } private func cosItem( _ firstParam: Int, _ secondParam: Int, _ lenght: Int ) -> Double { return cos((Double(2 * firstParam + 1) * Double(secondParam) * Double.pi) / Double(2 * lenght)) } }
Créons une instance de l'objet CosTransform
et testons-la.
let math = CosTransform(cosLimit: Int.max) ... redCosArray = cosFunction(points: redTrainPoints) blueCosArray = cosFunction(points: blueTrainPoints) noneCosArray = cosFunction(points: noneTrainPoints)
Nous utilisons ici quelques fonctions d'assistance simples :
func cosFunction(points: [Point]) -> [Double] { return math.shortArray( matrix: math.discreteCosTransform( for: points, width: 300, height: 300 ) ) }
Il y a un paramètre cosLimit
dans CosTransform
qui est utilisé dans la fonction shortArray, j'en expliquerai le but plus tard, pour l'instant ignorons-le et vérifions le résultat de 3000 points aléatoires de l'image originale par rapport à nos vecteurs créés redCosArray
, blueCosArray
et noneCosArray
. Pour que cela fonctionne, nous devons créer un autre vecteur DCT à partir d'un seul point extrait de l'image originale. Nous faisons cela exactement de la même manière et en utilisant les mêmes fonctions que nous avons déjà utilisées pour nos vecteurs Red
, Blue
et None
cos. Mais comment savoir à qui appartient ce nouveau vecteur ? Il existe une approche mathématique très simple pour cela : Dot Product
. Puisque nous avons pour tâche de comparer deux vecteurs et de trouver la paire la plus similaire, Dot Product nous donnera exactement cela. Si vous appliquez une opération de produit scalaire pour deux vecteurs identiques, cela vous donnera une valeur positive qui sera supérieure à tout autre résultat de produit scalaire s'appliquant au même vecteur et à tout autre vecteur ayant des valeurs différentes. Et si vous appliquez un produit scalaire aux vecteurs orthogonaux (vecteurs qui n'ont rien de commun entre eux), vous obtiendrez un 0 en conséquence. En prenant cela en considération, nous pouvons proposer un algorithme simple :
redCosArray
, puis avec blueCosArray
, puis avec noneCosArray
.Red
, Blue
, None
.
La seule fonctionnalité manquante ici est un produit scalaire, écrivons une fonction simple pour celui-ci :
func dotProduct(_ first: [Double], _ second: [Double]) -> Double { guard first.count == second.count else { return 0 } var result: Double = 0 for i in 0..<first.count { result += first[i] * second[i] } return result }
Et voici une implémentation de l'algorithme :
var count = 0 while count < 3000 { let index = Int.random(in: 0 ..< allPoints.count) let point = allPoints[index] count += 1 let testArray = math.shortArray( matrix: math.discreteCosTransform( for: [point], width: 300, height: 300 ) ) let redResult = dotProduct(redCosArray, testArray) let blueResult = dotProduct(blueCosArray, testArray) let noneResult = dotProduct(noneCosArray, testArray) var maxValue = redResult var result: Category = .red if blueResult > maxValue { maxValue = blueResult result = .blue } if noneResult > maxValue { maxValue = noneResult result = .none } fillPoints.append(Point(x: point.x, y: point.y, category: result)) }
Tout ce que nous devons faire maintenant est de dessiner une image à partir de fillPoints
. Jetons un coup d'œil aux points de train que nous avons utilisés, aux vecteurs DCT que nous avons créés à partir de nos données de train et au résultat final que nous avons obtenu :
Eh bien, cela ressemble à un bruit aléatoire. Mais jetons un coup d'œil à la représentation visuelle des vecteurs. Vous pouvez y voir quelques pics, c'est exactement les informations sur lesquelles nous devons nous concentrer et supprimer la plupart du bruit de notre résultat DCT. Si nous examinons la simple représentation visuelle de la matrice DCT, nous constaterons que les informations les plus utiles (celles qui décrivent les caractéristiques uniques de l'image) sont concentrées dans le coin supérieur gauche :
Prenons maintenant du recul et vérifions à nouveau la fonction shortArray
. Nous utilisons ici un paramètre cosLimit
exactement pour prendre le coin supérieur gauche de la matrice DCT et utiliser uniquement les paramètres les plus actifs qui rendent notre vecteur unique.
func shortArray(matrix: [[Double]]) -> [Double] { let height = matrix.count guard let width = matrix.first?.count else { return [] } var array: [Double] = [] for y in 0..<height { for x in 0..<width { if y + x <= cosLimit { array.append(matrix[y][x]) } } } return array }
Créons notre objet math
avec différents cosLimit
:
let math = CosTransform(cosLimit: 30)
Désormais, au lieu d'utiliser les 90 000 valeurs, nous n'en utiliserons que 30 x 30 / 2 = 450
à partir du coin supérieur gauche de la matrice DCT. Jetons un coup d'oeil au résultat que nous avons obtenu :
Comme vous pouvez le constater, c'est déjà mieux. Nous pouvons également observer que la plupart des pointes qui rendent les vecteurs uniques sont toujours situées dans la partie avant (comme sélectionné en vert sur l'image), essayons d'utiliser CosTransform(cosLimit: 6)
ce qui signifie que nous n'utiliserons que 6 x 6 / 2 = 18
valeurs sur 90 000 et vérifiez le résultat :
C'est beaucoup mieux maintenant, très proche de l'image originale. Cependant, il n’y a qu’un petit problème : cette mise en œuvre est lente. Vous n'avez pas besoin d'être un expert en complexité algorithmique pour réaliser que la DCT est une opération qui prend du temps, mais même le produit scalaire, qui a une complexité temporelle linéaire, n'est pas assez rapide lorsque vous travaillez avec de grands vecteurs à l'aide de tableaux Swift. La bonne nouvelle est que nous pouvons le faire beaucoup plus rapidement et plus simplement dans la mise en œuvre en utilisant vDSP
du framework Accelerate
d'Apple, que nous avons déjà comme bibliothèque standard. Vous pouvez en savoir plus sur vDSP
ici , mais en termes simples, il s'agit d'un ensemble de méthodes permettant d'exécuter des tâches de traitement du signal numérique de manière très rapide. Il contient de nombreuses optimisations de bas niveau qui fonctionnent parfaitement avec de grands ensembles de données. Implémentons notre produit scalaire et DCT en utilisant vDSP
:
infix operator • public func •(left: [Double], right: [Double]) -> Double { return vDSP.dot(left, right) } prefix operator ->> public prefix func ->>(value: [Double]) -> [Double] { let setup = vDSP.DCT(count: value.count, transformType: .II) return setup!.transform(value.compactMap { Float($0) }).compactMap { Double($0) } }
Pour le rendre moins fastidieux, j'ai utilisé certains opérateurs pour le rendre plus lisible. Vous pouvez maintenant utiliser ces fonctions de la manière suivante :
let cosRedArray = ->> redValues let redResult = redCosArray • testArray
Il y a un problème avec la nouvelle implémentation DCT concernant la taille actuelle de notre matrice. Cela ne fonctionnerait pas avec notre image 300 x 300 car elle est optimisée pour fonctionner avec des tailles spécifiques qui sont des puissances de 2. Par conséquent, nous devrons faire des efforts pour redimensionner l'image avant de la confier à la nouvelle méthode.
Merci à tous ceux qui ont réussi à lire ce texte jusqu'à présent ou qui ont eu la paresse de le parcourir sans le lire. Le but de cet article était de montrer que de nombreuses tâches que les gens n'envisagent pas de résoudre avec certains instruments natifs peuvent être résolues avec un minimum d'effort. Il est agréable de rechercher des solutions alternatives et ne vous limitez pas à l'intégration de la bibliothèque Python comme seule option pour résoudre de telles tâches.