TD 6
Jointure de fichiers CSV
Cet exercice a pour objectif de calculer la jointure de deux fichiers au format CSV selon l’une de leur colonnes. Les fichiers CSV sont des représentations textuelles de tableaux de données :
- chaque ligne du fichier représente une ligne du tableau ;
- les cellules de chaque ligne sont séparées par un caractère spécifique, généralement le caractère
','
.
Nous ferons quelques hypothèses simplificatrices sur la présentation des fichiers CSV :
- qu’il n’y a pas d’en-tête (généralement la première ligne sert à donner un nom à chaque colonne) ;
- que chaque ligne du fichier contient le même nombre de cellules ;
- que les valeurs contenues dans les cellules ne contiennent pas le caractère
','
et que la valeur y est directement représentée (pas de guillemets ou d’espaces parasites).
Nous allons représenter les fichiers CSV en utilisant les types suivants :
1. Découpage de listes
Nous allons commencer par définir une fonction decoupe
qui prend un élément c
de type a
et une liste d’éléments l
de type a
et qui renvoie la liste des listes obtenue en découpant l
avec le séparateur c
.
Le type de cette fonction est donc :
On a besoin que le type générique a
implémente la classe Eq
, de manière à être sûrs que l’on puisse comparer notre séparateur et les éléments de la liste.
Une façon naturelle de définir cette fonction en haskell est d’utiliser un fold
. Par exemple :
Ça paraît un peu mystique comme ça, mais si on reprend dans l’ordre ce que l’on fait dans le pli :
- L’accumulateur qu’on veut au début c’est une liste contenant une liste vide. La liste extérieure va toutes les sous listes et on commence avec une liste vide à l’intérieur qui servira à concaténer les premiers éléments.
- À chaque pli, on va comparer l’élément de la liste avec notre séparateur. On a alors deux possibilités :
- soit l’élément est le même que le séparateur, dans ce cas on doit commencer une nouvelle liste pour les éléments suivants et passer à l’élément suivant ;
- soit ce n’est pas un séparateur et dans ce cas, on l’ajoute à la dernière liste qu’on a créée.
Un petit exemple ?
- On commence avec l’accumulateur initial, donc
[[]]
. - Comme on plie par la droite, on commence par comparer notre séparateur avec le dernier élement. 9 étant différent de 0, on l’ajoute à la première liste de l’accumulateur. On a donc
[[9]]
. - On a ensuite un 0, notre séparateur. Du coup, on va ajouter une nouvelle liste à l’accumulateur, soit
[[], [9]]
. - On a ensuite encore un 0, donc on fait pareil et on a
[[], [], [9]]
. - Après on a un 8, donc on l’ajoute à la première liste de l’accumulateur, soit :
[[8], [], [9]]
. - Après on a un 7, donc on fait pareil, soit :
[[7, 8], [], [9]]
.
et ainsi de suite.
Pourquoi pas un fold à gauche ?
Si on remplaçait foldr
par foldl
, on ferait un parcours de la liste
de gauche à droite, ce qui pourrait sembler plus naturer pour l’ordre de
traitement. Cependant, si on utilise l’opérateur (:)
, les listes seront
construites à l’envers, et si on utilise (++)
alors on perd beaucoup en
efficacité…
2. Lecture d’un fichier CSV
En utilisant la fonction decoupe
, on va écrire une fonction csv
qui prend en argument une chaîne de caractères et renvoie une valeur
de type CSV
qui représente le tableau CSV contenu dans cette chaîne
de caractères.
On considère que les colonnes sont délimitées par le caractère ','
.
On pourra utiliser la fonction map :: (a -> b) -> [a] -> [b]
.
Ici, il faut se souvenir qu’on va récupérer une chaîne de caractères
qui contient plusieurs lignes.
Chaque ligne sera une ligne de notre format CSV
.
Il faut donc :
- que l’on découpe la première chaîne pour récupérer la liste des lignes ;
- que l’on découpe chacune de ces listes pour récupérer la liste des cellules.
Récupérer la liste des lignes, c’est facile, il suffit de découper avec
le caractère de retour à la ligne : decoupe '\n' texte
.
Par exemple, si on a le document suivant (ne pas faire attention au \
,
ils servent juste à écrire sur plusieurs lignes) :
On va récupérer assez facilement :
Au niveau du typage, on est bon, parce qu’on est passé de
String = [Char]
à [String] = [[Char]]
avec un séparateur Char
.
Maintenant, on veut séparer les contenus des éléments de cette liste
en fonction du séparateur ','
. Pour ça, on va encore utiliser decoupe
,
mais cette fois-ci, il faut qu’on l’applique à chaque sous-liste, ce qui
se fait naturellement avec un…
map (decoupe ',')
.
Et les types ici ? On est encore bon, parce qu’on est passé de
String = [Char]
à [String] = [[Char]]
avec un séparateur Char
.
Si on met tout ensemble, on a alors :
3. Extraction de la \(i\)-ème cellule d’une ligne
Pour pouvoir spécifier la jointure à calculer, on désigne les colonnes
par leur indice.
Pour faire ceci de manière simple, on va redéfinir l’opérateur
(!!) :: [a] -> Int -> a
, de manière sûre.
On crée donc l’opérateur (!?) :: [a] -> Int -> Maybe a
.
On adopte la même convention que l’indice du premier élément est 0.
L’implémentation que nous allons proposer utilise une fonction bien
choisie : find :: (a -> Bool) -> [a] -> Maybe a
qui renvoie le premier
élément d’une liste qui respecte un prédicat donné, s’il y en a un.
L’idée ici, c’est de ruser un peu. On commence par zipper la liste
initiale avec la liste infinie [0..]
, ce qui va créer des tuples
avec pour chaque élément de la liste, son index.
Ensuite, on va aller trouver (avec find
) le premier
(et normalement le seul) élément dont l’index correspond à celui recherché.
Finalement, on a un petit problème. Notre find
renvoie un Maybe (a, Int)
et pas un Maybe a
. Il faut que l’on puisse récupérer le premier élément
du tuple, sans retirer l’effet du Maybe
. Pour cela, on utilise la
fonction fst
, qui est fmappée pour agir dans le Maybe
.
4. Implémenter la jointure (1er essai)
On commence par une implémentation naïve de la jointure de deux valeurs
de type CSV
.
Il s’agit de la fonction joinN :: Int -> Int -> CSV -> CSV -> CSV
,
joinN i j csv1 csv2
renvoie la structure CSV qui contient les lignes
l1 ++ l2
telles que :
-
l1
est une ligne decsv1
; -
l2
est une ligne decsv2
; - la \(i\)-ème cellule de
l1
et la \(j\)-ème cellule del2
contiennent la même valeur ; - on considère que si les lignes de
csv1
n’ont pas de \(i\)-ème cellule et que si les lignes decsv2
n’ont pas de \(j\)-ème cellule alors toutes les lignes decsv1
et decsv2
peuvent être appariées. En pratique, cela signifie qu’il suffit de comparer les résultats de l’opérateur(!?)
pour savoir si deux lignes decsv1
et decsv2
doivent être appariées dans la jointure.
L’algorithme implémenté par joinN
consiste à parcourir les lignes de
csv1
et de chercher dans csv2
les lignes qui peuvent lui être
appariées dans la jointure.
Ici encore, il paraît naturel (si, si, promis) qu’utiliser un pliage permet de faire cette fonction élégamment.
Ce qu’on veut faire finalement, c’est que pour chaque ligne du premier fichier, on va regarder chaque ligne du deuxième. Si elles ont les bons attributs, on joint les lignes en les concaténant.
Encore une fois, ça peut paraître mystique au premier abord. Mais décortiquons un peu ce qu’on fait.
- L’accumulateur que l’on veut au début, c’est une liste vide. C’est cette liste qui va contenir l’ensemble des lignes du nouveau fichier que l’on va construire.
- À chaque pli, on va prendre la ligne courante et filtrer dans les lignes du deuxième fichier celles qui ont la clé qui correspond. Ensuite on va concaténer la ligne courante avec chacune des lignes filtrées et ajouter ça dans l’accumulateur.
Un petit exemple ?
On considère les CSV suivants :
- Quand on commence, l’accumulateur vaut
[]
, c’est normal. - Ensuite, on commence à replier avec la dernière ligne du
csv1
. Dans ce cas,l1
vaut["2", "Alice"]
etk1 = "Alice"
. - On filtre ensuite les lignes de
csv2
. Ici, on va récupérer la liste[["Alice", "12"]]
. - Maintenant, pour chaque élément de cette liste, on va concaténer
l1
devant, soit[["2", "Alice", "Alice", "12"]]
. - Finalement, on ajoute ça à notre accumulateur, qui pour le moment est vide.
On continue ainsi pour tous les éléments de la première liste, ce qui donnerait :
Pourquoi pas un fold à gauche ?
Alors là, bonne question. Est-ce qu’un pli à gauche changerait
quelque chose ici ? L’ordre dans lequel on regarde les éléments de csv1
a t-il un sens ? Qu’est ce qui changerait si on passait à foldl
?
5. Vers une implémentation plus efficace
Si les fichiers CSV avec lesquels on travaille sont volumineux,
l’algorithme implémenté par joinN i j csv1 csv2
peut prendre un temps important.
En effet, il s’exécute en \(\mathcal{O}(n1 \times n2 )\) si \(n1\) et
\(n2\) sont respectivement le nombre de
lignes de csv1
et csv2
.
On va donc utiliser un autre algorithme qui va consister à commencer
par trier les lignes des données de type CSV
puis s’appuyer sur le
fait que les données sont triées pour calculer la jointure.
Si chaque ligne est associée à au plus \(k\) lignes dans la jointure,
le temps d’exécution devient alors \(\mathcal{O}(n1 + k \times n2 )\)
pour la jointure proprement dite et \(\mathcal{O}(n1\cdot\mathsf{log}(n1) +
n2\cdot\mathsf{log}(n2))\) pour trier les données.
Si \(n1 = n2 = 10^8\), \(k = 1\) et que l’on peut traiter \(10^9\) lignes
par seconde, alors joinN i j csv1 csv2
s’exécute en
\(\frac{10^8 \times 10^8}{10^9} = 10^7\) secondes soit en 2800 heures,
plus de 100 jours.
En utilisant le nouvel algorithme, le temps d’exécution
devient (environ) \(\frac{10^8 + 10^8 +20 \times 10^8 + 20 \times 10^8}
{10^9} = 4,2\) secondes.
Même si les calculs sont très approximatifs, les ordres de grandeurs sont corrects et montrent très clairement que nous avons intérêt à utiliser ce second algorithme.
Pour cela, on va commencer par une fonction
intermédiaire avance :: Int -> Int -> Ligne
-> CSV -> (CSV, CSV)
telle que
avance i j l csv
, où csv
est une structure de type CSV
dont les
lignes sont triées par rapport à la colonne j
, renvoie la paire
(csv1, csv2)
telle que :
-
csv1
sont les lignes decsv
dont la \(j\)-ème colonne est égale à la \(i\)-ème colonne del
; -
csv2
sont les lignes decsv
dont la \(j\)-ème colonne contient une valeur supérieure ou égale à la \(i\)-ème colonne del
.
On va donner une implémentation de avance
, en utilisant
les fonctions
-
dropWhile :: (a -> Bool) -> [a] -> [a]
; -
takeWhile :: (a -> Bool) -> [a] -> [a]
; et - l’opérateur
(!?)
.
On a donc besoin de :
- récupérer la clé à la \(i\)-ème position de
l
, aveck = l !? i
- avoir un prédicat (pour utiliser dans
takeWhile
/dropWhile
), qui dit que la ligne qu’on regarde a une correspondance de clé. Par exemple, quelque chose commeligneOK l' = l' !? j == k
. - on veut commencer à la première ligne qui a une correspondance, donc
on jette toutes les lignes du début qui n’en ont pas avec
dropWhile (not . ligneOK) csv
- ensuite, on renvoie la paire comme convenu.
Ça peut donner quelque chose comme ça :
6. Implémentation de la jointure à base de tri
On peut maintenant implémenter le nouvel algorithme de jointure
join :: Int -> Int -> CSV -> CSV -> CSV
tel que :
- on commence par trier les deux fichiers
- pour chaque ligne du premier fichier, on avance dans le deuxième en accumulant les lignes qui sont compatibles pour former la jointure
Pour ça, on va utiliser :
-
avance
et(!?)
-
sortOn :: Ord b => (a -> b) -> [a] -> [a]
, qui trie la liste donnée en suivant l’ordre induit par la fonction en paramètre.
Mais ?? Il y a deux where
là !
Eh oui, c’est vrai… Mais en fait, ici, le deuxième where
est
dans la fonction auxilliaire et donc ce n’est pas la même chose que
de mettre deux where
dans la même fonction au même endroit