-
Notifications
You must be signed in to change notification settings - Fork 45
/
index.qmd
995 lines (737 loc) · 42.8 KB
/
index.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
---
title: "Introduction à ElasticSearch pour la recherche textuelle"
date: 2020-09-03T13:00:00Z
draft: false
weight: 30
tags:
- elastic
- levenshtein
- openfood
categories:
- Tutoriel
slug: elastic
type: book
summary: |
`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
bibliography: ../../../../reference.bib
---
Pour essayer les exemples présents dans ce tutoriel :
::: {.cell .markdown}
```{python}
#| echo: false
#| output: 'asis'
#| include: true
#| eval: true
import sys
sys.path.insert(1, '../../../../') #insert the utils module
from utils import print_badges
#print_badges(__file__)
print_badges("content/course/modern-ds/elastic_intro.qmd")
```
:::
Ce chapitre a été écrit avec [Milena Suarez-Castillo](https://milenasuarezcastillo.netlify.app/)
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](datalab.sspcloud.fr/) 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).
:warning: 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
```{python}
#| eval: false
!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:
```{python}
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.
{{% box status="hint" title="Hint" icon="fa fa-lightbulb" %}}
Plusieurs méthodes peuvent être mises en oeuvre pour récupérer
le script d'utilitaires. Voici une proposition
```python
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)
```
{{% /box %}}
Après l'avoir récupéré (cf. encadré dédié),
il convient d'importer les fonctions sous forme de module:
```{python}
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"_](http://jms-insee.fr/jms2022s28_2/)
(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:
```{python}
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](https://www.marmiton.org/recettes/recette_veloute-de-potiron-et-carottes_19009.aspx)
qui sera notre plat principal :
```{python}
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](#webscraping)
On va donc créer une liste de course compilant
ces deux
listes hétérogènes de noms de produits:
```{python}
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:
![](fraise.png)
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](https://fdc.nal.usda.gov/),
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
[ @galiana2022 ](https://dl.acm.org/doi/10.1145/3524458.3547244)
* L'[`OpenFoodFacts` database](https://fr.openfoodfacts.org/) qui est une base
collaborative française de produits alimentaires. Issue d'un projet [Data4Good](https://dataforgood.fr/), il s'agit d'une
alternative _opensource_ et _opendata_ à la base de données de l'application [Yuka](https://yuka.io/).
* La table de composition nutritionnelle [`Ciqual`](https://ciqual.anses.fr) 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.
![](openfood.png)
## 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`.
```{python}
#| eval: false
# 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()
```
```{python}
#| echo: false
openfood = fc.import_openfood()
ciqual = fc.import_ciqual()
```
```{python}
openfood.head()
```
```{python}
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`](https://elasticsearch-py.readthedocs.io/en/v7.12.0/).
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:
```{python}
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.
```{python}
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](#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`)
::: {layout-nrow=2}
![Scanner-data avant nettoyage](wordcloud_relevanc_start.png)
![OpenFood data avant nettoyage](wordcloud_openfood_start.png)
![Scanner-data après nettoyage](wordcloud_relevanc_clean.png)
![OpenFood data après nettoyage](wordcloud_openfood_clean.png)
:::
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](#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
{{% box status="exercise" title="Exercice 1" icon="fas fa-pencil-alt" %}}
**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`.
{{% /box %}}
A l'issue de la question 1, le jeu de données `ciqual` devrait
ressembler à celui-ci:
```{python}
#| echo: false
from unidecode import unidecode
ciqual['libel_clean'] = ciqual['alim_nom_fr'].apply(lambda s: unidecode(s))
ciqual.head(2)
```
Après avoir mis en majuscule, on se retrouve avec le jeu de données
suivant:
```{python}
#| echo: false
ciqual['libel_clean'] = ciqual['libel_clean'].str.upper()
ciqual.head(2)
```
Après retrait des _stop-words_, nos libellés prennent
la forme suivante:
```{python}
#| echo: false
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')]
ciqual['libel_clean'] = ciqual['libel_clean'].str.replace(
'|'.join([r'\b{}\b'.format(w) for w in stop_words]),
"", regex = True
)
ciqual.head(2)
```
La regex pour éliminer les caractères de ponctuation permet ainsi d'obtenir:
```{python}
#| echo: false
ciqual["libel_clean"] = ciqual["libel_clean"].str.replace(r'[^a-zA-Z]', ' ', regex=True)
ciqual.head(2)
```
Enfin, à l'issue de la question 5, le `DataFrame` obtenu est le suivant:
```{python}
#| echo: false
ciqual["libel_clean"] = ciqual["libel_clean"].str.replace(r'([ ]{2,})', ' ', regex=True)
ciqual.head(2)
```
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`.
```{python}
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.
```{python}
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:
```{python}
ciqual = clean_libelle(ciqual, yvar = 'alim_nom_fr', replace_regex = replace_regex, stopWords = stop_words)
ciqual.sample(10)
```
```{python}
openfood = clean_libelle(openfood, yvar = 'product_name', replace_regex = replace_regex, stopWords = stop_words)
openfood.sample(10)
```
```{python}
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:
```{python}
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](https://datalab.sspcloud.fr/accueil), architecture qui
repose sur la technologie [Kubernetes](https://kubernetes.io/) 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](https://datalab.sspcloud.fr/launcher/databases/elastic?autoLaunch=false&security.allowlist.enabled=false).
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 à suivre[^1].
[^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`.
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:
```shell
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.
```{python}
#| eval: false
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:
```{python}
#| eval: false
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.
```{python}
#| eval: false
es.count(index = 'openfood')
```
```
{'count': 0, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}}
```
Nous allons en rajouter quelques uns !
```{python}
#| eval: false
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'})
```
```{python}
#| eval: false
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`
![](index_management.png)
## Première recherche
Faisons notre première recherche: cherchons des noix de pécan!
```{python}
#| eval: false
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"`.
```{python}
#| eval: false
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:
```{python}
#| eval: false
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`
```{python}
#| eval: false
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](#nlp).
Pour une présentation plus complète, voir
[la documentation officielle d'Elastic](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
On propose les analyseurs stockés dans un fichier [schema.json](#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
```{python}
#| eval: false
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!)
```{python}
#| eval: false
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!
```{python}
#| eval: false
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 ---
```
```{python}
#| eval: false
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:
```{python}
#| eval: false
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`:
```{python}
#| eval: false
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)
```
```{=html}
<table border="1" class="dataframe">\n <thead>\n <tr style="text-align: right;">\n <th></th>\n <th>_index</th>\n <th>_type</th>\n <th>_id</th>\n <th>_score</th>\n <th>product_name</th>\n <th>libel_clean</th>\n <th>energy_100g</th>\n <th>nutriscore_score</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <th>0</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>405332</td>\n <td>22.837925</td>\n <td>Tarte noix</td>\n <td>TARTE NOIX</td>\n <td>1833.0</td>\n <td>23.0</td>\n </tr>\n <tr>\n <th>1</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>1103594</td>\n <td>22.823670</td>\n <td>Tarte aux noix</td>\n <td>TARTE NOIX</td>\n <td>4.0</td>\n <td>4.0</td>\n </tr>\n <tr>\n <th>2</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>1150755</td>\n <td>22.823670</td>\n <td>Tarte aux noix</td>\n <td>TARTE NOIX</td>\n <td>1929.0</td>\n <td>21.0</td>\n </tr>\n </tbody>\n</table>
```
Pour automatiser l'envoi de requêtes et la récupération du meilleur
écho, on peut définir la fonction suivante
```{python}
#| eval: false
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
```
```{python}
#| eval: false
matches = matchElastic(courses['libel_clean'])
matches = pd.concat(matches)
matches.sample(3)
```
```{=html}
<table border="1" class="dataframe">\n <thead>\n <tr style="text-align: right;">\n <th></th>\n <th></th>\n <th>_index</th>\n <th>_type</th>\n <th>_id</th>\n <th>_score</th>\n <th>_source.product_name</th>\n <th>_source.libel_clean</th>\n <th>_source.energy_100g</th>\n <th>_source.nutriscore_score</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <th>GOUSSE AIL</th>\n <th>0</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>1982062</td>\n <td>57.93140</td>\n <td>Gousse d\'ail</td>\n <td>GOUSSE AIL</td>\n <td>498.0</td>\n <td>5.0</td>\n </tr>\n <tr>\n <th>IGP SAUVIGNON</th>\n <th>0</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>1801406</td>\n <td>96.55756</td>\n <td>vin blanc Sauvignon</td>\n <td>VIN BLANC SAUVIGNON</td>\n <td>66.3</td>\n <td>1.0</td>\n </tr>\n <tr>\n <th>POTIRON</th>\n <th>0</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>1043961</td>\n <td>75.96385</td>\n <td>Potiron</td>\n <td>POTIRON</td>\n <td>172.0</td>\n <td>0.0</td>\n </tr>\n </tbody>\n</table>
```
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]
[^2]: Vous pouvez aussi explorer les possibilités de requêtes via la [doc Elastic](https://www.elastic.co/guide/en/elasticsearch/reference/6.8/query-dsl.html) et vous entrainer à un écrire avec votre index tout neuf.
```{python}
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
}
}
}
]
}
}
```
```{python}
#| eval: false
out = es.search(index = 'openfood', query = req, size = 1)
pd.json_normalize(out['hits']['hits'])
```
```{=html}
<table border="1" class="dataframe">\n <thead>\n <tr style="text-align: right;">\n <th></th>\n <th>_index</th>\n <th>_type</th>\n <th>_id</th>\n <th>_score</th>\n <th>_source.product_name</th>\n <th>_source.libel_clean</th>\n <th>_source.energy_100g</th>\n <th>_source.nutriscore_score</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <th>0</th>\n <td>openfood</td>\n <td>_doc</td>\n <td>960041</td>\n <td>174.27896</td>\n <td>Huile d olive</td>\n <td>HUILE OLIVE</td>\n <td>3761.0</td>\n <td>11.0</td>\n </tr>\n </tbody>\n</table>
```
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 @galiana2022
<div class="alert alert-success" role="alert">
A vous, de calculer le nombre de calories de notre recette de course !
</div>