diff --git a/EDdA/__init__.py b/EDdA/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..50e553f92d1aa016035f0efaa2f9ade8570140e8
--- /dev/null
+++ b/EDdA/__init__.py
@@ -0,0 +1 @@
+from EDdA.data import load, domains
diff --git a/lib/cache.py b/EDdA/cache.py
similarity index 100%
rename from lib/cache.py
rename to EDdA/cache.py
diff --git a/EDdA/classification/__init__.py b/EDdA/classification/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2dad969362f7b24d9d88e9156c59192ed94567ea
--- /dev/null
+++ b/EDdA/classification/__init__.py
@@ -0,0 +1,2 @@
+from EDdA.classification.nGramsFrequencies import topNGrams
+from EDdA.classification.classSimilarities import confusionMatrix, toPNG, metrics
diff --git a/EDdA/classification/classSimilarities.py b/EDdA/classification/classSimilarities.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcc7c057db53ab2c300cf37fa345fd2a9a816e03
--- /dev/null
+++ b/EDdA/classification/classSimilarities.py
@@ -0,0 +1,42 @@
+from EDdA import data
+import math
+import matplotlib.pyplot as plot
+import seaborn
+
+def keysIntersection(d1, d2):
+    return len(set(d1).intersection(d2))
+
+def scalarProduct(d1, d2):
+    return sum([d1[k] * d2[k] for k in set(d1.keys()).intersection(d2)])
+
+def norm(d):
+    return math.sqrt(scalarProduct(d, d))
+
+def colinearity(d1, d2):
+    return scalarProduct(d1, d2) / (norm(d1) * norm(d2))
+
+metrics = {
+        'keysIntersection': keysIntersection,
+        'colinearity': colinearity
+    }
+
+""" the variable 'domains' allows to restrict the matrices we're computing, but
+"   for our current needs they're still supposed to be about the classes devised
+"   for GÉODE so we give it this default value
+"""
+def confusionMatrix(vectorizer, metric, domains=data.domains):
+    m = []
+    matrixSize = len(domains)
+    for a in range(0, matrixSize):
+        m.append(matrixSize * [None])
+        for b in range(0, matrixSize):
+            m[a][b] = metric(vectorizer(domains[a]), vectorizer(domains[b]))
+    return m
+
+def toPNG(matrix, filePath, domains=data.domains):
+    plot.figure(figsize=(16,13))
+    ax = seaborn.heatmap(
+            matrix, xticklabels=domains, yticklabels=domains, cmap='Blues'
+        )
+    plot.savefig(filePath, dpi=300, bbox_inches='tight')
+
diff --git a/lib/model.py b/EDdA/classification/model.py
similarity index 97%
rename from lib/model.py
rename to EDdA/classification/model.py
index b05d95a1f116bb634ca6282aca1ae1f6b6436a48..aa6999858f1ade753bfe2e9aa510ea7d6d94a22b 100644
--- a/lib/model.py
+++ b/EDdA/classification/model.py
@@ -1,5 +1,4 @@
 import pickle
-import sklearn
 
 def vectorizerFileName(name, samplingSize):
     return "{name}_s{samplingSize}".format(name=name, samplingSize=samplingSize)
diff --git a/EDdA/classification/nGramsFrequencies.py b/EDdA/classification/nGramsFrequencies.py
new file mode 100644
index 0000000000000000000000000000000000000000..f80816a36972b6125864d1baf12f44b17f4e7a7e
--- /dev/null
+++ b/EDdA/classification/nGramsFrequencies.py
@@ -0,0 +1,61 @@
+from EDdA.cache import Cache
+from EDdA import data
+import nltk
+import pandas
+
+def frequenciesLoader(articles, n, domain):
+    texts = data.domain(articles, domain).contentWithoutClass
+    state = {}
+    for text in texts:
+        ngrams = list(nltk.ngrams(text.split(), n))
+        for k in ngrams:
+            state[k] = 1+(state[k] if k in state else 0)
+    return state
+
+def frequenciesPath(inputHash, n):
+    return lambda domain:\
+        "frequencies/{inputHash}/{n}grams/{domain}.tsv"\
+                .format(inputHash=inputHash, n=n, domain=domain)
+
+def loadFrequencies(f):
+    tsv = pandas.read_csv(f, sep='\t', na_filter=False)
+    return dict(zip(
+            tsv.ngram.map(lambda s: tuple(s.split(','))),
+            tsv.frequency
+        ))
+
+def saveFrequencies(freqs, f):
+    pandas.DataFrame(data={
+            'ngram': map(lambda t: ','.join(t), freqs.keys()),
+            'frequency': freqs.values()
+        }).to_csv(f, sep='\t', index=False)
+
+def frequencies(source, n):
+    return Cache(
+            lambda domain: frequenciesLoader(source.articles, n, domain),
+            pathPolicy=frequenciesPath(source.hash, n),
+            serializer=saveFrequencies,
+            unserializer=loadFrequencies
+        )
+
+def topLoader(frequencyEvaluator, n, ranks):
+    return lambda domain:\
+        dict(nltk.FreqDist(frequencyEvaluator(domain)).most_common(ranks))
+
+def topPath(inputHash, n, ranks):
+    return lambda domain:\
+        "topNGrams/{inputHash}/{n}grams/top{ranks}/{domain}.tsv".format(
+                inputHash=inputHash,
+                n=n,
+                ranks=ranks,
+                domain=domain
+            )
+
+def topNGrams(source, n, ranks):
+    freq = frequencies(source, n)
+    return Cache(
+            topLoader(freq, n, ranks),
+            pathPolicy=topPath(source.hash, n, ranks),
+            serializer=saveFrequencies,
+            unserializer=loadFrequencies
+        )
diff --git a/lib/data.py b/EDdA/data.py
similarity index 95%
rename from lib/data.py
rename to EDdA/data.py
index 74b919caf8ee18fbd4c53a301845c3d786f6e205..91d287dcad0b3edd30713a6a141845c611b13fe5 100644
--- a/lib/data.py
+++ b/EDdA/data.py
@@ -11,7 +11,7 @@ class Source:
 def load(name, textColumn="contentWithoutClass",
         classColumn="ensemble_domaine_enccre"):
     fileName = name if isfile(name) else "datasets/{name}.tsv".format(name=name)
-    return Source(pandas.read_csv(fileName, sep='\t')\
+    return Source(pandas.read_csv(fileName, sep='\t', na_filter=False)\
             .dropna(subset=[classColumn])\
             .reset_index(drop=True)
         )
diff --git a/README.md b/README.md
index a3d8afbc1fbde4e27983c3a539104eae7210db92..3ecf1ce8897e404d15a7a851157f834299e846d5 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,66 @@
-# Classification automatique d'articles encyclopédiques
+# PyEDdA
 
-Ce dépôt est proposé par **Khaled Chabane**, **Ludovic Moncla** et **Alice Brenon** dans le cadre du [Projet GEODE](https://geode-project.github.io/).
-Il contient le code développé pour l'article "*Classification automatique d'articles encyclopédiques*" ([https://hal.archives-ouvertes.fr/hal-03481219v1](https://hal.archives-ouvertes.fr/hal-03481219v1)) présenté lors de la conférence [EGC 2022](https://egc2022.univ-tours.fr/).
+Ce dépôt contient le code réalisé dans le cadre du projet
+[GEODE](https://geode-project.github.io/) par **Khaled Chabane**, **Ludovic
+Moncla** et **Alice Brenon**.
 
-## Présentation
-
-Ce dépôt contient le code développée pour une étude comparative de différentes approches de classification supervisée appliquées à la classification automatique d’articles encyclopédiques. Notre corpus d’apprentissage est constitué des 17 volumes de texte de l’Encyclopédie de Diderot et d’Alembert (1751-1772) représentant un total d’environ 70 000 articles. Nous avons expérimenté différentes approches de vectorisation de textes (sac de mots et plongement de mots) combinées à des méthodes d’apprentissage automatique classiques, d’apprentissage profond et des architectures BERT. En plus de la comparaison de ces différentes approches, notre objectif est d’identifier de manière automatique les domaines des articles non classés de l’Encyclopédie (environ 2 400 articles). 
+Il contient le code développé à l'origine pour l'article "*Classification
+automatique d'articles encyclopédiques*"
+([https://hal.archives-ouvertes.fr/hal-03481219v1](https://hal.archives-ouvertes.fr/hal-03481219v1))
+présenté lors de la conférence [EGC 2022](https://egc2022.univ-tours.fr/).
 
-## Méthodes testées
+## Utilisation
 
-Nos expérimentations concernent l’étude de différentes approches de classification comprenant deux étapes principales : la vectorisation et la classification supervisée. Nous avons testé et comparé les différentes combinaisons suivantes :
+Ce dépôt est un paquet python pouvant être installé avec
+[`pip`](https://pypi.org/) ainsi qu'avec [`guix`](https://guix.gnu.org/). À
+partir d'une copie, depuis ce dossier, il est possible d'obtenir un
+environnement dans lequel `pyedda` est installé et utilisable dans un shell à
+l'aide des commandes suivantes:
 
-1. vectorisation en sac de mots et apprentissage automatique classique (Naive Bayes, Logistic regression, Random Forest, SVM et SGD) ;
-2. vectorisation en plongement de mots statiques (Doc2Vec) et apprentissage automatique classique (Logistic regression, Random Forest, SVM et SGD) ;
-3. vectorisation en plongement de mots statiques (FastText) et apprentissage profond (CNN et LSTM) ;
-4. approche *end-to-end* utilisant un modèle de langue pré-entraîné (BERT,CamemBERT) et une technique de *fine-tuning* pour adapter le modèle sur notre tâche de classification.
+### Pip
 
+```sh
+pip install -e .
+```
 
+### Guix
 
-## Résultats
-
+```sh
+guix shell python -f guix.scm
+```
 
+## Présentation
 
+Ce dépôt contient le code développée pour une étude comparative de différentes
+approches de classification supervisée appliquées à la classification
+automatique d’articles encyclopédiques. Notre corpus d’apprentissage est
+constitué des 17 volumes de texte de l’Encyclopédie de Diderot et d’Alembert
+(1751-1772) représentant un total d’environ 70 000 articles. Nous avons
+expérimenté différentes approches de vectorisation de textes (sac de mots et
+plongement de mots) combinées à des méthodes d’apprentissage automatique
+classiques, d’apprentissage profond et des architectures BERT. En plus de la
+comparaison de ces différentes approches, notre objectif est d’identifier de
+manière automatique les domaines des articles non classés de l’Encyclopédie
+(environ 2 400 articles).
 
+## Méthodes testées
 
+Nos expérimentations concernent l’étude de différentes approches de
+classification comprenant deux étapes principales : la vectorisation et la
+classification supervisée. Nous avons testé et comparé les différentes
+combinaisons suivantes :
+
+1. vectorisation en sac de mots et apprentissage automatique classique (Naive
+   Bayes, Logistic regression, Random Forest, SVM et SGD) ;
+2. vectorisation en plongement de mots statiques (Doc2Vec) et apprentissage
+   automatique classique (Logistic regression, Random Forest, SVM et SGD) ;
+3. vectorisation en plongement de mots statiques (FastText) et apprentissage
+   profond (CNN et LSTM) ;
+4. approche *end-to-end* utilisant un modèle de langue pré-entraîné
+   (BERT,CamemBERT) et une technique de *fine-tuning* pour adapter le modèle sur
+   notre tâche de classification.
 
+## Résultats
 
 ### F-mesures moyennes des différents modèles pour les jeux de validation et de test avec un échantillonnage max de 500 (1) et 1 500 (2) articles par classe et sans échantillonnage (3).
 
@@ -74,22 +110,30 @@ Nos expérimentations concernent l’étude de différentes approches de clas
 | Mathématiques           | 164     | 0.88 | 0.00 | 0.89 | Superstition         | 26      | 0.81 | 0.00 | 0.73 |
 | Musique                 | 163     | 0.94 | 0.01 | 0.94 | Spectacle            | 11      | 0.17 | 0.00 | 0.00 |
 
-
-
 ### Matrice de confusion obtenue avec l’approche SGD+TF-IDF sur le jeu de test
 
 ![image info](./img/sgd_tf_idf_s10000.png)
 
-Cette figure présente la matrice de confusion obtenue avec la méthode SGD+TF-IDF sur le jeu de test. On peut voir qu’un grand nombre d’articles des classes *Arts et métiers* et *Economie domestique* a été classé dans la classe *Métiers*, de la même manière les classes *Mesure*, *Miné- ralogie*, *Pharmacie* et *Politique* sont souvent confondues avec les classes *Commerce*, *Histoire naturelle*, *Médecine - Chirurgie* et *Droit - Jurisprudence*, respectivement. Les proximités sé- mantiques entre ces classes montrent bien la difficulté pour les modèles de choisir entre l’une ou l’autre et les résultats confirment qu’en cas de trop grande proximité les modèles choisissent la classe la plus représentée dans le jeu de données.
-
-
+Cette figure présente la matrice de confusion obtenue avec la méthode SGD+TF-IDF
+sur le jeu de test. On peut voir qu’un grand nombre d’articles des classes *Arts
+et métiers* et *Economie domestique* a été classé dans la classe *Métiers*, de
+la même manière les classes *Mesure*, *Miné- ralogie*, *Pharmacie* et
+*Politique* sont souvent confondues avec les classes *Commerce*, *Histoire
+naturelle*, *Médecine - Chirurgie* et *Droit - Jurisprudence*, respectivement.
+Les proximités sé- mantiques entre ces classes montrent bien la difficulté pour
+les modèles de choisir entre l’une ou l’autre et les résultats confirment qu’en
+cas de trop grande proximité les modèles choisissent la classe la plus
+représentée dans le jeu de données.
 
 ## Citation
 
-Moncla, L., Chabane, K., et Brenon, A. (2022). Classification automatique d’articles encyclopédiques. *Conférence francophone sur l’Extraction et la Gestion des Connaissances (EGC)*. Blois, France.
-
-
+Moncla, L., Chabane, K., et Brenon, A. (2022). Classification automatique
+d’articles encyclopédiques. *Conférence francophone sur l’Extraction et la
+Gestion des Connaissances (EGC)*. Blois, France.
 
 ## Remerciements
 
-Les auteurs remercient le [LABEX ASLAN](https://aslan.universite-lyon.fr/) (ANR-10-LABX-0081) de l'Université de Lyon pour son soutien financier dans le cadre du programme français  "Investissements d'Avenir" géré par l'Agence Nationale de la Recherche  (ANR).
+Les auteurs remercient le [LABEX ASLAN](https://aslan.universite-lyon.fr/)
+(ANR-10-LABX-0081) de l'Université de Lyon pour son soutien financier dans le
+cadre du programme français  "Investissements d'Avenir" géré par l'Agence
+Nationale de la Recherche  (ANR).
diff --git a/guix.scm b/guix.scm
new file mode 100644
index 0000000000000000000000000000000000000000..578f37a44f0826bdf208e55188e0b91263d76a58
--- /dev/null
+++ b/guix.scm
@@ -0,0 +1,31 @@
+(use-modules ((gnu packages python-science) #:select (python-pandas))
+             ((gnu packages python-xyz) #:select (python-matplotlib
+                                                  python-nltk
+                                                  python-seaborn))
+             (guix gexp)
+             ((guix licenses) #:select (lgpl3+))
+             (guix packages)
+             (guix build-system python))
+
+(let
+  ((%source-dir (dirname (current-filename))))
+  (package
+    (name "python-pyedda")
+    (version "0.1.0")
+    (source
+      (local-file %source-dir
+                  #:recursive? #t
+                  #:select? (lambda (x . _) (not (string=? (basename x) ".git")))))
+    (build-system python-build-system)
+    (propagated-inputs
+      (list python-matplotlib
+            python-nltk
+            python-pandas
+            python-seaborn))
+    (home-page "https://gitlab.liris.cnrs.fr/geode/pyedda")
+    (synopsis "A set of tools to explore the EDdA")
+    (description
+	  "PyEDdA provides a python library to expose the data from the Encyclopédie
+	  by Diderot & d'Alembert, as well as several subpackages for the various
+	  approach tested in the course of project GÉODE.")
+    (license lgpl3+)))
diff --git a/lib/topNGrams.py b/lib/topNGrams.py
deleted file mode 100644
index ed33b93224fdfa9babbd988a7a575cc7a7fd893a..0000000000000000000000000000000000000000
--- a/lib/topNGrams.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from cache import Cache
-import data
-import nltk
-import pandas
-import results
-import sys
-
-def frequenciesLoader(articles, n, domain):
-    texts = data.domain(articles, domain).contentWithoutClass
-    state = {}
-    for text in texts:
-        ngrams = list(nltk.ngrams(text.split(), n))
-        for k in ngrams:
-            state[k] = 1+(state[k] if k in state else 0)
-    return state
-
-def frequenciesPath(inputHash, n):
-    return lambda domain:\
-        "frequencies/{inputHash}/{n}grams/{domain}.csv"\
-                .format(inputHash=inputHash, n=n, domain=domain)
-
-def loadFrequencies(f):
-    csv = pandas.read_csv(f, sep='\t')
-    return dict(zip(
-            csv.ngram.map(lambda s: tuple(s.split(','))),
-            csv.frequency
-        ))
-
-def saveFrequencies(data, f):
-    pandas.DataFrame(data={
-            'ngram': map(lambda t: ','.join(t), data.keys()),
-            'frequency': data.values()
-        }).to_csv(f, sep='\t', index=False)
-
-def frequencies(source, n):
-    return Cache(
-            lambda domain: frequenciesLoader(source.articles, n, domain),
-            pathPolicy=frequenciesPath(source.hash, n),
-            serializer=saveFrequencies,
-            unserializer=loadFrequencies
-        )
-
-def topLoader(frequencyEvaluator, n, ranks):
-    return lambda domain:\
-        dict(nltk.FreqDist(frequencyEvaluator(n, domain)).most_common(ranks))
-
-def topPath(inputHash, n, ranks):
-    return lambda domain:\
-        "topNGrams/{inputHash}/{n}grams/top{ranks}/{domain}.csv".format(
-                inputHash=inputHash,
-                n=n,
-                ranks=ranks,
-                domain=domain
-            )
-
-def topNGrams(source, n, ranks):
-    freq = frequencies(source, n)
-    return Cache(
-            topLoader(freq, n, ranks),
-            pathPolicy=topPath(source.hash, n, ranks),
-            serializer=saveFrequencies,
-            unserializer=loadFrequencies
-        )
-
-def __syntax(this):
-    print(
-            "Syntax: {this} {required} {optional}".format(
-                    this=this,
-                    required="ARTICLES_DATA(.csv)",
-                    optional="[NGRAM SIZE] [TOP_RANKS_SIZE] [DOMAIN]"
-                ),
-            file=sys.stderr
-        )
-    sys.exit(1)
-
-def __compute(articlesSource, ns, ranksToTry, domains):
-    for n in ns:
-        for ranks in ranksToTry:
-            cached = topNGrams(data.load(articlesSource), n, ranks)
-            for domain in domains:
-                cached(domain)
-
-if __name__ == '__main__':
-    argc = len(sys.argv)
-    if argc < 2:
-        __syntax(sys.argv[0])
-    else:
-        articlesSource = sys.argv[1]
-        ns = [int(sys.argv[2])] if argc > 2 else range(1,4)
-        ranksToTry = [int(sys.argv[3])] if argc > 3 else [10, 100, 50]
-        domains = [sys.argv[4]] if argc > 4 else data.domains
-        __compute(articlesSource, ns, ranksToTry, domains)
diff --git a/scripts/confusionMatrices.py b/scripts/confusionMatrices.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff0c990c75c50615104bca1f2dbe6526c370d257
--- /dev/null
+++ b/scripts/confusionMatrices.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+
+from EDdA import data
+from EDdA.classification import confusionMatrix, metrics, toPNG, topNGrams
+import os
+import sys
+
+def preparePath(root, source, n, ranks, metricName):
+    path = "{root}/confusionMatrix/{inputHash}/{n}grams_top{ranks}_{name}.png".format(
+            root=root,
+            inputHash=source.hash,
+            n=n,
+            ranks=ranks,
+            name=metricName
+        )
+    os.makedirs(os.path.dirname(path), exist_ok=True)
+    return path
+
+def __syntax(this):
+    print(
+            "Syntax: {this} {required} {optional}".format(
+                    this=this,
+                    required="ARTICLES_DATA(.csv) OUTPUT_DIR",
+                    optional="[NGRAM SIZE] [TOP_RANKS_SIZE] [METRIC_NAME]"
+                ),
+            file=sys.stderr
+        )
+    sys.exit(1)
+
+def __compute(sourcePath, ns, ranksToTry, metricNames, outputDir):
+    for n in ns:
+        for ranks in ranksToTry:
+            source = data.load(sourcePath)
+            vectorizer = topNGrams(source, n, ranks)
+            for name in metricNames:
+                imagePath = preparePath(outputDir, source, n, ranks, name)
+                toPNG(confusionMatrix(vectorizer, metrics[name]), imagePath)
+
+if __name__ == '__main__':
+    argc = len(sys.argv)
+    if argc < 2:
+        __syntax(sys.argv[0])
+    else:
+        sourcePath = sys.argv[1]
+        outputDir = sys.argv[2]
+        ns = [int(sys.argv[3])] if argc > 3 else range(1,4)
+        ranksToTry = [int(sys.argv[4])] if argc > 4 else [10, 100, 50]
+        metricNames = [sys.argv[5]] if argc > 5 else metrics.keys()
+        __compute(sourcePath, ns, ranksToTry, metricNames, outputDir)
diff --git a/scripts/topNGrams.py b/scripts/topNGrams.py
new file mode 100644
index 0000000000000000000000000000000000000000..db85b66fde1b1e801c9a7d0e5435c6324cf55f56
--- /dev/null
+++ b/scripts/topNGrams.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+
+from EDdA import data
+from EDdA.classification import topNGrams
+import sys
+
+def __syntax(this):
+    print(
+            "Syntax: {this} {required} {optional}".format(
+                    this=this,
+                    required="ARTICLES_DATA(.tsv)",
+                    optional="[NGRAM SIZE] [TOP_RANKS_SIZE] [DOMAIN]"
+                ),
+            file=sys.stderr
+        )
+    sys.exit(1)
+
+def __populateCache(articlesSource, ns, ranksToTry, domains):
+    for n in ns:
+        for ranks in ranksToTry:
+            cached = topNGrams(data.load(articlesSource), n, ranks)
+            for domain in domains:
+                cached(domain)
+
+if __name__ == '__main__':
+    argc = len(sys.argv)
+    if argc < 2:
+        __syntax(sys.argv[0])
+    else:
+        articlesSource = sys.argv[1]
+        ns = [int(sys.argv[2])] if argc > 2 else range(1,4)
+        ranksToTry = [int(sys.argv[3])] if argc > 3 else [10, 100, 50]
+        domains = [sys.argv[4]] if argc > 4 else data.domains
+        __populateCache(articlesSource, ns, ranksToTry, domains)
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..286add97b1c34f47a1d944342dd995f3806c3f7c
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+
+from setuptools import setup
+
+setup(name='EDdA',
+      version='0.1',
+      packages=['EDdA', 'EDdA.classification'])