Practical session

Duration1h30

Présentation & objectifs

L’objectif de cette séance est de mettre en pratique la programmation orientée objet dans le contexte de l’intelligence artificielle, en particulier avec les approches d’apprentissage supervisé.

Important

Le but de cette séance est de vous aider à maîtriser des notions importantes en informatique. Un assistant de programmation intelligent tel que GitHub Copilot, que vous avez peut-être déjà installé, pourra vous fournir une solution à ces exercices uniquement à partir d’un nom de fichier judicieusement choisi.

Pour l’entraînement, nous vous conseillons de désactiver d’abord ces outils.

À la fin de l’activité pratique, nous vous suggérons de refaire l’exercice avec ces outils activés. Suivre ces deux étapes améliorera vos compétences à la fois de manière fondamentale et pratique.

De plus, nous vous fournissons les solutions aux exercices. Assurez-vous de ne les consulter qu’après avoir trouvé une solution aux exercices, à des fins de comparaison ! Même si vous êtes sûr que votre solution est correcte, veuillez les consulter, car elles fournissent parfois des éléments supplémentaires que vous auriez pu manquer.

1 — Implémenter un classifieur générique en utilisant la POO

Implémentez un objet qui sera un classifieur générique avec les méthodes fit, predict et score. La classe doit être définie dans un fichier nommé classifiers.py et s’appeler GenericClassifier.

Le but de cet objet est de définir comment un classifieur doit se comporter, et nous utiliserons cette classe dans les prochains exercices pour implémenter effectivement des algorithmes d’apprentissage automatique.

  • La classe doit avoir un constructeur sans paramètres.
  • La méthode fit prend deux paramètres X et y (tous deux de type np.ndarray) et sera utilisée pour entraîner le modèle (ici, elle ne fait rien).
  • La méthode fit modifie un attribut booléen privé self._isfitted de la classe pour indiquer que le modèle a été entraîné.
  • La méthode predict prend un seul paramètre X (de type np.ndarray) et retourne les labels prédits predictions.
  • La méthode score prend deux paramètres X et y (tous deux de type np.ndarray) et retourne la précision du modèle sur les données données X selon les labels de vérité terrain y.
  • Les méthodes predict et score retournent une erreur si elles sont appelées alors que le modèle n’a pas encore été entraîné.
Correction
import numpy as np

class GenericClassifier:
    def __init__(self):
        """Initialize the GenericClassifier with default attributes."""
        self._isfitted = False  # Private attribute to indicate if the model is trained
    
    def fit(self, X: np.ndarray, y: np.ndarray):
        """
        Generic Method for training the classifier.
        Needs to be implemented in an actual classifier, here it does nothing.

        Parameters:
        - X: np.ndarray, training data.
        - y: np.ndarray, labels for training data.
        """
        # Set the internal flag to indicate the model is trained
        self._isfitted = True
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        Predict labels for given data.

        Parameters:
        - X: np.ndarray, input data to predict labels for.

        Returns:
        - predictions: np.ndarray, predicted labels.

        Raises:
        - ValueError: If the model is called without being trained.
        """
        if not self._isfitted:
            raise ValueError("The model must be trained (call fit) before predictions can be made.")
        
        # Prediction (to be implemented in subclasses) : here we just return zeros
        return np.zeros(X.shape[0], dtype=int)
    
    def score(self, X: np.ndarray, y: np.ndarray) -> float:
        """
        Calculate the accuracy of the model on the given data.

        Parameters:
        - X: np.ndarray, input data.
        - y: np.ndarray, ground truth labels.

        Returns:
        - accuracy: float, accuracy of the model on the data X given ground truth labels y .

        Raises:
        - ValueError: If the model is called without being trained.
        """
        if not self._isfitted:
            raise ValueError("The model must be trained (call fit) before scoring can be performed.")
        
        # Placeholder prediction logic
        predictions = self.predict(X)
        
        # Calculate and return accuracy
        return np.mean(predictions == y)

Ensuite, créez un fichier de test nommé test_classifier.py qui teste les trois méthodes de la classe individuellement.

2 — Implémenter un classifieur $k$-NN en utilisant le classifieur générique

Implémentez le classifieur KNN codé dans la session algoS6 en utilisant une instance héritant de GenericClassifier.

La classe doit être définie dans un fichier nommé knn_classifier.py et s’appeler KNNClassifier. Le paramètre k est spécifique à une instance de la classe.

  • La classe doit avoir un constructeur héritant de GenericClassifier, et qui prend un seul paramètre k qui est le nombre de voisins à considérer.
  • La méthode fit prend deux paramètres X et y (tous deux de type np.ndarray) et entraîne le modèle en utilisant l’algorithme décrit dans la session algo 6.
  • La méthode predict prend un seul paramètre X (de type np.ndarray) et retourne les labels prédits.
  • La méthode fit modifie un attribut booléen privé self._isfitted de la classe pour indiquer que le modèle a été entraîné.
  • La méthode predict retourne une erreur si elle est appelée alors que le modèle n’a pas encore été entraîné.
  • Vous n’avez pas à réimplémenter la méthode score car sa définition est héritée de GenericClassifier.

Ensuite, créez un fichier nommé main.py qui utilise la classe pour entraîner un modèle sur le jeu de données digits et afficher la précision du modèle sur le jeu de test.

Voici un extrait de code pour charger le jeu de données digits :

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

# Load the digits dataset
digits = load_digits()

# Split the data into a training and test set
X_train, X_test, y_train, y_test = train_test_split(digits.data, digits.target, random_state=0)

Idéalement, vous créez aussi un fichier de test nommé test_knn_classifier.py qui teste les trois méthodes de la classe individuellement.

Correction
import numpy as np

class KNNClassifier(GenericClassifier):
    def __init__(self, n_neighbors: int = 3):
        """
        Initialize the KNNClassifier with the number of neighbors.
        
        Parameters:
        - n_neighbors: int, the number of nearest neighbors to consider.
        """
        super().__init__()
        self.n_neighbors = n_neighbors
        self.X_train = None
        self.y_train = None
    
    def fit(self, X: np.ndarray, y: np.ndarray):
        """
        Store the training data and mark the model as fitted.
        
        Parameters:
        - X: np.ndarray, training data.
        - y: np.ndarray, labels for training data.
        """
        self.X_train = X
        self.y_train = y
        super().fit(X, y)
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        Predict labels for the given data using the KNN algorithm.
        
        Parameters:
        - X: np.ndarray, input data to predict labels for.
        
        Returns:
        - predictions: np.ndarray, predicted labels.
        """
        if not self._isfitted:
            raise ValueError("The model must be trained (call fit) before predictions can be made.")
        
        def euclidean_distance(x1, x2):
            """Compute the Euclidean distance between two points."""
            return np.sqrt(np.sum((x1 - x2) ** 2))

        # Predict labels for each test instance
        predictions = []
        for x_test in X:
            # Compute distances between the test point and all training points
            distances = [euclidean_distance(x_test, x_train) for x_train in self.X_train]
            
            # Get the indices of the k nearest neighbors
            k_indices = np.argsort(distances)[:self.n_neighbors]
            
            # Get the labels of the k nearest neighbors
            k_labels = np.array([self.y_train[i] for i in k_indices])
            
            # Perform majority vote with numpy
            unique_labels, counts = np.unique(k_labels, return_counts=True)
            most_common = unique_labels[np.argmax(counts)]
            predictions.append(most_common)
        
        return np.array(predictions)

3 — Implémenter l’assemblage de modèles (model ensembling)

Maintenant que vous avez implémenté le classifieur, vous pouvez les utiliser pour créer un modèle ensembliste. Codez une classe ModelEnsemble qui hérite de GenericClassifier, prend en argument une liste de classifieurs entraînés, et effectue un vote majoritaire de tous les classifieurs. La classe doit être définie dans un fichier nommé model_ensemble.py et s’appeler ModelEnsemble.

  • La classe prend en argument une liste de classifieurs entraînés.
  • À l’initialisation de la classe, elle lève une erreur si la liste fournie de classifieurs est vide.
  • La classe doit implémenter une méthode fit qui vérifie que tous les classifieurs de la liste ont été entraînés auparavant.
  • La classe doit avoir une méthode predict qui prend un seul paramètre X (de type np.ndarray) et retourne les labels prédits. Cette méthode retourne une erreur si les classifieurs de la liste n’ont pas été entraînés auparavant.

Ensuite, créez un fichier nommé main.py qui utilise la classe pour entraîner un modèle ensembliste sur le jeu de données digits et afficher la précision du modèle sur le jeu de test. Comme modèle ensembliste, vous pouvez utiliser une liste de KNN avec différentes valeurs de K.

Correction
import numpy as np

class ModelEnsemble(GenericClassifier):
    def __init__(self, classifiers: list):
        """
        Initialize the ModelEnsemble with a list of trained classifiers.

        Parameters:
        - classifiers: list, a list of trained classifier objects.
        """
        super().__init__()
        if len(classifiers)==0:
            raise ValueError("The list of classifiers cannot be empty.")
        self.classifiers = classifiers
    
    def fit(self, X: np.ndarray, y: np.ndarray):
        """
        Check that all classifiers in the ensemble are already trained.

        Parameters:
        - X: np.ndarray, training data (not used, only to match GenericClassifier interface).
        - y: np.ndarray, labels for training data (not used, only to match GenericClassifier interface).
        
        Raises:
        - ValueError: If any classifier in the ensemble is not trained.
        """
        for clf in self.classifiers:
            if not clf._isfitted:
                raise ValueError("All classifiers in the ensemble must be trained before using the ensemble.")
        self._isfitted = True  # Mark the ensemble as ready to use
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        Perform majority voting to predict labels for the given data.

        Parameters:
        - X: np.ndarray, input data to predict labels for.

        Returns:
        - predictions: np.ndarray, predicted labels by majority vote.
        
        Raises:
        - ValueError: If any classifier in the ensemble has not been trained.
        """
        # Collect predictions from all classifiers
        all_predictions = []
        for clf in self.classifiers:
            if not hasattr(clf, "_isfitted") or not clf._isfitted:
                raise ValueError("All classifiers must be trained before using the ensemble.")
            all_predictions.append(clf.predict(X))
        
        # Stack predictions into a 2D array
        all_predictions = np.stack(all_predictions, axis=1)
        
        # Perform majority voting
        unique_labels = np.unique(all_predictions)
        label_counts = np.zeros((all_predictions.shape[0], unique_labels.size), dtype=int)
        
        for i, label in enumerate(unique_labels):
            label_counts[:, i] = np.sum(all_predictions == label, axis=1)
        
        majority_votes = unique_labels[np.argmax(label_counts, axis=1)]
        
        return majority_votes

4 — Assemblage de modèles avec validation croisée

Analysez les performances du modèle ensembliste de la section précédente. La précision devrait être très élevée, et le modèle ensembliste n’est pas significativement meilleur que les modèles individuels.

Afin de mieux voir l’intérêt des modèles ensemblistes, et aussi pour montrer un cas d’usage plus réaliste de l’assemblage de modèles, nous allons simuler une situation dans laquelle chaque classifieur voit une répartition différente des données d’entraînement :

  • Gardez le jeu de test X_test,y_test identique à celui de l’exercice précédent
  • Divisez l’ancien jeu d’entraînement X_train,y_train en P sous-ensembles différents
  • Entraînez P algorithmes K-NN indépendamment sur chaque sous-ensemble, avec la même valeur de K. Comme les partitions sont différentes, chaque K-NN aura une performance légèrement différente sur le jeu de test.
  • Évaluez la performance sur un modèle ensembliste prenant tous les P K-NN.

Vous pouvez expérimenter avec ce cadre, en utilisant un nombre différent de partitions P, et différentes valeurs de K. Nous suggérons de commencer avec P=5 et K=1, mais n’hésitez pas à expérimenter.

5 — Optimisez vos solutions

Ce que vous pouvez faire maintenant est d’utiliser des outils d’IA tels que GitHub Copilot ou ChatGPT, soit pour générer la solution, soit pour améliorer la première solution que vous avez proposée ! Essayez cela pour tous les exercices ci-dessus, pour voir les différences avec vos solutions.

Pour aller plus loin

6 — Implémenter une recherche en grille avec validation croisée

Codez une classe CVGridSearch qui prend un jeu de données (données et labels), une plage d’un hyperparamètre (par exemple une plage de valeurs entières de K), divise en un jeu d’entraînement et de validation, et utilise le jeu de validation pour optimiser le meilleur hyperparamètre. Vous pouvez vérifier la validité sur votre implémentation KNN.

Pour aller encore plus loin

7 — Perceptron multicouche

Le perceptron multicouche (MLP) est un bloc de base très couramment utilisé dans les solutions d’apprentissage automatique basées sur le Deep Learning. Cette page utilise une définition de classe pour construire un MLP from scratch en utilisant numpy.

8 — Différentiation automatique

Une autre application intéressante (mais complexe) de la programmation orientée objet est donnée par pytorch, avec la notion de différentiation automatique. En résumé, pytorch effectue des opérations sur des tenseurs comme numpy, mais pytorch suit automatiquement l’historique et les dépendances entre tous les calculs et leurs gradients. Cela permet une implémentation très directe des architectures de Deep Learning. Plus d’informations peuvent être trouvées dans la documentation officielle de pytorch.

Vous pouvez aussi trouver un tutoriel complet pytorch ici