Introduction
Avec ChatGPT qui prend le monde d'assaut en ce moment, je n'ai pas pu m'empêcher de me remettre à la lecture de l'excellent livre Pattern Recognition and Machine Learning par Christopher M. Bishop (en fait, je dis excellent mais je n'en sais rien, je ne suis pas encore allé plus loin que le chapitre 2, mais jusque là j'adore ce bouquin, et il y a plein d'exercices ! :-) )
C'est le genre de bouquin qu'on lit avec un PC devant soi pour rendre le texte plus compréhensif en faisant plein d'expériences. Mon langage préféré du moment étant Kotlin, et le code étant assez sympa pour un premier contact avec ce langage, je vous fais profiter d'un mini projet qui génère des points en vue d'être utilisés par un algorithme de régression.
Voilà le topo: on veut entraîner un modèle qui va approximer une courbe par un polynome. Cette courbe est générée à partir de la fonction sinus sur une seule période avec un bruit gaussien dont la déviation standard est 0.3. Cette valeur est donnée dans l'annexe A du bouquin pour ceux qui vérifient que je ne dis pas n'importe quoi. Le fact checking va probablement devenir encore plus important dans les prochains mois/années mais je pense qu'il y a quand même mieux à faire que fact-checker un article technique comme celui-ci ! Enfin c'est votre vie :-)
Quelques mots sur Kotlin
Kotlin est un langage dérivé de Java qui tourne sur la JVM. L'un des avantages de Kotlin est son interopérabilité avec Java : Vous pouvez ajouter des classes Kotlin dans un projet Java, ajouter le bon plugin dans Maven/Gradle, et tout tourne ! Vos classes Java peuvent utiliser la classe Kotlin que vous venez d'introduire et inversement.
Kotlin fait la part belle au paradigme fonctionnel, même s'il reste principalement orienté objet. Un autre point fort de Kotlin est le fait qu'il soit bien moins verbeux que Java (même si Java fait des efforts en ce sens depuis plusieurs années). Il semble d'ailleurs y avoir une bonne synergie entre Java et Kotlin puisque les deux projets s'inspirent mutuellement.
Générer des points suivant une fonction
Les points à générer doivent répondre à plusieurs exigences : Suivre la courbe du sinus, et avoir un bruit aléatoire. Je suis un grand fan du travail de l'oncle Bob et je pense qu'il s'agit là de deux responsabilités distinctes qui doivent donc être gérées par deux classes distinctes. J'ai dans l'idée d'utiliser un décorateur pour ajouter le bruit mais on en parlera plus tard. Pour le moment, contentons-nous d'écrire le code pour générer les points selon une fonction donnée.
Ah on avait dit sinus ? Bon, un peu de généralisation prématurée n'a jamais fait de mal à personne (et non, ça ne remet pas en cause mon respect pour le travail de l'oncle Bob). Allez hop ! Voici comment on écrit une interface en Kotlin :-)
interface Generator {
fun generate(x : Double) : Double
}
Vous connaissez les implémentations par défaut dans les interfaces en Java ? Eh bien en Kotlin vous pouvez simplement fournir des implémentations dans les interfaces, tant que l'interface n'a pas de champ, vous êtes tout bon. Profitons-en donc pour ajouter une fonction qui génère un nombre donné équiréparti de points entre deux bornes:
interface Generator {
fun generate(x : Double) : Double
fun generatePoints(xMin : Double, xMax : Double, count : Int) : List<Point> {
return List(count) {
val x = xMin + it * (xMax - xMin) / (count - 1)
Point(x, generate(x))
}
}
}
Comme vous le constatez, pas de point-virgule en fin de ligne, pas besoin de déclarer le nom du paramètre dans une lambda (il est nommé it
par défaut), et les bindings sont introduits par val
. On peut aussi utiliser var
si la valeur est amenée à changer, mais Kotlin nous encourage à déterminer si nous avons besoin d'un nom pour une valeur donnée (fonctionnel) ou bien si c'est vraiment une variable qu'il nous faut (impératif). Pour finir, instancier un objet ne nécessite pas l'utilisation du mot-clé new
.
Bon il y a quand même quelque chose qui ne va pas dans le code ci-dessus, qu'est-ce que c'est que cette classe Point
avec un constructeur qui prend deux Double
en paramètre ? La classe java.awt.Point
prend deux Int
et n'est donc pas compatible. Il faut créer une nouvelle classe Point
:
data class Point(val x : Double, val y : Double)
Et ... c'est tout pour la classe Point :) Utiliser data class
plutôt que class
a à peu près le même effet que l'annotation @Data
de Lombok. Dans ce cas-ci, comme x
et y
sont des valeurs et pas des variables, il ne sera pas possible de mettre à jour x
et y
dans les instances de Point
, ce qui est exactement ce qu'on veut. Notez enfin qu'une classe en Kotlin est publique par défaut. Il nous reste à créer une implémentation de l'interface Generator qui générera des points en suivant la fonction sinus.
import kotlin.math.sin
import kotlin.math.PI
class SineGenerator(
private val amplitude : Double, private val verticalShift : Double,
private val period : Double, private val phaseShift : Double
) : Generator {
override fun generate(x : Double) =
amplitude * sin(x / period * 2 * PI * phaseShift) + verticalShift
}
J'ai profité de cette classe pour introduire une autre fonctionnalité interessante de Kotlin : Pour définir des fonctions courtes, on peut donner directement une expression après un signe =
. Dans ce cas, il n'est pas nécessaire de préciser le type de retour de la fonction : le compilateur va l'inférer automatiquement. Pour préciser qu'on implémente une méthode abstraite, on utilise le mot-clé override
plutôt que l'annotation Java @Override
.
Kotlin introduit certaines fonctions et constantes dans un package kotlin
. On préfère en général les utiliser quand elles sont disponibles plutôt que leurs équivalents Java, mais nous verrons plus loin un contre-exemple.
Décorateurs !
Avez-vous déjà entendu parler de ce design pattern ? Il permet d'ajouter une fonctionnalité à une classe existante sans interférer avec son fonctionnement. C'est très utile, par exemple, pour ajouter des instructions de log à une classe. Mais dans notre cas nous allons nous en servir pour ajouter du bruit à un générateur existant. Dans un premier temps, je n'avais pas compris que le bruit suivait une loi normale, et j'avais donc créé un générateur ajoutant un bruit uniforme :
import kotlin.random.Random
class UniformNoiseGenerator(
private val generator : Generator,
private val maxNoise : Double
) : Generator {
override fun generate(x : Double) : Double {
return generator.generate(x) + Random.nextDouble() * maxNoise * 2 - maxNoise
}
}
Le premier argument du constructeur prend un Generator
dont la classe est elle-même une implémentation. C'est typique pour les décorateurs. On utilise également Random.nextDouble
qui est fourni par la librairie standard de Kotlin. Il ne faut pas instancier d'objet Random
, ce qui est conforme à l'objectif général de concision poursuivi par Kotlin.
Mais nous voulons un bruit qui suit une distribution normale. Pas de problème, implémentons un autre décorateur qui utilisera java.util.Random.nextGaussian ... qui n'est pas dans la librairie standard de Kotlin.
import java.util.Random
class GaussianNoiseGenerator(
private val generator : Generator,
private val stddev : Double
) : Generator {
private val random = Random()
override fun generate(x : Double) =
generator.generate(x) + random.nextGaussian(0.0, stddev)
}
Exporter le résultat
Avant de construire la fonction main
, j'aimerais implémenter la fonctionnalité qui permet d'exporter le résultat au format supporté par gnuplot. L'idée étant d'afficher les points une fois ces derniers créés.
Alors aujourd'hui j'utilise gnuplot mais demain j'utiliserai peut-être R (qui aujourd'hui est capable de lire le format gnuplot mais qui sait si cette fonctionnalité ne disparaîtra pas demain ?) Enfin soit, c'est mon article, j'écris une interface dans un pur esprit d'over-engineering si ça m'amuse !
import java.io.File
interface Exporter {
fun export(point : Point) : String
fun export(points : List<Point>) : String
fun export(points : List<Point>, file : File) =
file.writeText(export(points))
}
... et l'implémentation qui va avec :
class GnuplotExporter : Exporter {
override fun export(point : Point) = "${point.x}, ${point.y}"
override fun export(points : List<Point) =
points.joinToString("\n") { export(it) }
}
Remarquez que l'implémentation ne fait pas du tout référence à java.io.File
, l'interface gérant totalement ce qui concerne les fichiers.
Lier la mayonnaise
Il ne nous reste plus qu'à écrire notre fonction main
! En kotlin, il suffit de déclarer une fonction qui s'appelle main
. Elle peut prendre en argument une liste de String
, ou bien rien du tout. Pour faire simple on va tout coder en dur et donc utiliser la deuxième option.
import java.io.File
fun main() {
val sineGenerator = SineGenerator(1.0, 0.0, 1.0, 0.0)
val generator = GaussianNoiseGenerator(sineGenerator, 0.3)
val exporter = GnuplotExporter()
val points = generator.generatePoints(0.0, 1.0, 100)
exporter.export(points, File("data"))
}
Si on ouvre le fichier data après avoir exécuté ce programme, on trouvera 100 points dedans, au format gnuplot :-)
Afficher les points avec gnuplot
Le plan initial était d'utiliser gnuplot après avoir généré les points avec le programme Kotlin pour afficher les points. Mais mon côté paresseux a vite repris le dessus. On peut facilement adapter la fonction main
ci-dessus pour lancer gnuplot et afficher les points directement à partir du fichier data
qui vient d'être créé.
import java.io.File
fun main() {
val sineGenerator = SineGenerator(1.0, 0.0, 1.0, 0.0)
val generator = GaussianNoiseGenerator(sineGenerator, 0.3)
val exporter = GnuplotExporter()
val points = generator.generatePoints(0.0, 1.0, 100)
exporter.export(points, File("data"))
ProcessBuilder("gnuplot", "-persist", "-e", "plot 'data')
.start()
.waitFor()
}
Conclusion
À travers cet article, en plus de créer un générateur de points adapté à l'apprentissage automatique, nous avons exploré les bases de Kotlin et vu le concept architectural des décorateurs. Libre à vous d'ajouter d'autres générateurs, certaines questions n'ont d'ailleurs pas été abordées. Voici une petite piste: comment créer un générateur qui a des zones blanches dans lesquelles aucun point ne peut être généré ?
Le code complet est disponible dans l'archive attachée.