Introduction à ElasticSearch pour la recherche textuelle

Pour essayer les exemples présents dans ce tutoriel :

Download nbviewer Onyxia
Binder Open In Colab githubdev

Ce chapitre a été écrit avec Milena Suarez-Castillo et présente quelques éléments qui servent de base à un travail en cours sur les inégalités socioéconomiques dans les choix de consommation alimentaire.

Introduction

Réplication de ce chapitre

Ce chapitre est plus exigeant en termes d’infrastructures que les précédents. Si la première partie de ce chapitre peut être menée avec une installation standard de Python, ce n’est pas le cas de la deuxième qui nécessite un serveur ElasticSearch. Les utilisateurs du SSP Cloud pourront répliquer les exemples de ce cours car cette technologie est disponible (que ce soit pour indexer une base ou pour requêter une base existante).

⚠️ Ce chapitre nécessite une version particulière du package ElasticSearch pour tenir compte de l’héritage de la version 7 du moteur Elastic. Pour cela, faire

!pip install elasticsearch==8.2.0
!pip install unidecode
!pip install rapidfuzz
!pip install xlrd

La première partie de ce tutoriel ne nécessite pas d’architecture particulière et peut ainsi être exécutée en utilisant les packages suivants:

import time
import pandas as pd

Le script functions.py, disponible sur Github, regroupe un certain nombre de fonctions utiles permettant d’automatiser certaines tâches de nettoyage classiques en NLP.

Hint

Plusieurs méthodes peuvent être mises en oeuvre pour récupérer le script d’utilitaires. Vous pouvez trouver en dessous de cet encadré une méthode qui va chercher la dernière version sur le dépôt Github du cours

import requests
baseurl = "https://raw.githubusercontent.com/linogaliana/python-datascientist"
branch = "master"
path = "content/course/modern-ds/elastic_intro/functions.py"

url = f"{baseurl}/{branch}/{path}"
r = requests.get(url, allow_redirects=True)

open('functions.py', 'wb').write(r.content)

Après l’avoir récupéré (cf. encadré dédié), il convient d’importer les fonctions sous forme de module:

import functions as fc

Cas d’usage

Ce notebook recense et propose d’appréhender quelques outils utilisés pour le papier présenté aux Journées de Méthodologie Statistiques 2022: Galiana and Suarez-Castillo, “Fuzzy matching on big-data: an illustration with scanner data and crowd-sourced nutritional data” (travail en cours!)

On va partir du cas d’usage suivant:

Combien de calories dans ma recette de cuisine de ce soir? Combien de calories dans mes courses de la semaine?

L’objectif est de reconstituer, à partir de libellés de produits, les caractéristiques nutritionnelles d’une recette. Le problème est que les libellés des tickets de caisse ne sont pas des champs textuels très propres, ils contiennent, par exemple, beaucoup d’abbréviations, toutes n’étant pas évidentes.

Voici par exemple une série de noms de produits qu’on va utiliser par la suite:

ticket = ['CROISSANTS X6 400G',
          'MAQUEREAUX MOUTAR.',
          'IGP OC SAUVIGNON B',
          'LAIT 1/2 ECRM UHT',
          '6 OEUFS FRAIS LOCA',
          'ANANAS C2',
          'L POMME FUDJI X6 CAL 75/80 1KG ENV',
          'PLT MIEL',
          'STELLA ARTOIS X6',
          'COTES DU LUBERON AIGUEBRUN 75C']

A ces produits, s’ajoutent les ingrédients suivants, issus de la recette du velouté de potiron et carottes de Marmiton qui sera notre plat principal :

ingredients = ['500 g de carottes',
 '2 pommes de terre',
 "1 gousse d'ail",
 '1/2 l de lait',
 '1/2 l de bouillon de volaille',
 "1 cuillère à soupe de huile d'olive",
 '1 kg de potiron',
 '1 oignon',
 '10 cl de crème liquide (facultatif)']

Essayer de récupérer par webscraping cette liste est un bon exercice pour réviser les concepts vus précedemment

On va donc créer une liste de course compilant ces deux listes hétérogènes de noms de produits:

libelles = ticket + ingredients

On part avec cette liste dans notre supermarché virtuel. L’objectif sera de trouver une méthode permettant de passer à l’échelle: automatiser les traitements, effectuer des recherches efficaces, garder une certaine généralité et flexibilité.

Ce chapitre montrera par l’exemple l’intérêt d’Elastic par rapport à une solution qui n’utiliserait que du Python.

Données utilisées

Les bases offrant des informations nutritionnelles

Pour un nombre restreint de produits, on pourrait bien-sûr chercher à la main les caractéristiques des produits en utilisant les fonctionalités d’un moteur de recherche:

Cependant, cette approche serait très fastidieuse et nécessiterait de récuperer, à la main, chaque caractéristique pour chaque produit. Ce n’est donc pas envisageable.

Les données disponibles sur Google viennent de l’USDA, l’équivalent américain de notre Ministère de l’Agriculture. Cependant, pour des recettes comportant des noms de produits français, ainsi que des produits potentiellement transformés, ce n’est pas très pratique d’utiliser une base de données de produits agricoles en Français. Pour cette raison, nous proposons d’utiliser les deux bases suivantes, qui servent de base au travail de Galiana and Suarez Castillo (2022)

  • L’OpenFoodFacts database qui est une base
    collaborative française de produits alimentaires. Issue d’un projet Data4Good, il s’agit d’une alternative opensource et opendata à la base de données de l’application Yuka.
  • La table de composition nutritionnelle Ciqual produite par l’Anses. Celle-ci propose la composition nutritionnelle moyenne des aliments les plus consommés en France. Il s’agit d’une base de données enrichie par rapport à celle de l’USDA puisqu’elle ne se cantonne pas aux produits agricoles non transformés. Avec cette base, il ne s’agit pas de trouver un produit exact mais essayer de trouver un produit type proche du produit dont on désire connaître les caractéristiques.

Import

Quelques fonctions utiles sont regroupées dans le script functions.py et importées dans le notebook.

La base OpenFood peut être récupérée en ligne via la fonction fc.import_openfood. Néanmoins, cette opération nécessitant un certain temps (les données brutes faisant autour de 2Go), nous proposons une méthode pour les utilisateurs du SSP-Cloud où une version est disponible sur l’espace de stockage.

La base Ciqual, qui plus légère, est récupérée elle directement en ligne via la fonction fc.import_ciqual.

# Pour les utilisateurs du SSP-Cloud
openfood = fc.import_openfood_s3()
# Pour les utilisateurs hors du SSP-Cloud
# openfood = fc.import_openfood()
ciqual = fc.import_ciqual()
/__w/python-datascientist/python-datascientist/content/course/modern-ds/elastic_intro/functions.py:54: DtypeWarning:

Columns (87) have mixed types. Specify dtype option on import or set low_memory=False.
openfood.head()

product_name nutriscore_score energy_100g
3 Skyr -5.0 238.0
11 moutarde au moût de raisin 18.0 936.0
20 Pur jus de pomme 9.0 264.0
22 Salade de carottes râpées 1.0 134.0
31 Tarte noix de coco 14.0 1594.0
ciqual.head()

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g)
5 Taboulé ou Salade de couscous, préemballé 179
6 Salade de pomme de terre à la piémontaise, pré... 130
9 Crudité, sans assaisonnement (aliment moyen) 29,9
13 Salade végétale à base de boulgour et/ou quino... 168
16 Salade de chou ou Coleslaw, avec sauce, préemb... 105

ElasticSearch ? Mais ce n’est pas du Python ?!

Qu’est-ce qu’Elastic ?

ElasticSearch c’est un logiciel qui fournit un moteur de recherche installé sur un serveur (ou une machine personnelle) qu’il est possible de requêter depuis un client (une session Python par exemple). C’est un moteur de recherche très performant, puissant et flexible, extrêmement utilisé dans le domaine de la datascience sur données textuelles.

Un cas d’usage est par exemple de trouver, dans un corpus de grande dimension (plusieurs sites web, livres…), un certain texte en s’autorisant des termes voisins (verbes conjugués, fautes de frappes…).

Un index est une collection de documents dans lesquels on souhaite chercher, préalablement ingérés dans un moteur de recherche les documents sont les établissements. L’indexation consiste à pré-réaliser les traitements des termes des documents pour gagner en efficacité lors de la phase de recherche. L’indexation est faite une fois pour de nombreuses recherches potentielles, pour lesquelles la rapidité de réponse peut être cruciale. Après avoir indexé une base, on effectuera des requêtes qui sont des recherches d’un document dans la base indexé (équivalent de notre web) à partir de termes de recherche normalisés.

Le principe est le même que celui d’un moteur de recherche du web comme Google. D’un côté, l’ensemble à parcourir est indexé pour être en mesure de parcourir de manière efficace l’ensemble du corpus. De l’autre côté, la phase de recherche permet de retrouver l’élément du corpus le plus cohérent avec la requête de recherche. L’indexation consiste, par exemple, à pré-définir des traitements des termes du corpus pour gagner en efficacité lors de la phase de recherche. En effet, l’indexation est une opération peu fréquente par rapport à la recherche. Pour cette dernière, l’efficacité est cruciale (un site web qui prend plusieurs secondes à interpréter une requête simple ne sera pas utilisé). Mais, pour l’indexation, ceci est moins crucial.

Les documents sont constitués de variables, les champs (‘fields’), dont le type est spécifié (“text”, “keywoard”, “geo_point”, “numeric”…) à l’indexation.

ElasticSearch propose une interface graphique nommée Kibana. Celle-ci est pratique pour tester des requêtes et pour superviser le serveur Elastic. Cependant, pour le passage à l’échelle, notamment pour mettre en lien une base indexée dans Elastic avec une autre source de données, les API proposées par ElasticSearch sont beaucoup plus pratiques. Ces API permettent de connecter une session Python (idem pour R) à un serveur Elastic afin de communiquer avec lui (échanger des flux via une API REST).

ElasticSearch et Python

En Python, le package officiel est elasticsearch. Ce dernier permet de configurer les paramètres pour interagir avec un serveur, indexer une ou plusieurs bases, envoyer de manière automatisée un ensemble de requêtes au serveur, récupérer les résultats directement dans une session Python

Premières limites de la distance de Levenshtein

Pour évaluer la similarité entre deux données textuelles, il est nécessaire de transformer l’information qualitative qu’est le nom du produit en information quantitative qui permettra de rapprocher différents types de produits. Les ordinateurs ont en effet besoin de transformer les informations textuelles en information numérique pour être en mesure de les exploiter.

On appelle distance de Levenshtein entre deux chaînes de caractères le coût minimal (en nombre d’opérations) pour transformer la première en la seconde par:

  • substitution
  • insertion
  • suppression

La distance de Levenshtein est une mesure très utilisée pour comparer la similarité entre deux chaînes de caractères. Il existe plusieurs packages pour calculer cette dernière. fuzzywuzzy est le plus connu mais ce dernier est assez lent (implémentation en pur Python). Le package rapidfuzz, présenté ici, propose les mêmes fonctionalités mais est plus rapide car implémenté en C++ qui est plus efficace. Cependant, nous allons le voir, ce package ne nous offrira pas des performances assez bonnes pour que nous puissions passer à l’échelle.

Voici trois exemples pour évaluer le coût de chaque opération:

import rapidfuzz

[
  rapidfuzz.distance.Levenshtein.distance('salut','slut', weights =(1,1,1)), # Suppression 
  rapidfuzz.distance.Levenshtein.distance('salut','saalut', weights =(1,1,1)), # Addition 
  rapidfuzz.distance.Levenshtein.distance('salut','selut', weights =(1,1,1)) # Substitution
] 
[1, 1, 1]

Premier essai: les produits Ciqual les plus similaires aux produits de la recette

On pourrait écrire une fonction qui prend en argument une liste de libellés d’intérêt et une liste de candidat au match et renvoie le libellé le plus proche. Cependant, le risque est que cet algorithme soit relativement lent s’il n’est pas codé parfaitement.

Il est, à mon avis, plus simple, quand on est habitué à la logique Pandas, de faire un produit cartésien pour obtenir un vecteur mettant en miroir chaque produit de notre recette avec l’ensembles des produits Ciqual et ensuite comparer les deux vecteurs pour prendre, pour chaque produit, le meilleur match.

Les bases étant de taille limitée, le produit cartésien n’est pas problématique. Avec des bases plus conséquentes, une stratégie plus parcimonieuse en mémoire devrait être envisagée.

Pour faire cette opération, on va utiliser la fonction match_product de note script d’utilitaires.

dist_leven = fc.match_product(libelles, ciqual)
dist_leven
Temps d'exécution total : 0.06 secondes ---

best_match distance index_ciqual produit
0 Croissant au jambon 59.459459 281 CROISSANTS X6 400G
1 Maquereau, mariné 78.787879 1462 MAQUEREAUX MOUTAR.
2 Saucisson brioché, cuit 55.000000 1177 IGP OC SAUVIGNON B
3 Lait entier, UHT 68.750000 1500 LAIT 1/2 ECRM UHT
4 Marsala aux oeufs 62.857143 1920 6 OEUFS FRAIS LOCA
5 Ananas, pulpe, cru 64.000000 487 ANANAS C2
6 Fécule de pomme de terre 44.827586 694 L POMME FUDJI X6 CAL 75/80 1KG ENV
7 Merlan, pané 52.631579 1456 PLT MIEL
8 Salami type danois 52.941176 1172 STELLA ARTOIS X6
9 Confiture d'abricot (extra ou classique) 50.000000 1952 COTES DU LUBERON AIGUEBRUN 75C
10 Carotte, crue 62.068966 299 500 g de carottes
11 Fécule de pomme de terre 73.170732 694 2 pommes de terre
12 Mousse de canard 60.000000 1107 1 gousse d'ail
13 Huile de lin 56.000000 2094 1/2 l de lait
14 Bouillon de volaille, déshydraté 73.333333 2165 1/2 l de bouillon de volaille
15 Huile ou beurre de karité 56.666667 2080 1 cuillère à soupe de huile d'olive
16 Accra de poisson 58.064516 1430 1 kg de potiron
17 Oignon rouge, cru 58.333333 337 1 oignon
18 Plat légumes, avec féculent et lait/crème, dès... 56.470588 2283 10 cl de crème liquide (facultatif)

Cette première étape naïve est décevante à plusieurs égards:

  • Certes, on a des matches cohérent (par exemple “Oignon rouge, cru” et “1 oignon”) mais on a plus de couples incohérents ;
  • Le temps de calcul peut apparaître faible mais le passage à l’échelle risque d’être compliqué ;
  • Les besoins mémoires sont potentiellement importants lors de l’appel à rapidfuzz.process.extract ce qui peut bloquer le passage à l’échelle ;
  • La distance textuelle n’est pas nécessairement la plus pertinente.

On a, en fait, négligé une étape importante: la normalisation (ou nettoyage des textes) présentée dans la partie NLP, notamment:

  • harmonisation de la casse, suppression des accents…
  • suppressions des mots outils (e.g. ici on va d’abord négliger les quantités pour trouver la nature de l’aliment, en particulier pour Ciqual)
Scanner-data avant nettoyage
OpenFood data avant nettoyage
Scanner-data après nettoyage
OpenFood data après nettoyage

Faisons donc en apparence un retour en arrière qui sera néanmoins salvateur pour améliorer la pertinence des liens faits entre nos bases de données.

Preprocessing pour améliorer la pertinence des matches

Objectif

Le preprocessing correspond à l’ensemble des opérations ayant lieu avant l’analyse à proprement parler. Ici, ce preprocessing est intéressant à plusieurs égards:

  • Il réduit le bruit dans nos jeux de données (par exemple des mots de liaisons) ;
  • Il permet de normaliser et harmoniser les syntaxes dans nos différentes sources.

L’objectif est ainsi de réduire nos noms de produits à la substantifique moelle pour améliorer la pertinence de la recherche.

Pour être pertinent, le preprocessing comporte généralement deux types de traitements. En premier lieu, ceux qui sont généraux et applicables à tous types de corpus textuels: retrait des stopwords, de la ponctuation, etc. les méthodes disponibles dans la partie NLP. Ensuite, il est nécessaire de mettre en oeuvre des nettoyages plus spécifiques à chaque corpus. Par exemple dans la source Ciqual, la cuisson est souvent renseignée et bruite les appariemments.

Démarche

Exercice 1

Exercice 1: preprocessing

  1. Pour transformer les lettres avec accents en leur équivalent sans accent, la fonction unidecode (du package du même nom) est pratique. La tester sur le jeu de données ciqual en créant une nouvelle colonne nommée libel_clean

  2. La casse différente selon les jeux de données peut être pénalisante pour trouver des produits similaires. Pour éviter ces problèmes, mettre tout en majuscule.

  3. Les informations sur les quantités ou le packaging peuvent apporter du bruit dans notre comparaison. Nous allons retirer ces mots, à travers la liste ['KG','CL','G','L','CRUE?S?', 'PREEMBALLEE?S?'], qu’on peut considérer comme un dictionnaire de stop-words métier. Pour cela, il convient d’utiliser une expression régulière dans la méthode str.replace de Pandas. Avec ceux-ci, on va utiliser la liste des stop-words de la librairie nltk pour retirer les stop-words classiques (_“le”,“la”, etc.). La librairie SpaCy, plus riche, pourrait être utilisée ; nous laissons cela sous la forme d’exercice supplémentaire.

  4. On a encore des signes de ponctuation ou des chiffres qui peuvent poluer la comparaison. Les retirer grâce à la méthode replace et une regex [^a-zA-Z]

  5. Enfin, par sécurité, on peut supprimer les espaces multiples. Utiliser la regex '([ ]{2,})' pour cela. Observer le résultat final.

  6. (Optionnel). Comme exercice supplémentaire, faire la même chose avec les pipelines SpaCy.

A l’issue de la question 1, le jeu de données ciqual devrait ressembler à celui-ci:

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
5 Taboulé ou Salade de couscous, préemballé 179 Taboule ou Salade de couscous, preemballe
6 Salade de pomme de terre à la piémontaise, pré... 130 Salade de pomme de terre a la piemontaise, pre...

Après avoir mis en majuscule, on se retrouve avec le jeu de données suivant:

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
5 Taboulé ou Salade de couscous, préemballé 179 TABOULE OU SALADE DE COUSCOUS, PREEMBALLE
6 Salade de pomme de terre à la piémontaise, pré... 130 SALADE DE POMME DE TERRE A LA PIEMONTAISE, PRE...

Après retrait des stop-words, nos libellés prennent la forme suivante:

[nltk_data] Downloading package stopwords to /github/home/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
5 Taboulé ou Salade de couscous, préemballé 179 TABOULE SALADE COUSCOUS,
6 Salade de pomme de terre à la piémontaise, pré... 130 SALADE POMME TERRE A PIEMONTAISE,

La regex pour éliminer les caractères de ponctuation permet ainsi d’obtenir:

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
5 Taboulé ou Salade de couscous, préemballé 179 TABOULE SALADE COUSCOUS
6 Salade de pomme de terre à la piémontaise, pré... 130 SALADE POMME TERRE A PIEMONTAISE

Enfin, à l’issue de la question 5, le DataFrame obtenu est le suivant:

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
5 Taboulé ou Salade de couscous, préemballé 179 TABOULE SALADE COUSCOUS
6 Salade de pomme de terre à la piémontaise, pré... 130 SALADE POMME TERRE A PIEMONTAISE

Ces étapes de nettoyage ont ainsi permis de concentrer l’information dans les noms de produits sur ce qui l’identifie vraiment.

Approche systématique

Pour systématiser cette approche à nos différents DataFrame, rien de mieux qu’une fonction. Celle-ci est présente dans le module functions sous le nom clean_libelle.

from functions import clean_libelle

Pour résumer l’exercice précédent, cette fonction va :

  • Harmoniser la casse et retirer les accents (voir functions.py) ;
  • Retirer tout les caractères qui ne sont pas des lettres (chiffres, ponctuations) ;
  • Retirer les caractères isolés.
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords')

stop_words = ['KG','CL','G','L','CRUE?S?', 'PREEMBALLEE?S?']
stop_words += [l.upper() for l in stopwords.words('french')]

replace_regex = {r'[^A-Z]': ' ', r'\b[A-Z0-9]{1,2}?\b':' '} # 
[nltk_data] Downloading package stopwords to /github/home/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!

Cela permet d’obtenir les bases nettoyées suivantes:

ciqual = clean_libelle(ciqual, yvar = 'alim_nom_fr', replace_regex = replace_regex, stopWords = stop_words)
ciqual.sample(10)

alim_nom_fr Energie, Règlement UE N° 1169/2011 (kcal/100 g) libel_clean
2134 Crottin de Chavignol (fromage de chèvre) 296 CROTTIN CHAVIGNOL FROMAGE CHEVRE
1709 Thon rouge, cru 155 THON ROUGE
2167 Provolone 340 PROVOLONE
524 Tomate, purée, appertisée 47,1 TOMATE PUREE APPERTISEE
1854 Beignet de crevette 222 BEIGNET CREVETTE
1037 Pain, sans gluten 261 PAIN SANS GLUTEN
1783 Truite arc en ciel, crue, élevage 132 TRUITE ARC CIEL ELEVAGE
2991 Sauce basquaise ou Sauce aux poivrons, préemba... 63,1 SAUCE BASQUAISE SAUCE POIVRONS
667 Pomme de terre noisette, surgelée, cuite 191 POMME TERRE NOISETTE SURGELEE CUITE
1134 Pâte sablée pur beurre, crue 383 PATE SABLEE PUR BEURRE
openfood = clean_libelle(openfood, yvar = 'product_name', replace_regex = replace_regex, stopWords = stop_words)
openfood.sample(10)

product_name nutriscore_score energy_100g libel_clean
1069228 7 Spécialités - Assortiment de bonbons de choc... 26.0 2276.0 SPECIALITES ASSORTIMENT BONBONS CHOCOLAT
1154836 Le sauté de poulet -3.0 583.0 SAUTE POULET
887377 Champignons de Paris a la Grecque 4.0 272.0 CHAMPIGNONS PARIS GRECQUE
19998 Tabasco - Sriracha 15.0 420.0 TABASCO SRIRACHA
1966363 Napolitana Cu Crema De Alune Fara Zahar 14.0 1902.4 NAPOLITANA CREMA ALUNE FARA ZAHAR
1311752 Frutas variadas 2.0 287.0 FRUTAS VARIADAS
2649476 Mini Almond 20.0 1470.0 MINI ALMOND
1039952 Pâte d'arachide 5.0 2598.0 PATE ARACHIDE
1806538 Light sweet chilli sauce 9.0 427.0 LIGHT SWEET CHILLI SAUCE
744081 Triple chocolate cheesecake 17.0 1393.0 TRIPLE CHOCOLATE CHEESECAKE
courses = pd.DataFrame(libelles, columns = ['libel'])
courses = clean_libelle(courses, yvar = 'libel', replace_regex = replace_regex, stopWords = stop_words)
courses.sample(10)

libel libel_clean
15 1 cuillère à soupe de huile d'olive CUILLERE SOUPE HUILE OLIVE
3 LAIT 1/2 ECRM UHT LAIT ECRM UHT
10 500 g de carottes CAROTTES
5 ANANAS C2 ANANAS
8 STELLA ARTOIS X6 STELLA ARTOIS
7 PLT MIEL PLT MIEL
13 1/2 l de lait LAIT
0 CROISSANTS X6 400G CROISSANTS
6 L POMME FUDJI X6 CAL 75/80 1KG ENV POMME FUDJI CAL ENV
17 1 oignon OIGNON

Les noms de produits sont déjà plus harmonisés. Voyons voir si cela permet de trouver un match dans l’Openfood database:

dist_leven_openfood = fc.match_product(courses["libel_clean"], openfood, "libel_clean")
dist_leven_openfood.sample(10)
Temps d'exécution total : 12.81 secondes ---

best_match distance index_ciqual produit
10 CAROTTES 100.000000 356901 CAROTTES
13 LAIT 100.000000 84025 LAIT
0 CROISSANTS 100.000000 4822 CROISSANTS
15 HUILE OLIVE CAL CUILLERES 78.431373 388350 CUILLERE SOUPE HUILE OLIVE
1 MAQUEREAU MOUTARDE 91.428571 557792 MAQUEREAUX MOUTAR
5 ANANAS 100.000000 31627 ANANAS
9 COTES AGNEAU GRILLER 62.222222 429118 COTES LUBERON AIGUEBRUN C
17 OIGNON 100.000000 412136 OIGNON
4 OEUFS FRAIS LOCAUX 94.117647 546312 OEUFS FRAIS LOCA
18 CREME LIQUIDE CUISINER 73.913043 682372 CREME LIQUIDE FACULTATIF

Pas encore parfait, mais on progresse sur les produits appariés! Concernant le temps de calcul, les quelques secondes nécessaires à ce calcul peuvent apparaître un faible prix à payer. Cependant, il convient de rappeler que le nombre de produits dans l’ensemble de recherche est faible. Cette solution n’est donc pas généralisable.

Réduire les temps de recherche

Finalement, l’idéal serait de disposer d’un moteur de recherche adapté à notre besoin, contenant les produits candidats, que l’on pourrait interroger, rapide en lecture, capable de classer les échos renvoyés par pertinence, que l’on pourrait requêter de manière flexible.

Par exemple, on pourrait vouloir signaler qu’un écho nous intéresse seulement si la donnée calorique n’est pas manquante. On pourrait même vouloir qu’il effectue pour nous des prétraitements sur les données.

Cela paraît beaucoup demander. Mais c’est exactement ce que fait ElasticSearch.

Indexer une base

A partir de maintenant, commence, à proprement parler, la démonstration Elastic. Cette partie développe les éléments les plus techniques, à savoir l’indexation d’une base. Tous les utilisateurs d’Elastic n’ont pas nécessairement à passer par là, ils peuvent trouver une base déjà indexée, idéalement par un data engineer qui aura optimisé les traitements.

Les utilisateurs du SSP Cloud, architecture qui repose sur la technologie Kubernetes peuvent répliquer les éléments de la suite du document.

Créer un cluster Elastic sur le DataLab

Pour lancer un service Elastic, il faut cliquer sur ce lien.

Une fois créé, vous pouvez explorer l’interface graphique Kibana. Cependant, grâce à l’API Elastic de Python, on se passera de celle-ci. Donc, en pratique, une fois lancé, pas besoin d’ouvrir ce service Elastic pour continuer à suivre1.

Dans un terminal, vous pouvez aussi vérifier que vous êtes en mesure de dialoguer avec votre cluster Elastic, qui est prêt à vous écouter:

kubectl get statefulset

Passer par la ligne de commande serait peu commode pour industrialiser notre recherche. Nous allons utiliser la librairie elasticsearch pour dialoguer avec notre moteur de recherche Elastic. Les instructions ci-dessous indiquent comment établir la connection.

from elasticsearch import Elasticsearch
HOST = 'elasticsearch-master'

def elastic():
    """Connection avec Elastic sur le data lab"""
    es = Elasticsearch([{'host': HOST, 'port': 9200, 'scheme': 'http'}], http_compress=True, request_timeout=200)
    return es

es = elastic()
<Elasticsearch([{'host': 'elasticsearch-master', 'port': 9200}])>

Maintenant que la connection est établie, deux étapes nous attendent:

  1. Indexation Envoyer les documents parmi lesquels on veut chercher des echos pertinents dans notre elastic. Un index est une collection de document. Nous pourrions en créer deux: un pour les produits ciqual, un pour les produits openfood
  2. Requête Chercher les documents les plus pertinents suivant une recherche textuelle flexible. Nous allons rechercher les libellés de notre recette et de notre liste de course.

Première indexation

On crée donc nos deux index:

if not es.indices.exists(index = 'openfood'):
    es.indices.create(index = 'openfood')
if not es.indices.exists(index = 'ciqual'):
    es.indices.create(index = 'ciqual')

Pour l’instant, nos index sont vides! Ils contiennent 0 documents.

es.count(index = 'openfood')
{'count': 0, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}}

Nous allons en rajouter quelques uns !

es.create(index = 'openfood',  id = 1, body = {'product_name': 'Tarte noix de coco', 'product_name_clean': 'TARTE NOIX COCO'})
es.create(index = 'openfood',  id = 2, body = {'product_name': 'Noix de coco', 'product_name_clean': 'NOIX COCO'})
es.create(index = 'openfood',  id = 3, body = {'product_name': 'Beurre doux', 'product_name_clean': 'BEURRE DOUX'})
es.count(index = 'openfood')
{'count': 3, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}}

Dans l’interface graphique Kibana, on peut vérifier que l’indexation a bien eue lieu en allant dans Management > Stack Management

Première recherche

Faisons notre première recherche: cherchons des noix de pécan!

es.search(index = 'openfood', q = 'noix de pécan')
ObjectApiResponse({'took': 116, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 2, 'relation': 'eq'}, 'max_score': 0.9400072, 'hits': [{'_index': 'openfood', '_type': '_doc', '_id': '2', '_score': 0.9400072, '_source': {'product_name': 'Noix de coco', 'product_name_clean': 'NOIX COCO'}}, {'_index': 'openfood', '_type': '_doc', '_id': '1', '_score': 0.8272065, '_source': {'product_name': 'Tarte noix de coco', 'product_name_clean': 'TARTE NOIX COCO'}}]}})

Intéressons nous aux hits (résultats pertinents, ou echos) : nous en avons 2. Le score maximal parmi les hits est mentionné dans max_score et correspond à celui du deuxième document indexé.

Elastic nous fournit ici un score de pertinence dans notre recherche d’information, et classe ainsi les documents renvoyés.

Ici nous utilisons la configuration par défaut. Mais comment est calculé ce score? Demandons à Elastic de nous expliquer le score du document 2 dans la requête "noix de pécan".

es.explain(index = 'openfood', id = 2, q = 'noix de pécan')
ObjectApiResponse({'_index': 'openfood', '_type': '_doc', '_id': '2', 'matched': True, 'explanation': {'value': 0.9400072, 'description': 'max of:', 'details': [{'value': 0.49917626, 'description': 'sum of:', 'details': [{'value': 0.49917626, 'description': 'weight(product_name_clean:noix in 1) [PerFieldSimilarity], result of:', 'details': [{'value': 0.49917626, 'description': 'score(freq=1.0), computed as boost * idf * tf from:', 'details': [{'value': 2.2, 'description': 'boost', 'details': []}, {'value': 0.47000363, 'description': 'idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:', 'details': [{'value': 2, 'description': 'n, number of documents containing term', 'details': []}, {'value': 3, 'description': 'N, total number of documents with field', 'details': []}]}, {'value': 0.48275858, 'description': 'tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:', 'details': [{'value': 1.0, 'description': 'freq, occurrences of term within document', 'details': []}, {'value': 1.2, 'description': 'k1, term saturation parameter', 'details': []}, {'value': 0.75, 'description': 'b, length normalization parameter', 'details': []}, {'value': 2.0, 'description': 'dl, length of field', 'details': []}, {'value': 2.3333333, 'description': 'avgdl, average length of field', 'details': []}]}]}]}]}, {'value': 0.9400072, 'description': 'sum of:', 'details': [{'value': 0.4700036, 'description': 'weight(product_name:noix in 1) [PerFieldSimilarity], result of:', 'details': [{'value': 0.4700036, 'description': 'score(freq=1.0), computed as boost * idf * tf from:', 'details': [{'value': 2.2, 'description': 'boost', 'details': []}, {'value': 0.47000363, 'description': 'idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:', 'details': [{'value': 2, 'description': 'n, number of documents containing term', 'details': []}, {'value': 3, 'description': 'N, total number of documents with field', 'details': []}]}, {'value': 0.45454544, 'description': 'tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:', 'details': [{'value': 1.0, 'description': 'freq, occurrences of term within document', 'details': []}, {'value': 1.2, 'description': 'k1, term saturation parameter', 'details': []}, {'value': 0.75, 'description': 'b, length normalization parameter', 'details': []}, {'value': 3.0, 'description': 'dl, length of field', 'details': []}, {'value': 3.0, 'description': 'avgdl, average length of field', 'details': []}]}]}]}, {'value': 0.4700036, 'description': 'weight(product_name:de in 1) [PerFieldSimilarity], result of:', 'details': [{'value': 0.4700036, 'description': 'score(freq=1.0), computed as boost * idf * tf from:', 'details': [{'value': 2.2, 'description': 'boost', 'details': []}, {'value': 0.47000363, 'description': 'idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:', 'details': [{'value': 2, 'description': 'n, number of documents containing term', 'details': []}, {'value': 3, 'description': 'N, total number of documents with field', 'details': []}]}, {'value': 0.45454544, 'description': 'tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:', 'details': [{'value': 1.0, 'description': 'freq, occurrences of term within document', 'details': []}, {'value': 1.2, 'description': 'k1, term saturation parameter', 'details': []}, {'value': 0.75, 'description': 'b, length normalization parameter', 'details': []}, {'value': 3.0, 'description': 'dl, length of field', 'details': []}, {'value': 3.0, 'description': 'avgdl, average length of field', 'details': []}]}]}]}]}]}})

Elastic nous explique donc que le score 0.9400072 est le maximum entre deux sous-scores, 0.4991 et 0.9400072.

Pour chacun de ces sous-scores, le détail de son calcul est donné. Le premier sous-score n’a accordé un score que par rapport au premier mot (noix), tandis que le second a accordé un score sur la base des deux mots déjà connu dans les documents (“noix” et “de”). Il a ignoré pécan! Jusqu’à présent, ce terme n’est pas connu dans l’index.

La pertinence d’un mot pour notre recherche est construite sur une variante de la TF-IDF, considérant qu’un terme est pertinent s’il est souvent présent dans le document (Term Frequency) alors qu’il est peu fréquent dans les autres document (inverse document frequency). Ici les notations des documents 1 et 2 sont très proches, la différence est dûe à des IDF plus faibles dans le document 1, qui est pénalisé pour être légérement plus long.

Bref, tout ça est un peu lourd, mais assez efficace, en tout cas moins rudimentaire que les distances caractères à caractères pour ramener des echos pertinents.

Limite de cette première indexation

Pour l’instant, Elastic n’a pas l’air de gérer les fautes de frappes! Pas le droit à l’erreur dans la requête:

es.search(index = 'openfood',q = 'TART NOI')
ObjectApiResponse({'took': 38, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'max_score': None, 'hits': []}})

Cela s’explique par la représentation des champs (‘product_name’ par exemple) qu’Elastic a inféré, puisque nous n’avons rien spécifié. La représentation d’une variable conditionne la façon dont les champs sont analysés pour calculer la pertinence. Par exemple, regardons la représentation du champ product_name

es.indices.get_field_mapping(index = 'openfood', fields = 'product_name')
ObjectApiResponse({'openfood': {'mappings': {'product_name': {'full_name': 'product_name', 'mapping': {'product_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}}}}}})

Elastic a compris qu’il s’agissait d’un champ textuel. En revanche, le type est keyword n’autorise pas des analyses approximatives donc ne permet pas de tenir compte de fautes de frappes.

Pour qu’un echo remonte, un des termes doit matcher exactement. Dommage ! Mais c’est parce qu’on a utilisé le mapping par défaut. En réalité, il est assez simple de préciser un mapping plus riche, autorisant une analyse “fuzzy” ou “flou”.

Améliorer l’indexation

On peut spécifier la façon dont l’on souhaite analyser le texte. Par exemple, on peut préciser que l’on souhaite enlever des stopwords, raciniser, analyser les termes via des n-grammes pour rendre la recherche plus robuste aux fautes de frappes…

Ces concepts sont présentés dans la partie NLP. Pour une présentation plus complète, voir la documentation officielle d’Elastic

On propose les analyseurs stockés dans un fichier schema.json

Les n-grammes sont des séquences de n caractères ou plus généralement n éléments qui s’enchaînent séquentiellement. Par exemple, NOI et OIX sont des tri-grammes de caractères dans NOIX.

Comparer les n-grammes composant des libellés peut permettre d’avoir dans des comparaisons à fautes de frappe/abbréviations près. Cela fait aussi plus de comparaisons à opérer ! D’où également, l’intérêt d’Elastic, qui intégre facilement et efficacement ces comparaisons.

On va préciser un peu le schéma de données qu’on souhaite indexer, et aussi préciser comment les différents champs seront analysés.

Une indexation plus adaptée

import json

if es.indices.exists(index = 'openfood'):
    es.indices.delete(index = 'openfood')

with open('schema.json') as f:
    mapping = json.load(f)
    
es.indices.create(index = "openfood", body = mapping)    

Maintenant, les champs textuels “product_name” et “product_name_clean” vont pouvoir être analysé aussi via leur n-grammes et après racinisation (et l’un n’exclut pas l’autre!)

es.indices.get_field_mapping(
  index = 'openfood', fields = 'product_name'
  )
ObjectApiResponse({'openfood': {'mappings': {'product_name': {'full_name': 'product_name', 'mapping': {'product_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}, 'ngr': {'type': 'text', 'analyzer': 'ngram_analyzer'}, 'stem': {'type': 'text', 'analyzer': 'stem_analyzer'}}}}}}}})

C’est parti, on envoie toute notre base OpenFood pour pouvoir la requêter ! La fonction suivante (index_elastic) va vous faire gagner du temps pour indexer car indexer chaque produit à la main n’est pas très efficace.

Du coup ça prend quelques minutes… mais c’est pour nous en faire gagner ensuite. Cette opération est faite une fois, pour préparer des requêtes potentiellement nombreuses!

fc.index_elastic(es =es, index_name = "openfood",
        setting_file = 'schema.json',
        df = openfood[['product_name',"libel_clean","energy_100g","nutriscore_score"]].drop_duplicates())
Temps d'exécution total : 125.57 secondes ---
es.count(index = 'openfood')['count']
738366

Nos premières requêtes

Vérifions qu’on recupère quelques tartes aux noix même si l’on fait plein de fautes:

es.search(index = 'openfood', q = 'TART NOI', size = 3)
ObjectApiResponse({'took': 60, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 10000, 'relation': 'gte'}, 'max_score': 22.837925, 'hits': [{'_index': 'openfood', '_type': '_doc', '_id': '405332', '_score': 22.837925, '_source': {'product_name': 'Tarte noix', 'libel_clean': 'TARTE NOIX', 'energy_100g': 1833.0, 'nutriscore_score': 23.0}}, {'_index': 'openfood', '_type': '_doc', '_id': '1103594', '_score': 22.82367, '_source': {'product_name': 'Tarte aux noix', 'libel_clean': 'TARTE NOIX', 'energy_100g': 4.0, 'nutriscore_score': 4.0}}, {'_index': 'openfood', '_type': '_doc', '_id': '1150755', '_score': 22.82367, '_source': {'product_name': 'Tarte aux noix', 'libel_clean': 'TARTE NOIX', 'energy_100g': 1929.0, 'nutriscore_score': 21.0}}]}})

Si on préfère sous une forme de DataFrame:

df = pd.json_normalize(
    es.search(index = 'openfood', q = 'TART NOI', size = 3)['hits']['hits']
)
df.columns = df.columns.str.replace("_source.", "", regex = False)
df.head(2)
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
_index_type_id_scoreproduct_namelibel_cleanenergy_100gnutriscore_score
0openfood_doc40533222.837925Tarte noixTARTE NOIX1833.023.0
1openfood_doc110359422.823670Tarte aux noixTARTE NOIX4.04.0
2openfood_doc115075522.823670Tarte aux noixTARTE NOIX1929.021.0

Pour automatiser l’envoi de requêtes et la récupération du meilleur écho, on peut définir la fonction suivante

def matchElastic(libelles):
    start_time = time.time()
    matches = {}
    for l in libelles:
        response = es.search(index = 'openfood', q = l, size = 1)
        if len(response['hits']['hits'])>0:
            matches[l] = pd.json_normalize(
              response['hits']['hits']
            )
    print(80*'-')
    print(f"Temps d'exécution total : {(time.time() - start_time):.2f} secondes ---")
    
    return matches
matches = matchElastic(courses['libel_clean'])
matches = pd.concat(matches)
matches.sample(3)
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
_index_type_id_score_source.product_name_source.libel_clean_source.energy_100g_source.nutriscore_score
GOUSSE AIL0openfood_doc198206257.93140Gousse d\'ailGOUSSE AIL498.05.0
IGP SAUVIGNON0openfood_doc180140696.55756vin blanc SauvignonVIN BLANC SAUVIGNON66.31.0
POTIRON0openfood_doc104396175.96385PotironPOTIRON172.00.0

Et voilà, on a un outil très rapide de requête ! La pertinence des résultats est encore douteuse. Pour cela, il conviendrait de préciser des requêtes plus sophistiquées!2

req = {
    "bool": {
      "should": [
        { "match": { "libel_clean":  { "query":  "HUILE OLIVE" , "boost" : 10}}},
        { "match": { "libel_clean.ngr":   "HUILE OLIVE" }}
        ],
      "minimum_should_match": 1,
      "filter": [
      { 
            "range" : {
                "nutriscore_score" : {
                    "gte" : 10,
                    "lte" : 20
                    }
                    }
                    }
      ]
    }
}
out = es.search(index = 'openfood', query = req, size = 1)
pd.json_normalize(out['hits']['hits'])
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
_index_type_id_score_source.product_name_source.libel_clean_source.energy_100g_source.nutriscore_score
0openfood_doc960041174.27896Huile d oliveHUILE OLIVE3761.011.0

Qu’a-t-on demandé ici?

  • De renvoyer 1 et 1 seul echo ("size":"1") et seulement si celui ci a:
  • "should": Au moins un ("minimum_should_match":"1") des termes des deux champs libel_clean et libel_clean.ngr qui matche sur un terme de HUILE OLIVE, l’analyse (la définition du “terme”) étant réalisé soit en tant que text (“libel_clean”) soit en tant que n-gramme ngr (“libel_clean.ngr”, une analyse que nous avons spécifié dans le mapping)
  • "filter": Le champ float nutriscore_score doit être compris entre 10 et 20 (“filter”).

A noter :

  1. Les clauses ("should"+"minimum_should_match":"1") peuvent être remplacé par un "must". Auquel cas, l’écho doit obligatoirement matcher sur chaque clause.
  2. Préciser dans "filter" (plutôt que dans "should") une condition signifie que celle-ci ne participe pas au score de pertinence.

On n’a pas encore un appariemment très satisfaisant, en particulier sur les boissons. Comment faire ? La réponse est dans Galiana and Suarez Castillo (2022)

Galiana, Lino, and Milena Suarez Castillo. 2022. “Fuzzy Matching on Big-Data: An Illustration with Scanner and Crowd-Sourced Nutritional Datasets.” In Proceedings of the 2022 ACM Conference on Information Technology for Social Good, 331–37. GoodIT ‘22. New York, NY, USA: Association for Computing Machinery. https://doi.org/10.1145/3524458.3547244.


  1. Le lancement du service a créé dans votre NAMESPACE Kubernetes (l’ensemble de tout vos services) un cluster Elastic. Vous n’avez droit qu’à un cluster par namespace (ou compte d’utilisateur). Votre service Jupyter, VSCode, RStudio, etc. est associé au même namespace. De même qu’il n’est pas nécessaire de comprendre comment fonctionne le moteur d’une voiture pour conduire, il n’est pas nécessaire de comprendre la manière dont tout ce beau monde dialogue pour pouvoir utiliser le SSP Cloud↩︎

  2. Vous pouvez aussi explorer les possibilités de requêtes via la doc Elastic et vous entrainer à un écrire avec votre index tout neuf. ↩︎

Previous
Next