Classification de pages via le Machine Learning sur un univers de concurrence avec Python – I.

Cet article fait suite aux 3 articles précédents :

Dans ce nouvel article nous allons répéter ce que nous avons fait dans l’article précédent, mais cette fois, dans l’univers de concurrence du site de Networking-Morbihan.

Pour déterminer cet univers de concurrence, il nous faut trouver les compétiteurs qui se positionnent sur les mêmes mots clés que ceux de Networking Morbihan dans les résultats de recherche.

Pour obtenir cela, nous pourrions passer par l’API de SEMrush ou bien celle de Yooda qui nous permettent de récupérer des couples URLs/Positions pour des expressions données. Mais ceci à un certain coût.

Comme nous avons un nombre assez restreint de mots clés à tester (487 quand même) nous allons utiliser un scraper de SERPs développé en Python et qui devrait pouvoir suffire :

Googlesearch par Mario Vilas

Afin de pouvoir scraper Google nous allons utiliser la bibliothèque googlesearch qui a été développée par Mario Vilas. Le code source se trouve sur Github à l’adresse https://github.com/MarioVilas/googlesearch et la documentation à l’adresse https://python-googlesearch.readthedocs.io/en/latest/.

Cette bibliothèque fonctionne correctement, toutefois elle ne permet pas de paramétrer des proxies, ce qui vous oblige à paramétrer des temps de pauses importants pour éviter de vous faire jeter par Google.

Si vous connaissez un bibliothèque qui permet de paramétrer des proxies, n’hésitez pas à nous en faire part. Sinon, si j’ai un peu de temps je modifierais googlesearch dans le futur, pour la rendre exploitable dans un produit fini.

De quoi aurons nous besoin ?

Python Anaconda

Rendez vous sur la page de téléchargement d’Anaconda Distribution pour télécharger la version de Python Anaconda qui vous convient selon votre ordinateur.

Jeu de données

Nous partons du jeu de données dfGSC1-MAI.json créé lors de la phase précédente. Vous pouvez télécharger l’archive contenant ces données sur notre GitHub. Dézipper le fichier dans le répertoire qui contient votre programme source Python.

Code Source

Vous pouvez soit copier/coller les morceaux de codes ci-dessous, soit récupérer le code source en entier gratuitement dans notre boutique : https://www.anakeyn.com/boutique/produit/script-python-ml-sur-concurrence-i/

Récupération des bibliothèques utiles et définition de la fonction TF*IDF

Ici nous ne présentons que la fonction utilisant la TfidfVectorizer de scikit-learn. Pour plus de détail sur la notion de TF*IDF, reportez vous à notre article précédent : Modèle Interne : test de classification de pages via le Machine Learning pour un seul site .

# -*- coding: utf-8 -*-
"""
Created on Mon Jun 17 18:39:18 2019

@author: Pierre
"""
##########################################################################
# GSCCompetitorsML1 -  - modifié le 26/11/2019 
# Auteur : Pierre Rouarch - Licence GPL 3
# Machine Learning sur un univers de concurrence Partie 1
#  focus sur la précision du set de test plutot que le F1 Score global
#####################################################################################

###################################################################
# On démarre ici 
###################################################################
#Chargement des bibliothèques générales utiles
import numpy as np #pour les vecteurs et tableaux notamment
import matplotlib.pyplot as plt  #pour les graphiques
#import scipy as sp  #pour l'analyse statistique
import pandas as pd  #pour les Dataframes ou tableaux de données
import seaborn as sns #graphiques étendues
#import math #notamment pour sqrt()
import os

from urllib.parse import urlparse #pour parser les urls
import nltk # Pour le text mining
# Machine Learning
from sklearn.neighbors import KNeighborsClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
#Matrice de confusion
from sklearn.metrics import confusion_matrix
#pour les scores
from sklearn.metrics import f1_score
#from sklearn.metrics import matthews_corrcoef
from sklearn.metrics import accuracy_score


#pip install google  #pour installer la library de Google Search de Mario Vilas
#https://python-googlesearch.readthedocs.io/en/latest/
import googlesearch  #Scrap serps
#pour randomize pause
import random
import time  #pour calculer le 'temps' de chargement de la page
import gc #pour vider la memoire


print(os.getcwd())  #verif
#mon répertoire sur ma machine - nécessaire quand on fait tourner le programme 
#par morceaux dans Spyder.
#myPath = "C:/Users/Pierre/MyPath"
#os.chdir(myPath) #modification du path
#print(os.getcwd()) #verif



############################################
# Calcul de la somme des tf*idf
#somme des TF*IDF pour chaque colonne de tokens calculé avec TfidfVectorizer
def getSumTFIDFfromDFColumn(myDFColumn) :
    from sklearn.feature_extraction.text import TfidfVectorizer
    corpus = myDFColumn.apply(' '.join)
    vectorizer = TfidfVectorizer(norm=None)
    X = vectorizer.fit_transform(corpus)
    return np.sum(X.toarray(), axis=1)



Récupération du jeu de données et préparation des expressions à scraper dans Google.

On enlève les requêtes en doublon pour éviter du scraping de SERPs inutiles.

#####################################################################################
# MODELE "UNIVERS DE CONCURRENCE" - 
# A partir des mots clés récupérés depuis Google Search Consol
# On va récupérer les pages/positions/mots clés grâce à un crawler de SERPs :
# GoogleSearch
#  Code source  : https://github.com/MarioVilas/googlesearch
#  #doc https://python-googlesearch.readthedocs.io/en/latest/
#######################################################################################
#Relecture ############
dfGSC1 = pd.read_json("dfGSC1-MAI.json")
dfGSC1.query
dfGSC1.info() # 514  enregistrements.


len(dfGSC1['query'].unique())  #487 mots clés.
#on sauvegarde les données de base de GSC qu'on ajoutera à la fin
dfGSC1Base = pd.DataFrame(columns=['query', 'page', 'position', 'source']) 
dfGSC1Base['query'] = dfGSC1['query']
dfGSC1Base['page'] = dfGSC1['page']
dfGSC1Base['position'] = dfGSC1['position']
dfGSC1Base['source'] = "GSC"  #source:  Google Search Console 


#tous les mots clés à récupérer : pas de doublons
myQueries = dfGSC1['query'].unique()
myQueries.size #487 mots clés

Scrap des SERPs dans Google

Comme nous l’avons indiqué précédemment, cette opération peut durer un certain temps car nous sommes obligés de temporiser la lecture des pages de Google pour ne pas nous faire jeter.

###########################################################################
# Pour scraper les SERPs de Google
# on utilise la bibliothèque GoogleSearch de Mario Vilas :
# https://python-googlesearch.readthedocs.io/en/latest/
# Attention : Systeme sans proxies ! Cela peut durer compte 
# tenu des pauses.
############################################################################

#dataFrame Scrap des pages de Google
dfScrap = pd.DataFrame(columns=['query', 'page', 'position', 'source'])
len(myQueries)


###############################
i=0
myNum=10
myStart=0
myStop=30
#myTbs= "qdr:m"   #recherche sur le dernier mois. pas utilisé.
#tbs=myTbs,
 #pause assez importante pour ne pas bloquer affiner les valeurs si besoin
myLowPause=15
myHighPause=45

#on boucle (peut durer plusieurs heures - faites cela pendant la nuit 🙂 !!!)
while i < len(myQueries) :
    myQuery = myQueries[i]
    print("PASSAGE NUMERO :"+str(i))
    print("Query:"+myQuery)
    #on fait varier le user_agent et la pause pour ne pas se faire bloquer
    myPause = random.randint(myLowPause,myHighPause)  #pause assez importante pour ne pas bloquer.
    print("Pause:"+str(myPause))
    myUserAgent =  googlesearch.get_random_user_agent() #modification du user_agent pour ne pas bloquer
    print("UserAgent:"+str(myUserAgent))
    df = pd.DataFrame(columns=['query', 'page', 'position', 'source']) #dataframe de travail
    try :
        urls = googlesearch.search(query=myQuery, tld='fr', lang='fr',  safe='off', 
                                   num=myNum, start=myStart, stop=myStop, domains=None, pause=myPause, 
                                   only_standard=False, extra_params={}, tpe='', user_agent=myUserAgent)
         
        for url in urls :
            #print("URL:"+url)
            df.loc[df.shape[0],'page'] = url
        df['query'] = myQuery  #fill avec la query courante
        df['position'] = df.index.values + 1 #position = index +1
        df['source'] = "Scrap"  #fill avec origine de la donnée
        dfScrap = pd.concat([dfScrap, df], ignore_index=True) #récupère dans l'ensemble des scaps
        time.sleep(myPause) #ajoute une pause
        i += 1 
    except :
        print("ERRREUR LECTURE GOOGLE")
        time.sleep(1200) #ajoute une grande pause si plantage
##############################




dfScrap.info()
dfScrapUnique=dfScrap.drop_duplicates()  #on enlève les evéntuels doublons
dfScrapUnique.info()
#Sauvegarde 
dfScrapUnique.to_csv("dfScrapUnique.csv", sep=";", encoding='utf-8', index=False)  #séparateur ; 
dfScrapUnique.to_json("dfScrapUnique.json") 


#Relecture ############
dfScrapUnique = pd.read_json("dfScrapUnique.json")
dfScrapUnique.query
dfScrapUnique.info() #13801

dfQPPS = pd.concat([dfScrapUnique, dfGSC1Base], ignore_index=True)
#Sauvegarde 
#dfQPPS.to_csv("dfQPPS.csv", sep=";", encoding='utf-8', index=False)  #séparateur ; 
dfQPPS.to_json("dfQPPS.json")

Créations de variables explicatives

On va enrichir les données que l’on a pour créer de nouvelles variables explicatives pour créer un modèle de Machine Learning. on va créer les mêmes variables que précédemment dans le « modèle interne ».

################################################################################
# On redémarre ici pour l'exploration ML
################################################################################
#########################################################################
# Détermination de variables techniques et construites à partir des 
# autres variables
#########################################################################
#Relecture ############
dfQPPS = pd.read_json("dfQPPS.json")
dfQPPS.query
dfQPPS.info() #14315 lignes



#création de la variable à expliquer
#Creation des Groupes
dfQPPS.loc[dfQPPS['position'] <= 10.5, 'group'] = 1
dfQPPS.loc[dfQPPS['position'] > 10.5, 'group'] = 0




#création variables explicatives
#Creation de variables d'url à partir de page
dfQPPS['webSite'] = dfQPPS['page'].apply(lambda x: '{uri.scheme}://{uri.netloc}/'.format(uri=urlparse(x)))
dfQPPS['uriScheme'] = dfQPPS['page'].apply(lambda x: '{uri.scheme}'.format(uri=urlparse(x)))
dfQPPS['uriNetLoc'] = dfQPPS['page'].apply(lambda x: '{uri.netloc}'.format(uri=urlparse(x)))
dfQPPS['uriPath'] = dfQPPS['page'].apply(lambda x: '{uri.path}'.format(uri=urlparse(x)))

#Est-ce que le site est en https ?  ici on a plusieurs sites donc peut être intéressant
dfQPPS.loc[dfQPPS['uriScheme']== 'https', 'isHttps'] = 1
dfQPPS.loc[dfQPPS['uriScheme'] != 'https', 'isHttps'] = 0
dfQPPS.info()

#Pseudo niveau dans l'arborescence calculé au nombre de / -2
dfQPPS['level'] = dfQPPS['page'].str[:-1].str.count('/')-2

############################################
#définition du tokeniser pour séparation des mots
tokenizer = nltk.RegexpTokenizer(r'\w+')  #définition du tokeniser pour séparation des mots

#on va décompter les mots de la requête dans le nom du site et l'url complète
#on vire les accents 
queryNoAccent= dfQPPS['query'].str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8')

#on tokenize la requete sans accents
dfQPPS['tokensQueryNoAccent'] = queryNoAccent.apply(tokenizer.tokenize) #séparation des mots pour la reqête

#Page
dfQPPS['lenPage']=dfQPPS['page'].apply(len) #taille de l'url complète en charactères
dfQPPS['tokensPage'] =  dfQPPS['page'].apply(tokenizer.tokenize)   #séparation des mots pour l'url entier
dfQPPS['lenTokensPage']=dfQPPS['tokensPage'].apply(len) #longueur de l'url en mots
#mots de la requete dans   Page
dfQPPS['lenTokensQueryInPage'] = dfQPPS.apply(lambda x : len(set(x['tokensQueryNoAccent']).intersection(x['tokensPage'])),axis=1)
#total des fréquences des mots dans  Page
dfQPPS['lenTokensQueryInPageFrequency'] = dfQPPS.apply(lambda x : x['lenTokensQueryInPage']/x['lenTokensPage'],axis=1)
#SumTFIDF
dfQPPS['sumTFIDFPage'] = getSumTFIDFfromDFColumn(dfQPPS['tokensPage'])
dfQPPS['sumTFIDFPageFrequency'] = dfQPPS.apply(lambda x : x['sumTFIDFPage']/(x['lenTokensPage']+0.01),axis=1) 

#WebSite    
dfQPPS['lenWebSite']=dfQPPS['webSite'].apply(len) #taille de l'url complète en charactères
dfQPPS['tokensWebSite'] =  dfQPPS['webSite'].apply(tokenizer.tokenize)   #séparation des mots pour l'url entier
dfQPPS['lenTokensWebSite']=dfQPPS['tokensWebSite'].apply(len) #longueur de l'url en mots
#mots de la requete dans   WebSite
dfQPPS['lenTokensQueryInWebSite'] = dfQPPS.apply(lambda x : len(set(x['tokensQueryNoAccent']).intersection(x['tokensWebSite'])),axis=1)
#total des fréquences des mots dans   WebSite
dfQPPS['lenTokensQueryInWebSiteFrequency'] = dfQPPS.apply(lambda x : x['lenTokensQueryInWebSite']/x['lenTokensWebSite'],axis=1)
#SumTFIDF
dfQPPS['sumTFIDFWebSite'] = getSumTFIDFfromDFColumn(dfQPPS['tokensWebSite']) 
dfQPPS['sumTFIDFWebSiteFrequency'] = dfQPPS.apply(lambda x : x['sumTFIDFWebSite']/(x['lenTokensWebSite']+0.01),axis=1)    
    
#Path   
dfQPPS['lenPath']=dfQPPS['uriPath'].apply(len) #taille de l'url complète en charactères
dfQPPS['tokensPath'] =  dfQPPS['uriPath'].apply(tokenizer.tokenize)   #séparation des mots pour l'url entier
dfQPPS['lenTokensPath']=dfQPPS['tokensPath'].apply(len) #longueur de l'url en mots
#mots de la requete dans   Path
dfQPPS['lenTokensQueryInPath'] = dfQPPS.apply(lambda x : len(set(x['tokensQueryNoAccent']).intersection(x['tokensPath'])),axis=1)
#total des fréquences des mots dans   Path
#!Risque de division par zero on fait une boucle avec un if
dfQPPS['lenTokensQueryInPathFrequency']=0
for i in range(0, len(dfQPPS)) :
    if dfQPPS.loc[i,'lenTokensPath'] > 0 :
        dfQPPS.loc[i,'lenTokensQueryInPathFrequency'] =dfQPPS.loc[i,'lenTokensQueryInPath']/dfQPPS.loc[i,'lenTokensPath']
#SumTFIDF
dfQPPS['sumTFIDFPath'] = getSumTFIDFfromDFColumn(dfQPPS['tokensPath'])   
dfQPPS['sumTFIDFPathFrequency'] = dfQPPS.apply(lambda x : x['sumTFIDFPath']/(x['lenTokensPath']+0.01),axis=1) 


######################################################################
dfQPPS.info()

####sauvegarde sous un autre nom pour cette étape.
#Sauvegarde 
dfQPPS.to_json("dfQPPS1-MAI.json")  

#on libere de la mémoire
del dfQPPS
gc.collect()


Préparation des données pour le Machine Learning

Comme précédemment : On va préparer les données : déterminer les variables explicatives et la variable à expliquer et splitter le jeu de données en des données d’entrainement et des données de test.

#############################################################################
# Machine Learning - Univers de Concurrence avec variables construite
# sur l'url et les mots clés
#############################################################################
#Relecture ############
dfQPPS1 = pd.read_json("dfQPPS1-MAI.json")
dfQPPS1.query
dfQPPS1.info() # 14315 enregistrements.
#on choisit nos variables explicatives
X =  dfQPPS1[['isHttps', 'level', 
             'lenWebSite',   'lenTokensWebSite',  'lenTokensQueryInWebSiteFrequency' , 'sumTFIDFWebSiteFrequency',             
             'lenPath',   'lenTokensPath',  'lenTokensQueryInPathFrequency' , 'sumTFIDFPathFrequency']]  #variables explicatives
y =  dfQPPS1['group']  #variable à expliquer


#on va scaler
scaler = StandardScaler()
scaler.fit(X)


X_Scaled = pd.DataFrame(scaler.transform(X.values), columns=X.columns, index=X.index)
X_Scaled.info()


#on choisit random_state = 42 en hommage à La grande question sur la vie, l'univers et le reste
#dans "Le Guide du voyageur galactique"   par  Douglas Adams. Ceci afin d'avoir le même split
#tout au long de notre étude.
X_train, X_test, y_train, y_test = train_test_split(X_Scaled,y, random_state=42)

Méthode des K-NN : k plus proches voisins

Pour plus d’explications sur l’algorithme k-NN reportez-vous à l’article précédent.

#Méthode des kNN, recherche du meilleur k

nMax=20
myTrainScore =  np.zeros(shape=nMax)
myTestScore = np.zeros(shape=nMax)
#myF1Score = np.zeros(shape=nMax)  #abandonné


for n in range(1,nMax) :
    knn = KNeighborsClassifier(n_neighbors=n) 
    knn.fit(X_train, y_train) 
    myTrainScore[n]=knn.score(X_train,y_train)
    print("Training set score: {:.3f}".format(knn.score(X_train,y_train))) #
    myTestScore[n]=knn.score(X_test,y_test)
    print("Test set score: {:.4f}".format(knn.score(X_test,y_test))) #



#Graphique train score vs test score 
sns.set()  #paramètres esthétiques ressemble à ggplot par défaut.
fig, ax = plt.subplots()  #un seul plot
sns.lineplot(x=np.arange(1,nMax), y=myTrainScore[1:nMax])
sns.lineplot(x=np.arange(1,nMax), y=myTestScore[1:nMax], color='red')
#fig.suptitle("", fontsize=14, fontweight='bold')
ax.set(xlabel='n neighbors', ylabel='Train (bleu) / Test (rouge)',
       title="La précision sur les données de test du modèle avec concurrence \n est moins bonne que celle pour 1 seul site. \nNous l'enrichirons avec d'autres variables par la suite.")
fig.text(.3,-.06,"Classification Knn - Univers de Concurrence - Position  dans 2 groupes \n vs variables construites en fonction des n voisins", 
         fontsize=9)
#plt.show()
fig.savefig("QPPS1-KNN-Classifier-2goups.png", bbox_inches="tight", dpi=600)

Recherche des k voisins

Le meilleur test score (en rouge) est obtenu avec k=2.

#on choist le premier n_neighbor ou myTestScore est le plus grand
#à vérifier toutefois en regardant la courbe.
indices = np.where(myTestScore == np.amax(myTestScore))
n_neighbor =  indices[0][0]
n_neighbor
knn = KNeighborsClassifier(n_neighbors=n_neighbor) 
knn.fit(X_train, y_train) 
print("N neighbor="+str(n_neighbor))
print("Training set score: {:.3f}".format(knn.score(X_train,y_train))) #
print("Test set score: {:.4f}".format(knn.score(X_test,y_test))) #
#myTestScore retenu pour knn - 2 voisins 0.7368  #Précédemment sur le modele interne 0.8837 

N neighbor=2
Training set score: 0.851
Test set score: 0.7368

Distribution des pages dans les groupes

#par curiosité regardons la distribution des pages dans les groupes 
sns.set()  #paramètres esthétiques ressemble à ggplot par défaut.
fig, ax = plt.subplots()  #un seul plot
sns.countplot(dfQPPS1['group'] , order=reversed(dfQPPS1['group'].value_counts().index))
fig.suptitle('Les groupes sont plus équilibrés que précédemment.', fontsize=14, fontweight='bold')
ax.set(xlabel='groupe', ylabel='Nombre de Pages',
       title="le groupe Top 10 (1) représente la moitié de l'autre groupe.")
fig.text(.2,-.06,"Univers de Concurrence - Distribution des pages/positions dans les 2 groupes.", 
         fontsize=9)
#plt.show()
fig.savefig("QPPS1-Distribution-2goups.png", bbox_inches="tight", dpi=600)
      

Distribution des Pages
Distribution des Pages

Modèles Linéaires

Comme précédemment, nous allons tester deux modèles linéaires : LogisticRegression et LinearSVC. Pour LogisticRegression nous ferons varier le paramère C.

#Classification linéaire 1 :   Régression Logistique
#on faire varier C : nverse of regularization strength; must be a positive float. 
#Like in support vector machines, smaller values specify stronger regularization.
#C=1 standard
logreg = LogisticRegression(solver='lbfgs').fit(X_train,y_train)
print("Training set score: {:.3f}".format(logreg.score(X_train,y_train)))  
print("Test set score: {:.3f}".format(logreg.score(X_test,y_test))) 
#0.693  :le Test  Score est moins bon que pour knn (0.7368)
logreg100 = LogisticRegression(C=100, solver='lbfgs').fit(X_train,y_train)
print("Training set score: {:.3f}".format(logreg100.score(X_train,y_train)))  
print("Test set score: {:.3f}".format(logreg100.score(X_test,y_test)))  
#pareil    0.693<  knn (0.7368)
logreg1000 = LogisticRegression(C=1000, solver='lbfgs').fit(X_train,y_train)
print("Training set score: {:.3f}".format(logreg1000.score(X_train,y_train)))  
print("Test set score: {:.3f}".format(logreg1000.score(X_test,y_test)))  
#pareil    0.693<  knn (0.7368)
logreg001 = LogisticRegression(C=0.01, solver='lbfgs').fit(X_train,y_train)
print("Training set score: {:.3f}".format(logreg001.score(X_train,y_train)))  
print("Test set score: {:.3f}".format(logreg001.score(X_test,y_test)))  
#moins bien  0.692 <  knn (0.7368)
#Classification linéaire 2 :  machine à vecteurs supports linéaire (linear SVC).
LinSVC = LinearSVC(max_iter=10000).fit(X_train,y_train)
print("Training set score: {:.3f}".format(LinSVC.score(X_train,y_train)))  
print("Test set score: {:.3f}".format(LinSVC.score(X_test,y_test)))
#pareil    0.693<  knn (0.7368)

Le meilleur test score est de 0.693, ce qui est assez décevant. Il faudra enrichir les données et trouver de nouvelles variables explicatives pour avoir un modèle plus pertinent.

Importance des variables.

Même si le Test Score est faible, regardons l'importance des variables pour la régression logistique avec C=1.

#######################################################################
# Affichage de l'importance des variables pour logreg
#######################################################################
signed_feature_importance = logreg.coef_[0] #pour afficher le sens 
feature_importance = abs(logreg.coef_[0])  #pous classer par importance
#feature_importance = 100.0 * (feature_importance / feature_importance.max())
sorted_idx = np.argsort(feature_importance)
pos = np.arange(sorted_idx.shape[0]) + .5

sns.set()  #paramètres esthétiques ressemble à ggplot par défaut.
fig, ax = plt.subplots()  #un seul plot
ax.barh(pos, signed_feature_importance[sorted_idx], align='center')
ax.set_yticks(pos)
ax.set_yticklabels(np.array(X.columns)[sorted_idx], fontsize=8)
fig.suptitle("Le fait que le nom de domaine soit dans la thématique (TFIDF).\n et qu'il comporte des mots clés de la requête semblent être des facteurs importants.  \n La longueur en caractères du nom de domaine importe peu ici.", fontsize=10)
ax.set(xlabel='Importance Relative des variables')
fig.text(.3,-.06,"Régression Logistique - Univers de concurrence \n Importance des variables", 
         fontsize=9)
fig.savefig("QPPS1-Importance-Variables-2goups.png", bbox_inches="tight", dpi=600)
##############################################################



##########################################################################
# MERCI pour votre attention !
##########################################################################
#on reste dans l'IDE
#if __name__ == '__main__':
#  main()

Importance des variables

Comme on l'a vu, les modèles linéaires ne sont pas très efficaces ici. Il conviendra dans un prochain article de déterminer de nouvelles variables explicatives.

A bientôt,

Pierre

Leave a Reply

Your email address will not be published. Required fields are marked *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

En continuant à utiliser le site, vous acceptez l’utilisation des cookies. Plus d’informations

Les paramètres des cookies sur ce site sont définis sur « accepter les cookies » pour vous offrir la meilleure expérience de navigation possible. Si vous continuez à utiliser ce site sans changer vos paramètres de cookies ou si vous cliquez sur "Accepter" ci-dessous, vous consentez à cela.

Fermer