
Ce blog présente une solution d’apprentissage automatique préservant la confidentialité (PPML) pour le défi Titanic trouvé sur Kaggle à l’aide de la boîte à outils open source Concrete-ML. Son ambition principale est de montrer que le cryptage entièrement homomorphique (FHE) peut être utilisé pour protéger les données lors de l’utilisation d’un modèle d’apprentissage automatique pour prédire les résultats sans dégrader ses performances. Dans cet exemple, un modèle de classificateur XGBoost sera considéré car il atteint une précision proche de l’état de l’art.
Kaggle est une communauté en ligne qui permet à quiconque de créer et de partager des projets autour de l’apprentissage automatique et de la science des données. Il propose gratuitement des ensembles de données, des cours, des exemples et des concours à tous les data scientists désireux de découvrir ou d’approfondir leurs connaissances dans le domaine. Sa simplicité d’utilisation en fait l’une des plateformes les plus populaires parmi la communauté ML.
De plus, Kaggle propose plusieurs didacticiels avec différents niveaux de difficulté pour que les nouveaux débutants commencent à manipuler des outils de science des données de base sur un exemple réel. Parmi ces tutoriels, on retrouve le concours Titanic. C’est un problème de classification binaire en utilisant un simple ensemble de données de passagers voyageant pendant le tragique naufrage du Titanic.
Le Jupyter Notebook envoyé par l’équipe Concrete-ML pour ce concours est disponible ici. Il a été créé avec l’aide de plusieurs autres cahiers accessibles au public afin d’offrir des directives claires ainsi que des résultats efficaces.
Avant de pouvoir construire le modèle, quelques étapes de préparation sont nécessaires.
Exigences du paquet
Concrete-ML est un package Python, le code est donc réalisé à l’aide de ce langage de programmation. Quelques packages supplémentaires sont nécessaires, notamment le framework Pandas utilisé pour le prétraitement des données ainsi que certains outils de validation croisée scikit-learn.
import numpy as np import pandas as pd from sklearn.model_selection import GridSearchCV, ShuffleSplit from xgboost import XGBClassifier from concrete.ml.sklearn import XGBClassifier as ConcreteXGBClassifier
Nettoyer les données
Les ensembles de données d’entraînement et de test sont fournis dans la plate-forme Kaggle. Une fois cela fait, chargeons les données et extrayons les ID cibles.
train_data = pd.read_csv("./local_datasets/titanic/train.csv") test_data = pd.read_csv("./local_datasets/titanic/test.csv") datasets = [train_data, test_data] test_ids = test_data["PassengerId"]
Voici à quoi ressemblent les données :

Plusieurs déclarations peuvent être faites :
– La variable cible est la variable Survived.
– Certaines variables sont numériques, telles que PassengerID, Pclass, SbSp, Parch ou Fare
– Certaines variables sont catégorielles (non numériques), telles que Nom, Sexe, Billet, Cabine ou Embarqué
Une première étape de pré-traitement consiste à supprimer les variables PassengerId et Ticket car elles semblent être des identifiants aléatoires qui n’ont aucun impact sur leur survie. De plus, nous pouvons remarquer que certaines valeurs manquent dans la variable Cabin. Il faut donc approfondir cette observation en imprimant les montants totaux des valeurs manquantes pour chaque variable.
print(train_data.isnull().sum()) print(test_data.isnull().sum())
Cela produit les résultats suivants.

Il semble que quatre variables soient incomplètes, Cabine, Âge, Embarqué et Tarif. Cependant, celui de la cabine semble manquer beaucoup plus de données que les autres :
for incomp_var in train_data.columns: missing_val = pd.concat(datasets)[incomp_var].isnull().sum() if missing_val > 0 and incomp_var != "Survived": total_val = pd.concat(datasets).shape[0] print( f"Percentage of missing values in {incomp_var}: " f"{missing_val/total_val*100:.1f}%" )
Percentage of missing values in Cabin: 77.5% Percentage of missing values in Age: 20.1% Percentage of missing values in Embarked: 0.2% Percentage of missing values in Fare: 0.1%
Étant donné que la variable Cabin manque plus de 2/3 de ses valeurs, il peut également ne pas être approprié de la conserver. Nous supprimons donc ces variables des deux ensembles de données.
drop_column = ["PassengerId", "Ticket", “Cabin”] for dataset in datasets: dataset.drop(drop_column, axis=1, inplace=True)
En ce qui concerne les trois autres variables qui manquent de valeurs, les supprimer pourrait signifier perdre beaucoup d’informations pertinentes sur les témoins qui pourraient aider le modèle à prédire la survie des passagers. C’est surtout vrai pour la variable Age puisque plus de 20% de ses valeurs manquent. Des techniques alternatives peuvent ainsi être utilisées pour renseigner ces variables incomplètes. Étant donné que l’âge et le tarif sont numériques, les valeurs manquantes peuvent être remplacées par les médias disponibles. Pour la variable Embarquée, qui est catégorique, nous utilisons la valeur la plus courante comme substitut.
for dataset in datasets: dataset.Age.fillna(dataset.Age.median(), inplace=True) dataset.Embarked.fillna(dataset.Embarked.mode()[0], inplace=True) dataset.Fare.fillna(dataset.Fare.median(), inplace=True)
Concevoir de nouvelles fonctionnalités
De plus, nous pouvons créer manuellement de nouvelles variables à partir de variables déjà existantes afin d’aider le modèle à interpréter certains comportements pour de meilleures prédictions. Parmi toutes les possibilités disponibles, quatre nouveautés ont été sélectionnées :
- TailleFamille: La taille de la famille avec laquelle l’individu voyageait, 1 étant quelqu’un qui voyageait seul.
- Est seul: Une variable booléenne indiquant si l’individu voyageait seul (1) ou non (0). Cela pourrait aider le modèle à mettre l’accent sur cette idée de voyager avec des proches ou non.
- Titre: Le titre de l’individu (M., Mme, …), indiquant souvent un certain statut social.
- Farbin et AgeBin: Version groupée des variables Fare et Age. Il regroupe des valeurs entre elles, réduisant généralement l’impact d’erreurs d’observation mineures.
Créons ces nouvelles variables pour les deux ensembles de données.
# Function that helps generating proper bin names def get_bin_labels(bin_name, number_of_bins): labels = [] for i in range(number_of_bins): labels.append(bin_name + f"_{i}") return labels for dataset in datasets: dataset["FamilySize"] = dataset.SibSp + dataset.Parch + 1 dataset["IsAlone"] = 1 dataset.IsAlone[dataset.FamilySize > 1] = 0 dataset["Title"] = dataset.Name.str.extract( r" ([A-Za-z]+).", expand=False ) dataset["FareBin"] = pd.qcut( dataset.Fare, 4, labels=get_bin_labels("FareBin", 4) ) dataset["AgeBin"] = pd.cut( dataset.Age.astype(int), 5, labels=get_bin_labels("AgeBin", 5) ) # Removing outdated variables drop_column = ["Name", "SibSp", "Parch", "Fare", "Age"] dataset.drop(drop_column, axis=1, inplace=True)
De plus, l’impression des différents titres trouvés dans les ensembles de données montre que seuls quelques-uns d’entre eux représentent la plupart des individus.
data = pd.concat(datasets) titles = data.Title.value_counts() print(titles)

Afin d’éviter que le modèle ne devienne trop spécifique, tous les titres “peu communs” sont regroupés dans une nouvelle variable “Rare”.
uncommon_titles = titles[titles < 10].index for dataset in datasets: dataset.Title.replace(uncommon_titles, "Rare", inplace=True)
Appliquer la dummification
Enfin, nous pouvons "dummifier" les variables catégorielles restantes. La dummification est une technique courante de transformation de données catégorielles (non numériques) en données numériques sans avoir à cartographier des valeurs ni à considérer un quelconque ordre entre chacune d'entre elles. L'idée est de prendre toutes les différentes valeurs trouvées dans une variable et de créer une nouvelle variable binaire associée.
Par exemple, la variable "Embarqué" a trois valeurs catégorielles, "S", "C" et "Q". La dummification des données créera trois nouvelles variables, "Embarked_S", "Embarked_C" et "Embarked_Q". Ensuite, la valeur de "Embarqué_S" (resp. "Embarqué_C" et "Embarqué_Q") est mise à 1 pour chaque point de données initialement étiqueté par "S" (resp. "C" et "Q") dans la variable "Embarqué" , et 0 sinon.
categorical_features = train_data.select_dtypes(exclude=np.number).columns x_train = pd.get_dummies(train_data, prefix=categorical_features) x_test = pd.get_dummies(test_data, prefix=categorical_features) x_test = x_test.to_numpy()
Nous séparons ensuite la variable cible des autres, une étape nécessaire avant l'apprentissage.
target = "Survived" x_train = x_train.drop(columns=[target]) x_train = x_train.to_numpy() y_train = train_data[target].to_numpy()
Entraînement avec XGBoost
Construisons d'abord un modèle de classificateur à l'aide de XGBoost. Étant donné que plusieurs paramètres doivent être fixés au préalable, nous utilisons la méthode GridSearchCV de scikit-learn pour effectuer une validation croisée afin de maximiser nos chances de trouver les meilleurs. Les plages données sont volontairement petites afin de maintenir le temps d'exécution FHE par inférence relativement faible (inférieur à 10 secondes). En fait, nous avons découvert que, dans cet exemple particulier du Titanic, les modèles avec un plus grand nombre d'estimateurs ou une profondeur maximale n'obtiennent pas une bien meilleure précision. Nous ajustons ensuite ce modèle à l'aide des ensembles d'apprentissage.
cv = ShuffleSplit(n_splits=5, test_size=0.3, random_state=0) param_grid = { "max_depth": list(range(1, 5)), "n_estimators": list(range(1, 5)), "learning_rate": [0.01, 0.1, 1], } model = GridSearchCV( XGBClassifier(), param_grid, cv=cv, scoring="roc_auc" ) model.fit(x_train, y_train)
Formation avec Concrete-ML
Maintenant, faisons la même chose en utilisant la méthode XGBClassifier de Concrete-ML.
Pour ce faire, nous devons spécifier le nombre de bits sur lesquels les entrées, les sorties et les poids seront quantifiés. Cette valeur peut influencer la précision du modèle ainsi que son temps d'exécution d'inférence, et peut donc conduire la validation croisée de la recherche de grille à trouver un ensemble de paramètres différent. Dans notre cas, la définition de cette valeur sur 2 bits produit un excellent score de précision tout en fonctionnant plus rapidement.
param_grid["n_bits"] = [2] x_train = x_train.astype(np.float32) concrete_model = GridSearchCV( ConcreteXGBClassifier(), param_grid, cv=cv, scoring="roc_auc" ) concrete_model.fit(x_train, y_train)
L'API Concrete-ML a été pensée pour être aussi proche que la plupart des bibliothèques Python d'apprentissage automatique et d'apprentissage profond afin de rendre son utilisation aussi simple que possible. De plus, cela permet à tous les scientifiques des données d'utiliser la technologie de Zama sans avoir besoin de connaissances préalables en cryptographie. La construction et l'ajustement d'un modèle compatible FHE deviennent donc très intuitifs et pratiques pour quiconque est habitué aux flux de travail courants en science des données tels que les outils scikit-learn. En fait, ces observations restent valables en ce qui concerne le processus de prédiction, avec seulement quelques étapes supplémentaires à considérer.
Calculons d'abord les prédictions en clair à l'aide du modèle XGBoost.
clear_predictions = model.predict(x_test)
En outre, il est également possible de calculer les prédictions en clair en utilisant le modèle Concrete-ML. Cela exécutera essentiellement la version XGBoost de la bibliothèque du modèle en tenant compte du processus de quantification. Aucun calcul lié à FHE n'est traité ici.
clear_quantized_predictions = concrete_model.predict(x_test)
Afin de faire la même chose avec FHE, une étape de compilation supplémentaire est nécessaire. En lui donnant un sous-ensemble des données d'entrée utilisées pour représenter la plage de valeurs atteignable, la méthode de compilation construit un circuit FHE approprié qui sera ensuite exécuté lors de la prédiction. Notez que le paramètre execute_in_fhe doit également être défini sur True.
fhe_circuit = concrete_model.best_estimator_.compile(x_train[:100]) fhe_predictions = concrete_model.best_estimator_.predict( x_test, execute_in_fhe=True )
En utilisant une machine avec 8 processeurs Intel® Core™ i5-1135G7 de 11e génération de 2,40 GHz (4 cœurs, 2 threads chacun), le temps d'exécution moyen par inférence se situe entre 2 et 3 secondes. Cela n'inclut pas le temps de génération de la clé, qui ne se produit qu'une seule fois avant que toutes les prédictions ne soient faites et n'atteigne pas plus de 12 secondes.
De plus, les calculs FHE devraient être exacts. Cela signifie que le modèle exécuté dans FHE donne les mêmes prédictions que celui de Concrete-ML, qui est exécuté en clair et ne considère que la quantification.
number_of_equal_preds = np.sum( fhe_predictions == clear_quantized_predictions ) pred_similarity = number_of_equal_preds / len(clear_predictions) * 100 print( "Prediction similarity between both Concrete-ML models" f"(quantized clear and FHE): {pred_similarity:.2f}%" )
Similitude de prédiction entre les deux modèles Concrete-ML (quantized clear et FHE) : 100,00 %
Cependant, comme vu précédemment, la validation croisée de la grille de recherche a été effectuée séparément entre le modèle XGBoost et celui de Concrete-ML. Pour cette raison, les deux modèles ne partagent pas le même ensemble d'hyperparamètres, ce qui rend leurs limites de décision différentes.
Comparer la similarité de leurs prédictions une par une n'est donc pas pertinent et seul le score de précision final donné par la plateforme Kaggle doit être pris en compte pour évaluer leurs performances.
Par conséquent, enregistrons les prédictions des modèles XGBoost et Concrete-ML sous forme de fichiers csv. Ensuite, ces fichiers peuvent être soumis sur la plateforme Kaggle en utilisant ce lien. Le modèle FHE produit une précision d'environ 78%, ce qui peut être vu dans le classement public. En comparaison, le XGBoost clear one obtient un score de 77 %.
En fait, en utilisant l'ensemble de données fourni, la plupart des cahiers soumis ne semblent pas dépasser 79 % de précision. Par conséquent, un prétraitement et une ingénierie de fonctionnalités supplémentaires pourraient aider à augmenter notre score actuel, mais probablement pas de beaucoup.
submission = pd.DataFrame( { "PassengerId": test_ids, "Survived": fhe_predictions, } ) submission.to_csv("titanic_submission_fhe.csv", index=False) submission = pd.DataFrame( { "PassengerId": test_ids, "Survived": clear_predictions, } ) submission.to_csv("titanic_submission_xgb_clear.csv", index=False)
Merci d'avoir lu! Notre idée principale ici n'était pas seulement de construire un modèle prédictif qui réponde à la question : "Quelles sortes de personnes étaient les plus susceptibles de survivre ?" mais aussi de le faire sur des données cryptées.
Cela a été possible grâce à notre package Python : Concrete-ML qui vise à simplifier l'utilisation du chiffrement entièrement homomorphe (FHE) pour les data scientists.
Romain Bredehoft est ingénieur en apprentissage automatique chez Zama.