Analyse de points d'intérêts (POI) : Autoencoder, espace latent et embending¶

Le notebook : https://sergelhomme.fr/notebook/Intro_IA_poi.ipynb

En géographie et en aménagement, les principaux exemples mobilisés pour illustrer l'intérêt des réseaux de neurones portent généralement sur le traitement d'images, notamment en télédétection. En matière d'enseignement, les modèles de prédiction économique constituent également des supports pédagogiques intéressants pour appréhender le fonctionnement des réseaux de neurones et développer ses premiers modèles. Néanmoins, depuis la fin des années 2010, l'adaptation de modèles de plongement lexical tels que Word2Vec aux données spatiales a favorisé l'émergence d'un champ de recherche original, encore relativement peu mis en avant : l'analyse spatiale des points d'intérêt (POI). L'objectif est alors de caractériser (de classer) les territoires à partir de ces POIs ou de mieux comprendre la logique d'implantation de ces activités. Dit de manière érudite, il s'agit de saisir la sémantique de l'espace ! On cherche à définir le « sens » d'un lieu — sa fonction urbaine — en analysant les types de commerces, de services ou d'infrastructures qui l'entourent et en traduisant la configuration spatiale d'un territoire en « concepts sémantiques ».

Les recherches récentes sur les POI s'intéressent notamment à la classification des espaces géographiques. Cette problématique n'est toutefois pas nouvelle en géographie, où les méthodes de statistique multivariée diffusées lors de la révolution quantitative des années 1960 occupent une place importante. Les analyses en composantes principales (ACP), parfois associées à des classifications ascendantes hiérarchiques (CAH) ou à des algorithmes de partitionnement tels que le k-means, constituent ainsi des outils classiques de typologie territoriale. Leur principal intérêt réside dans leur caractère exploratoire : les regroupements sont déterminés à partir des régularités observées dans les données plutôt qu'à partir d'une classification définie a priori. Ces approches présentent néanmoins plusieurs limites. Les résultats dépendent fortement des choix méthodologiques effectués (sélection et transformation des variables, métrique de distance, nombre de classes retenues), tandis que certaines méthodes reposent sur des hypothèses de linéarité qui peuvent s'avérer restrictives face à la complexité des phénomènes spatiaux.

Cette dernière limite est particulièrement importante dans la mesure où la géographie s'intéresse à des phénomènes spatiaux complexes dont les relations ne sont pas nécessairement linéaires. Dans cette perspective, les réseaux de neurones apparaissent comme une alternative intéressante grâce à leur capacité à apprendre des représentations non linéaires des données. Toutefois, ces méthodes sont souvent associées à l'apprentissage supervisé et aux tâches de prédiction nécessitant des données préalablement étiquetées. Cette caractéristique semble a priori peu compatible avec l'objectif exploratoire des classifications territoriales, puisqu'elle implique de fournir au modèle des catégories préexistantes servant de référence pour l'apprentissage (ce qui serait possible, car on peut imaginer demander à des experts de classer une partie des lieux d'un territoire, puis l'objectif du réseau sera d'apprendre la logique des experts et de réaliser la classification à grande échelle). Or, l'un des intérêts des démarches de classification en géographie est précisément de faire émerger des regroupements susceptibles de questionner ces catégories expertes ou institutionnelles.

Autoencoder et espace latent : classification non supervisée non linéaire¶

Les travaux menés dans le domaine de l'apprentissage profond ont néanmoins permis de dépasser en partie cette opposition. Parmi les avancées les plus importantes figurent les autoencodeurs, notamment développés et popularisés par Geoffrey Hinton et ses collaborateurs, qui permettent d'apprendre automatiquement des représentations latentes des données sans recourir à des labels. Pour cela, les autoencodeurs vont chercher à transformer les données de telle manière à les simplifier au maximum puis à reconstruire ces données pour retrouver le résultat initial. Un autoencodeur est un réseau de neurones qui prend un objet complexe, le contraint à passer dans une sorte d'entonnoir informationnel, puis tente d'en reconstruire une version aussi fidèle que possible. Il apprend ainsi à partir des données elles-mêmes, en cherchant à reconstruire son entrée. La première partie du réseau, appelée encodeur, est constituée de couches de neurones de taille décroissante jusqu'à atteindre la couche la plus petite, appelée espace latent ou couche latente. À ce stade, l'information a été compressée sous une forme plus compacte. La seconde partie, appelée décodeur, est alors chargée de reconstruire l'objet initial à partir de cette représentation condensée grâce à une succession de couches de neurones de taille croissante.

Pour faire très simple, un réseau de neurones doit être guidé pour apprendre, il doit être supervisé. Il doit pouvoir faire des prédictions dont il peut vérifier la justesse (X -> Y). Si on ne peut pas lui donner en entrée des labels (des Y, des données étiquetées, des prédictions à faire), les autoencodeurs sont une solution. Ils vont alors simplifier (compresser) les données qu'on leur donne, puis ils vont chercher à reconstruire les données à partir de cette simplification pour vérifier s'ils ont bien simplifié, s'ils ont bien compris (X -> Compression (Espace Latent) -> X). On utilisera l'espace latent pour produire Y (une prédiction qui n'en est pas vraiment une, une simple transformation).

Bien que le principe puisse paraître complexe, sa mise en œuvre est aujourd'hui relativement simple grâce aux bibliothèques disponibles en Python. En entrée, l'autoencodeur utilise le même type de tableau de données que celui mobilisé dans une classification non supervisée fondée sur une ACP. Les lignes correspondent aux objets géographiques que l'on souhaite caractériser et les colonnes aux variables descriptives. Dans cet exemple, nous nous appuyons sur les données de la Base Permanente des Équipements (BPE) pour l'Île-de-France. Une jointure spatiale est d'abord réalisée entre les équipements et les communes franciliennes à l'aide de la fonction sjoin(). La fonction crosstab() permet ensuite de construire un tableau de dénombrement croisant les communes et les types d'équipements. Les lignes correspondent alors aux communes, les colonnes aux catégories d'équipements, et chaque cellule indique le nombre d'équipements d'un type donné présents dans une commune. Les données sont téléchargeables ici.

In [1]:
import geopandas as gpd
import pandas as pd
import numpy as np

zones = gpd.read_file("COMMUNE-IDF.shp") 
poi = gpd.read_file("BPE_IDF.shp") 

poi_dans_zones = gpd.sjoin(poi, zones, how="inner", predicate="within")
matrice_brute = pd.crosstab(poi_dans_zones['INSEE_COM'], poi_dans_zones['Type']) 
In [2]:
matrice_brute.head()
Out[2]:
Type A101 A104 A105 A108 A109 A120 A121 A122 A124 A125 ... F307 F312 F313 F314 F315 G101 G102 G103 G104 G105
INSEE_COM
75101 9 0 0 0 0 1 0 0 0 0 ... 1 4 4 0 7 78 81 0 44 3
75102 1 0 0 0 0 4 0 1 0 0 ... 1 0 0 0 5 62 37 0 31 0
75103 2 0 0 0 0 1 0 0 0 0 ... 2 5 0 1 2 34 31 0 34 0
75104 5 0 1 0 1 0 0 0 0 0 ... 4 6 4 0 6 19 40 0 32 1
75105 3 0 0 0 0 1 0 0 0 0 ... 4 8 1 0 6 36 80 0 32 2

5 rows × 228 columns

Comme dans toute démarche de classification statistique, plusieurs questions se posent concernant le traitement des données : faut-il utiliser les données brutes ou les transformer ? Une normalisation est-elle nécessaire et, si oui, laquelle retenir ? Les réseaux de neurones, bien qu'ils permettent d'apprendre des relations non linéaires, ne dispensent pas de ces choix méthodologiques. Les résultats obtenus demeurent fortement dépendants de la manière dont les données sont préparées. Dans notre cas, il paraît pertinent de travailler sur des données relatives obtenues par une normalisation ligne par ligne. Sans cette étape, le réseau aurait tendance à regrouper les communes principalement en fonction de leur nombre total d'équipements plutôt qu'en fonction de la structure de leur offre d'équipements. Toutefois, une normalisation par ligne présente également un inconvénient : elle conduit à considérer comme identiques deux communes présentant la même répartition d'équipements, même si l'une en possède dix fois plus d'équipements que l'autre. Afin de conserver cette information de taille, nous ajoutons donc une variable supplémentaire correspondant au nombre total d'équipements présents dans chaque commune.

In [3]:
from sklearn.preprocessing import Normalizer
scaler = Normalizer(norm='l1') 
X = scaler.fit_transform(matrice_brute)
taille = matrice_brute.sum(axis=1).to_numpy() # Prise en compte de la taille
X = np.column_stack((X, taille))
nb_variables = X.shape[1] 

Nous disposons ainsi de 229 variables descriptives, que nous choisissons de projeter dans un espace latent de cinq dimensions. Pourquoi cinq dimensions ? Nous reviendrons en partie sur cette question lors de l'analyse des résultats. Pour réaliser cette compression, l'encodeur comporte une couche intermédiaire de 32 neurones. D'autres architectures auraient pu être envisagées, avec 16, 64 ou 128 neurones, voire plusieurs couches intermédiaires successives. En pratique, ces choix relèvent largement d'une démarche empirique et sont guidés par la qualité des résultats obtenus, notamment en termes de reconstruction et d'interprétation. La fonction d'activation retenue est une fonction ReLU, couramment utilisée dans ce type d'application. L'encodeur étant défini, le décodeur est construit de manière symétrique afin de reconstruire les données initiales à partir de l'espace latent. Seule la couche de sortie diffère et utilise une activation linéaire, adaptée à la reconstruction de variables quantitatives continues. Si le jeu de données n'avait contenu que les variables normalisées comprises entre 0 et 1, une fonction sigmoïde aurait également pu être envisagée pour la couche de sortie.

In [4]:
import tensorflow as tf
from tensorflow.keras import layers, models

dimension_latente = 5

input_layer = layers.Input(shape=(nb_variables,))
# Encodeur : Compresse les données via des fonctions non linéaires (ReLU)
encoded = layers.Dense(32, activation='relu')(input_layer)
bottleneck = layers.Dense(dimension_latente, activation='relu', name="couche_latente")(encoded)
# Décodeur : Tente de reconstruire le vecteur d'origine à partir de la compression
decoded = layers.Dense(32, activation='relu')(bottleneck)
output_layer = layers.Dense(nb_variables, activation='linear')(decoded)

Il convient également de noter que le réseau a été implémenté à l'aide de l'API fonctionnelle de Keras plutôt qu'avec l'API séquentielle. Dans le cas présent, l'autoencodeur aurait pu être défini sous forme séquentielle sans difficulté particulière. Toutefois, l'API fonctionnelle offre davantage de souplesse pour construire des architectures plus complexes, notamment lorsque plusieurs couches, entrées ou sorties doivent être combinées. Une fois le réseau défini, le modèle est compilé puis entraîné de manière classique en minimisant l'erreur de reconstruction entre les données d'entrée et les données reconstruites par le décodeur.

In [5]:
# Assemblage et compilation du modèle complet
autoencoder = models.Model(inputs=input_layer, outputs=output_layer)
autoencoder.compile(optimizer='adam', loss='mse')
# Entraînement : Le modèle apprend à prédire X à partir de X (auto-supervision)
history = autoencoder.fit(X, X, epochs=100, batch_size=32, shuffle=True, verbose=0)
# Extraction de l'encodeur seul pour récupérer les signatures compressées
encoder_modele = models.Model(inputs=input_layer, outputs=autoencoder.get_layer("couche_latente").output)
X_latent = encoder_modele.predict(X)
41/41 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step

Une fois l'apprentissage terminé, nous disposons d'un espace latent que l'on peut explorer et exploiter pour construire une classification. Dans ce cadre non supervisé, le réseau de neurones ne produit pas directement les classes. Son rôle consiste plutôt à apprendre une représentation compacte des données en réduisant la dimensionnalité du problème. Cette démarche est conceptuellement proche de celle de l'ACP ou de l'ACM : dans les deux cas, il s'agit de résumer l'information contenue dans un grand nombre de variables à l'aide d'un nombre réduit de dimensions. La différence essentielle est qu'un autoencodeur est capable d'apprendre des relations non linéaires entre les variables. Pour autant, les questions méthodologiques classiques ne disparaissent pas. Il faut toujours déterminer le nombre de dimensions de l'espace latent, choisir une méthode de classification et définir le nombre de classes à retenir. Dans cet exemple, nous utilisons simplement l'algorithme K-means afin de partitionner l'espace latent en cinq groupes.

In [6]:
from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=5, random_state=42)
labels = kmeans.fit_predict(X_latent)

resultats = pd.DataFrame({'INSEE_COM': matrice_brute.index, 'cluster_id': labels})
zones_classifiees = zones.merge(resultats, on='INSEE_COM', how='left')
zones_classifiees.plot('cluster_id')
C:\Users\Serge\anaconda3\Lib\site-packages\sklearn\cluster\_kmeans.py:870: FutureWarning: The default value of `n_init` will change from 10 to 'auto' in 1.4. Set the value of `n_init` explicitly to suppress the warning
  warnings.warn(
C:\Users\Serge\anaconda3\Lib\site-packages\sklearn\cluster\_kmeans.py:1382: UserWarning: KMeans is known to have a memory leak on Windows with MKL, when there are less chunks than available threads. You can avoid it by setting the environment variable OMP_NUM_THREADS=6.
  warnings.warn(
Out[6]:
<Axes: >
No description has been provided for this image

L'évaluation d'une classification obtenue par apprentissage non supervisé n'est pas une tâche simple. Dans un premier temps, il est possible d'examiner les résultats afin d'en apprécier la cohérence géographique et l'interprétabilité. Dans notre cas, les classes obtenues semblent faire apparaître une logique centre-périphérie relativement cohérente. Il est toutefois possible que la prise en compte explicite du nombre total d'équipements ait renforcé cette structure au détriment d'autres dimensions plus fines, hypothèse qu'il serait possible de tester statistiquement. Cette question est d'autant plus complexe à résoudre ici que les données utilisées n'ont fait l'objet d'aucune pondération préalable. Les 228 catégories d'équipements sont traitées de manière équivalente, indépendamment de leur importance fonctionnelle ou de leur rôle dans l'organisation territoriale. Dès lors, le réseau tend naturellement à apprendre les structures les plus saillantes présentes dans les données. Si certaines dimensions jugées importantes par l'analyste ne sont que faiblement représentées, elles risquent d'être moins visibles dans l'espace latent. N'hésitez pas à expérimenter différents paramètres, architectures ou transformations des données afin d'observer l'impact de ces choix sur les classifications produites.

Dit plus simplement, on peut s'attendre naïvement que le réseau retrouve notre logique et produise des classifications attendues, mais ne pas l'aider rend la mission complexe voire impossible pour lui. Lorsqu'il n'est pas supervisé, logiquement il raisonne à sa façon. Si on veut qu'il raisonne comme nous sans données étiquetées, sans données initiales qu’il peut prédire et qui vont guider l'apprentissage, il faudra lui donner des indications dans le tableau de données.

J'espère que cet exemple montre que les réseaux de neurones ne remettent pas fondamentalement en cause les principes des classifications non supervisées. Ils constituent avant tout une alternative permettant d'apprendre des représentations non linéaires des données, tout en conservant la plupart des questionnements méthodologiques classiques. Leur intérêt apparaît notamment lorsque les relations entre variables sont complexes ou lorsque le volume de données devient très important. Comme pour tout modèle, il convient toutefois de vérifier la qualité de l'apprentissage. L'examen de la fonction de perte (loss) permet notamment de s'assurer de la convergence du réseau et de détecter d'éventuelles anomalies. L'analyse de l'espace latent constitue également un outil précieux. Dans notre exemple, l'information semble principalement portée par deux des cinq dimensions latentes. Cela suggère qu'un espace latent plus compact, limité à deux dimensions, pourrait sans doute être testé lors d'expérimentations ultérieures. Ce faible nombre laisse à penser qu'une variable, ici le nombre total d'équipements, écrase les autres.

In [7]:
X_latent[0:5,:]
Out[7]:
array([[2143.5483,    0.    ,    0.    , 3118.9468,    0.    ],
       [1328.3812,    0.    ,    0.    , 1933.0895,    0.    ],
       [1339.4637,    0.    ,    0.    , 1949.1862,    0.    ],
       [1361.1276,    0.    ,    0.    , 1980.6943,    0.    ],
       [1773.9767,    0.    ,    0.    , 2581.3057,    0.    ]],
      dtype=float32)
In [8]:
import matplotlib.pyplot as plt

loss_valeurs = history.history['loss']
epoches = range(1, len(loss_valeurs) + 1)

plt.figure(figsize=(8, 5))
plt.plot(epoches, loss_valeurs, 'b-')
plt.title("Fonction de Loss pendant l'entraînement")
plt.xlabel('Époques')
plt.ylabel('Erreur (Loss)')
plt.grid(True)
plt.legend()

plt.show()
No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.
No description has been provided for this image

Embending : la base des LLM pour créer une sémantique territoriale¶

Nous allons maintenant aborder la notion d'embedding textuel, qui constitue l'un des concepts fondamentaux à l'origine des grands modèles de langage (LLM). Si l'on a compris le principe des autoencodeurs et de l'espace latent, il devient relativement facile de saisir l'intuition derrière les embeddings. Dans les deux cas, l'objectif consiste à représenter des objets complexes à l'aide d'un nombre réduit de dimensions numériques. Un embedding peut ainsi être vu comme une représentation vectorielle apprise automatiquement par un réseau de neurones, autrement dit comme une forme particulière d'espace latent adaptée à un problème donné. L'encodeur est le mécanisme (la fonction) qui transforme les données jusqu'à l'espace latent (l'espace géométrique appris par le modèle). L'embending ce sont les vecteurs finaux (la position d'un objet dans cet espace).

Si l'on applique cette idée aux points d'intérêt, la problématique devient alors : « Dis-moi quels lieux t'entourent et je te dirai quel type de lieu tu es. » Cette approche introduit une dimension nouvelle dans l'analyse spatiale quantitative : elle cherche à faire émerger la sémantique des lieux à partir des configurations spatiales observées. Bien entendu, la géographie et la statistique spatiale disposaient déjà de nombreux outils pour étudier les relations entre objets géographiques. Cependant, ces méthodes n'étaient généralement pas formulées en termes de sémantique spatiale et ne visaient pas explicitement à apprendre une représentation du « sens » des lieux à partir de leur contexte. Selon moi, ces approches nouvelles sont potentiellement disruptives.

L'approche CBOW (Continuous Bag of Words) consiste à prédire le mot central à partir du contexte. L'approche Skip-Gram consiste à prédire le contexte à partir du mot central. Dans les deux cas se pose une question importante : faut-il tenir compte de l'ordre exact des mots ou simplement des mots présents dans le voisinage ? Les modèles de type Word2Vec adoptent une réponse relativement simple. Ils considèrent principalement les mots présents dans une fenêtre de contexte donnée, sans chercher à modéliser finement leur organisation syntaxique. Cette approche s'est révélée remarquablement efficace pour apprendre des représentations sémantiques des mots. Néanmoins, des architectures plus récentes peuvent conserver explicitement l'information de position grâce à des couches supplémentaires ou à des mécanismes dédiés, permettant ainsi au réseau d'apprendre que certains voisins ont une influence différente selon leur position dans la séquence. En tant que géographe dire que la distance n'a pas d'intérêt est une position forte et très discutable. On donc va s'inspirer des approches plus récentes pour tenir compte de la distance, plus exactement ici, de l'ordre de proximité.

On commence par charger les données : 39 types d'équipements répartis dans le Val-de-Marne. On cherche ensuite à déterminer le voisinage des équipements. Pour cela, on va retenir les 10 plus proches voisins par ordre de proximité à l'aide d'un algorithme efficace KDtree(). Chaque équipement possède ainsi une liste ordonnée de 10 voisins correspondant à 10 types d'équipements. A noter qu'un point d'intérêt peut très bien avoir plusieurs voisins de même nature, par exemple ci-dessous le supermarché a deux parfumeries à proximité direct.

In [9]:
import geopandas as gpd
import numpy as np
from scipy.spatial import KDTree

gdf = gpd.read_file("BPE_IDF_39_VDM.shp")  
coordonnees = np.array(list(zip(gdf.geometry.x, gdf.geometry.y))) #Extraction des coordonnées X, Y 
arbre = KDTree(coordonnees)
distances, indices = arbre.query(coordonnees, k=11) #on cherche les 11 voisins les plus proches
indices_voisins = indices[:, 1:]  #Extraction des types de POI à partir des indices trouvés
types_codes = gdf['NOM_EQ'].values
gdf['voisins_types'] = [list(types_codes[idx]) for idx in indices_voisins] # Création de la liste des types des 10 voisins pour chaque ligne
print(gdf[['NOM_EQ', 'voisins_types']].head())
             NOM_EQ                                      voisins_types
0  PRESSING-LAVERIE  [PAPETERIE_ET_PRESSE, SUPERMARCHE, MAGASIN_OPT...
1       SUPERMARCHE  [SUPERMARCHE, PARFUMERIE, VETERINAIRE, PARFUME...
2          EPICERIE  [DENTISTE, BANQUE, BOULANGERIE, BANQUE, RECHAR...
3          EPICERIE  [CRECHE, EPICERIE, MAGASIN_ELECTROMENAGER, MED...
4          EPICERIE  [PRESSING-LAVERIE, MAGASIN_EQUIPEMENTS_DU_FOYE...

Pour créer notre réseau, nous commençons par transformer les mots en identifiants entiers. Cette étape ne produit pas encore de représentation sémantique : elle permet simplement d'obtenir un format exploitable par la couche d'embedding. Celle-ci apprendra ensuite à associer à chaque mot un vecteur numérique reflétant son usage dans les données. Pour réaliser cet encodage initial, nous utilisons le LabelEncoder de sklearn.

In [10]:
import numpy as np
import tensorflow as tf
from tensorflow.keras import layers, models
from sklearn.preprocessing import LabelEncoder
# On initialise l'encodeur de texte
le = LabelEncoder()
# On ajuste l'encodeur sur l'ensemble de tes 39 types de POI possibles
le.fit(gdf['NOM_EQ'])

taille_vocabulaire = len(le.classes_) 
# Encodage de la cible (la sortie : ce que le réseau doit deviner)
y = le.transform(gdf['NOM_EQ'])
# Encodage des entrées (les listes de 10 voisins)
# On transforme chaque liste de chaînes de caractères en liste d'entiers
X = np.array([le.transform(voisins) for voisins in gdf['voisins_types']])

Notre réseau débute par une couche d'embedding chargée d'apprendre une représentation vectorielle des mots. La dimension des embeddings est fixée à 16 ; ce choix reste empirique et sera évalué à partir des résultats obtenus. Les vecteurs produits sont ensuite aplatis (Flatten) afin que chaque position du contexte soit représentée par un ensemble distinct de variables dans les couches suivantes. Cette opération permet au réseau de prendre en compte la position des mots dans la séquence. Le modèle comporte ensuite deux couches cachées de respectivement 64 et 32 neurones utilisant une fonction d'activation de type LeakyReLU. Enfin, la couche de sortie possède autant de neurones qu'il existe de types d'activités à prédire et utilise une activation softmax. Cette dernière produit une distribution de probabilités sur l'ensemble des classes possibles ; lors de la prédiction, la classe retenue correspond à celle présentant la probabilité la plus élevée.

In [11]:
# =====================================================================
# ARCHITECTURE DU RÉSEAU DE NEURONES (AVEC EMBEDDING)
# =====================================================================

dim_embedding = 16  # Chaque POI sera représenté par un vecteur de 16 chiffres
longueur_sequence = 10  # On regarde les 10 voisins les plus proches

model = models.Sequential([
    # Couche d'Entrée : Reçoit les listes de 10 indices de voisins
    layers.Input(shape=(longueur_sequence,)),
    # Couche d'Embedding : Transforme (1300, 10) en (1300, 10, 16) elle crée l'espace sémantique de tes types d'équipements
    layers.Embedding(input_dim=taille_vocabulaire, output_dim=dim_embedding),
    # Couche de Flatten : On "aplatit" la matrice (10 * 16 = 160 neurones) pour la passer aux couches denses suivantes, en préservant l'ordre
    layers.Flatten(),
    # Couches cachées non linéaires pour capter les synergies de voisinage
    layers.Dense(64),
    layers.LeakyReLU(negative_slope=0.01),
    layers.Dropout(0.2), # Évite le surapprentissage
    layers.Dense(32),
    layers.LeakyReLU(negative_slope=0.01),
    # Couche de Sortie : 39 neurones avec Softmax pour obtenir les probabilités
    layers.Dense(taille_vocabulaire, activation='softmax')
])

On compile le modèle, toujours avec l'optimiseur adam.

In [12]:
# 'sparse_categorical_crossentropy' est parfaite car 'y' contient des entiers (0 à 38) et non des vecteurs binaires (One-Hot encoded)
model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)
model.summary()

history = model.fit(
    X, y, 
    epochs=80, 
    batch_size=32, 
    validation_split=0.2, # Garde 20% des données pour tester la généralisation
    shuffle=True
)
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃ Layer (type)                         ┃ Output Shape                ┃         Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ embedding (Embedding)                │ (None, 10, 16)              │             624 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ flatten (Flatten)                    │ (None, 160)                 │               0 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ dense_3 (Dense)                      │ (None, 64)                  │          10,304 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ leaky_re_lu (LeakyReLU)              │ (None, 64)                  │               0 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ dropout (Dropout)                    │ (None, 64)                  │               0 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ dense_4 (Dense)                      │ (None, 32)                  │           2,080 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ leaky_re_lu_1 (LeakyReLU)            │ (None, 32)                  │               0 │
├──────────────────────────────────────┼─────────────────────────────┼─────────────────┤
│ dense_5 (Dense)                      │ (None, 39)                  │           1,287 │
└──────────────────────────────────────┴─────────────────────────────┴─────────────────┘
 Total params: 14,295 (55.84 KB)
 Trainable params: 14,295 (55.84 KB)
 Non-trainable params: 0 (0.00 B)
Epoch 1/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 2s 3ms/step - accuracy: 0.0965 - loss: 3.4714 - val_accuracy: 0.2203 - val_loss: 2.9502
Epoch 2/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2255 - loss: 2.9622 - val_accuracy: 0.2464 - val_loss: 2.8439
Epoch 3/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2432 - loss: 2.8638 - val_accuracy: 0.2639 - val_loss: 2.8053
Epoch 4/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2732 - loss: 2.7909 - val_accuracy: 0.2680 - val_loss: 2.7879
Epoch 5/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2796 - loss: 2.7396 - val_accuracy: 0.2726 - val_loss: 2.7870
Epoch 6/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2899 - loss: 2.6990 - val_accuracy: 0.2827 - val_loss: 2.7730
Epoch 7/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2981 - loss: 2.6906 - val_accuracy: 0.2845 - val_loss: 2.7792
Epoch 8/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3061 - loss: 2.6516 - val_accuracy: 0.2799 - val_loss: 2.7844
Epoch 9/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.2945 - loss: 2.6471 - val_accuracy: 0.2887 - val_loss: 2.7822
Epoch 10/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3059 - loss: 2.6172 - val_accuracy: 0.2896 - val_loss: 2.7854
Epoch 11/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3155 - loss: 2.5588 - val_accuracy: 0.2900 - val_loss: 2.7905
Epoch 12/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3138 - loss: 2.5600 - val_accuracy: 0.2887 - val_loss: 2.8060
Epoch 13/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3178 - loss: 2.5538 - val_accuracy: 0.2919 - val_loss: 2.8158
Epoch 14/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3244 - loss: 2.5331 - val_accuracy: 0.2910 - val_loss: 2.8161
Epoch 15/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3251 - loss: 2.4967 - val_accuracy: 0.2900 - val_loss: 2.8198
Epoch 16/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3244 - loss: 2.4801 - val_accuracy: 0.2896 - val_loss: 2.8287
Epoch 17/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3384 - loss: 2.4759 - val_accuracy: 0.2873 - val_loss: 2.8482
Epoch 18/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3333 - loss: 2.4607 - val_accuracy: 0.2965 - val_loss: 2.8514
Epoch 19/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3397 - loss: 2.4333 - val_accuracy: 0.2919 - val_loss: 2.8545
Epoch 20/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3328 - loss: 2.4510 - val_accuracy: 0.2896 - val_loss: 2.8711
Epoch 21/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3482 - loss: 2.3911 - val_accuracy: 0.2887 - val_loss: 2.8786
Epoch 22/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3479 - loss: 2.3864 - val_accuracy: 0.2887 - val_loss: 2.8987
Epoch 23/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3517 - loss: 2.3284 - val_accuracy: 0.2887 - val_loss: 2.8922
Epoch 24/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3468 - loss: 2.3488 - val_accuracy: 0.2855 - val_loss: 2.9118
Epoch 25/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3512 - loss: 2.3668 - val_accuracy: 0.2786 - val_loss: 2.9232
Epoch 26/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3505 - loss: 2.3386 - val_accuracy: 0.2868 - val_loss: 2.9373
Epoch 27/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3636 - loss: 2.2864 - val_accuracy: 0.2836 - val_loss: 2.9430
Epoch 28/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3488 - loss: 2.2975 - val_accuracy: 0.2822 - val_loss: 2.9606
Epoch 29/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3584 - loss: 2.2617 - val_accuracy: 0.2804 - val_loss: 2.9782
Epoch 30/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3588 - loss: 2.2666 - val_accuracy: 0.2804 - val_loss: 2.9726
Epoch 31/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3651 - loss: 2.2498 - val_accuracy: 0.2735 - val_loss: 2.9831
Epoch 32/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3667 - loss: 2.2410 - val_accuracy: 0.2763 - val_loss: 3.0001
Epoch 33/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3662 - loss: 2.2340 - val_accuracy: 0.2735 - val_loss: 3.0175
Epoch 34/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3698 - loss: 2.2167 - val_accuracy: 0.2726 - val_loss: 3.0143
Epoch 35/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3657 - loss: 2.2137 - val_accuracy: 0.2749 - val_loss: 3.0218
Epoch 36/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3680 - loss: 2.1877 - val_accuracy: 0.2772 - val_loss: 3.0317
Epoch 37/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3693 - loss: 2.1901 - val_accuracy: 0.2731 - val_loss: 3.0503
Epoch 38/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3714 - loss: 2.1859 - val_accuracy: 0.2671 - val_loss: 3.0495
Epoch 39/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3685 - loss: 2.1877 - val_accuracy: 0.2740 - val_loss: 3.0703
Epoch 40/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3692 - loss: 2.1596 - val_accuracy: 0.2676 - val_loss: 3.0818
Epoch 41/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3822 - loss: 2.1425 - val_accuracy: 0.2717 - val_loss: 3.1019
Epoch 42/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3776 - loss: 2.1240 - val_accuracy: 0.2694 - val_loss: 3.0949
Epoch 43/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3863 - loss: 2.1088 - val_accuracy: 0.2754 - val_loss: 3.0972
Epoch 44/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3935 - loss: 2.1023 - val_accuracy: 0.2698 - val_loss: 3.1182
Epoch 45/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3907 - loss: 2.0767 - val_accuracy: 0.2680 - val_loss: 3.1231
Epoch 46/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3791 - loss: 2.1196 - val_accuracy: 0.2694 - val_loss: 3.1355
Epoch 47/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3849 - loss: 2.1118 - val_accuracy: 0.2657 - val_loss: 3.1419
Epoch 48/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3897 - loss: 2.0780 - val_accuracy: 0.2735 - val_loss: 3.1559
Epoch 49/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 4ms/step - accuracy: 0.3987 - loss: 2.0493 - val_accuracy: 0.2767 - val_loss: 3.1606
Epoch 50/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3938 - loss: 2.0710 - val_accuracy: 0.2698 - val_loss: 3.1684
Epoch 51/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3965 - loss: 2.0745 - val_accuracy: 0.2653 - val_loss: 3.1989
Epoch 52/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3974 - loss: 2.0601 - val_accuracy: 0.2657 - val_loss: 3.2007
Epoch 53/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3927 - loss: 2.0504 - val_accuracy: 0.2653 - val_loss: 3.2135
Epoch 54/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3985 - loss: 2.0331 - val_accuracy: 0.2648 - val_loss: 3.2229
Epoch 55/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4036 - loss: 2.0310 - val_accuracy: 0.2588 - val_loss: 3.2210
Epoch 56/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4016 - loss: 2.0156 - val_accuracy: 0.2643 - val_loss: 3.2266
Epoch 57/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.3972 - loss: 2.0318 - val_accuracy: 0.2584 - val_loss: 3.2426
Epoch 58/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4057 - loss: 2.0146 - val_accuracy: 0.2579 - val_loss: 3.2484
Epoch 59/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4093 - loss: 2.0158 - val_accuracy: 0.2570 - val_loss: 3.2626
Epoch 60/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4141 - loss: 1.9742 - val_accuracy: 0.2593 - val_loss: 3.2574
Epoch 61/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4034 - loss: 2.0067 - val_accuracy: 0.2565 - val_loss: 3.2872
Epoch 62/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.3948 - loss: 2.0064 - val_accuracy: 0.2634 - val_loss: 3.2895
Epoch 63/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4039 - loss: 1.9841 - val_accuracy: 0.2630 - val_loss: 3.3148
Epoch 64/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4171 - loss: 1.9755 - val_accuracy: 0.2593 - val_loss: 3.3081
Epoch 65/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4092 - loss: 1.9735 - val_accuracy: 0.2611 - val_loss: 3.3229
Epoch 66/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4140 - loss: 1.9557 - val_accuracy: 0.2575 - val_loss: 3.3240
Epoch 67/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4111 - loss: 1.9604 - val_accuracy: 0.2602 - val_loss: 3.3333
Epoch 68/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4179 - loss: 1.9623 - val_accuracy: 0.2666 - val_loss: 3.3289
Epoch 69/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4192 - loss: 1.9498 - val_accuracy: 0.2542 - val_loss: 3.3466
Epoch 70/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4078 - loss: 1.9655 - val_accuracy: 0.2616 - val_loss: 3.3689
Epoch 71/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4228 - loss: 1.9100 - val_accuracy: 0.2593 - val_loss: 3.3618
Epoch 72/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4105 - loss: 1.9538 - val_accuracy: 0.2616 - val_loss: 3.3699
Epoch 73/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4138 - loss: 1.9237 - val_accuracy: 0.2630 - val_loss: 3.3929
Epoch 74/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4033 - loss: 1.9628 - val_accuracy: 0.2653 - val_loss: 3.3901
Epoch 75/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4195 - loss: 1.9256 - val_accuracy: 0.2602 - val_loss: 3.3990
Epoch 76/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4150 - loss: 1.9377 - val_accuracy: 0.2598 - val_loss: 3.3970
Epoch 77/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4256 - loss: 1.9156 - val_accuracy: 0.2538 - val_loss: 3.4067
Epoch 78/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - accuracy: 0.4193 - loss: 1.8860 - val_accuracy: 0.2570 - val_loss: 3.4124
Epoch 79/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4273 - loss: 1.9008 - val_accuracy: 0.2588 - val_loss: 3.4357
Epoch 80/80
273/273 ━━━━━━━━━━━━━━━━━━━━ 1s 3ms/step - accuracy: 0.4223 - loss: 1.9238 - val_accuracy: 0.2542 - val_loss: 3.4546

Bien entendu, on peut observer l'évolution de la fonction de loss pour vérifier que l'apprentissage s'est bien passé. On peut aussi regarder ce que produit le modèle sur certains exemples :

In [13]:
nouvelle_liste_voisins = gdf['voisins_types'][8]
print(gdf['NOM_EQ'][8])
print(nouvelle_liste_voisins)
probabilites = model.predict(np.array([le.transform(nouvelle_liste_voisins)]))[0]
print(probabilites)

indices_top10 = np.argsort(probabilites)[::-1][:10]
print("Top 10 des prédictions pour ce contexte :")
for idx in indices_top10:
    print(f"  - {le.classes_[idx]:<20} : {probabilites[idx]*100:.1f}%")
COLLEGE
['TERRAINS_DE_JEUX', 'PRESSING-LAVERIE', 'RECHARGE_VE', 'ECOLE_PRIMAIRE', 'ACCUEIL_DE_LOISIR', 'RECHARGE_VE', 'VETERINAIRE', 'ACCUEIL_DE_LOISIR', 'ACCUEIL_DE_LOISIR', 'RECHARGE_VE']
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 94ms/step
[8.3928734e-02 3.0777149e-03 2.1206073e-03 2.1306642e-03 6.1625942e-05
 2.8441139e-02 6.5922454e-02 3.0475393e-01 8.4460163e-03 1.5319550e-02
 7.4578857e-04 8.4470175e-03 2.6921999e-02 1.3197457e-02 2.7266217e-03
 3.8608789e-04 1.8879889e-04 2.2408354e-01 1.9120071e-05 2.1077096e-03
 2.9752188e-04 8.0991845e-04 8.2608581e-02 1.2611956e-02 1.1769765e-04
 5.9316467e-06 4.8209622e-05 2.1286301e-04 6.1705210e-03 5.7809381e-04
 3.6112575e-05 8.0829486e-03 7.7409162e-03 4.5228195e-03 3.8374970e-03
 4.9597099e-03 5.0288141e-03 6.8905674e-02 3.9757942e-04]
Top 10 des prédictions pour ce contexte :
  - COLLEGE              : 30.5%
  - GYMNASES             : 22.4%
  - ACCUEIL_DE_LOISIR    : 8.4%
  - MAGASIN_DE_VETEMENTS : 8.3%
  - TERRAINS_DE_JEUX     : 6.9%
  - BOULANGERIE          : 6.6%
  - BOUCHERIE            : 2.8%
  - ECOLE_PRIMAIRE       : 2.7%
  - CRECHE               : 1.5%
  - ELEMENTAIRE          : 1.3%

Comme attendu, le réseau identifie correctement un collège à partir des types d'équipements présents dans son voisinage. Le modèle semble avoir appris qu'un environnement associant notamment des terrains de jeu, une école primaire ou encore un accueil de loisirs est fréquemment lié à la présence d'autres équipements d'enseignement. L'examen des autres probabilités prédites est également instructif pour évaluer la cohérence du modèle. En effet, lorsqu'un grand nombre de catégories est considéré, il est souvent plus pertinent d'analyser les prédictions les plus probables que de se limiter à la seule classe majoritaire. Dans notre exemple, le modèle attribue également des probabilités relativement élevées à un gymnase, à un terrain de jeu ou à un accueil de loisir. Ces équipements partagent des caractéristiques fonctionnelles proches et apparaissent fréquemment dans des contextes similaires. À l'inverse, des activités telles qu'une agence de voyage ou un magasin d'électroménager reçoivent des probabilités très faibles. La distribution des probabilités suggère ainsi que le modèle a appris des associations spatiales cohérentes entre les différents types d'équipements. Enfin, les embeddings peuvent être projetés dans un espace de faible dimension à l'aide d'une ACP afin d'explorer les proximités apprises par le réseau. Dans notre cas, l'un des principaux axes semble notamment distinguer des équipements majoritairement publics d'équipements davantage orientés vers les activités privées, ce qui constitue une structure particulièrement plausible au regard de l'organisation des territoires.

In [14]:
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
import pandas as pd

couche_embedding = model.layers[0]
poids_embedding = couche_embedding.get_weights()[0]
noms_poi = le.classes_
df_embedding = pd.DataFrame(poids_embedding, index=noms_poi)

pca = PCA(n_components=4) # ACP
coords_pca = pca.fit_transform(df_embedding)
variance_expliquee = pca.explained_variance_ratio_ * 100

print(f"L'Axe 1 explique {variance_expliquee[0]:.1f}% de la variance.")
print(f"L'Axe 2 explique {variance_expliquee[1]:.1f}% de la variance.")
plt.figure(figsize=(14, 10))
plt.scatter(coords_pca[:, 0], coords_pca[:, 1], color='blue', alpha=0.6, edgecolors='k', s=80)
for i, nom in enumerate(noms_poi):
    plt.text(coords_pca[i, 0] + 0.02, coords_pca[i, 1] + 0.02, nom, fontsize=10, alpha=0.8)
plt.title("Espace sémantique des équipements", fontsize=14, fontweight='bold')
plt.xlabel(f"Axe Principal 1 ({variance_expliquee[0]:.1f}%)", fontsize=12)
plt.ylabel(f"Axe Principal 2 ({variance_expliquee[1]:.1f}%)", fontsize=12)
plt.grid(True, linestyle='--', alpha=0.5)
# Centrage des axes sur 0
plt.axhline(0, color='black', linewidth=0.8, linestyle='-')
plt.axvline(0, color='black', linewidth=0.8, linestyle='-')
plt.show()

print(f"L'Axe 3 explique {variance_expliquee[2]:.1f}% de la variance.")
print(f"L'Axe 4 explique {variance_expliquee[3]:.1f}% de la variance.")
plt.figure(figsize=(14, 10))
plt.scatter(coords_pca[:, 2], coords_pca[:, 3], color='blue', alpha=0.6, edgecolors='k', s=80)
for i, nom in enumerate(noms_poi):
    plt.text(coords_pca[i, 2] + 0.02, coords_pca[i, 3] + 0.02, nom, fontsize=10, alpha=0.8)
plt.title("Espace sémantique des équipements", fontsize=14, fontweight='bold')
plt.xlabel(f"Axe Principal 3 ({variance_expliquee[2]:.1f}%)", fontsize=12)
plt.ylabel(f"Axe Principal 4 ({variance_expliquee[3]:.1f}%)", fontsize=12)
plt.grid(True, linestyle='--', alpha=0.5)
# Centrage des axes sur 0
plt.axhline(0, color='black', linewidth=0.8, linestyle='-')
plt.axvline(0, color='black', linewidth=0.8, linestyle='-')
plt.show()
L'Axe 1 explique 11.3% de la variance.
L'Axe 2 explique 10.4% de la variance.
No description has been provided for this image
L'Axe 3 explique 9.7% de la variance.
L'Axe 4 explique 8.7% de la variance.
No description has been provided for this image