Importer OpenFoodFacts dans PostgreSQL
Open Food Facts est une base de données contributive sur les produits alimentaires, en français et en anglais, sous licence ouverte ODBL.
Ce service permet sur le web, à partir d’un code de produit (code barres) d’en connaître les ingrédients, de renseigner ceux d’un produit non encore connu de la base pour l’enrichir, et de faire des requêtes en ligne avec un formulaire de recherche. Il a également une application mobile pour les smartphones et une API, le tout étant en source sur github.
Le service utilise le moteur NoSQL MongoDB, mais les données sont disponibles en téléchargement aux formats CSV et RDF entre autres, de sorte qu’on puisse les importer dans l’outil de notre choix. Comme sur ce blog on n’est pas trop branchés MongoDB, mais plutôt PostgreSQL, on va voir comment importer ces données pour les requêter avec notre SGBD préféré :)
Ca peut paraître trivial, puisque PostgreSQL supporte l’import de CSV nativement à travers la commande COPY, mais en pratique ça ne l’est pas pour diverses raisons:
-
CSV n’est pas un format rigoureusement spécifié a priori. Il est spécifié a posteriori par les règles de la RFC 4180 avec laquelle PostgreSQL est compatible, mais de nombreux exports CSV les ignorent. La seule chose à peu près garantie sur les fichiers annoncés comme CSV, c’est qu’ils ont des champs texte séparés par un certain caractère.
-
les données OpenData sont souvent plus ou moins “sales”, parfois au point de nécessiter un nettoyage ou filtrage pour les rendre importables.
-
les fichiers ont souvent un grand nombre de colonnes dont on connait les noms mais pas forcément les types, et une création à la main des tables est fastidieuse.
Ce billet détaille les étapes nécessaires pour importer une extraction d’OpenFoodFacts, jusqu’à arriver à une base requêtable. La majorité des problèmes et solutions mentionnés ne sont pas spécifiques à cet import, ils pourraient se retrouver dans d’autres jeux de données “OpenData”.
L’export se présente sous la forme d’un seul fichier de grande taille, c’est-à-dire, au 20/12/2018, une taille de 2 Go pour un peu plus de 700 mille lignes:
$ wget https://fr.openfoodfacts.org/data/fr.openfoodfacts.org.products.csv
$ wc -l fr.openfoodfacts.org.products.csv
709988 fr.openfoodfacts.org.products.csv
$ ls -sh fr.openfoodfacts.org.products.csv
2.0G fr.openfoodfacts.org.products.csv
Création de la table
Pour tenter l’import avec COPY, il faut d’abord créer une table correspondant à la structure du fichier. En effet COPY ne crée pas la table même quand il y a des noms de colonne sur la 1ere ligne (l’option HEADER ne sert à l’import qu’à ignorer cette ligne).
Les infos dont on dispose pour ça sont:
- la 1ère ligne du fichier CSV avec les noms de colonnes, qu’on peut voir
avec la commande:
$ head -n 1 fr.openfoodfacts.org.products.csv
On voit aussi sur cette première ligne que les colonnes sont séparées par une tabulation (on pourrait dire que le fichier est au format TSV en fait). - le texte descriptif de la plupart des colonnes.
Pour avoir cette liste des colonnes sous forme lisible, on peut utiliser la commande Unix suivante qui va sortir les noms de colonnes alignés verticalement:
$ head -n 1 fr.openfoodfacts.org.products.csv | sed -e 's/\t/\n/g'
code
url
creator
created_t
created_datetime
last_modified_t
last_modified_datetime
product_name
...etc... (173 lignes)
Pour éviter de spécifier un par un les types de données de chaque colonne, dans un CREATE TABLE, on peut utiliser un programme qui fait ça automatiquement: csvkit.
csvkit
est écrit en python et installable avec pip, ou via un paquet
d’une distribution Linux. Ici j’utilise Debian 9, qui propose la
version 0.9.1 de csvkit, installable par:
# apt install python3-csvkit
En plus de bibliothèques en python, ce paquet nous installe une série d’outils appelables en ligne de commande:
/usr/bin/csvformat /usr/bin/csvcut /usr/bin/csvjson /usr/bin/csvclean /usr/bin/csvsql /usr/bin/csvstack /usr/bin/sql2csv /usr/bin/csvlook /usr/bin/csvjoin /usr/bin/csvstat /usr/bin/in2csv /usr/bin/csvpy /usr/bin/csvgrep /usr/bin/csvsort
Celui qui nous intéresse est csvsql
, et d’après sa documentation, il peut générer un script de
création de table, et optionnellement insérer les données.
Par défaut il déclare les champs texte en VARCHAR(N)
où N
est la
taille maximale constatée dans les données, mais ce n’est pas forcément
pertinent pour PostgreSQL où limiter la taille déclarée n’apporte
aucun gain de performance, et surtout ce n’est pas parce qu’un champ
n’a pas dépassé N caractères jusque là que des entrées qu’on pourrait
ajouter plus tard devraient forcément se conformer à cette limite.
On utilise donc l’option --no-constraints
pour éviter ça:
--no-constraints Generate a schema without length limits or null checks. Useful when sampling big tables.
Donc voici l’invocation qui convient:
$ csvsql --dialect postgresql --tabs --table openfoodfacts \
--no-constraints fr.openfoodfacts.org.products.csv > create_table.sql
La commande prend quelques minutes, et produit ce fichier qui contient un ordre CREATE TABLE avec 173 colonnes comportant:
- 76 float
- 95 varchar
- 2 integer
Dans l’ensemble le résultat semble correct, à part deux détails:
-
les champs
created_datetime
etlast_modified_datetime
mériteraient d’être entimestamptz
. On les modifiera après. -
les colonnes nommées
_100g
sont pratiquement toutes en type “float” (équivalent à “double precision”), sauf deux:
"nervonic-acid_100g" INTEGER,
"nutrition-score-fr_100g" INTEGER,
C’est probablement parce qu’il n’y avait aucun point décimal dans aucune des valeurs de tout le fichier pour ces deux colonnes. Par souci d’homogénéité, on pourra les retyper comme les autres en “float”.
Insertion du contenu avec csvsql
Pour commençer on créé une base dédiée:
$ psql -d postgres -U postgres
=# CREATE DATABASE foodfacts ENCODING 'UTF8';
Puisque csvsql sait créer la table, autant lui demander d’insérer aussi le contenu,
avec son option --insert
.
Une option --db
attend une “SQL alchemy connection string” qui, dans le cas où on
reste sur l’utilisateur par défaut, peut se réduire au nom de la base:
--db postgresql:///foodfacts
, ou être du style --db postgresql://user@host:port/dbname
Il faudra aussi installer le driver psycopg2 pour python pour profiter de l’import, par exemple sur Debian:
# apt install python3-psycopg2
$ csvsql --db postgresql:///foodfacts --table openfoodfacts --tabs \
--insert --no-constraints fr.openfoodfacts.org.products.csv
L’import fonctionne jusqu’au bout sans message d’erreur, en une douzaine de minutes, mais il s’avère qu’après l’import des contrôles basiques d’intégrité donnent des résultats inquiétants. Pour commencer, il manque des entrées par rapport au nombre de lignes du fichier, 660 exactement:
$ wc -l fr.openfoodfacts.org.products.csv
709988
$ psql -d foodfacts
=> select count(*) from openfoodfacts;
count
--------
709327
Seulement 0.1% des lignes du fichier manquent à l’appel, mais c’est mauvais signe. Il ne devrait y avoir une différence que de 1 entre ces deux nombres (correspondant à la ligne d’entête).
Un autre contrôle basique donne aussi un résultat qui confirme que cet import
pose problème: si on considère la colonne created_datetime
, date de création
de l’entrée, et qu’on cherche son minimum, son maximum, et le nombre de fois où
elle n’est pas remplie, on obtient:
=> SELECT min(created_datetime),
max(created_datetime),
count(*) filter (where created_datetime is null)
FROM openfoodfacts;
Résultat:
min | max | count
----------------------+---------------------+-------
2012-01-31T14:43:58Z | Super U, Magasins U | 10
(1 row)
Le min
semble correct, mais le max
montre qu’un nom d’enseigne
s’est invité dans les dates, ce qui signifie que des données ont
“glissé” d’une colonne à l’autre, voire sur plusieurs colonnes. Par
ailleurs, 10 entrées n’ont pas de date de création, alors qu’on
s’attendrait à ce qu’il y en ait zéro.
Dans la mesure où csvkit ne sort pas de message d’erreur alors qu’il a clairement des problèmes avec ces données, je n’insiste pas avec cet outil pour l’import. Il a été utile pour sortir un CREATE TABLE, c’est déjà pas mal.
Import avec COPY
COPY FROM FILE
est réservé aux superutilisateurs (parce qu’il peut
lire des fichiers sur le serveur avec les droits de postgres
), on va
donc utiliser le \copy
de psql qui est très proche à l’usage mais
ouvre le fichier côté client et le fait passer par la liaison
client-serveur, ce qui ne nécessite pas de droit particulier:
=> \copy openfoodfacts from 'fr.openfoodfacts.org.products.csv' with (format csv, header, delimiter E'\t')
Le premier résultat est que l’import échoue assez rapidement:
ERROR: missing data for column "bicarbonate_100g"
CONTEXT: COPY openfoodfacts, line 3193: "0011110084767 http://world-fr.openfoodfacts.org/produit/0011110084767/8-white-cake-decorated usda-nd..."
Il s’agit probablement d’un décalage de champ mais comment voir ce qui
ne va pas avec cette ligne? Pour ça on va l’isoler du fichier avec une
commande shell spécifique. Puis on va l’importer en base
en tant qu’un seul champ de texte, au format text
au sens de la
commande COPY (et non CSV):
# extraction de la ligne sur laquelle l'erreur est signalée
$ sed '3193q;d' fr.openfoodfacts.org.products.csv > line-3193
# vérification qu'il n'y a pas d'antislash ni de caractère de code 0x01
# (pour l'utiliser comme échappement) dans la ligne de données:
$ fgrep '\' line-3193 # resultat vide
$ fgrep $'\x01' line-3193 # resultat vide
=> CREATE TEMPORARY TABLE ligne(data text);
CREATE TABLE
=> \copy ligne FROM 'line-3193' WITH (format text, delimiter E'\001')
COPY 1
Une fois importée on peut, en SQL, découper cette ligne en champs pour les apparier
avec les colonnes de notre table principale (via pg_attribute
et par numéro de champ),
histoire de bien visualiser les associations [numéro de champ] / [nom de colonne] / [valeur]
(attention: cette requête suppose qu’on n’a pas supprimé de colonne avec
ALTER TABLE openfoodfacts DROP COLUMN ...
, car les colonnes
supprimées restent perpétuellement, avec leurs numéros initiaux,
dans pg_attribute
).
Je ne vais reproduire tout le résultat ici car même un seul enregistrement
produit beaucoup de données à l’écran, mais en regardant les champs
un par un, on voit qu’à la colonne serving_size
, un guillemet qui en
principe est un caractère spécial en (CSV d’encadrement de champ) est
employé pour signifier le “pouce” anglo-saxon, et pas pour encadrer un
champ.
39 | traces_tags |
40 | traces_fr |
41 | serving_size | 28 g (1 " CUBE | ABOUT)
42 | serving_quantity | 28
43 | no_nutriments |
Ce qui gêne ici, c’est que ça va à l’encontre de cette règle du CSV (de la RFC 4180 citée plus haut):
5. Each field may or may not be enclosed in double quotes (however some programs, such as Microsoft Excel, do not use double quotes at all). If fields are not enclosed with double quotes, then double quotes may not appear inside the fields
Il aurait fallu que le champ soit formatté ainsi: "28 g (1 "" CUBE | ABOUT)"
.
Comment contourner ce problème? Il se trouve que l’interprétation du
guillemet est paramétrable dans COPY via l’option QUOTE
, justement
pour les cas où le format de fichier s’éloigne du CSV strict. Pour
que le caractère guillemet devienne normal, il faut spécifier un autre
caractère à sa place. Les contraintes: que ce caractère n’apparaisse
pas dans les contenus par ailleurs (ou qu’il soit doublé mais on n’est
pas dans ce cas là), et que de plus son codage UTF-8 tienne en un seul
octet non nul, c’est-à-dire dans la plage 0x01-0x7F
.
Donc cherchons le premier caractère qui convient (c’est-à-dire qui ait 0 occurrence dans les contenus) avec ces commandes shell successives:
$ fgrep -c $'\x01' fr.openfoodfacts.org.products.csv
4
$ fgrep -c $'\x02' fr.openfoodfacts.org.products.csv
2
$ fgrep -c $'\x03' fr.openfoodfacts.org.products.csv
0
A noter que des octets de valeurs 01
et 02
n’ont en principe pas leur
place dans des contenus texte, il s’agit de codes de contrôle qui ne correspondent à aucune lettre de l’alphabet,
et qui certainement sont du “bruit” supprimable dans ces données.
Quoiqu’il en soit, le code 03
est disponible pour l’indiquer à COPY au lieu
du guillemet pour rendre à ce dernier son caractère neutre, et on peut réessayer notre
import avec l’option quote '\003'
. Cette fois-ci, ça va nettement plus loin:
=> \copy openfoodfacts from 'fr.openfoodfacts.org.products.csv' with (format csv, header, delimiter E'\t', quote E'\003')
ERROR: unquoted carriage return found in data
HINT: Use quoted CSV field to represent carriage return.
CONTEXT: COPY openfoodfacts, line 268349
Cette nouvelle erreur nous dit qu’il y a un retour chariot à l’intérieur d’un champ.
Ce n’est pas que le retour chariot soit interdit en CSV (aucun caractère n’est interdit),
mais il est autorisé seulement si l’ensemble du champ est encadré par
des guillemets (ou au pire par le caractère spécifié par l’option quote
), mais là
le fichier n’utilise pas l’encadrement des champs comme vu plus haut.
Ceci nous mène à vérifier un point important: y-a-t’il des sauts de ligne (caractère de code 10) à l’intérieur des champs en plus des retours chariot (caractère de code 13)?
C’est vérifiable simplement avec awk
, à qui on peut demander les
lignes qui ne contiennent pas strictement 173 champs séparés par des
tabulations:
$ awk -F'\t' '{if (NF!=173) print $0}' fr.openfoodfacts.org.products.csv
Il se trouve qu’il n’y a aucun résultat, c’est-à-dire qu’en fait il n’y a pas de saut de ligne à l’intérieur des champs, sinon forcément certains s’étendraient sur plusieurs lignes et donc certaines lignes porteraient moins de 173 champs.
Du côté des caractères retours chariot, leur nombre est trouvable par la commande:
$ fgrep -c $'\x0d' fr.openfoodfacts.org.products.csv
34
Ces 34 retours chariots dans les contenus, posent problème compte-tenu
du fait que les champs ne sont pas encadrés. On va filtrer
(=supprimer) ces caractères en utilisant une autre clause de \copy
,
la clause program
avec la commande Unix tr
comme filtre.
=> \copy openfoodfacts from program 'tr -d ''\r'' <fr.openfoodfacts.org.products.csv' with (format csv, header, delimiter E'\t', quote E'\003')
COPY 709987
Et enfin, cette commande-là ne rencontre pas d’erreur et permet d’intégrer la totalité des lignes (709988 moins l’entête), en à peu près une minute.
Vérification de cohérence de l’import
Une fois l’import passé, faisons encore quelques vérifications de cohérence sur des critères basiques:
- l’unicité des valeurs si une des colonnes semble une clef primaire.
- la plausibilité des valeurs sur les colonnes de date.
=> SELECT
min(created_datetime),
max(created_datetime),
count(*) filter (where created_datetime is null)
FROM
openfoodfacts;
min | max | count
----------------------+----------------------+-------
2012-01-31T14:43:58Z | 2018-12-12T07:24:25Z | 1
On voit qu’on a une entrée avec une date de création nulle, ce qui est anormal mais pas inquiétant en volume.
En principe les valeurs de la colonne code
devraient être uniques puisque
c’est le code barres du produit, mais on trouve un certain nombre de doublons:
=> SELECT code,count(*) FROM openfoodfacts GROUP BY code HAVING count(*)>1;
code | count
--------------------+-------
0016000459335 | 2
0019320001376 | 2
0051500006962 | 2
0055577102152 | 2
...
8901262260152 | 2
9415142005904 | 2
(113 rows)
On peut soit ignorer ce problème et décider de ne pas déclarer la clef primaire dans la table, soit créer une clef primaire synthétique (un compteur qui n’a pas d’autre signification que numéro unique), soit supprimer les entrées en doublon.
Distinguer un doublon d’un quasi-doublon
Si deux lignes sont exactement en doublon (les valeurs de toutes les colonnes sont identiques), il suffit d’en supprimer une des deux. Mais souvent, on a un quasi-doublon, dans le sens où l’application de saisie de ces données a produit deux fiches au lieu d’une pour la même chose, mais pas avec rigoureusement les mêmes contenus.
Voici une méthode pour afficher en SQL les colonnes qui diffèrent
entre deux lignes dont beaucoup de colonnes sont supposément égales
par ailleurs. On va utiliser ici une requête préparée qui prend en
argument ($1
dans le texte de la requête) la valeur de la clef qui
se présente en doublon:
=> PREPARE diff AS
WITH valeurs AS (SELECT key, value
FROM
(SELECT row_to_json(o.*) AS line
FROM openfoodfacts AS o
WHERE code=$1) AS r
CROSS JOIN LATERAL json_each_text(r.line))
SELECT distinct v1.key
FROM valeurs v1 JOIN valeurs v2 ON (v1.key=v2.key
AND v1.value IS DISTINCT FROM v2.value);
=> EXECUTE diff('0051500006962');
key
-----------
countries
(1 row)
=> SELECT code,countries FROM openfoodfacts WHERE code='0051500006962';
code | countries
---------------+---------------
0051500006962 | États-Unis
0051500006962 | United States
(2 rows)
=> EXECUTE diff('6003326008341');
key
------------------------
image_small_url
last_modified_t
image_url
last_modified_datetime
(4 rows)
Ici je vais aller au plus vite en ne gardant pour chaque doublon que
le dernier modifié sur la base de la colonne last_modified_datetime
,
et en cas d’égalité sur cette colonne, un arbitrage sur la pseudo-colonne ctid
(emplacement physique de la ligne) qui est forcément différente d’une
ligne à l’autre.
Finalement on peut créer un index unique:
=> CREATE UNIQUE INDEX code_index ON openfoodfacts(code);
Si on voulait mieux faire, il faudrait examiner au cas par cas ces doublons, et agir pour les supprimer dans la base source des données.
Retypage
Les colonnes de type “horodatage” n’ont pas été typées en timestamptz
par
csvsql, mais il est toujours temps de le faire après coup avec ALTER
TABLE.
On peut aussi supprimer les colonnes created_t
et last_modified_t
qui
sont des timestamps Unix (nombre de secondes depuis le 1er janvier 1970)
avec la même signification que created_datetime
et last_modified_datetime
.
En faisant tout dans la même commande:
Pour finir, une petite requête d’exemple montrant le nombre de références ajoutées par an:
=> SELECT extract(year from created_datetime), count(*)
FROM openfoodfacts
GROUP BY 1 ORDER BY 1 DESC;
date_part | count
-----------+--------
| 1
2018 | 316197
2017 | 284243
2016 | 46218
2015 | 35256
2014 | 13417
2013 | 10078
2012 | 4464
(8 rows)