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 eÌtude comparative de diffeÌrentes approches de classification superviseÌe appliqueÌes aÌ€ la classification automatique d’articles encyclopeÌdiques. Notre corpus d’apprentissage est constitueÌ des 17 volumes de texte de l’EncyclopeÌdie de Diderot et d’Alembert (1751-1772) repreÌsentant un total d’environ 70 000 articles. Nous avons expeÌrimenteÌ diffeÌrentes approches de vectorisation de textes (sac de mots et plongement de mots) combineÌes aÌ€ des meÌthodes d’apprentissage automatique classiques, d’apprentissage profond et des architectures BERT. En plus de la comparaison de ces diffeÌrentes approches, notre objectif est d’identifier de manieÌ€re automatique les domaines des articles non classeÌs de l’EncyclopeÌ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 expeÌrimentations concernent l’eÌtude de diffeÌrentes approches de classification comprenant deux eÌtapes principales : la vectorisation et la classification superviseÌe. Nous avons testeÌ et compareÌ les diffeÌ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 modeÌ€le de langue preÌ-entraiÌ‚neÌ (BERT,CamemBERT) et une technique de *fine-tuning* pour adapter le modeÌ€le sur notre taÌ‚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 eÌtude comparative de diffeÌrentes +approches de classification superviseÌe appliqueÌes aÌ€ la classification +automatique d’articles encyclopeÌdiques. Notre corpus d’apprentissage est +constitueÌ des 17 volumes de texte de l’EncyclopeÌdie de Diderot et d’Alembert +(1751-1772) repreÌsentant un total d’environ 70 000 articles. Nous avons +expeÌrimenteÌ diffeÌrentes approches de vectorisation de textes (sac de mots et +plongement de mots) combineÌes aÌ€ des meÌthodes d’apprentissage automatique +classiques, d’apprentissage profond et des architectures BERT. En plus de la +comparaison de ces diffeÌrentes approches, notre objectif est d’identifier de +manieÌ€re automatique les domaines des articles non classeÌs de l’EncyclopeÌdie +(environ 2 400 articles). +## Méthodes testées +Nos expeÌrimentations concernent l’eÌtude de diffeÌrentes approches de +classification comprenant deux eÌtapes principales : la vectorisation et la +classification superviseÌe. Nous avons testeÌ et compareÌ les diffeÌ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 modeÌ€le de langue preÌ-entraiÌ‚neÌ + (BERT,CamemBERT) et une technique de *fine-tuning* pour adapter le modeÌ€le sur + notre taÌ‚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 expeÌrimentations concernent l’eÌtude de diffeÌ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  -Cette figure preÌsente la matrice de confusion obtenue avec la meÌthode SGD+TF-IDF sur le jeu de test. On peut voir qu’un grand nombre d’articles des classes *Arts et meÌtiers* et *Economie domestique* a eÌteÌ classeÌ dans la classe *MeÌtiers*, de la meÌ‚me manieÌ€re les classes *Mesure*, *MineÌ- ralogie*, *Pharmacie* et *Politique* sont souvent confondues avec les classes *Commerce*, *Histoire naturelle*, *MeÌdecine - Chirurgie* et *Droit - Jurisprudence*, respectivement. Les proximiteÌs seÌ- mantiques entre ces classes montrent bien la difficulteÌ pour les modeÌ€les de choisir entre l’une ou l’autre et les reÌsultats confirment qu’en cas de trop grande proximiteÌ les modeÌ€les choisissent la classe la plus repreÌsenteÌe dans le jeu de donneÌes. - - +Cette figure preÌsente la matrice de confusion obtenue avec la meÌthode SGD+TF-IDF +sur le jeu de test. On peut voir qu’un grand nombre d’articles des classes *Arts +et meÌtiers* et *Economie domestique* a eÌteÌ classeÌ dans la classe *MeÌtiers*, de +la meÌ‚me manieÌ€re les classes *Mesure*, *MineÌ- ralogie*, *Pharmacie* et +*Politique* sont souvent confondues avec les classes *Commerce*, *Histoire +naturelle*, *MeÌdecine - Chirurgie* et *Droit - Jurisprudence*, respectivement. +Les proximiteÌs seÌ- mantiques entre ces classes montrent bien la difficulteÌ pour +les modeÌ€les de choisir entre l’une ou l’autre et les reÌsultats confirment qu’en +cas de trop grande proximiteÌ les modeÌ€les choisissent la classe la plus +repreÌsenteÌe dans le jeu de donneÌ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'])