diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b81fba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# pycharm +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b01acfd --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, Guillaume Dubuisson Duplessis +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcf4985 --- /dev/null +++ b/README.md @@ -0,0 +1,348 @@ +# gowpy + +A very simple framework for exploiting graph-of-words in NLP. +Currently at version **0.1.0** (alpha). + +gowpy leverages graph-of-words representation in order to do: +- **document classification** in a [scikit-learn](https://scikit-learn.org)-like + way via useful vectorizers, and +- **keyword extraction** from a document. + +## Quick Start +### Requirements and Installation +This project is based on Python 3.6+, [scikit-learn](https://github.com/scikit-learn/scikit-learn) and +[NetworkX](https://github.com/networkx/networkx). + +#### Installation from PyPI +```bash +pip install gowpy +``` + +#### Installation from the GitHub Source +First, clone the project: +```bash +git clone https://github.com/GuillaumeDD/gowpy.git +``` + +Then, `cd` to the project folder and run the install command: +```bash +cd gowpy/ +python setup.py install +``` + +### Example Usage + +#### Building a Graph-of-Words from a Document + +```python +from gowpy.gow.builder import GoWBuilder + +# Creation of a graph-of-words builder +# Here: +# - the graph-of-words will be directed, and +# - an edge will link every tokens co-occurring in a sliding window of size 4 +# +builder = GoWBuilder(directed=True, window_size=4) + +text = """gowpy is a simple framework for exploiting graph-of-words in nlp gowpy +leverages graph-of-words representation for document classification and for keyword extraction +from a document""" + +# Here, a preprocessing step fitted to the need of the project should be carried out + +# Creation of the graph-of-words +gow = builder.compute_gow_from_document(text) +``` + +Then, it is possible to visualize the document as a graph-of-words: +```python +import matplotlib.pyplot as plt +import networkx as nx + +g = gow.to_labeled_graph() + +options = { + "font_weight" : 'normal', + "font_color" : 'darkblue', + # + "edge_color" : 'lightgray', + # + "node_size" : 200, + "node_color": 'white', + "with_labels": True, +} +nx.draw(g, **options) +``` + +![A graph-of-words example](./resources/gow.png) + +#### Unsupervised Keywords Extraction +Graph-of-words can be leveraged to extract an automatically adaptative number of +cohesive keywords from a text document in an unsupervised fashion [[2,3]](#references). + +```python +from gowpy.summarization.unsupervised import GoWKeywordExtractor + +# Initialization of the keyword extractor +extractor_kw = GoWKeywordExtractor(directed=False, window_size=4) + +# +# Note that preprocessing is particularly important for keyword extraction +# in order to keep and normalize important terms such as adjectives and nouns. +# +# An already preprocessed text in which to extract keywords +preprocessed_text = """gowpy simple framework exploiting graph-of-words nlp gowpy +leverages graph-of-words representation document classification keyword extraction +document""" + +extractor_kw.extract(preprocessed_text) +``` + +Returns: +```text +[('gowpy', 4), + ('simple', 4), + ('framework', 4), + ('exploiting', 4), + ('graph-of-words', 4), + ('nlp', 4)] +``` + + +#### Classification with TW-IDF: a graph-based term weighting score +TW-IDF [[0]](#references) challenges the term independence assumption behind +the bag-of-words model by (i) exploiting a graph-of-words representation of a +document (here an unweighted directed graph of terms), and by (ii) leveraging +this new representation to replace the term frequency (TF) by graph-based term +weights (TW). + +TW-IDF is accessible via a dedicated vectorizer: +```python +from gowpy.feature_extraction.gow import TwidfVectorizer + +corpus = [ + 'hello world !', + 'foo bar' +] + +vectorizer_gow = TwidfVectorizer( + # Graph-of-words specificities + directed=True, + window_size=4, + # Token frequency filtering + min_df=0.0, + max_df=1.0, + # Graph-based term weighting approach + term_weighting='degree' +) + +X = vectorizer_gow.fit_transform(corpus) +X +``` +Returns: +```text +<2x5 sparse matrix of type '' + with 3 stored elements in Compressed Sparse Row format> +``` + +TW-IDF vectorizer fits seamlessly in a grid search: +```python +from sklearn.pipeline import Pipeline +from sklearn.svm import SVC + +from sklearn.model_selection import GridSearchCV + +pipeline = Pipeline([ + ('gow', TwidfVectorizer()), + ('svm', SVC()), +]) + +parameters = { + 'gow__directed' : [True, False], + 'gow__window_size' : [2, 4, 8, 16], + 'gow__b' : [0.0, 0.003], + 'gow__term_weighting' : ['degree', 'pagerank'], + 'gow__min_df' : [0, 5, 10], + 'gow__max_df' : [0.8, 0.9, 1.0], +# + 'svm__C' : [0.1, 1, 10], + 'svm__kernel' : ['linear'] +} + +# find the best parameters for both the feature extraction and the +# classifier +grid_search = GridSearchCV(pipeline, + parameters, + cv=10, + n_jobs=-1) +``` + +#### Going further: classification based on frequent subgraphs +Frequent subgraphs corresponding to long range n-gram can be mined and +subsequently used for document classification [[1]](#references). + +Classification with frequent subgraphs happens in a 3-step process: +1. Conversion of the corpus of already preprocessed documents into a collection + of graph-of-words +1. Mining the frequent subgraphs +1. Loading the frequent subgraphs and exploiting them for classification + +##### Conversion of the corpus into a collection of graph-of-words +The first step consists in turning the corpus into a graph-of-words and collection +and then export that collection into a file format suited for frequent subgraph +mining. +```python +from gowpy.gow.miner import GoWMiner +import gowpy.gow.io + +corpus = [ + 'hello world !', + 'foo bar', + # and many more... +] + +# Conversation of the corpus into a collection of graph-of-words +gow_miner = GoWMiner(directed=False, window_size=4) +corpus_gows = gow_miner.compute_gow_from_corpus(corpus) + +# Exportation of the collection of graph-of-words into a file for +# interoperability with other languages such as C++ +with open("corpus_gows.data", "w") as f_output: + data = gowpy.gow.io.gow_to_data(corpus_gows) + f_output.write(data) +``` + +##### Mining the frequent subgraphs +Frequent subgraphs mining can be realized via the [gSpan algorithm](https://www.cs.ucsb.edu/~xyan/software/gSpan.htm). +This step is not included in this project and has to be carried out by another +program. + +This project supports the reimplementation from [gBolt available at GitHub](https://github.com/Jokeren/gBolt). +Currently this implementation is limited to **undirected graph**. +To mine frequent subgraphs (after having installed gBolt on your machine): +```bash +OMP_NUM_THREADS=1 ./gbolt --input corpus_gows.data --output gbolt-mining-corpus_gow --dfs --nodes --support 0.01 +``` +Notice the **support parameter** which defines the minimum frequency of a subgraph +to be considered as frequent. Here it is set to 1% (0.01). +This parameter is **corpus specific** and should be carefully tuned (see [[1]](#references)). + +Mining produces two files: +- `gbolt-mining-corpus_gow.t0`: the frequent subgraphs with more than one node +- `gbolt-mining-corpus_gow.nodes`: the frequent single nodes + +These two files can be loaded by the same `gow_miner` used for exportation: +```python +gow_miner.load_graphs('gbolt-mining-corpus_gow.t0', + 'gbolt-mining-corpus_gow.nodes') +gow_miner +``` +Returns: +```text +Graph-of-word miner: + - is_directed: False + - window_size: 4 + - edge_labeling: True + + - Number of tokens: 5 + - Number of links between tokens: 4 + + - Number of loaded subgraph: 13 +``` + +##### Classification with frequent subgraphs +Classification with frequent subgraphs is accessible via a dedicated vectorizer: +```python +from gowpy.feature_extraction.gow import GoWVectorizer + +vectorizer_gow = GoWVectorizer(gow_miner) +X = vectorizer_gow.fit_transform(corpus) +# X is a sparse matrix +``` + +Before tuning the `min_df` (the minimum being the support chosen during mining) +and the `max_df`, it is possible the have a look at the normalized frequency +distribution: +```python +import pandas as pd +s_freq_per_pattern = pd.Series(gow_miner.stat_relative_freq_per_pattern()) +s_freq_per_pattern.describe() +``` +For instance, it can returns the following distribution: +```text +count 10369.000000 +mean 0.026639 +std 0.046551 +min 0.008333 +25% 0.010000 +50% 0.013333 +75% 0.022778 +max 0.865000 +dtype: float64 +``` + + +GoW vectorizer fits nicely in a grid search: +```python +from sklearn.pipeline import Pipeline +from sklearn.svm import SVC +from sklearn.feature_extraction.text import TfidfTransformer + +from sklearn.model_selection import GridSearchCV + +pipeline = Pipeline([ + ('gow', GoWVectorizer(gow_miner)), + ('tfidf', TfidfTransformer()), + ('svm', SVC()), +]) + +parameters = { + 'gow__subgraph_matching' : ['partial', 'induced'], + 'gow__min_df' : [0.00833, 0.01, 0.013333], + 'gow__max_df' : [0.022778, 0.5, 0.865], +# + 'svm__C' : [0.1, 1, 10], + 'svm__kernel' : ['linear'] +} + +# find the best parameters for both the feature extraction and the +# classifier +grid_search = GridSearchCV(pipeline, + parameters, + cv=10, + n_jobs=-1) +``` + +## References + +Detailed explanations, evaluations and discussions can be found in these papers: +- Information retrieval (TW-IDF) + + [0] [Graph-of-word and TW-IDF: New Approach to Ad Hoc IR](https://dl.acm.org/doi/abs/10.1145/2505515.2505671). + *Rousseau, François, and Michalis Vazirgiannis*. + *Proceedings of the 22nd ACM international conference on Information & Knowledge Management*.(**CIKM 2013**) +- Document classification with frequent subgraphs + + [1] [Text Categorization as a Graph Classification Problem](http://www.aclweb.org/anthology/P15-1164). + *Rousseau, François, Emmanouil Kiagias, and Michalis Vazirgiannis*. + *Proceedings of the 53rd Annual Meeting of the Association for Computational Linguistics and the 7th International + Joint Conference on Natural Language Processing* (**ACL 2015**) +- Keyword extraction from graph-of-words + + [2] [Main Core Retention on Graph-of-words for Single-Document Keyword Extraction](https://link.springer.com/chapter/10.1007/978-3-319-16354-3_42). + *Rousseau, François, and Michalis Vazirgiannis*. + *Proceedings of the 37th European Conference on Information Retrieval*. + (**ECIR 2015**) + + [3] [A Graph Degeneracy-based Approach to Keyword Extraction](https://www.aclweb.org/anthology/D16-1191/). + *Tiwier, Antoine Tixier, Malliaros Fragkiskos, and Vazirgiannis, Michalis*. + *Proceedings of the 2016 Conference on Empirical Methods in Natural Language Processing*. + (**EMNLP 2016**) + +This library involves the following algorithms: +- Frequent subgraph Mining (**currently not included in this library**) + + gSpan algorithm implementation for subgraph mining: [gBolt--very fast implementation for gSpan algorithm in data mining ](https://github.com/Jokeren/gBolt) +- Subgraph matching + + VF2 algorithm for subgraph isomorphism matching: [VF2 algorithm for subgraph isomorphism from NetworkX](https://networkx.github.io/documentation/stable/reference/algorithms/isomorphism.vf2.html) +- Graph degeneracy + + [k-core decomposition with NetworkX](https://networkx.github.io/documentation/stable/reference/algorithms/core.html) + + +## License +Released under the 3-Clause BSD license (see [LICENSE file](./LICENSE)) diff --git a/examples/README.ipynb b/examples/README.ipynb new file mode 100644 index 0000000..fc18ddc --- /dev/null +++ b/examples/README.ipynb @@ -0,0 +1,618 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# gowpy: README.md examples" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Building a Graph-of-Words from a Document" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from gowpy.gow.builder import GoWBuilder" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "builder = GoWBuilder(directed=True, \n", + " window_size=4)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "text = \"\"\"gowpy is a simple framework for exploiting graph-of-words in nlp gowpy \n", + "leverages graph-of-words representation for document classification and for keyword extraction \n", + "from a document\"\"\"\n", + "# ...\n", + "preprocessed_text = \"\"\"gowpy simple framework exploiting graph-of-words nlp gowpy \n", + "leverages graph-of-words representation document classification keyword extraction document\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "gow = builder.compute_gow_from_document(text)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Graph-of-words\n", + "Nodes: ['gowpy', 'is', 'a', 'simple', 'framework', 'for', 'exploiting', 'graph-of-words', 'in', 'nlp', 'leverages', 'representation', 'document', 'classification', 'and', 'keyword', 'extraction', 'from']\n", + "Edges: ['framework__graph-of-words', 'is__simple', 'exploiting__nlp', 'leverages__representation', 'for__exploiting', 'gowpy__a', 'graph-of-words__document', 'in__gowpy', 'extraction__document', 'for__classification', 'gowpy__graph-of-words', 'extraction__a', 'in__nlp', 'document__for', 'keyword__extraction', 'gowpy__leverages', 'a__document', 'graph-of-words__representation', 'a__for', 'gowpy__simple', 'for__in', 'is__a', 'extraction__from', 'nlp__gowpy', 'exploiting__graph-of-words', 'and__for', 'representation__for', 'leverages__graph-of-words', 'document__classification', 'for__document', 'in__leverages', 'from__a', 'gowpy__representation', 'simple__exploiting', 'simple__framework', 'nlp__graph-of-words', 'representation__classification', 'document__and', 'framework__for', 'for__from', 'classification__keyword', 'is__framework', 'nlp__leverages', 'graph-of-words__for', 'a__simple', 'and__keyword', 'for__keyword', 'representation__document', 'simple__for', 'gowpy__is', 'graph-of-words__nlp', 'leverages__for', 'keyword__from', 'graph-of-words__gowpy', 'framework__exploiting', 'exploiting__in', 'and__extraction', 'classification__and', 'for__extraction', 'keyword__a', 'for__graph-of-words', 'classification__for', 'for__and', 'from__document', 'graph-of-words__in', 'a__framework']" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gow" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import networkx as nx" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "g = gow.to_labeled_graph()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "options = {\n", + " \"font_weight\" : 'normal',\n", + " \"font_color\" : 'darkblue',\n", + " #\n", + " \"edge_color\" : 'lightgray',\n", + " #\n", + " \"node_size\" : 200,\n", + " \"node_color\": 'white',\n", + " \"with_labels\": True,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "nx.draw(g, **options)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Unsupervised Keywords Extraction" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "from gowpy.summarization.unsupervised import GoWKeywordExtractor" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "extractor_kw = GoWKeywordExtractor(directed=False, window_size=4)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "15" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "preprocessed_text = \"\"\"gowpy simple framework exploiting graph-of-words nlp gowpy \n", + "leverages graph-of-words representation document classification keyword extraction \n", + "document\"\"\"\n", + "len(preprocessed_text.split())" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[('gowpy', 4),\n", + " ('simple', 4),\n", + " ('framework', 4),\n", + " ('exploiting', 4),\n", + " ('graph-of-words', 4),\n", + " ('nlp', 4)]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "extractor_kw.extract(preprocessed_text)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Classification with TW-IDF: a graph-based term weighting score" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "from gowpy.feature_extraction.gow import TwidfVectorizer" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "corpus = [\n", + " 'hello world !',\n", + " 'foo bar'\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "vectorizer_gow = TwidfVectorizer( \n", + " # Graph-of-words specificities\n", + " directed=True,\n", + " window_size=4,\n", + " # Token frequency filtering\n", + " min_df=0.0,\n", + " max_df=1.0,\n", + " # Graph-based term weighting approach\n", + " term_weighting='degree'\n", + ")\n", + "\n", + "X = vectorizer_gow.fit_transform(corpus)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[0.89442719, 0. , 0. , 0. , 0.4472136 ],\n", + " [0. , 1. , 0. , 0. , 0. ]])" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "X.toarray()" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "<2x5 sparse matrix of type ''\n", + "\twith 3 stored elements in Compressed Sparse Row format>" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "X" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.pipeline import Pipeline\n", + "from sklearn.svm import SVC\n", + "\n", + "from sklearn.model_selection import GridSearchCV\n", + "\n", + "pipeline = Pipeline([\n", + " ('gow', TwidfVectorizer()),\n", + " ('svm', SVC()),\n", + "])\n", + "\n", + "parameters = {\n", + " 'gow__directed' : [True, False],\n", + " 'gow__window_size' : [2, 4, 8, 16],\n", + " 'gow__b' : [0.0, 0.003],\n", + " 'gow__term_weighting' : ['degree', 'pagerank'],\n", + " 'gow__min_df' : [0, 5, 10],\n", + " 'gow__max_df' : [0.8, 0.9, 1.0],\n", + "#\n", + " 'svm__C' : [0.1, 1, 10],\n", + " 'svm__kernel' : ['linear']\n", + "}\n", + "\n", + "# find the best parameters for both the feature extraction and the\n", + "# classifier\n", + "grid_search = GridSearchCV(pipeline, \n", + " parameters, \n", + " cv=10,\n", + " n_jobs=-1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Going further: classification based on frequent subgraphs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Conversion of the corpus into a collection of graph-of-words" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "from gowpy.gow.miner import GoWMiner\n", + "import gowpy.gow.io" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "corpus = [\n", + " 'hello world !',\n", + " 'foo bar',\n", + " # and many more...\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "gow_miner = GoWMiner(directed=False, window_size=4)\n", + "corpus_gows = gow_miner.compute_gow_from_corpus(corpus)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "with open(\"corpus_gows.data\", \"w\") as f_output:\n", + " data = gowpy.gow.io.gow_to_data(corpus_gows)\n", + " f_output.write(data)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Mining the frequent subgraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Graph-of-word miner:\n", + " - is_directed: False\n", + " - window_size: 4\n", + " - edge_labeling: True\n", + "\n", + " - Number of tokens: 5\n", + " - Number of links between tokens: 4\n", + "\n", + " - Number of loaded subgraph: 13\n", + " " + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gow_miner.load_graphs('gbolt-mining-corpus_gow.t0', \n", + " 'gbolt-mining-corpus_gow.nodes')\n", + "gow_miner" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Classification with frequent subgraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "count 13.0\n", + "mean 0.5\n", + "std 0.0\n", + "min 0.5\n", + "25% 0.5\n", + "50% 0.5\n", + "75% 0.5\n", + "max 0.5\n", + "dtype: float64" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "s_freq_per_pattern = pd.Series(gow_miner.stat_relative_freq_per_pattern())\n", + "s_freq_per_pattern.describe()" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "<2x13 sparse matrix of type ''\n", + "\twith 13 stored elements in Compressed Sparse Row format>" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from gowpy.feature_extraction.gow import GoWVectorizer\n", + "\n", + "vectorizer_gow = GoWVectorizer(gow_miner)\n", + "X = vectorizer_gow.fit_transform(corpus)\n", + "X" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "feature_names = vectorizer_gow.get_feature_names()" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Nombre de features: 10\n", + "\t- hello world hello__world\n", + "\t- hello world ! world__! hello__world\n", + "\t- hello world ! world__! hello__world hello__!\n", + "\t- hello world ! hello__world hello__!\n", + "\t- hello ! hello__!\n", + "\t- hello world ! world__! hello__!\n", + "\t- world ! world__!\n", + "\t- hello\n", + "\t- !\n", + "\t- world\n" + ] + } + ], + "source": [ + "features = [feature for presence, feature in zip(X.toarray()[0], feature_names) if presence > 0]\n", + "print(\"Nombre de features: {}\".format(len(features)))\n", + "for feature in features:\n", + " print(f'\\t- {feature}')" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.pipeline import Pipeline\n", + "from sklearn.svm import SVC\n", + "from sklearn.feature_extraction.text import TfidfTransformer\n", + "\n", + "from sklearn.model_selection import GridSearchCV" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = Pipeline([\n", + " ('gow', GoWVectorizer(gow_miner)),\n", + " ('tfidf', TfidfTransformer()),\n", + " ('svm', SVC()),\n", + "])\n", + "\n", + "parameters = {\n", + " 'gow__subgraph_matching' : ['partial', 'induced'],\n", + " 'gow__min_df' : [0.00833, 0.01, 0.013333],\n", + " 'gow__max_df' : [0.022778, 0.25, 0.5, 1.0],\n", + "#\n", + " 'svm__C' : [0.1, 1, 10],\n", + " 'svm__kernel' : ['linear']\n", + "}\n", + "\n", + "# find the best parameters for both the feature extraction and the\n", + "# classifier\n", + "grid_search = GridSearchCV(pipeline, \n", + " parameters, \n", + " cv=10,\n", + " n_jobs=-1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [conda env:gowpy36]", + "language": "python", + "name": "conda-env-gowpy36-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.10" + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": true + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/gowpy/__init__.py b/gowpy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gowpy/feature_extraction/__init__.py b/gowpy/feature_extraction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gowpy/feature_extraction/gow/__init__.py b/gowpy/feature_extraction/gow/__init__.py new file mode 100644 index 0000000..bc2fa07 --- /dev/null +++ b/gowpy/feature_extraction/gow/__init__.py @@ -0,0 +1,2 @@ +from .gow_vectorizer import GoWVectorizer +from .tw_vectorizer import TwVectorizer, TwidfVectorizer diff --git a/gowpy/feature_extraction/gow/gow_vectorizer.py b/gowpy/feature_extraction/gow/gow_vectorizer.py new file mode 100644 index 0000000..1918667 --- /dev/null +++ b/gowpy/feature_extraction/gow/gow_vectorizer.py @@ -0,0 +1,225 @@ +import networkx.algorithms.isomorphism as iso +from networkx.algorithms import isomorphism + +import numbers + +from scipy.sparse import csr_matrix + +from typing import Sequence, Tuple, Generator + +from gowpy.gow.builder import GraphOfWords +from gowpy.gow.typing import Nodes + +from sklearn.base import BaseEstimator +from gowpy.gow.miner import GoWMiner + +SUBGRAPH_MATCHING_INDUCED = "induced" +SUBGRAPH_MATCHING_PARTIAL = "partial" + + +class GoWVectorizer(BaseEstimator): + """Convert a collection of text documents to a matrix of frequent subgraphs matching counts + + Frequent subgraphs have to be mined before using this vectorizer. + + This implementation produces a sparse representation of the counts using + scipy.sparse.csr_matrix. + + Parameters + ---------- + graph_of_words: GoWMiner + A graph-of-words miner containing the frequent subgraphs. + max_df : float in range [0.0, 1.0] or int, default=1.0 + Ignore frequent subgraphs that have a document frequency strictly + higher than the given threshold (corpus-specific frequent subgraphs). + If float, the parameter represents a proportion of documents, integer + absolute counts. + min_df : float in range [0.0, 1.0] or int, default=0.0 + Ignore frequent subgraphs that have a document frequency strictly + lower than the given threshold. This value is also called support + in the literature. + Note that the smallest value is defined with the support used when + mining frequent subgraphs. + If float, the parameter represents a proportion of documents, integer + absolute counts. + in the mapping should not be repeated and should not have any gap + between 0 and the largest index. + indexing : boolean, True by default + Frequent subgraphs are indexed for faster retrieval when computing + document features. + subgraph_matching : string {'induced', 'partial'} + Frequent subgraph matching approach + 'partial' (default) : subgraph matching corresponding to node and + edge inclusion. + 'induced' : slower approach, node-induced subgraph matching + """ + + def __init__(self, + graph_of_words: GoWMiner, + min_df: float = 0.0, + max_df: float = 1.0, + subgraph_matching: str = SUBGRAPH_MATCHING_PARTIAL, + indexing: bool = True): + self.graph_of_words = graph_of_words + + # Subgraph mining patterns + self.min_df: float = min_df + self.max_df: float = max_df + if self.min_df < 0.0: + raise ValueError("min_df is smaller than 0%") + + if self.max_df < 0.0: + raise ValueError("max_df is smaller than 0%") + + self.subgraph_matching: str = subgraph_matching + + if self.graph_of_words is None: + raise ValueError("No provided graph-of-words miner to compute features (graph_of_words is None)") + + self.indexing = indexing + + def __compute_subpatterns(self) -> Sequence[Tuple[int, GraphOfWords]]: + # Filtering patterns out by support + if self.graph_of_words is not None: + max_doc_count = (self.max_df / float(self.graph_of_words.corpus_size) + if isinstance(self.max_df, numbers.Integral) + else self.max_df) + min_doc_count = (self.min_df / float(self.graph_of_words.corpus_size) + if isinstance(self.min_df, numbers.Integral) + else self.min_df) + # Selecting subpatterns + subpatterns = [subgraph for subgraph in self.graph_of_words.frequent_subgraphs + if (float(subgraph.freq) / float(self.graph_of_words.corpus_size)) >= min_doc_count + if (float(subgraph.freq) / float(self.graph_of_words.corpus_size)) <= max_doc_count + ] + else: + subpatterns = [] + + if self.indexing: + # Indexing patterns by node codes + self.node_code_to_feature_i_s_ = {} + for feature_i, subgraph in enumerate(subpatterns): + for node_code in subgraph.nodes: + if node_code not in self.node_code_to_feature_i_s_: + self.node_code_to_feature_i_s_[node_code] = set() + + self.node_code_to_feature_i_s_[node_code].add(feature_i) + + return [(i, subgraph) for i, subgraph in enumerate(subpatterns)] + + def fit(self, raw_documents: Sequence[str], y=None): + self.selected_subpatterns_: Sequence[Tuple[int, GraphOfWords]] = self.__compute_subpatterns() + self.node_matcher_ = iso.categorical_node_match('label', -1) + + return self + + def fit_transform(self, raw_documents: Sequence[str], y=None): + self.fit(raw_documents, y) + return self.transform(raw_documents) + + def __get_probable_features_via_nodes(self, document_nodes: Nodes) -> Generator[ + Tuple[int, GraphOfWords], None, None]: + subpatterns = self.selected_subpatterns_ + + feature_i_s = set() + for node_code in document_nodes: + if node_code in self.node_code_to_feature_i_s_: + # Getting the feature indices in which the node code appears + temp_feature_i_s = self.node_code_to_feature_i_s_[node_code] + feature_i_s.update(temp_feature_i_s) + + for feature_i in sorted(feature_i_s): + _, subgraph = subpatterns[feature_i] + yield (feature_i, subgraph) + + def __iterate_over_features(self, document_nodes: Nodes) -> Generator[Tuple[int, GraphOfWords], None, None]: + if self.indexing: + return self.__get_probable_features_via_nodes(document_nodes) + else: + subpatterns = self.selected_subpatterns_ + return subpatterns + + def __is_iso_induced(self, + feature_gow: GraphOfWords, + document_gow: GraphOfWords) -> bool: + is_iso = False + document_nodes = document_gow.nodes + document_edges = document_gow.edges + + # optimisation: + # checking nodes and edges inclusion in document before running + # subgraph matching algorithms + # + if (feature_gow.nodes.issubset(document_nodes)) and \ + (feature_gow.edges.issubset(document_edges)): + if len(feature_gow.nodes) <= 2: + is_iso = True + else: + document_graph = document_gow.to_graph() + feature_graph = feature_gow.to_graph() + GM = isomorphism.GraphMatcher(document_graph, feature_graph, + node_match=self.node_matcher_) + is_iso = GM.subgraph_is_isomorphic() + + return is_iso + + @staticmethod + def __is_iso_partial(feature_gow: GraphOfWords, + document_gow: GraphOfWords) -> bool: + return (feature_gow.nodes.issubset(document_gow.nodes)) and \ + (feature_gow.edges.issubset(document_gow.edges)) + + def transform(self, raw_documents: Sequence[str]): + indptr = [0] + indices = [] + data = [] + + subpatterns = self.selected_subpatterns_ + temp_num_features = len(subpatterns) + + if temp_num_features > 0: + for document in raw_documents: + # Document to gowpy + document_gow = self.graph_of_words.compute_gow_from_document(document) + if self.subgraph_matching == SUBGRAPH_MATCHING_INDUCED: + # Feature computation + retained_features = [i_feature + for i_feature, feature_gow in self.__iterate_over_features(document_gow.nodes) + if self.__is_iso_induced(feature_gow, document_gow) + ] + else: + # Feature computation + retained_features = [i_feature + for i_feature, feature_gow in self.__iterate_over_features(document_gow.nodes) + if GoWVectorizer.__is_iso_partial(feature_gow, document_gow) + ] + + # Building blocks of the sparse matrix + for i_feature in retained_features: + indices.append(i_feature) + data.append(1) + indptr.append(len(indices)) + + resulting_matrix = csr_matrix((data, indices, indptr), dtype=int) + else: + resulting_matrix = csr_matrix((len(raw_documents), 0)) + return resulting_matrix + + def get_feature_names(self) -> Sequence[str]: + feature_names = [] + + subpatterns = self.selected_subpatterns_ + + for _, subgraph in subpatterns: + temp = [] + for n in subgraph.nodes_str(): + temp.append(n) + for e in subgraph.edges_str(): + temp.append(e) + + feature_names.append(' '.join(temp)) + + return feature_names + + def _more_tags(self): + return {'X_types': ['string']} diff --git a/gowpy/feature_extraction/gow/tw_vectorizer.py b/gowpy/feature_extraction/gow/tw_vectorizer.py new file mode 100644 index 0000000..6b518ba --- /dev/null +++ b/gowpy/feature_extraction/gow/tw_vectorizer.py @@ -0,0 +1,427 @@ +import networkx as nx +from networkx.algorithms.link_analysis.pagerank_alg import pagerank_numpy +from networkx.algorithms.centrality import degree_centrality, closeness_centrality, betweenness_centrality + +from typing import Sequence, Dict + +from gowpy.gow.builder import GoWBuilder, Tokenized_document +from gowpy.gow.typing import Tokenizer +from gowpy.utils.defaults import default_tokenizer + +from sklearn.base import BaseEstimator +from sklearn.feature_extraction.text import TfidfTransformer +from sklearn.pipeline import Pipeline + +from operator import itemgetter +import numbers +from collections import defaultdict + +import numpy as np +import scipy.sparse as sp + + +TERM_WEIGHT_DEGREE = "degree" +TERM_WEIGHT_DEGREE_CENTRALITY = "degree_centrality" +TERM_WEIGHT_CLOSENESS_CENTRALITY = "closeness_centrality" +TERM_WEIGHT_BETWEENNESS_CENTRALITY = "betweenness_centrality" +TERM_WEIGHT_PAGERANK = "pagerank" + + +# +# From: https://github.com/scikit-learn/scikit-learn/blob/95d4f0841d57e8b5f6b2a570312e9d832e69debc/sklearn/feature_extraction/text.py#L820 +# +def _document_frequency(X): + """Count the number of non-zero values for each feature in sparse X.""" + if sp.isspmatrix_csr(X): + return np.bincount(X.indices, minlength=X.shape[1]) + else: + return np.diff(X.indptr) + + +class TwVectorizer(BaseEstimator): + """Convert a collection of text documents to a matrix of graph-based weight for each token + + This implementation produces a sparse representation of the counts using + scipy.sparse.csr_matrix. + + Parameters + ---------- + max_df : float in range [0.0, 1.0] or int, default=1.0 + Ignore tokens that have a document frequency strictly + higher than the given threshold (corpus-specific stop words). + If float, the parameter represents a proportion of documents, integer + absolute counts. + min_df : float in range [0.0, 1.0] or int, default=1 + Ignore frequent subgraphs that have a document frequency strictly + lower than the given threshold. This value is also called support + in the literature. + If float, the parameter represents a proportion of documents, integer + absolute counts. + in the mapping should not be repeated and should not have any gap + between 0 and the largest index. + b : float {0.0, 0.003}, default=0.0 + Slope parameter of the tilting. + directed : boolean, True by default + If True, the graph-of-words is directed, else undirected + window_size : int, default=4 + Size of the window (in token) to build the graph-of-words. + term_weighting : string {'degree', 'degree_centrality', 'closeness_centrality', 'betweenness_centrality', 'pagerank'} + Graph-based term weighting approach for the nodes in the graph-of-words + 'degree' (default) : degree (undirected) or indegree (directed) of the nodes. + 'degree_centrality' : normalized degree centrality of the nodes + 'closeness_centrality' : very slow, closeness centrality of the nodes + 'betweenness_centrality' : very slow, the shortest-path betweenness centrality of the nodes + 'pagerank' : slow, the PageRank of the nodes + tokenizer : callable or None (default) + Override the string tokenization step. + """ + def __init__(self, + min_df: float = 0.0, + max_df: float = 1.0, + b: float = 0.0, + directed: bool = True, + window_size: int = 4, + term_weighting: str = TERM_WEIGHT_DEGREE, + tokenizer: Tokenizer = None): + # Subgraph mining patterns + self.min_df: float = min_df + self.max_df: float = max_df + if self.min_df < 0.0: + raise ValueError("min_df is smaller than 0%") + + if self.max_df < 0.0: + raise ValueError("max_df is smaller than 0%") + + self.term_weighting = term_weighting + + self.b = b + + self.tokenizer: Tokenizer = tokenizer if tokenizer is not None else default_tokenizer + + self.window_size = window_size + if self.window_size < 2: + raise ValueError("window_size < 2") + + self.directed = directed + + def __tw(self, tokens: Tokenized_document) -> Dict[str, int]: + """Computes the graph-based weight for each token of the document""" + gow = self.gow_builder_.compute_gow_from_tokenized_document(tokens) + graph = gow.to_graph() + tw = {} + if self.term_weighting == TERM_WEIGHT_DEGREE: + if graph.is_directed(): + dgraph = nx.DiGraph(graph) + for (node, degree) in dgraph.in_degree(graph.nodes): + token = self.gow_builder_.get_token_(node) + tw[token] = degree + else: + for (node, degree) in graph.degree(graph.nodes): + token = self.gow_builder_.get_token_(node) + tw[token] = degree + else: + degree_centrality, closeness_centrality, betweenness_centrality + if self.term_weighting == TERM_WEIGHT_DEGREE_CENTRALITY: + weighting_fct = degree_centrality + elif self.term_weighting == TERM_WEIGHT_CLOSENESS_CENTRALITY: + weighting_fct = closeness_centrality + elif self.term_weighting == TERM_WEIGHT_BETWEENNESS_CENTRALITY: + weighting_fct = betweenness_centrality + elif self.term_weighting == TERM_WEIGHT_PAGERANK: + weighting_fct = pagerank_numpy + else: + weighting_fct = lambda x: 1 + + if graph.is_directed(): + dgraph = nx.DiGraph(graph) + node_to_weight = weighting_fct(dgraph) + for (node, p) in node_to_weight.items(): + token = self.gow_builder_.get_token_(node) + tw[token] = p + else: + node_to_weight = weighting_fct(graph) + for (node, p) in node_to_weight.items(): + token = self.gow_builder_.get_token_(node) + tw[token] = p + return tw + + # + # Largely inspired by the CountVectorizer from scikit-learn + # See: https://github.com/scikit-learn/scikit-learn/blob/95d4f0841d57e8b5f6b2a570312e9d832e69debc/sklearn/feature_extraction/text.py#L1113 + # + def __count_vocab(self, tokenized_documents: Sequence[Tokenized_document], fixed_vocab: bool): + if fixed_vocab: + vocabulary = self.vocabulary_ + else: + vocabulary = defaultdict() + vocabulary.default_factory = vocabulary.__len__ + + j_indices = [] + indptr = [0] + data = [] + + for tokens in tokenized_documents: + feature_counter = {} + + tw = self.__tw(tokens) + + document_length = len(tokens) + denominator = 1.0 - self.b + self.b * (float(document_length) / self.avdl_) + + for feature in tokens: + try: + feature_idx = vocabulary[feature] + + if feature_idx not in feature_counter: + feature_counter[feature_idx] = tw[feature] / denominator + + except KeyError: + # Ignore out-of-vocabulary items for fixed_vocab=True + continue + + j_indices.extend(feature_counter.keys()) + data.extend(feature_counter.values()) + indptr.append(len(j_indices)) + + # disable defaultdict behaviour + if not fixed_vocab: + # disable defaultdict behaviour + vocabulary = dict(vocabulary) + if not vocabulary: + raise ValueError("empty vocabulary; perhaps the documents only" + " contain stop words") + + X = sp.csr_matrix((data, j_indices, indptr), + shape=(len(indptr) - 1, len(vocabulary)), + dtype=float) + + X.sort_indices() + + return vocabulary, X + + # + # Inspired by: https://github.com/scikit-learn/scikit-learn/blob/95d4f0841d57e8b5f6b2a570312e9d832e69debc/sklearn/feature_extraction/text.py#L1058 + # + def __sort_features(self, X, vocabulary): + """Sort features by name + Returns a reordered matrix and modifies the vocabulary in place + """ + sorted_features = sorted(vocabulary.items()) + map_index = np.empty(len(sorted_features), dtype=X.indices.dtype) + for new_val, (term, old_val) in enumerate(sorted_features): + vocabulary[term] = new_val + map_index[old_val] = new_val + + X.indices = map_index.take(X.indices, mode='clip') + return X + + # + # Inspired by: https://github.com/scikit-learn/scikit-learn/blob/95d4f0841d57e8b5f6b2a570312e9d832e69debc/sklearn/feature_extraction/text.py#L1072 + # + def __limit_features(self, X, vocabulary, high=None, low=None): + """Remove too rare or too common features. + Prune features that are non zero in more samples than high or less + documents than low, modifying the vocabulary, and restricting it to + at most the limit most frequent. + This does not prune samples with zero features. + """ + if high is None and low is None: + return X, set() + + # Calculate a mask based on document frequencies + dfs = _document_frequency(X) + mask = np.ones(len(dfs), dtype=bool) + if high is not None: + mask &= dfs <= high + if low is not None: + mask &= dfs >= low + + new_indices = np.cumsum(mask) - 1 # maps old indices to new + removed_terms = set() + for term, old_index in list(vocabulary.items()): + if mask[old_index]: + vocabulary[term] = new_indices[old_index] + else: + del vocabulary[term] + removed_terms.add(term) + kept_indices = np.where(mask)[0] + if len(kept_indices) == 0: + raise ValueError("After pruning, no terms remain. Try a lower" + " min_df or a higher max_df.") + return X[:, kept_indices], removed_terms + + def fit(self, raw_documents: Sequence[str], y=None): + self.fit_transform(raw_documents, y) + return self + + def fit_transform(self, raw_documents: Sequence[str], y=None): + max_df = self.max_df + min_df = self.min_df + + self.gow_builder_ = GoWBuilder(window_size=self.window_size, + directed=self.directed, + tokenizer=self.tokenizer) + N = len(raw_documents) + self.N_ = N + + avdl = 0.0 + tokenized_documents = [] + for document in raw_documents: + tok_document = self.tokenizer(document) + tokenized_documents.append(tok_document) + avdl += len(tok_document) + + avdl = avdl / float(N) + self.avdl_ = avdl + + vocabulary, X = self.__count_vocab(tokenized_documents, fixed_vocab=False) + X = self.__sort_features(X, vocabulary) + + max_doc_count = (max_df + if isinstance(max_df, numbers.Integral) + else max_df * N) + min_doc_count = (min_df + if isinstance(min_df, numbers.Integral) + else min_df * N) + + X, self.stop_words_ = self.__limit_features(X, vocabulary, max_doc_count, min_doc_count) + + self.vocabulary_ = vocabulary + + return X + + def transform(self, raw_documents: Sequence[str]): + _, X = self.__count_vocab([self.tokenizer(doc) for doc in raw_documents], fixed_vocab=True) + + return X + + def get_feature_names(self) -> Sequence[str]: + return [t for t, i in sorted(self.vocabulary_.items(), key=itemgetter(1))] + + def _more_tags(self): + return {'X_types': ['string']} + + +class TwidfVectorizer(BaseEstimator): + """Convert a collection of text documents to a TW-IDF matrix + + Equivalent to :class:`TwVectorizer` followed by + :class:`TfidfTransformer`. + + This implementation produces a sparse representation of the counts using + scipy.sparse.csr_matrix. + + Parameters + ---------- + max_df : float in range [0.0, 1.0] or int, default=1.0 + Ignore tokens that have a document frequency strictly + higher than the given threshold (corpus-specific stop words). + If float, the parameter represents a proportion of documents, integer + absolute counts. + min_df : float in range [0.0, 1.0] or int, default=1 + Ignore frequent subgraphs that have a document frequency strictly + lower than the given threshold. This value is also called support + in the literature. + If float, the parameter represents a proportion of documents, integer + absolute counts. + in the mapping should not be repeated and should not have any gap + between 0 and the largest index. + b : float {0.0, 0.003}, default=0.0 + Slope parameter of the tilting. + directed : boolean, True by default + If True, the graph-of-words is directed, else undirected + window_size : int, default=4 + Size of the window (in token) to build the graph-of-words. + term_weighting : string {'degree', 'degree_centrality', 'closeness_centrality', 'betweenness_centrality', 'pagerank'} + Graph-based term weighting approach for the nodes in the graph-of-words + 'degree' (default) : degree (undirected) or indegree (directed) of the nodes. + 'degree_centrality' : normalized degree centrality of the nodes + 'closeness_centrality' : very slow, closeness centrality of the nodes + 'betweenness_centrality' : very slow, the shortest-path betweenness centrality of the nodes + 'pagerank' : slow, the PageRank of the nodes + tokenizer : callable or None (default) + Override the string tokenization step. + norm : 'l1', 'l2' or None, optional (default='l2') + Each output row will have unit norm, either: + * 'l2': Sum of squares of vector elements is 1. The cosine + similarity between two vectors is their dot product when l2 norm has + been applied. + * 'l1': Sum of absolute values of vector elements is 1. + See :func:`preprocessing.normalize` + use_idf : boolean (default=True) + Enable inverse-document-frequency reweighting. + smooth_idf : boolean (default=True) + Smooth idf weights by adding one to document frequencies, as if an + extra document was seen containing every term in the collection + exactly once. Prevents zero divisions. + """ + + def __init__(self, + min_df: float = 0.0, + max_df: float = 1.0, + b: float = 0.0, + directed: bool = True, + window_size: int = 4, + term_weighting: str = TERM_WEIGHT_DEGREE, + tokenizer: Tokenizer = None, + # + norm='l2', + use_idf=True, + smooth_idf=True): + # Subgraph mining patterns + self.min_df: float = min_df + self.max_df: float = max_df + if self.min_df < 0.0: + raise ValueError("min_df is smaller than 0%") + + if self.max_df < 0.0: + raise ValueError("max_df is smaller than 0%") + + self.term_weighting = term_weighting + + self.b = b + + self.tokenizer: Tokenizer = tokenizer if tokenizer is not None else default_tokenizer + + self.window_size = window_size + if self.window_size < 2: + raise ValueError("window_size < 2") + + self.directed = directed + + self.norm = norm + self.use_idf = use_idf + self.smooth_idf = smooth_idf + + def fit(self, raw_documents: Sequence[str], y=None): + self.fit_transform(raw_documents, y) + return self + + def fit_transform(self, raw_documents: Sequence[str], y=None): + self.tw_vectorizer_ = TwVectorizer( + min_df=self.min_df, + max_df=self.max_df, + b=self.b, + directed=self.directed, + window_size=self.window_size, + term_weighting=self.term_weighting, + tokenizer=self.tokenizer) + self.tfidf_transformer_ = TfidfTransformer( + norm=self.norm, + use_idf=self.use_idf, + smooth_idf=self.smooth_idf) + + self.pipeline_ = Pipeline([ + ('tw', self.tw_vectorizer_), + ('idf', self.tfidf_transformer_) + ]) + return self.pipeline_.fit_transform(raw_documents, y) + + def transform(self, raw_documents: Sequence[str]): + return self.pipeline_.transform(raw_documents) + + def get_feature_names(self) -> Sequence[str]: + return [t for t, i in sorted(self.tw_vectorizer_.vocabulary_.items(), key=itemgetter(1))] + + def _more_tags(self): + return {'X_types': ['string']} diff --git a/gowpy/gow/__init__.py b/gowpy/gow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gowpy/gow/builder.py b/gowpy/gow/builder.py new file mode 100644 index 0000000..83fedbd --- /dev/null +++ b/gowpy/gow/builder.py @@ -0,0 +1,264 @@ +from typing import Sequence, Dict, Optional, Union, List, Callable + +from gowpy.gow.typing import Token, Tokenized_document, Tokenizer, \ + Edge, Edge_with_code, Edge_label, Edges, Nodes +import networkx as nx +from gowpy.utils.defaults import default_tokenizer + + +def mk_undirected_edge(node_start_code: int, + node_end_code: int, + code: Optional[int] = None) -> Union[Edge, Edge_with_code]: + """Builds an unambiguous representation of an undirected edge""" + if node_start_code < node_end_code: + n1, n2, label = node_start_code, node_end_code, code + else: + n1, n2, label = node_end_code, node_start_code, code + + if code is None: + return n1, n2 + else: + return n1, n2, label + + +def mk_directed_edge(node_start_code: int, + node_end_code: int, + code: Optional[int] = None) -> Union[Edge, Edge_with_code]: + """Builds an unambiguous representation of a directed edge""" + if code is None: + return node_start_code, node_end_code + else: + return node_start_code, node_end_code, code + + +class GraphOfWords(object): + """ + Represents a graph-of-words + + .. seealso:: gowpy.gow.builder.GoWBuilder + .. note:: this class should not be used directly, see GoWBuilder + """ + def __init__(self, + nodes: Nodes, + edges: Edges, + get_token: Callable[[int], str], + get_label: Optional[Callable[[int], Edge_label]], + freq: int = 1, + directed: bool = False): + self.get_token = get_token + self.get_label = get_label + + self.nodes = nodes + self.edges = edges + self.directed = directed + self.freq = freq + + self.graph_: Optional[nx.Graph] = None + + def is_edge_labeling(self): + return self.get_label is not None + + def __str__(self): + nodes = self.nodes_str() + edges = self.edges_str() + return """Graph-of-words\nNodes: {}\nEdges: {}\n""".format(nodes, edges) + + def __repr__(self): + return self.__str__() + + def nodes_str(self) -> List[str]: + return [self.get_token(node_code) for node_code in self.nodes] + + def __edges_to_str(self, edge: Edge) -> str: + start_node, end_node = edge + return f'{self.get_token(start_node)}__{self.get_token(end_node)}' + + def edges_str(self) -> List[str]: + if self.is_edge_labeling(): + return [self.__edges_to_str(self.get_label(edge_label_code)) + for _, _, edge_label_code in self.edges] + else: + return [self.__edges_to_str(mk_directed_edge(node_start, node_end) if self.directed else mk_undirected_edge(node_start, node_end)) + for node_start, node_end in self.edges] + + def to_graph(self) -> nx.Graph: + """Computes and memoize a NetworkX representation + + This representation is suited for algorithms rather than visualisation. + """ + if self.graph_ is None: + g = nx.Graph() if not self.directed else nx.DiGraph() + + [g.add_node(node, label=node) for node in self.nodes] + + if self.is_edge_labeling(): + [g.add_edge(node_start_code, node_end_code, label=edge_code) + for node_start_code, node_end_code, edge_code in self.edges] + else: + g.add_edges_from(self.edges) + + self.graph_ = g + + return self.graph_ + + def to_labeled_graph(self) -> nx.Graph: + """Computes a NetworkX representation suited for drawing""" + g = nx.Graph() if not self.directed else nx.DiGraph() + + [g.add_node(self.get_token(node)) for node in self.nodes] + + if self.is_edge_labeling(): + [g.add_edge(self.get_token(node_start_code), self.get_token(node_end_code)) + for node_start_code, node_end_code, _ in self.edges] + else: + g.add_edges_from([(self.get_token(node_start_code), self.get_token(node_end_code)) + for node_start_code, node_end_code in self.edges]) + + return g + + +class GoWBuilder(object): + """Builder to construct graph-of-words from a single document or a corpus of documents + + Parameters + ---------- + directed : boolean, False by default + If True, the graph-of-words is directed, else undirected + window_size : int, default=4 + Size of the window (in token) to build the graph-of-words. + edge_labeling : boolean, False by default + If True, edges are labeled with a unique code, else edges are not labeled. + tokenizer : callable or None (default) + Override the string tokenization step. + """ + + def __init__(self, + directed: bool = False, + window_size: int = 4, + tokenizer: Tokenizer = None, + edge_labeling: bool = False): + # Graph parameters + self.directed: bool = directed + self.window_size: int = window_size + + self.corpus_size: Optional[int] = None + + self.tokenizer: Tokenizer = tokenizer if tokenizer is not None else default_tokenizer + + self.TOKEN_TO_INT_: Dict[Token, int] = {} + self.INT_TO_TOKEN_: Dict[int, Token] = {} + + self.edge_labeling = edge_labeling + if self.edge_labeling: + self.LABEL_TO_INT_: Dict[Edge_label, int] = {} + self.INT_TO_LABEL_: Dict[int, Edge_label] = {} + + # TODO generate a real formal python representation + def __repr__(self): + return f'''Graph-of-word builder: + - is_directed: {self.directed} + - window_size: {self.window_size} + - edge_labeling: {self.edge_labeling} + + - Number of tokens: {len(self.TOKEN_TO_INT_)} + - Number of links between tokens: {len(self.LABEL_TO_INT_)} + '''.lstrip() + + def __str__(self): + return self.__repr__() + + # Node + def get_code_(self, token: Token) -> int: + if token not in self.TOKEN_TO_INT_: + last_token_id_ = len(self.TOKEN_TO_INT_) + self.TOKEN_TO_INT_[token] = last_token_id_ + self.INT_TO_TOKEN_[last_token_id_] = token + + return self.TOKEN_TO_INT_[token] + + def get_token_(self, code: int) -> Token: + return self.INT_TO_TOKEN_[code] + + # Edge + def get_label_id_(self, label: Edge_label) -> int: + if label not in self.LABEL_TO_INT_: + last_label_id_ = len(self.LABEL_TO_INT_) + self.LABEL_TO_INT_[label] = last_label_id_ + self.INT_TO_LABEL_[last_label_id_] = label + + return self.LABEL_TO_INT_[label] + + def get_label_(self, code: int) -> Edge_label: + return self.INT_TO_LABEL_[code] + + def get_edge_code_(self, edge: Edge) -> int: + node_start_code, node_end_code = edge + # Computation of the edge label and edge label ID + if self.directed: + t1, t2 = (node_start_code, node_end_code) + else: + if node_start_code < node_end_code: + t1, t2 = (node_start_code, node_end_code) + else: + t1, t2 = (node_end_code, node_start_code) + + edge_label = (t1, t2) + + edge_code = self.get_label_id_(edge_label) + + return edge_code + + def compute_gow_from_corpus(self, raw_documents: Sequence[str]) -> Sequence[GraphOfWords]: + """Computes a graph-of-words representation for each given documents""" + result_graph_of_words = [] + + for raw_document in raw_documents: + gow = self.compute_gow_from_document(raw_document) + result_graph_of_words.append(gow) + + self.corpus_size = len(result_graph_of_words) + + return result_graph_of_words + + def compute_gow_from_tokenized_document(self, tokens: Tokenized_document) -> GraphOfWords: + nodes = set() + token_ids = [] + for token in tokens: + token_id = self.get_code_(token) + token_ids.append(token_id) + nodes.add(token_id) + + N = len(tokens) + + edges = set() + if self.edge_labeling: + for j in range(N): + for i in range(max(j - self.window_size + 1, 0), j): + # Only keep edges between two *different* tokens + if token_ids[i] != token_ids[j]: + edge = (token_ids[i], token_ids[j]) + edge_code = self.get_edge_code_(edge) + if self.directed: + edges.add(mk_directed_edge(token_ids[i], token_ids[j], edge_code)) + else: + edges.add(mk_undirected_edge(token_ids[i], token_ids[j], edge_code)) + else: + for j in range(N): + for i in range(max(j - self.window_size + 1, 0), j): + # Only keep edges between two *different* tokens + if token_ids[i] != token_ids[j]: + if self.directed: + edges.add(mk_directed_edge(token_ids[i], token_ids[j])) + else: + edges.add(mk_undirected_edge(token_ids[i], token_ids[j])) + + return GraphOfWords(nodes=nodes, + edges=edges, + get_label=self.get_label_ if self.edge_labeling else None, + get_token=self.get_token_, + directed=self.directed) + + def compute_gow_from_document(self, raw_document: str) -> GraphOfWords: + """Computes a graph-of-words representation from a document""" + tokens = self.tokenizer(raw_document) + return self.compute_gow_from_tokenized_document(tokens) diff --git a/gowpy/gow/io.py b/gowpy/gow/io.py new file mode 100644 index 0000000..7428d2a --- /dev/null +++ b/gowpy/gow/io.py @@ -0,0 +1,226 @@ +import re + +from typing import Tuple, List, Sequence, Callable + +from gowpy.gow.builder import mk_undirected_edge, mk_directed_edge +from gowpy.gow.builder import GraphOfWords +from gowpy.gow.typing import Edge_label + + +def gow_to_data(gows: Sequence[GraphOfWords]) -> str: + """ + Convert a sequence of graph-of-words into a text representation for interoperability with other programs + + Format: + - "t # N" means the Nth graph, + - "v M L" means that the Mth vertex in this graph has label L, + - "e P Q L" means that there is an edge connecting the Pth vertex with the Qth vertex. The edge has label L. + + :param gows: + :return: + """ + result_data = [] + + for i, gow in enumerate(gows): + nodes = gow.nodes + edges = gow.edges + + if len(nodes) > 0: + result_data.append(u"t # {}\n".format(i)) + + node_label_to_id = {} + + for node_label in nodes: + if not (node_label in node_label_to_id): + new_id = len(node_label_to_id) + node_label_to_id[node_label] = new_id + + node_id = node_label_to_id[node_label] + result_data.append(u"v {} {}\n".format(node_id, node_label)) + + edge_tuples = [] # TODO implementation with a heap to be more efficient? + for (node_start_label, node_end_label, edge_label_id) in edges: + # Computation of the node IDs in this graph given their node labels + node_start_id = node_label_to_id[node_start_label] + node_end_id = node_label_to_id[node_end_label] + + edge_tuples.append((node_start_id, node_end_id, edge_label_id)) + edge_tuples.sort() + + for node_start_id, node_end_id, edge_label_id in edge_tuples: + result_data.append(u"e {} {} {}\n".format(node_start_id, + node_end_id, + edge_label_id)) + + result_data.append(u"t # {}".format(-1)) + return u"".join(result_data) + + +r_new_graph_ = re.compile(u't +# +(\d+) +\\* +(\d+)') +r_new_vertex_ = re.compile(u'v +(\d+) +(\d+)') +r_new_edge_ = re.compile(u'e +(\d+) +(\d+) +(\d+)') +r_new_parent_graphs_ = re.compile(u'x: +([\d ]+)') + + +def load_graphs(input_file_subgraph: str, + input_file_frequent_nodes: str, + get_token: Callable[[int], str], + get_label: Callable[[int], Edge_label], + is_directed: bool=False) -> Sequence[GraphOfWords]: + # + current_id = None + current_freq = None + current_vertices = None + current_edges = None + current_parent_graph_ids = None + + subgraphs = [] + + with open(input_file_subgraph, 'r') as f_input_file: + for line in f_input_file: + m_new_graph = r_new_graph_.search(line) + m_new_vertex = r_new_vertex_.search(line) + m_new_edge = r_new_edge_.search(line) + m_new_parent_graphs = r_new_parent_graphs_.search(line) + + if m_new_graph: + # Saving + if current_id is not None: + subgraphs.append(_to_gow(current_id, + current_freq, + (current_vertices, current_edges), + current_parent_graph_ids, + get_token, get_label, + is_directed)) + + # Initialisation of the new graph + current_id = int(m_new_graph.group(1)) + current_freq = int(m_new_graph.group(2)) + current_vertices = [] + current_edges = [] + current_parent_graph_ids = None + + elif m_new_vertex: + vertex_id = int(m_new_vertex.group(1)) + vertex_label = int(m_new_vertex.group(2)) + + current_vertices.append((vertex_id, vertex_label)) + + elif m_new_edge: + node_start = int(m_new_edge.group(1)) + node_end = int(m_new_edge.group(2)) + edge_label = int(m_new_edge.group(3)) + + current_edges.append((node_start, node_end, edge_label)) + + elif m_new_parent_graphs: + current_parent_graph_ids = [int(graph_id) for graph_id in + m_new_parent_graphs.group(1).strip().split(' ')] + # assert len(current_parent_graph_ids) == current_freq + + else: + pass # other lines (probably empty) + + # Last line + if current_id and current_parent_graph_ids: + subgraphs.append( + _to_gow(current_id, current_freq, (current_vertices, current_edges), current_parent_graph_ids, + get_token, get_label, + is_directed)) + + current_id = None + PADDING_ID = len(subgraphs) + current_freq = None + current_vertices = None + current_edges = None + current_parent_graph_ids = None + + with open(input_file_frequent_nodes, 'r') as f_input_file: + for line in f_input_file: + m_new_graph = r_new_graph_.search(line) + m_new_vertex = r_new_vertex_.search(line) + m_new_parent_graphs = r_new_parent_graphs_.search(line) + + if m_new_graph: + # Saving + if current_id is not None: + subgraphs.append(_to_gow(current_id, + current_freq, + (current_vertices, current_edges), + current_parent_graph_ids, + get_token, get_label, + is_directed)) + + # Initialisation of the new graph + current_id = int(m_new_graph.group(1)) + PADDING_ID + current_freq = int(m_new_graph.group(2)) + current_vertices = [] + current_edges = [] + current_parent_graph_ids = None + + elif m_new_vertex: + vertex_id = int(m_new_vertex.group(1)) + vertex_label = int(m_new_vertex.group(2)) + + current_vertices.append((vertex_id, vertex_label)) + + elif m_new_parent_graphs: + current_parent_graph_ids = [int(graph_id) for graph_id in + m_new_parent_graphs.group(1).strip().split(' ')] + # assert len(current_parent_graph_ids) == current_freq + + else: + pass # other lines (probably empty) + + # Last line + if current_id and current_parent_graph_ids: + subgraphs.append( + _to_gow(current_id, current_freq, (current_vertices, current_edges), current_parent_graph_ids, + get_token, get_label, is_directed)) + + return subgraphs + +IO_Nodes = List[Tuple[int, int]] # (node_id, node_code) +IO_Edges = List[Tuple[int, int, int]] # (node_start_id, node_end_id, edge_code) +IO_Subgraph = Tuple[IO_Nodes, IO_Edges] + +def _to_gow(subg_id: int, + subg_freq: int, + subgraph: IO_Subgraph, + subg_current_parent_graph_ids: Sequence[int], + get_token: Callable[[int], str], + get_label: Callable[[int], Edge_label], + is_directed: bool) -> GraphOfWords: + id_: int = subg_id + freq: int = subg_freq + + subg_vertices, subg_edges = subgraph + + size = len(subg_vertices) + parents = subg_current_parent_graph_ids + + # Recomputation of nodes + # Dealing with nodes: + # Node = (node id in *this* graph, node code) + node_id_to_node_code = {} + nodes = set() + for node_id, node_code in subg_vertices: + node_id_to_node_code[node_id] = node_code + nodes.add(node_code) + + # Dealing with edges + edges = set() + for node_start_id, node_end_id, edge_label_code in subg_edges: + node_start_code = node_id_to_node_code[node_start_id] + node_end_code = node_id_to_node_code[node_end_id] + if is_directed: + edges.add(mk_directed_edge(node_start_code, node_end_code, edge_label_code)) + else: + edges.add(mk_undirected_edge(node_start_code, node_end_code, edge_label_code)) + + return GraphOfWords(nodes=nodes, + edges=edges, + get_token=get_token, + get_label=get_label, + freq=freq, + directed=is_directed) \ No newline at end of file diff --git a/gowpy/gow/miner.py b/gowpy/gow/miner.py new file mode 100644 index 0000000..0fccce4 --- /dev/null +++ b/gowpy/gow/miner.py @@ -0,0 +1,72 @@ +import numpy as np +from typing import Sequence, Optional + +import gowpy.gow.io +from gowpy.gow.builder import GraphOfWords +from gowpy.gow.typing import Tokenizer +from gowpy.gow.builder import GoWBuilder + + +class GoWMiner(GoWBuilder): + """A miner of frequent subgraphs for a collection of graph-of-words + + Currently, the mining operation is delegated to a C++ program. This class makes it possible to load the + mined sub-graphs-of-words. + + Parameters + ---------- + directed : boolean, False by default + If True, the graph-of-words is directed, else undirected + window_size : int, default=4 + Size of the window (in token) to build the graph-of-words. + tokenizer : callable or None (default) + Override the string tokenization step. + """ + + def __init__(self, + directed: bool = False, + window_size: int = 4, + tokenizer: Tokenizer = None): + # /!\ Edge labeling is important for IO + super().__init__(directed, window_size, tokenizer, edge_labeling=True) + self.frequent_subgraphs: Optional[Sequence[GraphOfWords]] = None + + # TODO generate a real formal python representation + def __repr__(self): + if self.frequent_subgraphs is None: + len_frequent_subgraphs = "not loaded yet" + else: + len_frequent_subgraphs = len(self.frequent_subgraphs) + return f'''Graph-of-word miner: + - is_directed: {self.directed} + - window_size: {self.window_size} + - edge_labeling: {self.edge_labeling} + + - Number of tokens: {len(self.TOKEN_TO_INT_)} + - Number of links between tokens: {len(self.LABEL_TO_INT_)} + + - Number of loaded subgraph: {len_frequent_subgraphs} + '''.lstrip() + + def load_graphs(self, + input_file_subgraph: str, + input_file_frequent_nodes: str) -> None: + self.frequent_subgraphs = gowpy.gow.io.load_graphs(input_file_subgraph, input_file_frequent_nodes, + self.get_token_, self.get_label_, + self.directed) + + def stat_freq_per_pattern(self) -> np.array: + """Computes the subgraph frequency series""" + return np.array([pattern.freq for pattern in self.frequent_subgraphs]) + + def stat_relative_freq_per_pattern(self) -> np.array: + """Computes the subgraph normalised frequency series""" + return np.array([pattern.freq / float(self.corpus_size) for pattern in self.frequent_subgraphs]) + + def stat_num_nodes_per_pattern(self) -> np.array: + """Computes the number of nodes per subgraph series""" + return np.array([len(pattern.nodes) for pattern in self.frequent_subgraphs]) + + def stat_num_edges_per_pattern(self) -> np.array: + """Computes the number of edges per subgraph series""" + return np.array([len(pattern.edges) for pattern in self.frequent_subgraphs]) diff --git a/gowpy/gow/typing.py b/gowpy/gow/typing.py new file mode 100644 index 0000000..325512a --- /dev/null +++ b/gowpy/gow/typing.py @@ -0,0 +1,12 @@ +from typing import Tuple, Callable, Sequence, Set, Union + +Token = str +Tokenized_document = Sequence[Token] +Tokenizer = Callable[[str], Tokenized_document] +Node = int +Nodes = Set[Node] + +Edge_label = Tuple[int, int] +Edge = Tuple[Node, Node] +Edge_with_code = Tuple[Node, Node, int] +Edges = Union[Set[Edge], Set[Edge_with_code]] diff --git a/gowpy/summarization/__init__.py b/gowpy/summarization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gowpy/summarization/unsupervised/__init__.py b/gowpy/summarization/unsupervised/__init__.py new file mode 100644 index 0000000..50b2e0d --- /dev/null +++ b/gowpy/summarization/unsupervised/__init__.py @@ -0,0 +1 @@ +from .keyword_extractor_gow import GoWKeywordExtractor \ No newline at end of file diff --git a/gowpy/summarization/unsupervised/keyword_extractor_gow.py b/gowpy/summarization/unsupervised/keyword_extractor_gow.py new file mode 100644 index 0000000..27ca107 --- /dev/null +++ b/gowpy/summarization/unsupervised/keyword_extractor_gow.py @@ -0,0 +1,48 @@ +from typing import Sequence, Tuple + +from gowpy.gow.builder import GoWBuilder +from gowpy.gow.typing import Tokenizer + +from networkx.algorithms.core import core_number + + +class GoWKeywordExtractor(object): + """Extract keywords from a text document based on a graph-of-words representation + + Parameters + ---------- + directed : boolean, False by default + If True, the graph-of-words is directed, else undirected + window_size : int, default=4 + Size of the window (in token) to build the graph-of-words. + tokenizer : callable or None (default) + Override the string tokenization step. + """ + def __init__(self, + directed: bool = False, + window_size: int = 4, + tokenizer: Tokenizer = None): + # TODO is_weighted + self.builder = GoWBuilder( + directed=directed, + window_size=window_size, + tokenizer=tokenizer) + + def extract(self, document: str) -> Sequence[Tuple[str, float]]: + gow = self.builder.compute_gow_from_document(document) + graph = gow.to_graph() + kcore = core_number(graph) + + keywords = [] + k_max = 0 + for v, k in kcore.items(): + if k > k_max: + keywords.clear() + k_max = k + + if k == k_max: + token_code = graph.nodes[v]['label'] + token = self.builder.get_token_(token_code) + keywords.append((token, k)) + + return keywords diff --git a/gowpy/utils/__init__.py b/gowpy/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gowpy/utils/defaults.py b/gowpy/utils/defaults.py new file mode 100644 index 0000000..3b9447d --- /dev/null +++ b/gowpy/utils/defaults.py @@ -0,0 +1,5 @@ +from gowpy.gow.typing import Tokenized_document + + +def default_tokenizer(document: str) -> Tokenized_document: + return document.split() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9110b1b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +networkx>=2.4 +scikit-learn>=0.22.2 +matplotlib>=3.1 \ No newline at end of file diff --git a/resources/gow.png b/resources/gow.png new file mode 100644 index 0000000..6dd3760 Binary files /dev/null and b/resources/gow.png differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f577190 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup, find_packages + +with open("requirements.txt") as f: + required = f.read().splitlines() + +setup( + name="gowpy", + version="0.1.0", + description="A very simple graph-of-words framework for NLP", + long_description=open("README.md", encoding="utf-8").read(), + long_description_content_type="text/markdown", + author="Guillaume Dubuisson Duplessis", + author_email="guillaume@dubuissonduplessis.fr", + url="https://github.com/GuillaumeDD/gowpy.git", + packages=find_packages(exclude="tests"), + license="new BSD", + install_requires=required, + include_package_data=True, + python_requires=">=3.6", + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules' + ], +)