Introduction à ElasticSearch pour la recherche textuelle

ElasticSearch est un moteur de recherche extrêmement rapide et flexible. Cette technologie s’est imposée dans le domaine du traitement des données textuelles. L’API Python permet d’intégrer cette technologie dans des processus Python afin de les accélérer. Ce chapitre présente cette intégration d’ Elastic avec l’exemple de la recherche dans les données alimentaires de l’ OpenFoodFacts Database

Tutoriel
Avancé
Author

Lino Galiana

Published

2024-04-12

Pour essayer les exemples présents dans ce tutoriel :

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.

import requests

url = "https://github.com/linogaliana/python-datascientist/raw/master/content/modern-ds/functions.py"
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 web scraping cette liste est un bon exercice pour réviser les concepts vus précédemment

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()
openfood.head()
ciqual.head()

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
]

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

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

Scanner-data avant nettoyage

OpenFood data avant nettoyage

OpenFood data avant nettoyage

Scanner-data après nettoyage

Scanner-data après nettoyage

OpenFood 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

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

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

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

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

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

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": " "}  #

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)
openfood = clean_libelle(
    openfood, yvar="product_name", replace_regex=replace_regex, stopWords=stop_words
)
openfood.sample(10)
courses = pd.DataFrame(libelles, columns=["libel"])
courses = clean_libelle(
    courses, yvar="libel", replace_regex=replace_regex, stopWords=stop_words
)
courses.sample(10)

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)

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)

Informations additionnelles

environment files have been tested on.

Python version used:

Package Version
affine 2.4.0
aiobotocore 2.12.2
aiohttp 3.9.3
aioitertools 0.11.0
aiosignal 1.3.1
alembic 1.13.1
aniso8601 9.0.1
annotated-types 0.6.0
appdirs 1.4.4
archspec 0.2.3
astroid 3.1.0
asttokens 2.4.1
attrs 23.2.0
bcrypt 4.1.2
beautifulsoup4 4.12.3
black 24.3.0
blinker 1.7.0
blis 0.7.11
bokeh 3.4.0
boltons 23.1.1
boto3 1.34.51
botocore 1.34.51
branca 0.7.1
Brotli 1.1.0
cachetools 5.3.3
cartiflette 0.0.2
Cartopy 0.23.0
catalogue 2.0.10
cattrs 23.2.3
certifi 2024.2.2
cffi 1.16.0
charset-normalizer 3.3.2
chromedriver-autoinstaller 0.6.4
click 8.1.7
click-plugins 1.1.1
cligj 0.7.2
cloudpathlib 0.16.0
cloudpickle 3.0.0
colorama 0.4.6
comm 0.2.2
conda 24.3.0
conda-libmamba-solver 24.1.0
conda-package-handling 2.2.0
conda_package_streaming 0.9.0
confection 0.1.4
contextily 1.6.0
contourpy 1.2.1
cryptography 42.0.5
cycler 0.12.1
cymem 2.0.8
cytoolz 0.12.3
dask 2024.4.1
dask-expr 1.0.10
debugpy 1.8.1
decorator 5.1.1
dill 0.3.8
distributed 2024.4.1
distro 1.9.0
docker 7.0.0
duckdb 0.10.1
en-core-web-sm 3.7.1
entrypoints 0.4
et-xmlfile 1.1.0
exceptiongroup 1.2.0
executing 2.0.1
fastjsonschema 2.19.1
fiona 1.9.6
flake8 7.0.0
Flask 3.0.2
folium 0.16.0
fontawesomefree 6.5.1
fonttools 4.51.0
fr-core-news-sm 3.7.0
frozenlist 1.4.1
fsspec 2023.12.2
GDAL 3.8.4
gensim 4.3.2
geographiclib 2.0
geopandas 0.12.2
geoplot 0.5.1
geopy 2.4.1
gitdb 4.0.11
GitPython 3.1.43
google-auth 2.29.0
graphene 3.3
graphql-core 3.2.3
graphql-relay 3.2.0
graphviz 0.20.3
greenlet 3.0.3
gunicorn 21.2.0
h11 0.14.0
hvac 2.1.0
idna 3.6
imageio 2.34.0
importlib_metadata 7.1.0
importlib_resources 6.4.0
inflate64 1.0.0
ipykernel 6.29.3
ipython 8.22.2
ipywidgets 8.1.2
isort 5.13.2
itsdangerous 2.1.2
jedi 0.19.1
Jinja2 3.1.3
jmespath 1.0.1
joblib 1.3.2
jsonpatch 1.33
jsonpointer 2.4
jsonschema 4.21.1
jsonschema-specifications 2023.12.1
jupyter-cache 1.0.0
jupyter_client 8.6.1
jupyter_core 5.7.2
jupyterlab_widgets 3.0.10
kaleido 0.2.1
kiwisolver 1.4.5
kubernetes 29.0.0
langcodes 3.3.0
lazy_loader 0.4
libmambapy 1.5.7
llvmlite 0.42.0
locket 1.0.0
lxml 5.2.1
lz4 4.3.3
Mako 1.3.2
mamba 1.5.7
mapclassify 2.6.1
Markdown 3.6
MarkupSafe 2.1.5
matplotlib 3.8.3
matplotlib-inline 0.1.6
mccabe 0.7.0
menuinst 2.0.2
mercantile 1.2.1
mizani 0.11.1
mlflow 2.11.3
mlflow-skinny 2.11.3
msgpack 1.0.7
multidict 6.0.5
multivolumefile 0.2.3
munkres 1.1.4
murmurhash 1.0.10
mypy 1.9.0
mypy-extensions 1.0.0
nbclient 0.10.0
nbformat 5.10.4
nest_asyncio 1.6.0
networkx 3.3
nltk 3.8.1
numba 0.59.1
numpy 1.26.4
oauthlib 3.2.2
opencv-python-headless 4.9.0.80
openpyxl 3.1.2
outcome 1.3.0.post0
OWSLib 0.28.1
packaging 23.2
pandas 2.2.1
paramiko 3.4.0
parso 0.8.4
partd 1.4.1
pathspec 0.12.1
patsy 0.5.6
Pebble 5.0.7
pexpect 4.9.0
pickleshare 0.7.5
pillow 10.3.0
pip 24.0
pkgutil_resolve_name 1.3.10
platformdirs 4.2.0
plotly 5.19.0
plotnine 0.13.4
pluggy 1.4.0
polars 0.20.18
preshed 3.0.9
prometheus_client 0.20.0
prometheus-flask-exporter 0.23.0
prompt-toolkit 3.0.42
protobuf 4.25.3
psutil 5.9.8
ptyprocess 0.7.0
pure-eval 0.2.2
py7zr 0.20.8
pyarrow 15.0.0
pyarrow-hotfix 0.6
pyasn1 0.5.1
pyasn1-modules 0.3.0
pybcj 1.0.2
pycodestyle 2.11.1
pycosat 0.6.6
pycparser 2.21
pycryptodomex 3.20.0
pydantic 2.7.0
pydantic_core 2.18.1
pyflakes 3.2.0
Pygments 2.17.2
PyJWT 2.8.0
pylint 3.1.0
PyNaCl 1.5.0
pynsee 0.1.7
pyOpenSSL 24.0.0
pyparsing 3.1.2
pyppmd 1.1.0
pyproj 3.6.1
pyshp 2.3.1
PySocks 1.7.1
python-dateutil 2.9.0
python-dotenv 1.0.1
python-magic 0.4.27
pytz 2024.1
pyu2f 0.1.5
pywaffle 1.1.0
PyYAML 6.0.1
pyzmq 25.1.2
pyzstd 0.15.10
QtPy 2.4.1
querystring-parser 1.2.4
rasterio 1.3.9
referencing 0.34.0
regex 2023.12.25
requests 2.31.0
requests-cache 1.2.0
requests-oauthlib 2.0.0
rpds-py 0.18.0
rsa 4.9
Rtree 1.2.0
ruamel.yaml 0.18.6
ruamel.yaml.clib 0.2.8
s3fs 2023.12.2
s3transfer 0.10.1
scikit-image 0.23.1
scikit-learn 1.4.1.post1
scipy 1.13.0
seaborn 0.13.2
selenium 4.19.0
setuptools 69.2.0
shapely 2.0.3
six 1.16.0
smart-open 6.4.0
smmap 5.0.0
sniffio 1.3.1
snuggs 1.4.7
sortedcontainers 2.4.0
soupsieve 2.5
spacy 3.7.4
spacy-legacy 3.0.12
spacy-loggers 1.0.5
SQLAlchemy 2.0.29
sqlparse 0.4.4
srsly 2.4.8
stack-data 0.6.2
statsmodels 0.14.1
tabulate 0.9.0
tblib 3.0.0
tenacity 8.2.3
texttable 1.7.0
thinc 8.2.3
threadpoolctl 3.4.0
tifffile 2024.2.12
tomli 2.0.1
tomlkit 0.12.4
toolz 0.12.1
topojson 1.8
tornado 6.4
tqdm 4.66.2
traitlets 5.14.2
trio 0.25.0
trio-websocket 0.11.1
truststore 0.8.0
typer 0.9.4
typing_extensions 4.11.0
tzdata 2024.1
Unidecode 1.3.8
url-normalize 1.4.3
urllib3 1.26.18
wasabi 1.1.2
wcwidth 0.2.13
weasel 0.3.4
webdriver-manager 4.0.1
websocket-client 1.7.0
Werkzeug 3.0.2
wheel 0.43.0
widgetsnbextension 4.0.10
wordcloud 1.9.3
wrapt 1.16.0
wsproto 1.2.0
xgboost 2.0.3
xlrd 2.0.1
xyzservices 2024.4.0
yarl 1.9.4
yellowbrick 1.5
zict 3.0.0
zipp 3.17.0
zstandard 0.22.0

View file history

SHA Date Author Description
005d89b 2023-12-20 17:23:04 Lino Galiana Finalise l’affichage des statistiques Git (#478)
3fba612 2023-12-17 18:16:42 Lino Galiana Remove some badges from python (#476)
4cd44f3 2023-12-11 17:37:50 Antoine Palazzolo Relecture NLP (#474)
1f23de2 2023-12-01 17:25:36 Lino Galiana Stockage des images sur S3 (#466)
a06a268 2023-11-23 18:23:28 Antoine Palazzolo 2ème relectures chapitres ML (#457)
69cf52b 2023-11-21 16:12:37 Antoine Palazzolo [On-going] Suggestions chapitres modélisation (#452)
889a71b 2023-11-10 11:40:51 Antoine Palazzolo Modification TP 3 (#443)
df6a66b 2023-11-05 10:56:31 Julien PRAMIL Pb redirection snippet elastic (#445)
652009d 2023-10-09 13:56:34 Lino Galiana Finalise le cleaning (#430)
a771183 2023-10-09 11:27:45 Antoine Palazzolo Relecture TD2 par Antoine (#418)
154f09e 2023-09-26 14:59:11 Antoine Palazzolo Des typos corrigées par Antoine (#411)
9977c5d 2023-08-28 10:43:36 Lino Galiana Fix bug path pandas (#397)
8082302 2023-08-25 17:48:36 Lino Galiana Mise à jour des scripts de construction des notebooks (#395)
3bdf3b0 2023-08-25 11:23:02 Lino Galiana Simplification de la structure 🤓 (#393)
29ff3f5 2023-07-07 14:17:53 linogaliana description everywhere
f21a24d 2023-07-02 10:58:15 Lino Galiana Pipeline Quarto & Pages 🚀 (#365)
003a625 2022-10-28 18:53:05 Lino Galiana Rebuild (#311)
e3a6b2d 2022-10-28 08:01:32 Lino Galiana Proposition utilisation minio pour download openfood (#307)
044abdb 2022-10-25 13:57:53 Lino Galiana Finalise tuto elastic (#306)
3e26719 2022-10-24 19:09:22 Lino Galiana Tutoriel Elastic reprise (#305)
1f1668a 2022-10-24 10:02:16 Lino Galiana Corrige tutoriel Elastic (#303)
f10815b 2022-08-25 16:00:03 Lino Galiana Notebooks should now look more beautiful (#260)
bacb5a0 2022-07-04 19:05:20 Lino Galiana Enrichir la partie elastic (#241)
12965ba 2022-05-25 15:53:27 Lino Galiana :launch: Bascule vers quarto (#226)
2a8809f 2021-10-27 12:05:34 Lino Galiana Simplification des hooks pour gagner en flexibilité et clarté (#166)
b5615b4 2021-09-09 16:52:47 Lino Galiana Un petit tuto Elastic (#135)
8166e22 2021-09-09 11:06:20 Lino Galiana Wordcloud du site (#136)
Back to top

References

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.

Footnotes

  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.↩︎

Citation

BibTeX citation:
@book{galiana2023,
  author = {Galiana, Lino},
  title = {Python Pour La Data Science},
  date = {2023},
  url = {https://pythonds.linogaliana.fr/},
  doi = {10.5281/zenodo.8229676},
  langid = {en}
}
For attribution, please cite this work as:
Galiana, Lino. 2023. Python Pour La Data Science. https://doi.org/10.5281/zenodo.8229676.