diff --git a/README.md b/README.md index e5f286e..733571b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,24 @@ [![CircleCI](https://circleci.com/gh/CDECatapult/fornax.svg?style=svg&circle-token=2110b6bc1d713698d241fd08ae60cd925e60062f)](https://circleci.com/gh/CDECatapult/fornax) [![Coverage Status](https://coveralls.io/repos/github/CDECatapult/fornax/badge.svg?branch=master)](https://coveralls.io/github/CDECatapult/fornax?branch=master) +[![Known Vulnerabilities](https://snyk.io/test/github/CDECatapult/fornax/badge.svg)](https://snyk.io/test/github/CDECatapult/fornax/badge.svg) + # Fornax An implementation of [NeMa: Fast Graph Search with Label Similarity](http://www.vldb.org/pvldb/vol6/p181-khan.pdf) using python3 and sqlite or postgres. -![FORNAX](./fornax.png) +![FORNAX](./docs/img/fornax.png) ## Install (Dev) From the root directory: ```bash +# install dev dependencies pip install -r requirements/dev.txt + +# install fornax +pip install -e . ``` ## Test @@ -30,146 +36,40 @@ The available options for installing SciPy packages are listed [here](https://sc See the tutorials for a full working example. -### Tutorial Dependencies +* [Part 1](docs/tutorial/tutorial1.ipynb) - Download a small graph dataset +* [Part 2](docs/tutorial/tutorial2.ipynb) - Search the dataset using fornax + +### Install Tutorial Dependencies (using conda) The following tutorials use jupyter notebooks to create a worked example. -We reccomend you use the anaconda python distribution to run the notebooks. +We recommend you use the anaconda python distribution to run the notebooks. ```bash conda env create -f environment.yml -pip install -r requirements.txt ``` -* [Part 1](https://github.com/CDECatapult/fornax/blob/master/notebooks/tutorial/Tutorial%201%20-%20Creating%20a%20Dataset.ipynb) -* [Part 2](https://github.com/CDECatapult/fornax/blob/master/notebooks/tutorial/Tutorial%202%20-%20Making%20a%20Query.ipynb) - -## Database Setup - -By default fornax will use an in memory SQlite database. - -Alternative databases can be used by setting the environment variable `FORNAX_DB_URL` using the [sqlalchemy database url format](https://docs.sqlalchemy.org/en/latest/core/engines.html). -SQLite and Postgresql are supported although other databases are untested. - -All tables and indicies are initialised at import time if they do not exist already. - -## Quick start - -```python -# create a query graph -query_graph_handle = fornax.GraphHandle.create() -query_graph_handle.add_nodes(id_src=[0, 1, 2], label=['Hulk', 'Lady', 'Storm']) -query_graph_handle.add_edges([0, 1], [1, 2]) +### Run the Tutorials +```bash +source activate fornax_tutorial +cd docs/tutorial +jupyter-notebook +``` -# create a target graph -target_graph_handle = fornax.GraphHandle.create() -target_graph_handle.add_nodes(id_src=comic_book_nodes['id], label=comic_book_nodes['name']) -target_graph_handle.add_edges(comic_book_edges['start'], comic_book_edges['end']) +## Documentation -matches = [ - (query_node_id, target_node_id, weight) - for query_node_id, target_node_id, weight - in string_similarities -] +### Build the Docs (requires dev dependencies) -match_starts, match_ends, weights = zip(*matches) +```bash +cd docs +make html +``` -# stage a query -query = fornax.QueryHandle.create(query_graph_handle, target_graph_handle) -query.add_matches(match_starts, match_ends, weights) +### View the Docs Locally -# go! -query.execute() +```bash +cd _build/html +python3 -m http.server ``` -```json -{ - "graphs": [ - { - "cost": 0.024416640711327393, - "nodes": [ - { - "id": 9437002, - "type": "query", - "id_src": 0, - "label": "hulk" - }, - { - "id": 13982314, - "type": "query", - "id_src": 1, - "label": "lady" - }, - { - "id": 76350203, - "type": "query", - "id_src": 2, - "label": "storm" - }, - { - "id": 75367743, - "type": "target", - "id_src": 37644418, - "label": " Susan Storm", - "type_": 2 - }, - { - "id": 5878004, - "type": "target", - "id_src": 995920086, - "label": "Lady Liberators", - "type_": 1 - }, - { - "id": 71379958, - "type": "target", - "id_src": 2142361735, - "label": "She-Hulk", - "type_": 0 - } - ], - "links": [ - { - "start": 9437002, - "end": 71379958, - "type": "match", - "weight": 0.9869624795392156 - }, - { - "start": 13982314, - "end": 5878004, - "type": "match", - "weight": 0.9746778514236212 - }, - { - "start": 76350203, - "end": 75367743, - "type": "match", - "weight": 0.9651097469031811 - }, - { - "start": 9437002, - "end": 13982314, - "type": "query", - "weight": 1.0 - }, - { - "start": 13982314, - "end": 76350203, - "type": "query", - "weight": 1.0 - }, - { - "start": 5878004, - "end": 71379958, - "type": "target", - "weight": 1.0 - } - ] - } - ], - "iters": 2, - "hopping_distance": 2, - "max_iters": 10 -} -``` +navigate to `0.0.0.0:8000` in your browser. diff --git a/docs/Makefile b/docs/Makefile index a0e42a0..298ea9e 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,10 +3,9 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = python -msphinx -SPHINXPROJ = fornax -SOURCEDIR = source -BUILDDIR = build +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..17ad2aa --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,80 @@ +.. module:: fornax.api + +API +=== + +.. _fornax-api-introduction: + +Introduction +------------ + +This part of the documentation covers the the interface for creating an searching graphs using the fornax package. +For the full documentation of the module api see :ref:`fornax-api-module`. + + +All of the functionality in :mod:`fornax` can be accessed via the follwoing three classes. + +* :class:`Connection` +* :class:`GraphHandle` +* :class:`QueryHandle` + +:class:`Connection` is used to manage a connection to a SQL database. +:class:`GraphHandle` and :class:`QueryHandle` are used to create, insert +update and delete graphs and queries. + +Connection API +-------------------- + + +Fornax stores and queries graphs using a database via a database connection. +:class:`Connection` manages the lifecycle of this database connection, +the creation of database schema (if required) +and any cleanup once the connection is closed. + + +.. autoclass:: Connection + :members: + :noindex: + +Graph API +-------------------------------- + +Since Graphs are persisted in a database they are not represented +directly by any object. +Rather, graphs are accessed via a graph handle which permits the user +to manipulate graphs via a :class:`Connection` instance. + +.. autoclass:: GraphHandle + :members: + :noindex: + +Query API +------------------------------ + +Like Graphs, queries exist in a database and a accessed via a handle. +Queries are executed using the :func:`QueryHandle.execute` method. + +A query brings together three important concenpts. + +A **target graph** is the graph which is going to be searched. + +A **query graph** is the subgraph that is being seached for in the target graph. + +**matches** are label similarities between nodes in the query graph and target graph +with a weight where :math:`0 \lt weight \lt= 1`. +Users are free to caculate label similarity scores however they like. +Fornax only needs to know about non zero weights between matches. + +Once a query has been created and executed it will return the *n* subgraphs in the +target graph which are most similar to the query graph based on the similarity score +between nodes and their surrounding neighbourhoods. + +.. note:: + Nodes in the target graph will only be returned from a query if they have a + non zero similarity score to at least one node in the query graph. + + +.. autoclass:: QueryHandle + :members: + :noindex: + diff --git a/docs/source/conf.py b/docs/conf.py similarity index 67% rename from docs/source/conf.py rename to docs/conf.py index 871d921..6cdcbef 100644 --- a/docs/source/conf.py +++ b/docs/conf.py @@ -1,28 +1,35 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# fornax documentation build configuration file, created by -# sphinx-quickstart on Fri Nov 16 10:25:34 2018. +# Configuration file for the Sphinx documentation builder. # -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -#import os -#import sys -#sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('../fornax')) + + +# -- Project information ----------------------------------------------------- + +project = 'fornax' +copyright = '2018, Digital Catapult (https://www.digicatapult.org.uk)' +author = 'Daniel Staff' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '0.0.1' -# -- General configuration ------------------------------------------------ +# -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # @@ -31,9 +38,13 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', +extensions = [ + 'sphinx.ext.autodoc', 'sphinx.ext.doctest', - 'sphinx.ext.viewcode'] + 'sphinx.ext.mathjax', + 'sphinx.ext.viewcode', + 'nbsphinx' +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -47,20 +58,6 @@ # The master toctree document. master_doc = 'index' -# General information about the project. -project = 'fornax' -copyright = '2018, Daniel Staff' -author = 'Daniel Staff' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.0.1' -# The full version, including alpha/beta/rc tags. -release = '0.0.1' - # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # @@ -70,17 +67,17 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [ + '_build', 'Thumbs.db', '.DS_Store', '_build', + '**.ipynb_checkpoints' +] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = None -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. @@ -97,30 +94,26 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_logo = 'img/logo.png' # Custom sidebar templates, must be a dictionary that maps document names # to template names. # -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - 'donate.html', - ] -} +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} -# -- Options for HTMLHelp output ------------------------------------------ +# -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'fornaxdoc' -# -- Options for LaTeX output --------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). @@ -149,7 +142,7 @@ ] -# -- Options for manual page output --------------------------------------- +# -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). @@ -159,7 +152,7 @@ ] -# -- Options for Texinfo output ------------------------------------------- +# -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, @@ -171,4 +164,22 @@ ] +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# -- Extension configuration ------------------------------------------------- diff --git a/docs/source/fornax.rst b/docs/fornax.rst similarity index 75% rename from docs/source/fornax.rst rename to docs/fornax.rst index 80a04de..9d24613 100644 --- a/docs/source/fornax.rst +++ b/docs/fornax.rst @@ -1,35 +1,34 @@ -fornax package +Submodules ============== -Submodules ----------- +.. _fornax-api-module: -fornax\.api module ------------------- +fornax.api module +----------------- .. automodule:: fornax.api :members: :undoc-members: :show-inheritance: -fornax\.model module --------------------- +fornax.model module +------------------- .. automodule:: fornax.model :members: :undoc-members: :show-inheritance: -fornax\.opt module ------------------- +fornax.opt module +----------------- .. automodule:: fornax.opt :members: :undoc-members: :show-inheritance: -fornax\.select module ---------------------- +fornax.select module +-------------------- .. automodule:: fornax.select :members: diff --git a/docs/guide.rst b/docs/guide.rst new file mode 100644 index 0000000..900e34a --- /dev/null +++ b/docs/guide.rst @@ -0,0 +1,66 @@ +======== +Guide +======== + +Fornax is an open source library to perform fuzzy subgraph matching +between labelled undirected graphs based on +`NeMa: Fast Graph Search with Label Similarity. `_ + + +Subgraph Matching +----------------- + +A subgraph is any collection of node and edges that form some subset of a graph. +For example in the image below the graph on the left is isomorphic to the green nodes +in the graph on the right, hence they form a subgraph. + +.. image:: /img/subgraph.png + +If we refer to the graph on the left as the *query graph* +and the graph on the right as the *target graph* +subgraph matching is the process of finding the *query graph* +in the *target graph* such that the node labels and edges are strictly the same. + +Fornax will kind the *n* most similar subgraphs in a *target graph* based on a user +specified *query graph* using a user specified *label similarity function*. + +Fornax will not only find exact subgraph isomorphisms +but the *n* most similar subgraphs even if they are not exact isomorphisms of the query graph. +Hence, fornax can be used for **fuzzy** subgraph matching. + +For example, Fornax can be used to find subgraphs where labels are similar, yet different, +based on a user specified definition. +Additionally neighbours in the query graph may be absent, or are neighbours of neighbours in the +target graph. + +Example Problems +---------------- + +Common fuzzy subgraph matching problems include: + +* searching knowledge graphs +* mining social networks +* searching geospation data as a graph +* searching text as a graph + +Goals +----- + +*fornax* was written with three primary goals in mind + +* to demonstrate process and provide ease of use over performance +* to be flexible and accomidate the users notions of similarity +* to scale to large target graphs of millions of nodes and edges + +Architecture +------------ + +In order to support large graphs and persist them +between python interpreter sessions fornax stores all data +in a database. + +To facilite ease of use fornax can use *sqlite* or *postgresql* +as a back end. +For more details see the API :ref:`fornax-api-introduction`. + +.. image:: /img/fornax.png \ No newline at end of file diff --git a/fornax.png b/docs/img/fornax.png similarity index 100% rename from fornax.png rename to docs/img/fornax.png diff --git a/docs/img/iron_man.svg b/docs/img/iron_man.svg new file mode 100644 index 0000000..016d14a --- /dev/null +++ b/docs/img/iron_man.svg @@ -0,0 +1 @@ +
Iron
Man
[Not supported by viewer]
The
Avengers
The<br>Avengers<br>
Tony
Tony<br>
Earth's Mightiest
Heros
Earth's Mightiest<br>Heros<br>
Hero
Alias
[Not supported by viewer]
Hero
Alias
[Not supported by viewer]
Hero
Hero
Team
Team
Team
Alias
[Not supported by viewer]
\ No newline at end of file diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100644 index 0000000..5e69ca2 Binary files /dev/null and b/docs/img/logo.png differ diff --git a/docs/img/subgraph.png b/docs/img/subgraph.png new file mode 100644 index 0000000..14c8553 Binary files /dev/null and b/docs/img/subgraph.png differ diff --git a/docs/source/index.rst b/docs/index.rst similarity index 54% rename from docs/source/index.rst rename to docs/index.rst index d33f853..72dfd3f 100644 --- a/docs/source/index.rst +++ b/docs/index.rst @@ -1,16 +1,20 @@ .. fornax documentation master file, created by - sphinx-quickstart on Fri Nov 16 10:25:34 2018. + sphinx-quickstart on Wed Nov 28 10:27:45 2018. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -.. automodule:: fornax.api - :members: __doc__ +Welcome to fornax's documentation! +================================== -.. autoclass:: fornax.GraphHandle - :members: +.. toctree:: + :maxdepth: 2 + + guide + tutorial/tutorial1 + tutorial/tutorial2 + api + modules -.. autoclass:: fornax.QueryHandle - :members: Indices and tables ================== diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..27f573b --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/source/modules.rst b/docs/modules.rst similarity index 71% rename from docs/source/modules.rst rename to docs/modules.rst index 9322be9..075975f 100644 --- a/docs/source/modules.rst +++ b/docs/modules.rst @@ -1,5 +1,5 @@ -fornax -====== +Modules +======= .. toctree:: :maxdepth: 4 diff --git a/notebooks/tutorial/Tutorial 1 - Creating a Dataset.ipynb b/docs/tutorial/tutorial1.ipynb similarity index 79% rename from notebooks/tutorial/Tutorial 1 - Creating a Dataset.ipynb rename to docs/tutorial/tutorial1.ipynb index 19369ae..86b23da 100644 --- a/notebooks/tutorial/Tutorial 1 - Creating a Dataset.ipynb +++ b/docs/tutorial/tutorial1.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Tutorial 1 - Creating a Dataset From WikiData" + "# Creating a Dataset" ] }, { @@ -22,19 +22,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Introduction\n", + "To install the use the dependencies for this notebook:\n", "\n", - "Welcome to this three part fornax fuzzy graph tutorial. \n", - "* In part 1 we will create a sample graph dataset from WikiData.\n", - "* In part 2 we will demonstrate how to create a database backend for fornax\n", - "* In part 3 we will demonstrate some queries and results" + "```bash\n", + "conda env create -f environment.yml\n", + "source activate fornax_tutorial\n", + "```\n", + "\n", + "To run this notebook from the project root:\n", + "\n", + "```bash\n", + "cd docs/tutorial\n", + "jupyter-notebook\n", + "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Dowloading A Dataset\n", + "## Download\n", "\n", "For the duration of this tutorial we will be using the social network of Marvel Comicbook characters.\n", "\n", @@ -114,7 +121,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Loading into Pandas\n", + "## Loading with Pandas\n", "\n", "We'll be using pandas to do some data manipulation so lets put the result inside a pandas dataframe." ] @@ -180,48 +187,48 @@ " \n", " \n", " 0\n", - " http://www.wikidata.org/entity/Q59665\n", - " http://www.wikidata.org/entity/Q1922133\n", - " Jubilee\n", - " Generation X\n", - " Jubilation Lee\n", - " Jubilation Lee\n", + " http://www.wikidata.org/entity/Q60002\n", + " http://www.wikidata.org/entity/Q2603976\n", + " Colossus\n", + " Excalibur\n", + " Пётр Николаевич Распутин\n", + " Peter Rasputin, Piotr Nikolayevich Rasputin, P...\n", " \n", " \n", " 1\n", - " http://www.wikidata.org/entity/Q95153\n", - " http://www.wikidata.org/entity/Q1319624\n", - " Silver Surfer\n", - " The Order\n", - " None\n", - " Norrin Radd\n", + " http://www.wikidata.org/entity/Q258015\n", + " http://www.wikidata.org/entity/Q2603976\n", + " Rachel Summers\n", + " Excalibur\n", + " Rachel Anne Summers\n", + " Phoenix, Prestige, Marvel Girl, Mother Askani,...\n", " \n", " \n", " 2\n", - " http://www.wikidata.org/entity/Q302186\n", - " http://www.wikidata.org/entity/Q2211423\n", - " Beast\n", - " X-Factor\n", - " Henry Philip McCoy\n", - " Hank McCoy, Henry McCoy\n", + " http://www.wikidata.org/entity/Q369197\n", + " http://www.wikidata.org/entity/Q2527918\n", + " Black Widow\n", + " Thunderbolts\n", + " Наталья Алиановна Романова\n", + " Natalie Rushman, Natasha Romanoff, asesina rus...\n", " \n", " \n", " 3\n", - " http://www.wikidata.org/entity/Q369805\n", - " http://www.wikidata.org/entity/Q2109149\n", - " Selene\n", - " Hellfire Club\n", + " http://www.wikidata.org/entity/Q388316\n", + " http://www.wikidata.org/entity/Q2527918\n", + " Bullseye\n", + " Thunderbolts\n", " None\n", - " Black Queen, Selene Gallio\n", + " Lester, Hawkeye, Benjamin Poindexter\n", " \n", " \n", " 4\n", - " http://www.wikidata.org/entity/Q431862\n", - " http://www.wikidata.org/entity/Q2211423\n", - " Sabretooth\n", - " X-Factor\n", - " Victor Creed\n", - " Victor Creed\n", + " http://www.wikidata.org/entity/Q432272\n", + " http://www.wikidata.org/entity/Q2457162\n", + " Medusa\n", + " Frightful Four\n", + " None\n", + " None\n", " \n", " \n", "\n", @@ -229,25 +236,32 @@ ], "text/plain": [ " character_id \\\n", - "0 http://www.wikidata.org/entity/Q59665 \n", - "1 http://www.wikidata.org/entity/Q95153 \n", - "2 http://www.wikidata.org/entity/Q302186 \n", - "3 http://www.wikidata.org/entity/Q369805 \n", - "4 http://www.wikidata.org/entity/Q431862 \n", + "0 http://www.wikidata.org/entity/Q60002 \n", + "1 http://www.wikidata.org/entity/Q258015 \n", + "2 http://www.wikidata.org/entity/Q369197 \n", + "3 http://www.wikidata.org/entity/Q388316 \n", + "4 http://www.wikidata.org/entity/Q432272 \n", "\n", - " group_id name group \\\n", - "0 http://www.wikidata.org/entity/Q1922133 Jubilee Generation X \n", - "1 http://www.wikidata.org/entity/Q1319624 Silver Surfer The Order \n", - "2 http://www.wikidata.org/entity/Q2211423 Beast X-Factor \n", - "3 http://www.wikidata.org/entity/Q2109149 Selene Hellfire Club \n", - "4 http://www.wikidata.org/entity/Q2211423 Sabretooth X-Factor \n", + " group_id name group \\\n", + "0 http://www.wikidata.org/entity/Q2603976 Colossus Excalibur \n", + "1 http://www.wikidata.org/entity/Q2603976 Rachel Summers Excalibur \n", + "2 http://www.wikidata.org/entity/Q2527918 Black Widow Thunderbolts \n", + "3 http://www.wikidata.org/entity/Q2527918 Bullseye Thunderbolts \n", + "4 http://www.wikidata.org/entity/Q2457162 Medusa Frightful Four \n", "\n", - " birth_name alt_names \n", - "0 Jubilation Lee Jubilation Lee \n", - "1 None Norrin Radd \n", - "2 Henry Philip McCoy Hank McCoy, Henry McCoy \n", - "3 None Black Queen, Selene Gallio \n", - "4 Victor Creed Victor Creed " + " birth_name \\\n", + "0 Пётр Николаевич Распутин \n", + "1 Rachel Anne Summers \n", + "2 Наталья Алиановна Романова \n", + "3 None \n", + "4 None \n", + "\n", + " alt_names \n", + "0 Peter Rasputin, Piotr Nikolayevich Rasputin, P... \n", + "1 Phoenix, Prestige, Marvel Girl, Mother Askani,... \n", + "2 Natalie Rushman, Natasha Romanoff, asesina rus... \n", + "3 Lester, Hawkeye, Benjamin Poindexter \n", + "4 None " ] }, "execution_count": 4, @@ -307,7 +321,7 @@ "\n", "Lets quickly examine the data to check it still makes sense.\n", "\n", - "There are 388 unique characters in the dataset" + "There are 399 unique characters in the dataset" ] }, { @@ -318,7 +332,7 @@ { "data": { "text/plain": [ - "399" + "400" ] }, "execution_count": 7, @@ -397,18 +411,14 @@ " \n", " \n", " \n", - " \n", - " 103\n", - " http://www.wikidata.org/entity/Q186422\n", - " Wolverine\n", - " \n", " \n", "\n", "" ], "text/plain": [ - " character_id name\n", - "103 http://www.wikidata.org/entity/Q186422 Wolverine" + "Empty DataFrame\n", + "Columns: [character_id, name]\n", + "Index: []" ] }, "execution_count": 9, @@ -493,42 +503,42 @@ " \n", " \n", " \n", - " 167\n", + " 187\n", " Logan\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 168\n", + " 188\n", " Weapon X\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 169\n", + " 189\n", " Jim Logan\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 170\n", + " 190\n", " Patch\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 171\n", + " 191\n", " James Howlett\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 172\n", + " 192\n", " Agent Ten\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 173\n", + " 193\n", " Experiment X\n", " http://www.wikidata.org/entity/Q186422\n", " \n", " \n", - " 174\n", + " 194\n", " Weapon Ten\n", " http://www.wikidata.org/entity/Q186422\n", " \n", @@ -538,14 +548,14 @@ ], "text/plain": [ " alt_name character_id\n", - "167 Logan http://www.wikidata.org/entity/Q186422\n", - "168 Weapon X http://www.wikidata.org/entity/Q186422\n", - "169 Jim Logan http://www.wikidata.org/entity/Q186422\n", - "170 Patch http://www.wikidata.org/entity/Q186422\n", - "171 James Howlett http://www.wikidata.org/entity/Q186422\n", - "172 Agent Ten http://www.wikidata.org/entity/Q186422\n", - "173 Experiment X http://www.wikidata.org/entity/Q186422\n", - "174 Weapon Ten http://www.wikidata.org/entity/Q186422" + "187 Logan http://www.wikidata.org/entity/Q186422\n", + "188 Weapon X http://www.wikidata.org/entity/Q186422\n", + "189 Jim Logan http://www.wikidata.org/entity/Q186422\n", + "190 Patch http://www.wikidata.org/entity/Q186422\n", + "191 James Howlett http://www.wikidata.org/entity/Q186422\n", + "192 Agent Ten http://www.wikidata.org/entity/Q186422\n", + "193 Experiment X http://www.wikidata.org/entity/Q186422\n", + "194 Weapon Ten http://www.wikidata.org/entity/Q186422" ] }, "execution_count": 11, @@ -634,7 +644,7 @@ "data": { "text/plain": [ "(116, group_id group\n", - " 101 http://www.wikidata.org/entity/Q128452 X-Men)" + " 125 http://www.wikidata.org/entity/Q128452 X-Men)" ] }, "execution_count": 13, @@ -669,7 +679,7 @@ { "data": { "text/plain": [ - "(8, 114 Cannonball\n", + "(8, 102 Cannonball\n", " Name: name, dtype: object)" ] }, @@ -694,7 +704,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Exporting a csv dataset\n", + "## Export to CSV\n", "\n", "Let's write each node to a csv file, we need to record\n", "\n", @@ -768,45 +778,45 @@ " \n", " \n", " 0\n", - " Jubilee\n", + " Colossus\n", " 0\n", - " 1126900601\n", + " 2105314676\n", " \n", " \n", " 1\n", - " Silver Surfer\n", + " Rachel Summers\n", " 0\n", - " 440245546\n", + " 298635603\n", " \n", " \n", " 2\n", - " Beast\n", + " Black Widow\n", " 0\n", - " 1370118169\n", + " 1897346471\n", " \n", " \n", " 3\n", - " Selene\n", + " Bullseye\n", " 0\n", - " 87770955\n", + " 2027281781\n", " \n", " \n", " 4\n", - " Sabretooth\n", + " Medusa\n", " 0\n", - " 1299099267\n", + " 347320780\n", " \n", " \n", "\n", "" ], "text/plain": [ - " label type uid\n", - "0 Jubilee 0 1126900601\n", - "1 Silver Surfer 0 440245546\n", - "2 Beast 0 1370118169\n", - "3 Selene 0 87770955\n", - "4 Sabretooth 0 1299099267" + " label type uid\n", + "0 Colossus 0 2105314676\n", + "1 Rachel Summers 0 298635603\n", + "2 Black Widow 0 1897346471\n", + "3 Bullseye 0 2027281781\n", + "4 Medusa 0 347320780" ] }, "execution_count": 17, @@ -851,7 +861,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "metadata": {}, "outputs": [], "source": [ diff --git a/docs/tutorial/tutorial2.ipynb b/docs/tutorial/tutorial2.ipynb new file mode 100644 index 0000000..9f91497 --- /dev/null +++ b/docs/tutorial/tutorial2.ipynb @@ -0,0 +1,1098 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "import pandas as pd\n", + "import json\n", + "import matplotlib.pyplot as plt\n", + "import networkx as nx\n", + "import fornax\n", + "\n", + "%matplotlib inline\n", + "from IPython.core.display import SVG\n", + "\n", + "# Add project root dir\n", + "ROOT_DIR = os.path.abspath(\"../../\")\n", + "sys.path.append(ROOT_DIR)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To install the use the dependencies for this notebook:\n", + "\n", + "```bash\n", + "conda env create -f environment.yml\n", + "source activate fornax_tutorial\n", + "```\n", + "\n", + "To run this notebook from the project root:\n", + "\n", + "```bash\n", + "cd docs/tutorial\n", + "jupyter-notebook\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this tutorial we will:\n", + "\n", + "* Load a graph of superheros and their teams from csv files\n", + "\n", + "* Search for nodes in the graph using a string similarity function\n", + "\n", + "* Use fornax to search for nodes using string similarity and fuzzy graph matching\n", + "\n", + "The data in this tutorial we be generated using the preceding notebook: `Tutorial1.ipynb`.\n", + "\n", + "## Introduction\n", + "\n", + "`nodes.csv` and `edges.csv` contain a graph of superheros and their teams along with alternative names for those heros and groups (or aliases).\n", + "\n", + "The image below uses the example of Iron Man, who is known as \"Tony\" to his friends.\n", + "Iron man is a member of the Avengers, a.k.a. Earth's Mightiest Superheros.\n", + "Other heros are also members of The Avengers, and they will also have aliases.\n", + "Other heros will also be members of other teams and so and so forth.\n", + "\n", + "\n", + "All of these heros, teams and aliases together make our target graph, a graph which we will search using fornax." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "
Iron
Man
[Not supported by viewer]
The
Avengers
The<br>Avengers<br>
Tony
Tony<br>
Earth's Mightiest
Heros
Earth's Mightiest<br>Heros<br>
Hero
Alias
[Not supported by viewer]
Hero
Alias
[Not supported by viewer]
Hero
Hero
Team
Team
Team
Alias
[Not supported by viewer]
" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "SVG('../img/iron_man.svg')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's load the data into the notebook using pandas." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# used for converting csv values in nodes.csv\n", + "mapping = {\n", + " '0': 'hero',\n", + " '1': 'team', \n", + " '2': 'hero_alias', \n", + " '3': 'team_alias'\n", + "}\n", + "\n", + "nodes_df = pd.read_csv(\n", + " './nodes.csv', \n", + " # rename the columns as targets as this will form the target graph\n", + " # (the graph which we will be searching)\n", + " names=['target_label', 'target_type', 'target_id'],\n", + " # ignore the header\n", + " header=0,\n", + " converters = {\n", + " # convert target_type from numeric values to\n", + " # literal string representations for ease of reading\n", + " 'target_type': lambda key: mapping.get(key)\n", + " }\n", + ")\n", + "\n", + "# contains pairs of target node ids\n", + "edges_df = pd.read_csv('./edges.csv')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that the target nodes have a label (the hero's primary name).\n", + "The target_type column will be one of `hero`, `team`, `hero alias`, `team alias`, the four types of nodes in the graph.\n", + "\n", + "(Note that by hero we mean a person in a comic book who has superpowers regardless of them being good or bad)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 Colossus\n", + "1 Rachel Summers\n", + "2 Black Widow\n", + "3 Bullseye\n", + "4 Medusa\n", + "Name: target_label, dtype: object" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nodes_df['target_label'].head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Edges are pairs of `target_id` values.\n", + "Note that fornax deals with undirected graphs so there is no need to add the edge in the reverse direction.\n", + "Doing so will cause an exception as the edge will be considered a duplicate." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
endstart
013676981422105314676
11367698142298635603
29609147721897346471
39609147722027281781
4849109791347320780
\n", + "
" + ], + "text/plain": [ + " end start\n", + "0 1367698142 2105314676\n", + "1 1367698142 298635603\n", + "2 960914772 1897346471\n", + "3 960914772 2027281781\n", + "4 849109791 347320780" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "edges_df.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Label similarity\n", + "\n", + "For some motivation, before using fornax, let us search for nodes just using their labels.\n", + "Let's search for nodes similar to `guardians`, `star` and `groot`.\n", + "\n", + "We will create a function that given a pair of labels, it will return a score where:\n", + "\n", + "$$0 <= score <= 1$$\n", + "\n", + "Secondly we'll create a search function that returns rows from our table of target nodes that have a non zero similarity score." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "def node_scoring_function(first: str, second: str):\n", + " \"\"\" node scoring function takes two strings and returns a \n", + " score in the range 0 <= score <= 1\n", + " \"\"\"\n", + " first_, second_ = sorted((first.lower(), second.lower()), key=len)\n", + " # if first is not a substring of second: score = 0\n", + " if not first_ in second_:\n", + " return 0\n", + " # otherwise use the relative difference between\n", + " # the two lengths\n", + " score = len(second_) - len(first_)\n", + " score /= max(len(first_), len(second_))\n", + " score = 1. - score\n", + " return score" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def search(query_id: int, query_label: str):\n", + " # compute all of the scores\n", + " scores = nodes_df['target_label'].apply(\n", + " node_scoring_function, \n", + " args=(query_label,)\n", + " )\n", + " # create a boolean mask\n", + " mask = scores > 0\n", + " # graph the non zero scoring nodes\n", + " matches = nodes_df[mask].copy()\n", + " # add extra columns\n", + " matches['score'] = scores[mask]\n", + " matches['query_label'] = query_label\n", + " matches['query_id'] = query_id\n", + " return matches" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Aside:\n", + "Note that these string search functions are not terribly efficient.\n", + "They involve repeated full scans of the target nodes table.\n", + "If we were searching a larger graph we could use a search tree as an index, an external sting matching service or database. However, since this is a tutorial, the above functions are simpler and more reproducible.\n", + "This is important as we will be using these search results with fornax." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "query_labels = ['guardians', 'star', 'groot']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Examining the table below we can see that we have a conundrum.\n", + "There are 22 nodes with varying similarity to `star` and 4 nodes similar to `galaxy`." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
target_labeltarget_typetarget_idscorequery_labelquery_id
262Guardianhero10816750.888889guardians0
405Guardians of the Galaxyteam8708072710.391304guardians0
483Guardians of the Galaxy (1969 team)team12954003890.257143guardians0
994Guardianhero_alias20627913260.888889guardians0
10Firestarhero2748217420.500000star1
15Danielle Moonstarhero20838509190.235294star1
69Star-Lordhero10618676050.444444star1
87Northstarhero12608802840.444444star1
174Darkstarhero12767533090.500000star1
269Starfoxhero15942942590.571429star1
302Ultimate Firestarhero17180267720.235294star1
352Shatterstarhero12419255060.363636star1
413Starjammersteam8951174950.363636star1
414Upstartsteam8398510790.500000star1
503Starforceteam16059411170.444444star1
520James Proudstarhero_alias2681493750.266667star1
556John Proudstarhero_alias8801970810.285714star1
602Anthony \"Tony\" Edward Carbonell Starkhero_alias20078060130.108108star1
661Star-Lordhero_alias925714790.400000star1
678Moonstarhero_alias2943734730.444444star1
764Starlordhero_alias17883144070.444444star1
765Star Lordhero_alias9254346460.400000star1
985Anthony Edward \"Tony\" Starkhero_alias21389963950.142857star1
986Tony Starkhero_alias1822991330.363636star1
989The Star Spangled Man With A Planhero_alias19155735630.117647star1
1051Firestarhero_alias15800653670.444444star1
68Groothero746714341.000000groot2
\n", + "
" + ], + "text/plain": [ + " target_label target_type target_id score \\\n", + "262 Guardian hero 1081675 0.888889 \n", + "405 Guardians of the Galaxy team 870807271 0.391304 \n", + "483 Guardians of the Galaxy (1969 team) team 1295400389 0.257143 \n", + "994 Guardian hero_alias 2062791326 0.888889 \n", + "10 Firestar hero 274821742 0.500000 \n", + "15 Danielle Moonstar hero 2083850919 0.235294 \n", + "69 Star-Lord hero 1061867605 0.444444 \n", + "87 Northstar hero 1260880284 0.444444 \n", + "174 Darkstar hero 1276753309 0.500000 \n", + "269 Starfox hero 1594294259 0.571429 \n", + "302 Ultimate Firestar hero 1718026772 0.235294 \n", + "352 Shatterstar hero 1241925506 0.363636 \n", + "413 Starjammers team 895117495 0.363636 \n", + "414 Upstarts team 839851079 0.500000 \n", + "503 Starforce team 1605941117 0.444444 \n", + "520 James Proudstar hero_alias 268149375 0.266667 \n", + "556 John Proudstar hero_alias 880197081 0.285714 \n", + "602 Anthony \"Tony\" Edward Carbonell Stark hero_alias 2007806013 0.108108 \n", + "661 Star-Lord hero_alias 92571479 0.400000 \n", + "678 Moonstar hero_alias 294373473 0.444444 \n", + "764 Starlord hero_alias 1788314407 0.444444 \n", + "765 Star Lord hero_alias 925434646 0.400000 \n", + "985 Anthony Edward \"Tony\" Stark hero_alias 2138996395 0.142857 \n", + "986 Tony Stark hero_alias 182299133 0.363636 \n", + "989 The Star Spangled Man With A Plan hero_alias 1915573563 0.117647 \n", + "1051 Firestar hero_alias 1580065367 0.444444 \n", + "68 Groot hero 74671434 1.000000 \n", + "\n", + " query_label query_id \n", + "262 guardians 0 \n", + "405 guardians 0 \n", + "483 guardians 0 \n", + "994 guardians 0 \n", + "10 star 1 \n", + "15 star 1 \n", + "69 star 1 \n", + "87 star 1 \n", + "174 star 1 \n", + "269 star 1 \n", + "302 star 1 \n", + "352 star 1 \n", + "413 star 1 \n", + "414 star 1 \n", + "503 star 1 \n", + "520 star 1 \n", + "556 star 1 \n", + "602 star 1 \n", + "661 star 1 \n", + "678 star 1 \n", + "764 star 1 \n", + "765 star 1 \n", + "985 star 1 \n", + "986 star 1 \n", + "989 star 1 \n", + "1051 star 1 \n", + "68 groot 2 " + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# find the nodes similar to 'guardians', 'star' and 'groot'\n", + "matches = pd.concat(search(id_, label) for id_, label in enumerate(query_labels))\n", + "matches" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Fornax enables a more powerful type of search. \n", + "By specifying 'guardians', 'star', 'groot' as nodes in a graph, \n", + "and by specifying the relationships between them, \n", + "we can search for nodes in our target graph with the same relationships." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Creating a target graph\n", + "\n", + "Fornax behaves much like a database. In fact it uses SQLite or Postgresql to store graph data and index it.\n", + "To insert a new graph into fornax we can use the following three steps:\n", + "1. create a new graph\n", + "2. add nodes and node meta data\n", + "3. add edges and edge meta data\n", + "\n", + "The object `fornax.GraphHandle` is much like a file handle. It does not represent the graph but it is an accessor to it.\n", + "If the `GraphHandle` goes out of scope the graph will still persist until it is explicitly deleted, much like a file." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "conn = fornax.Connection()\n", + "conn.open()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "target_graph = fornax.GraphHandle.create(conn)\n", + "target_graph.add_nodes(\n", + " # use id_src to set a custom id on each node \n", + " id_src=nodes_df['target_id'],\n", + " # use other keyword arguments to attach arbitrary metadata to each node\n", + " label=nodes_df['target_label'],\n", + " # the type keyword is reserved to we use target_type\n", + " target_type=nodes_df['target_type']\n", + " # meta data must be json serialisable\n", + ")\n", + "target_graph.add_edges(edges_df['start'], edges_df['end'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can use the `graph_id` to access our graph in the future." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "target_graph.graph_id\n", + "another_target_graph_handle = fornax.GraphHandle.read(conn, target_graph.graph_id)\n", + "another_target_graph_handle == target_graph" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Creating a query graph\n", + "\n", + "Let's imagine that we suspect `groot` is directly related to `guardians` and `star` is also directly related to `guardians`.\n", + "For example `groot` and `star` could both be members of a team called `guardians`.\n", + "Let's create another small graph that represents this situation:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "# create a new graph\n", + "query_graph = fornax.GraphHandle.create(conn)\n", + "\n", + "# insert the three nodes: \n", + "# 'guardians' (id=0), 'star' (id=1), 'groot' (id=2)\n", + "query_graph.add_nodes(label=query_labels)\n", + "\n", + "# alternatively:\n", + "# query_graph.add_nodes(id_src=query_labels)\n", + "# since id_src can use any unique hashable items\n", + "\n", + "edges = [\n", + " (0, 1), # edge between groot and guardians\n", + " (0, 2) # edge between star and guardians\n", + "]\n", + "\n", + "sources, targets = zip(*edges)\n", + "query_graph.add_edges(sources, targets)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Search\n", + "\n", + "We can create a query in an analogous way to creating graphs using a `QueryHandle`,\n", + "a handle to a query stored in the fornax database.\n", + "To create a useful query we need to insert the string similarity scores we computed in part 1.\n", + "Fornax will use these scores and the graph edges to execute the query." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "query = fornax.QueryHandle.create(conn, query_graph, target_graph)\n", + "query.add_matches(matches['query_id'], matches['target_id'], matches['score'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally we can execute the query using a variety of options.\n", + "We specify we want the top 5 best matches between the query graph and the target graph." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 79.2 ms, sys: 4.3 ms, total: 83.5 ms\n", + "Wall time: 80.8 ms\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/numpy/core/records.py:513: FutureWarning: Numpy has detected that you may be viewing or writing to an array returned by selecting multiple fields in a structured array. \n", + "\n", + "This code may break in numpy 1.15 because this will return a view instead of a copy -- see release notes for details.\n", + " return obj.view(dtype=(self.dtype.type, obj.dtype))\n" + ] + } + ], + "source": [ + "%time results = query.execute(n=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualise\n", + "\n", + "`query.execute` returns an object describing the search result.\n", + "Of primary interest is the `graph` field which contains a list of graphs in `node_link_graph` format.\n", + "We can use networkx to draw these graphs and visualise the results." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "def draw(graph):\n", + " \"\"\" function for drawing a graph using matplotlib and networkx\"\"\"\n", + " \n", + " # each graph is already in node_link_graph format \n", + " G = nx.json_graph.node_link_graph(graph)\n", + " \n", + " labels = {node['id']: node['label'] for node in graph['nodes']}\n", + " node_colour = ['r' if node['type'] == 'query' else 'b' for node in graph['nodes']]\n", + " pos = nx.spring_layout(G)\n", + " nx.draw_networkx_nodes(G, pos, node_size=600, node_color=node_colour, alpha=.3)\n", + " edgelist = [(e['source'], e['target']) for e in graph['links'] if e['type'] != 'match']\n", + " nx.draw_networkx_edges(G, pos, width=3, edgelist=edgelist, edge_color='grey', alpha=.3)\n", + " edgelist = [(e['source'], e['target']) for e in graph['links'] if e['type'] == 'match']\n", + " nx.draw_networkx_edges(G, pos, width=3, edgelist=edgelist, style='dashed', edge_color='pink')\n", + " nx.draw_networkx_labels(G, pos, font_size=12, font_family='sans-serif', labels=labels)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Result 1 contains the best match. The three query nodes (in red) best match the three target nodes (in blue). The dashed lines show which pairs of query and target nodes matched each other. The blue nodes are a subgraph of the target graph. Note that the result does not describe the whole target graph because in principle it can be very large.\n", + "\n", + "Here we can see that the blue subgraph has exactly the same shape as the red query graph. However, the labels are not exactly the same (e.g. `guardians != Guardians of the Galaxy`) so the result scores less than the maximum score of 1.\n", + "However, we can see that our query graph is really similar to Groot and Star-Lord from Guardians of the Galaxy.\n", + "Since this is the best match we know that " + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/networkx/drawing/nx_pylab.py:611: MatplotlibDeprecationWarning: isinstance(..., numbers.Number)\n", + " if cb.is_numlike(alpha):\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXwAAAEICAYAAABcVE8dAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDMuMC4yLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvOIA7rQAAIABJREFUeJzt3Xl8lNX1+PHPSTLJZCUEEnZQwA1wD+La4N7WtbZq1aLWX1urtbXV2qq1jXHrV1u17gtq1VqtFPetWhdSUFTCoggIiLIvgUD2PXN+f9xnkskwWYAJWea8X6+8SGaeeXInCee5z7n3niuqijHGmL4vrrsbYIwxZvewgG+MMTHCAr4xxsQIC/jGGBMjLOAbY0yMsIBvjDExwgK+6ZVEZLKIrO3udhjTm1jAN7tMRFaKSI2IVIrIRhF5UkTSuqENJ7TzfKKITPeOUxGZvBub1+XEuV1ESryP20VE2jj2eu93FfyoEZGAiAwMOeYEEZknIlUislZEztl978Z0FQv4JlpOU9U04CDgYOC6bm5PJLOAHwEbu7shoUQkIQqn+RlwJnAgcABwGnBppANV9TZVTQt+ALcDM1R1i9eeccCzwB+Aft4550ahjaabWcA3UaWqG4G3cYEfABFJEpG/ishqEdkkIg+LSLL33EAReV1ESkVkq4jMFJE47zkVkbEh53lSRG4J/54i8g9gJPCa12P9XYR21avq31R1FtC0I+/J6z3fLSLFIlIuIgtFZIL3XLKI3Ckiq0SkTERmhby300VkkffeZojIfiHnXCkivxeRz4EqEUkQkaEi8oKIbBaRb0TkVzvQzIuAO1V1raquA+4ELu7MewMuBJ4KefgG4BFVfUtVG1W1RFVX7EBbTA9lAd9ElYgMB74DfBXy8P8Be+MuAmOBYcCfvOeuBtYC2cAg4Hpgh+p9qOoUYDXeXYaq3rEr7yGCk4Bv4d5DP+AcoMR77q/AocCRQBbwOyAgInsDzwG/xr23N3EXpMSQ854HnAJkAgHgNeAz3M/neODXInIygIgcLSKl7bRxvPfaoM+8xzpyDJADvBDy2OHe91woIhtE5BkRyerEuUwPZwHfRMvLIlIBrAGKgXxo7kH+DPiNqm5V1QrgNuCH3usagCHAKFVtUNWZ2vMKPDUA6cC+gKjqElXd4N2JXAJcqarrVLVJVT9S1TrgXOANVf2vqjbgLgzJuAtD0L2qukZVa4CJQLaq3uTdjXwNTMX7OanqLFXNbKeNaUBZyNdlQFpbefwQFwHTVbUy5LHhwBTg+8BeXrvv6+A8phewgG+i5UxVTQcm4wJjcAAwG0gB5nqpjVLgP97jAH/B3Q28IyJfi8i1u7fZHVPV94H7gQeAYhF5VEQycO/RD0RKdwwFVoWcI4C7GA4LOWZNyOejgKHBn5H3c7oed9fTGZVARsjXGUBlexdPEUkBzqZ1OgegBvi7qi7zLgS3Ad/tZDtMD2YB30SVqhYCT+J6tABbcAFkvKpmeh/9vMFCVLVCVa9W1dHA6cBVInK899pq3MUiaHB73zqa72O7k6veq6qHAuNwqZ1rcO+tFhgT4SXrcUEcaL7TGQGsCz1tyOdrgG9CfkaZqpquqp0NtItwg6tBB3qPted7wFZgRtjjn4e1rafdcZmdZAHfdIW/ASeKyIFez3YqcLeI5ACIyLCQ3PSpIjLWC4hluAHVgHeeBcD5IhIvIt8G8tr5npuA0e01yhs89ntfJoqIP5jyEJGLRWRlG6+bKCKTRMQHVOGCfMB7b08Ad3kDrvEicoSIJAHTgFNE5HjvdVcDdcBHbTTvU6DCG8hN9s41QUQmtveeQjyNu1gOE5Gh3vd7soPXXAQ8HeEu4O/Aj0VktHcXcC3weifbYXowC/gm6lR1My4ABQdmf49L23wsIuXAu8A+3nN7eV9XArOBB1X1A++5K3HTC0uBC4CX2/m2fwZu8NIhv23jmKW4u41huJlENbT0wkcAH7bxugzcRWsbLk1TgktFAfwWWAjMwfWWbwfiVHUpbgrofbg7gdNwg8r1kb6BqjYBp+IGtr/xXvMYbpAYETlGRCojvdbzCG7QdyHwBfCG9xje6ytF5JiQr4cBx+F+T+FtecJ7/BPv/dYBOzJjyPRQ0vPGx4zZ/UTkHdzg65LubosxXcUCvjHGxAhL6RhjTIywgG+MMTHCAr4xxsQIC/jGGBMjLOAbY0yMsIBvjDExwgK+McbECAv4xhgTI6Kx044xneN2dkoH4nEFuapxpYGNMbuBBXzTtURScUXN9sVtEBK6tDsOkSpgJa7OzWZs6bcxXcZKK5iu4SpGHorbXzWAK4AWqTfvwxUI8+MqXhaiWhLhOGPMLrKAb6JPZDBwIm6npE20lDvuSCZu56bZwOe48sPGmCixgG+iS2QEbp/WUlzt+B0Vjytf/BnwkQV9Y6LHZumY6BHJxgX7EnYu2IPbAGUNLhV0SJRaZozBAr6JFrer0/FABW5HqF2huK0AD0Oks3u6GmM6YAHfRMsEXA6+PErnCw70HotIfJTOaUxMs4Bvdp3r3R+MG6CNpgqgPzAkyuc1JiZZwDfRMBxIBBojPXkUnLcXXLWT567E5fONMbvIAr6JhlFEnmMfDWXVMMpbpWuM2QUW8E00DMX1xDkOvp8MT/rg+Qx46ArInQ1nr4BjEmBaf7gXEc6G4zPgQR88nwZTz4BvB092HUxIgb8fB9/3w1NHwkW4xVmmDxFBREgWIV2EVBFsrKaLWa/J7Bo3oJoOVDwJwz6GU16Bq06CrS9ATgPEzYd/F8OQ5XAXACd8e8KFg4b84tb+/cvGZvRb+9bSJYHvvzTt5+9+94x9T8jOWbXPos/T6z75KMuXmTVi/YCB1zSuWOYHUnHTPU0vJoIPGAHshesoJIY8rSJswZXaWKFK2e5vYd9mAd/sqji8+jiJEAiA7x0YcRiUfR+KAe4LOXjxuHF7DR4wYMxp48YnAAOAAaccksu3F33OokDTcSeM3Ys94oT4ok945Te/O87f2LgnN99wO6F3o4VFxwEXAJtx32P7f/Ny67r+rZvO8nrvE3DlNhJxd4SltB73Edzq7EOBw0RYCcy2wB89FvDNrgrg/qNyPmx4A6Y+DeffCyNHwLyH4PHggTV+v68qJSWzITV1yFtLl1Dw/jss27KZgCrVDQ3sP7hlMk52aip+nw9qaspCvk/QIcAl7baqsKgcdwGYTl7utWHP7Q8MpuUisYW83PqdfP+mAyL0B44DsnEzuSIO7hOsoOo+wM3OOleE/wFLVbGyALvIAr7ZNapNiJQDSUDdP+F/wP8WQ/Lp8Isr4KIc2ACwrX//dIAv53264PQFC0781cDsJ96beMTXqf2z0ifO/OD81WvXVLJ+3TdlxcWjmhqbxlNTs4aqyrXed6oM+a45nWhZhveRFeG5nwOXt3qksKiUyHcL75CXOzPsWCEv14JPJ4iQDZwO1OMW0+2ILbjiescDGSLMsaC/ayzgm2hYD+zxJAxcAgOuhsWDoMEH9QGIy4LSZXBwWWpqBkB1IBDfpJrg31y8Kmnz+0t+BYfOh+EJW0teZvnSZz6GCeVwNTff8Gvc3cNgaHVb/zzwFa7HmBPh34G0/G1vjtDeSBeMTO9j77DHq4GZYY+9TmHR4bSVTmr970rycne2zESvJkI/XLCvpvUFe0c0AGuBiUAdrsaS2UkW8E00rALGVULCY3DRXTBcoGkQLHkcHsiEhlPh2INWrLgjMyGh9PUDD5x6amLiU3fW1//uL+DbAz4dBZ+0ce5+wGpUm5ofycudC8xtszWFRXG44J2NW7wV7gvc+EHoBaKtGWuRLhiDcHcOWbg6/+05D/hXWPvuxvVc27pIbCMvt1cXjRMhDpiMS9/sbLAPCuDuDo4UYb1qxN+J6QSrlml2nZsjPwXXC2+IdEi9zxf/2YEHHgKuy37Q/PlzE5qaOhPUhgOvorqj6YDOcxeILFouAKF3Cy+Sl/t52PErcWsPOuME8nLfC3v9FtwFpy1NuHTGZuBc8nIXh7xWgDO954MXidKedoEQYT/gWFwhvGjJwP19TVftdMltE8J6+GbXqTYiMg84gjbytKVe/h4gsa6uqpPBPgM3FXNDVNrZFhcst3gfSzrxirG4C0SkdFL4v63bXlgUnJ3UnnjcXcQgti9Elwa8GPZYo3cRiXS3cCd5uS3ncBcMunIMwuvdT/S+fzSV4zoAQ9jx8QBE5ElgrareEOV29RoW8E20LMLNre4H20+jK09Pbw74KdXVkdIs4eK8c/27x9XEz8ttxAWznQloApxP+xeJ0EVm4emLSOMPCbhxjsFhjytwe9hj+wNFFBZ1NPZQDGySyROHAXcA43F3HkuAX3tf/0RVj47QnqG4dRPbIjzXgcG3wZEfwIv/beOASu89dN0dXx9mAd9Eh+vlfwCchZuR0arUQk1KSlrw8/SKio4CvuA2QZmNat/K1+blNgDPtXtMYVESblwhm+3z3wq8SusLRDqRbfUuTqGyceMHQ72PNpVWVqzB3WVdBkwDEqec9N2fXvztU//47txPfc+++/YwCot+zHYXi9yRdFgiuyIO0nfmQl4KjBIhQTXy9E4RSVDVtqZ+xjQL+CZ6VEsQeQM4Dfe3VQHQGB8fV5eU1BzwM0tL2xvES8AFonnAgi5sbc/lFo2tI1IvNi/3a+CMVo8VFvmJfLcgEc4+sLPN+GzF8gogQ1WDF6gaCouql6z65rS7pj1LQ1Mjad/+1hMJ8fGUvvEBb8yexQ2PP4Q/cR3+xLTA/qNP+nrm5097RfNeyIEfPDZs4IXPbC59+fSE+H5bDhiz+Nbla/1lJeUJnVwD8fPD4LkLoTYbmCJS/1NVXQIgIiuBh3AL8vYRkVTcncDjuDvPN8GmdFrAN9Gluh6RF4ETcIF7Y2lmZmrwf1piQ0NNUn19W72vLNxm5oXAImxGQee4HP0aOjNAmpf7PIVFr9Hx2EP2fiP3WAoMFpGncDONPtYZc3L2G7UnD191LY+98Qqz7n+s+dSp/mSeuq6AD784jIbGRXHXTZ0yCn42CR5tnoGVmrzymLuv+F+GSFxGkm/D4wCllfGLLrxt9HXtN/zpofDYNXDlrVBQAvuPgJWvicg4VQ1eMM7D7bi2BZcSfBn4G3A/7iL5HNunuGKKBXwTfaqbEZmOq5F/UFN8/ODEujp/g89Xl1xdHd67T8LlrH24Hu0sVHci92s6LS+3GjeVdlV7h3mDBfsBvwemAoOzTjtu5tt/uf/XC75adtSmbVuPBN7Hu0hMPvjQ7MYmcj76Ii5pzyH7st+oyevmLp0zgZAptz/Iu361PzGl1QynhqamQHZ2cb+SkgHlgTaTPI8dAyPnwJ0LgKGw4DHIvBA4EpjhHXSvqq4BEJFv4f6m/qau4zBdRHa2RHefYQHfdA3VBuBTRD7//IADzui/bduE1KqqzIGbN1fSsqGJ4AZ4FwHLUN3aXc01kXkpk4sBRGTfbRUVzxz284sOx6VIhpKXe2HwWBGZBPwfJE8UaYxTbRoOY74JPV+cjF2+tTwuPdGnGY+9/vthHy16M6kpEBifnjH5kvj4V+5tO+Bvy4Ks4HiOQL8A7o5mWMhBoXc4Q4F12vousd0LXCywgG+6VMGNN9bhcvmfxjc2yimvv/5x1oIFDQTrprTcjpseTlW/9KY2Xgr8J8IhzwL3w8onVHNq4OApUJkResAt/8h5FdJfBsjIeCx5/wOWTfAlSHx1bXzNggWJTRHO6em/FdYG7wwCUFmJq7oZOs4RGtw3AMNEREKC/khgRaffcB9k9fBNV+uHm1dOU0JC9cHz5xejug3VUgv2PZuI7CsiV4vIcO/rEbg8+ce4ImjDRSS0vHE6sBVyVsIDE2BJXnvnT02tSlagvlGb6uuTqlueaYyHDb6Wj9J4uGQWrJ4IvzsAKuJh4CW4UgsftXH62bhVvr8SEZ+InAUctjM/h77Eevimq4UWL7OUTe9SAUwCrhKRTNyUyNeBa3DTLhcBG0UkoKoDcQXp7oS4ATD2a9h7FtSktnXylJTq5udqa/0hAf+1y91H0NgZsPwu+PBOmHoZ3N0fGouA07SNToOq1ntBfipwCy4FFb5gLeZYwDddLXRVqW1g0ouoK2dxTjuHnBJ2/HRgurfJyRTcwitvRtb3i0FPDz3e769LCX5eXZ3srdvYeH3b327qxzB1DfAfVVaGfe89IrS/CDdxwHgspWO6mvXwY4wqDcB8XGmINiUm1iUHP6+sTKtu71hPCq7y5tqODjSRWcA3XaagoCCNli3sGtj1qomm9/gClwKKuAo4Kak2IT6+yQegKoGqqtQOVuYiuEVj77e1wtZ0zAK+6Uqt0jn5+fm2kCpGeL3893GlGZLCn09Pr2hO59TXJ9aodhiKhgGfqVrvfldYwDddydI5MUyVYtxgaTYuHdMsJaW6+eu6On976Zw43PTL5biZN2YXWMA3XSm0h28BPwapshp4BVcyYzBefZ/k5JrmgF9T46+J/GrScD37ecAHqrQzT990hs3SMV2ioKAgGQgOyjURoWSyiQ2qrBfhedw8+HFAU3x8U3MJ6Kqq1NAefrAsdrC88ouqbNyd7e3LLOCbrhKaztmWn5/fs2ram91KlVrgfyIsiI9v3CsuLnBueXlGqohqWVm/frjevOCmca4GFgMbbGer6LKAb7qKpXPMdlQpLyi4dTkws6oqJaG4ODtu6dJ9P8SVRagBKlStjHFXsYBvukpoD98WXJlQ6QCpqdWNe+65apOlbHYfG7Q1UVdQUOCjZf61slNb3Zk+LHQLx/Jua0UMsoBvukJo774sPz/fZleYUKEVNC3g70YW8E1XsPo5pj0W8LuJBXzTFWzBlYmooKAgiZaVt01AVTc2J+ZYwDdRVVBQEA9khjxkAd+ECu3dV1i5jd3LAr6Jtv54qylx/6FtkxMTKrSYmqVzdjML+CbaLJ1j2mMzdLqRBXwTbTb/3rTHBmy7kQV8EzUFBQWC9fBNGwoKCuJwJRSCLODvZhbwTTQ1b1gO1OTn57dVBdHEplRaYk5Nfn5+Q3c2JhZZwDfRZL170x5L53QzC/gmmmzBlWmPBfxuZgHfRJP18E17bIZON7OAb6IibMPy+vz8/IrubI/pkWwOfjezgG+ixerfmzYVFBQk4rY5BCup0G0s4JtosXSOaY+VVOgBLOCbaLEBW9MeG7DtASzgm10WYcNy+w9twlnA7wEs4JtosA3LTUdshk4PYAHfRIOlc0ybrKRCz2EB30SDDdia9lhJhR7CAr7ZJd50O9uw3LTH8vc9hAV8s6tsw3LTEQv4PYQFfLOrrP696YgN2PYQFvDNrrL8vemIlVToISzgm51mG5abjlhJhZ7FAr7ZFbZhuemIlVToQSzgm11h6RzTERuw7UEs4JtdYQuuTEcs4PcgFvDNTvFWT/YPech6+CYSm6HTg1jANzsrA9uw3LTDSir0PBbwzc6y/L3piJVU6GEs4JudZfl70xHL3/cwFvDNzrIevumIBfwexgK+2WG2YbnpJAv4PYwFfLMzbMNy0xkW8HsYC/hmZ1g6x7QrrKRCACup0CNYwDc7wwZsTUda9e6tpELPYAHf7BDbsNx0kqVzeiAL+GZH2YblpjMs4PdAFvDNjrJ0jukMC/g9kAV8s6NswNa0yyupYJue9EAW8E2n2YblppOspEIPZQHf7IjQ3n2pbVhu2mDpnB7KAr7ZEZbOMZ1hAb+HsoBvdoQFfNMZFvB7qITuboDpgUSCm5P3w21yklDv8wUmnH76vluzsqqLc3KqGn0+C/imLRbweygL+KaFSCowFjgQt7hKgAYgUOP3p49fuHAfQONUK/ZevnwVN974FaqV3dhi08NYSYWezQK+AREB9gK+hUvzlRA2A2frgAGJFRkZJQD9t24tBSYChyEyE1iKqi3AMmAlFXo0C/ixTsQHTMYF/E1AfaTDqlJTm+dVp9TUlOBy+D7gWGBPRN5Dta7L22t6Okvn9GA2aBvLXLA/ERgNrKGNYN8UFye1fn/z3qSZpaXBNE6D97phwMmIJEZ6vYkpFvB7MAv4sW0SMBJY395B5RkZKSoSB5DQ2FifUlMTfmHYCAwBjuiSVprexAJ+D2YBP1aJDAUOoINgD1CekdGczkmuqWlrd6sNwHhERkSngaa3sZIKPZ8F/FjkBmm/hcvDdzioFpq/z1227I+/dbN4mAxnj4Nfek8psAXIw7sbMDHHSir0cDZoG5sG4+bXr+3owIAItcnJzfn70Nk4M+DfYYfX4KppDu3MuU2fY+mcHs56YrFpH6C6vQMqvL+NyrS05Ka4uASA+ECgUTu+I6gE9otKK01vYwG/h7Mefi8kIocAj+MWSf0Ht8BlOfAV8BNVPTrkWAX2UtWvROQU4BYf7OuDqoPgnQ/hOYAXIOcH8NhZcN9bcF46bNoE110eCJz46vz5Z9erJn4rNXVGaDuOgvOKYchyuAtgNPx+PYwPQFKDSBFwmaou8trxJG4Rzh64dNJi4HxVXSEuxXQXcAFu0c4q4DxV/aJLfoCmq1jA7+Gsh9/LiJv6+BLwJK62zXPA9zr58qqx8NMquOp2KJgL3/mZm6nTbCFM+AIumwv5U2HE81VVF96w3/h3P/v+ua/jTx5QAwMT/X5fpJMfAnM/g0s3wTV+WAj8M+yQHwIFuHTSV8Ct3uMn4S4Ce+PKOZyDba7SG1nA7+Gsh9/7HI77vd2rqgq8KCKfduaFqjoDkUzgkCtg7ePwvzkwAfgkeMzN8OxoqAN4Go4a6/cvufjwo1JzRo/99kvj96f/zX/kuPMv+hOj96rL+8/rjV9u3tTEGT+4gfq6sul1daVsWHcAL09f/Sf46/VwuYj0U9Uy7/QvqeqnACLyT7w7A9x8/nRgX+BTVV0ShZ+T6UIiZOIu3IOAjKSk2oSJE487sH//baVZWdvKZ8zIq8nP7+ZGmu1YwO99hgLrvGAftKYzLxSRST74azwc1AhxAfCNgVmhx5zsZtoAsA0GDExKqvYlJaUCpCYmMSAlxT0ZJ0mJiYlJKcnJkJl5WFMgwB/eeZN/zJ1TsxmksWUR10AKi244/4STz83O7F9BYdH7wKYnr833/erevw6hsOinOmPOpiN/cclr85cvnVpbXz9ERF4Efquq1kvsQUSIA0YAB+MG/sF1Dur9/prUrVv7ZxcXZw9raEisWbVqj3gRFgDLVanprjab1izg9z4bgGEiIiFBfwSwApcjTwkeKCKDw177bDI8+jX8awCsPhh+Utn6Npz4kEHZrPj48pKGhuG1VZUraspKlzYpCSVVVWNUtckdGnLiz+bxypJFvHXqmf8b//wzL/0B3rrd5eIFGOKLT/Cn+VP8uFIMjBo0hPSUVIBHAT564AkAlqxa+ddxF509EbgG+CMAhUWXA8NxpR+KQz42ASXk5dpGLF1MhAxc2m0kUAqsC32+X7+K5JSUmkqA8vJ+m3F/i0cAh4rwviqrdnOTTQQW8Huf2UATcIWIPAScAhwGzAA+A8aLyEHAl8CNYa9Nr4CNAyD+Dth3CeSNgPltfaMfpqbOu7Ki4syHZ7w755SkpCcuW7r08EbY8+2/P3rjidk5yz6prZ2yBYYx55O3Pvty8dF1tTX7Z23cULQREu6Aa0NOldPeG5rz5SICAeWQvfclMy1tHbA/biA66ALgyDZerhQWbaHlAnATebmFrY4oLDoAN3uoGKgiL9cKeu0AEYYB3wEaaeNu0u+vae5o1NT4a3B3eOtxVVdPEWE+8IkqVmSvG1nA72VUtV5EzgIeA/4MvAW8DtSp6jIRuQl4Fzcn/jrg0pCXX65wZwJkj4Sle8OsGrdYJqJTMzO3fJyS8uZda9acdXsgkDAJXkwOpnw2F9eUQ0UplPHStI9/Dgv+DVfvWfje1YlQofBb4DLvVKe8NHPGM4Oysspu+cll/wYGff71V0dU1lRPAaZ/s2HDuBsef+iAdZuL/Y1NTTcBrwF/CWlKexcMAbK9j/HAnRGOeQuXCgOoobAo0p1C8PO3yMu1vXo9IgwFTsMt0mszNeP31zUH/Kqq1NApvzW4NRkHe+f7WLXjxX6ma0jrVLDpjUTkE+BhVf17J1+wJ3AyYbfl4Zbvtdeo0n79cgAGFRevHrl69aZOnH0Y8C6qX3WqLZ1RWHQxLm01CBf8gx+DaL0LF0AueblzQ14ruN5mZzs348jLbRk0LixKAuay/YVh+4tGXm6fqv0uQipwLu7uqM1gLxLgkEPm5YqoAHz22YHzGhoSw9Nsgvsdvq1K9P42zA6xHn4vJCJ5wFJcb/sCXE2c/+zAKdbiBtsSaaNCJkC9z5cU/DyptrYzpY8TcLf9q3egLR3Ly32yzecKi3zAQFouAEvDjkgBFoQ8n0T7isO+Dt45jO+wnYVF1cAA8nJrQx7LAc5n+wtFCXm5jR2es5uIILg0mtBOsAdIS6tMDgb7pqaEhgjBHtzY0EYgT4SNqtjGOd3AAn7vtA8wDZeO+Rr4gapu6PSrVRsQmQ0cRzszfBp8vuDORfg7F/AHA7NQbfMiEnV5uQ24gezI79/1uicCwd5+GpHvFHJwwT08ndPu+EOYplbB3tkbuDvCsUphUQmt7xSWk5f7x1ZHFRYlA/Hk5VaKyGTgGVUdDiAii4BfqOqMHWhjZ+XgFvZ1WCIjLa0yGaC8fM6AFSv+dCaU9YcT/gFvvR52aD3uAnIA8BG0XhgY1dbvBBG5mLCFi32NBfxeSFUfxZvdsguW4f5DZxMyFTMoIEJjQkJzffuU6uqOAn4WLugu2sV2dR03WFvhfXQ2wCwBDgJyfn7Xn894/aNZpxWXbh3iT0xqGp6dXTvlpFPKfn/+RRInMojt7w7AXUwiEdydyUBa7h4WEpyZ1OIHwNMUFlX/5457yy+8LT+LwqLXgE06Y84rwEEUFg3FXThWkZcbrcA5jg569kHJyW7Adv36qUf6fKOXNDV9ek/kIwffBkcXwnQRYa4qUd0wx1uUeC3urnc4bjbR58DdqvpONL9Xb2UBP1apBhCZAZyJW0DTqmdb6/cnqit5QHxTU4OvsbG92RWZ3r/v97mtDvNya4DPRORq4GzgF8DbDY2NlUtWVR10/dQHfnv91Acu0Rlz6nEzUsKtBO5n+7uJARGObb5giEiCqjbpQLCgAAAaY0lEQVTScoeRkuRLTPEl+ABObaO1rwJntHqksGgKbiZX22MPYTOXREjE7YC2sY3v04rfX5sC0Ni4rR9MnNf+0YEAbkrvMNzdaTRN9857IS2zz47DvX8L+FhphdjmNiB/FZd3H4zrdQJQk5zcnOv2NTa21xMbhMvPvkofXSglIv2Am4DLVXW6qlaoM19VL1DVOvJyVSZPfFNEfhLyuotl8sR7yMv9JXm558rkiZ/L5InpMnliokyeOO+oK/7fmbi7h5OOv+ryF/c493S/iDwjIuXAxSKSfMTll5zf/9TjGHfROcxZurhVu/Y493TeLXKLpD9dsoi9f/T9w0WkVEQ2iMj9Xo93EnCuTJ74y4dfeeHmvS4469HMU459+Rd/u/0jL41SsWzt6uojxh9Qm5SYWCMiWyB1Gu5vQc+evHWfH39ny4HfmVQ6asKeNZnxcZdNgn73Q+Jzrsf++PCkpPqUhQt/MKW2dvUetbUvToGEafD00FaN5fAfwaZx8MrPwfc32OuOkCdPEJHlXtsf8GorBX+Gl4jIEhHZJiJvi8ioNn5HJ+B2bztDVT9R1Xrv4z+qemXIcdeKyAoRqRCRxSLSZlkSEblHRNaISLmIzBWRY0Kee1NE7gz5+l8i8oSIJIrIVhHZP+S5HBGpFpHstr7X7mIBP9a5IP0iLsUzAlfLpnXAr6+PFPAzcItwvgFeRLUvT2U8AjfY+8ounmcOLsBnAc9+9MXnj8jkiUvJy/3v+/PmLFy1acMkXC81E1eHKP/jxQurG5saB4wZOmzcHx9/aPnmsm2bcbWTfl5WVVm6atPGl4Fp1XW1c6/54ZR/4lJERwDHA5cTMgbx+uxZzHn4KT5/4jmmffAub386G4D8Jx7xn3rE0UmVb/3vFmA4nPyv4Gu+M6nsnO8ds+3my87YfN8vvjfz6YT4p/5ww5S7B/7jD5+XnH7U2QMy026467wj5fTTjn35zaSk4avh9Ieh8Ry4MGxjnY+fgUGL4YyHoeFCWP5syJOn4sZZDsDVUToZQETOAK4HzsKlHmfiFfuL4ATgE1XtaMxhBXAM7u+8AHhGRIa0cWyr3xfwbxEJjmtdAkwRkeNE5ALcWpgr1Y1f/Qv4Uch5zgPeU9XNHbSty1nAN6Bai2oh8DIu7zksvrFxZHJ1dbqvvj4xubpacemKLNx89uG4PPirqL6HarullvuAgcAWL8UCgIh85PVIa0TkW505iao+o6olqtqoqnfiLiL7hBwyW1VfVtWAqtbggt+tFdXVW1/7aOaSuoaGv9Y3NNSTl/syebmPlFZWlP3kL7c8QF7uuZOvvDT3p3+55Srv3CuBR4A84A684HPOsSc8k5me/veRgwa/kbvPfltnL/6iFKj1JSSwatMGPl68sF5Va+HFxbjFfST5NJiuY+bnb5K7z2QO2++YlH6p8SMv+c4VQ+LiAv6Gui9y05Ljkt0KbKETGghZEQ78n6qWqupq4ANckAX4OfBnVV3i/exvAw5qo5c/kJAUlIhkeb+fMhFpHkhX1X+r6nrvZ/w8rsrsYZEa2d7vS1U34taZPAXcA1yoqsHd4J4Czgu5U5kC/KMzP5iuZgHftFBdh+rrwL8WTZiwoGTgwI2B+PhAUl1dPS7tsxJ4H/gXqq+i2qkaPn1ACTBQRJrHvFT1SFXN9J7r1P8jEfmtl54oE5FSXC9zYMgh4T/PoWGPtVmeQET2FpHXRWSjlxK6DRhIXm4Rebn/BLjozzcWkJd7CXm5p/7n09mv3fTU1AeAlOq62rEvzZzx/OQrL73Kzfw58szgebeUJSzdVpGwsLo2bk3xtg0NOf1bMjVxcXEM7DeEkvJNlFZpeVNTYGenmYaOFVTjZlIBjALu8QJ3KW7xl+Dy9OFKcPsqA6CqW73fz6GETMUVkQtFZEHIOSfQ+ndAyLEd/b5ew41HLFXV5ppUqvqJ9z4mi8i+uMkRr3bmB9HVbNDWbE+19MOCgq24mvUAH+Xn58dyueLZuHULZwAvtHNcq1pGtBQYw8v//g6XalmkqgER2UbrLnH4KsgNuDRbcObTyHa+90O4gcrzVLVCRH6Nm+HTvrxcnT7jvRW40tWIyNHw8Xvw5s3w3U1X3jcyZDbYhnNFivb4at29j4waVJ+Z3a8uc/Wmldet2+ZbtKWysUY7HrAPvj8fHWzA41kD3Kqq4WW2I3kP+KWIDG8rrePdGUzF/Q5mq2qTiCwgwm1JJ39ft+Jmce0pIuepami66SncndVGYLq7c+p+1sM3bQkNXH1qBemOUtVSXL73QRH5gYiki0icV7MotDTFAuAsEUkRkbHA/wt5Lh13l7QZSBCRPxFWuC6CacB1ItJfRIbTsn9wJOm4GvSVXq/ysnaObUVEzvbOD7ANNADxEYL3JbNUV+Uu/PrGka/PZs1T7/xoTEOT1i3bcuDbNfXUBwId9fBTS2HdYNzPrDPrRh7Gvf/xXjv7icjZkQ70pl1+ALwsIpO8wVMfrpx4cwNwF53N3vl+jOvhR9Lu78tL4/0YNyPoIuA+EQm983gGN9byI+DpTrzX3cICvtlOQUFBAm4VLrgiZlGdL90bqeodwFW4Xt8m7+MR4Pd4i4hwC6zqveeeovUGMG/jVkMvw6Vmaum4rHWBd+w3uGmF7eWBf4tb0VuB68U+37l3BrgB009ExJu15b8KTt7CdvHh4nXw4zvhkUsh+5/wxeGjR9/0r/j4lEBtrb8iwnnDXPwqLD4K/PfDoEs7OlpVXwJuB/7lpam+wBVxa8v3cHWlnsGNRX2Dm5N/sne+xbhaS7Nxv6P9gQ/bOFebvy8RycAF8StUdZ2qzsTtQPf3YN5eXbpzHu4CM7Oj97q7WC0ds52CgoIM3IAfQGV+fv4H3dkes/uJcCxuO8rtFuUF7bffkrGpqZX9AYqLc9asXj2qM/P243Ezbp6O9sKrnkZEngDWq+oN3d2WIMvhm0hC0xR9fQaOiWwxbgeyiOLimiQlpbpf8OutW7NKO3negcCiGAj2e+Cmkx7cvS1pzVI6JhLL3xtX26eNWkKZmaVpIoE4gMbGhPrKyvTODEom4lIcn0etlT2QiNyMSz/9RVW/6e72hLKAbyKxHn6M82rWz8aN4WxXMiIzs7S5d19VldaZ3r3gZi0V9vVKmar6R1VNU9Vbu7st4Szgm0hCe/gW8GOUKlW4gcv+hAX9tLTK5gVZZWX9ymhfHG6x3jzcSlfTTSzgm0gspWMAUGUDboFRBt6io+Tk6sTExPpk97xoSUlWezWUkmkJ9p/YblfdywZtTSsFBQWC9fBNCFXWizANV4NmVFpaRYIqiEBtbXJFU1NCpAVXibgLRB3wmmqUN8UxO8UCvgmXTMtqwrr8/PxIuxeZGKNKuQhvASMGDNj6g4qKjAGglJb2r8SVHFBcPPHj/n6qcesTlqvSI1aZGgv4ZnuWzjERqRIoKChYAywuLs5eu3nzwPT33jt+Oa43L7jFSRuBMmCLKtZZ6GEs4Jtwls4x7ckCEnJyNlfn5GzeMm3aOYXd3SDTeTZoa8JZwDftCZ2XH2lLR9ODWcA34ULn4FtKx4SzgN+LWcA34ayHbyIqKChIxlWRBLcgq806O6ZnsoBvwtkqW9OW0N59ic3g6n0s4JtmBQUFPtzmFGBlkc32LJ3Ty1nAN6FaTcnMz8+3VZEGgIKCgjhcWeOgTd3VFrPzLOCbUJa/N23JwtWyB9cZsAH9XsgCvgll+XvTlkEhn1s6p5eygG9C2Spb0xbL3/cBFvBNKEvpmO0UFBSkAGnelwGgpBubY3aBBXwTylI6JpLQ3v0Wm47Ze1nAN0BzWeTQTS4s4JsgS+f0ERbwTVBoWeRa68UZaJ6OOTDkIQv4vZgFfBNk6RwTyQBsOmafYQHfBNmArYnE0jl9iAV8E2RTMk0koQHfVtf2chbwTZCldEwrYdMxm7DpmL2eBXwTZCkdEy68OmakzcpNL2IB3wRZSseEs3ROH2MB34SXRW7Kz8+3ssgxzqZj9k0W8A1Y/t5sL3Q6ZmV+fr79XfQBFvANWDrHbM+mY/ZBFvAN2ICt2Z6VQ+6DLOAbsJSOCVFQUJBKy9+ETcfsQyzgG7CUjmktvDqmTcfsIyzgG7CUjmnN8vd9lAX8GOdNv7OyyAaAgoKCeGw6Zp9lAd+El0W22/fYNoCWuGDTMfsYC/jG8vcmlKVz+jAL+Mby9yaUBfw+zAK+sSmZBrDpmLHAAr6xlI4JCu3db7bxnL7HAr6xHr4JstW1fZwFfGM5fBOcjjkg5CEL+H2QBfwYVlBQkAgkeF9aWeTYNpCWeFCRn59f052NMV3DAn5ss/y9CbLZOTHAAn5ss/y9CbKAHwMs4Mc2y98bCgoK0mj5W2gEtnZjc0wXsoAf2yylY8CqY8YMC/ixzVI6BiydEzMs4Mc26+HHOJuOGVss4MeoCGWRbRpebLLpmDHEAn7sahXsLW8bsyydE0Ms4Mcuy98baB3wN3VbK8xuYQE/dln+PsZFmI65rRubY3YDC/ixy+bgG6uOGWMs4McuS+kYy9/HmISODzG9nkgc7j93FjAUSD1rwoTxlWlp8SUDBmwbuXr1cm68MR7Vpu5tqNldCgoKErDpmDHHAn5fJpII7A0cjOvRK643X68i6WkVFb7+27YNGvPVVwocjsgC4EtUa7uv0WY3CZ2OWZ6fn2+/8xhgAb+vEhkCHAekA1uA0uBTdYmJCXVJSQ1AQ71qpa+paTWQCEwCDkDkA1TXdEezzW5j6ZwYZDn8vkhkAvA9IACsA1rVua9OTU0Kfp7Q0BB8rh5Y7x17GiKHIiK7p8GmG1jAj0HWw+9rRMYDk3GBvjHSITV+f3PAT2xoCL+VrwZqgcNxKaB5XdJO020KCgrSaVl4Z9MxY4j18PsSkUFAHu0Ee4D6pKTmgO+rr4+0y1UAWAscgciwaDfTdDubjhmjLOD3FSI+4Fhcrr7NYA9Ql5joD36eVFfX1raGAVzu/zhE/G0cY3onS+fEKAv4fceeQH+goqMDGxITm3v4/tra9mZnVOMWaO21y60zPYI3HTMr5CEL+DHEAn5f4AZXD6aTOxU1+HzNPfbkmpqONi4vAQ4WkRki8pOdb6TpIWw6ZgyzgN839Pc+2lwxW+H9rpvi4qQxPt4HIEByTU19B+euA1KS3LRN0/tZsbQYZrN0ejkROSQJ/hGAMcOhSEBzYP1kWHAPXH04vP4RnDES5i+Du8+Oi/vu+/Pnn1MbCCSPSEpa+UAgsOIk787gJtj3HvhpOQzLgHVXwtQ/wZcnwel1bo7+ISLyN+BJVb2iW9+46ZAISUAGLRfr+muuSRmWmtrcL7B0TowRVe3uNpidJG4l7fLD4YV3YPFvYPQTcM0keHEyLLgdbjkUXnoD/lkPcffBvn+Da28aM+a58yafOObXH7w7etHmYt/Sycc/tmzD+ob95356/TkJCY892NDw/mVw9DS4bBb87DCIHwhXl8D9qvpYd79v0zYRknHjOfsDmbiptQLg91f7R45cs7ffX1M5cuSa5YWFec+Ul2dYSieGWA+/dzscSJgJryXA8Mdg9quwPOT5wKvwbI43a+dVyDvI5ys8OS1t1bBRo6c8PeUSf/+bb2DlvuPyP/H7OXDjev5x+a9/RSDw02caG8sXP3Rv/Ozx+9982OqV98R/tawl/VdYJMBBuB7iZvJyO0oLmS4mQgIwATjMe6gUt5Cu2aBBxUPS08tLGhp8iQsWHLRnRUXGhSJ8AnyhitVRigEW8Hu3ocC60F9iBmwOfp4E5UOgIfh1BWSNaWiYc9DSpYtIiPenJcQzICWVdeVlrC8vZ1Rmf3dgXFwyiYnJe+cMojI+fnSE75tB6IKswqIy3PctJngRaP3vdPJyG7Y/jYkGEfoBJ+Dy8xsgcvBOT6/sJwKJiQ31gUDcN7iLwpHAaBHeVe14hpfp3Szg924bgGENUOnz8rTlkJ0NGwHE3c43S4etmyAHny+Or1c8XSqStaWq8pSBAV3SPyFh4NclJVkoghAPsLpsG9/eex/YuKGmqfXc/tCBP4B+3sfYCG1UYFqrRwqLxgMv0PYFIvTzreTlWu+zDSJkAmd4X65t67iEhIY4v782Pfh1ScmA4HqNtbiqmWeK8Ioq5V3ZXtO9LOD3brOBpnHw3c+g+DcwaQvsNQYWRjr4FPjffXDNPRUVhec99uDLJ8GPs+DLfaY+cG0ZpC+ERy/4w9UPPZIzaP4NqpPnbdky5ei9xz1IZUVcwKUHgr39ROALINv7aG+215YIAXsosI/30ZGvgTGtHiksOhAX5CJdIErJy42JlaMi+IFTcIvkSts7dsCAkn7B6399fVJ1XZ0/9AJegpvl9V0RXlKlo6m6ppeygN+LqWq9iJz1DTzRD/YeAUUjYY4vJI0T6q/w2TJ45g9w3TWQNgiWPAl3ABwGFdfBTffBz/oXb7o8HTb8HvLHvvvWEmDYPvDAx3CPiFwG/ENV9wegsCgOFyxycME//N9I+f3wO4T2lER4bBJQ0MbxTRQWhd4xfEBe7q2tjigsGgKkeceUk5fbW2cuTMItjNvY0YH9+pX3C35eWZkW6eKwDRgC5AIfRquBpmexWTp9hciZQMpAyD8W3vo3vBelMyfjevDPE60/lsKiNGAP2r5IhP77IXm5p4W9/g/ALZ38btPIyz037PW3Add5X9WzfUop9M5hHnm5C3bk7e0OIgzBVURdS1jqLpKDD55/UHx8ow9gxYoxS7Zty6qMdFpgODBd1aZs9kXWw+/lRCQPWLoaFv4Z/rQN9rg0uhUuBwDvRS3YA+TlVuJSQh1zdxDhPgT+TOSLREbYsZECV+gdRiIwzPuI5HagdcAvLLoHOIrt00nbXzTycmvaOO+u2h+opBPBPiOjLDkY7AOBuKbS0sxIwR5aNsgZj83R75Ms4Pd++wDTRkJqJmz5HTxwQvTK3fbDpVS+jtL5dlykfHxe7gxgRsTjC4uSaH0B2BDhqDJgpfd8aoTnQ0UKfBOAQzt4XbA9PyUv97Gwx34BJLH9hWIzebnt5s9F5IcQfzXIARBfA2mb4Ij34aU32/rv3L//tszg59XVKWWq7S6w3wrsLTJpA3y6BPCparvF+EzvYQG/l1PVR4FHARDJBM7BBZNdHXjz4XbLmk5v+g/vAuZa2pmxQl7u1cDVABQWpdB2OimH8N69k70DLYpU3+gqWgbAW2s9xXUzcD15uYsBRORq4HcHjz314eLSu1/dVjF0YXXdg6Pgwe/B1ncgJ+z3VB0HKYH09Mrm/H15eUZZB+317hgOz4RPO/H2TG9iOfy+RmQUbuZGMTsf9H24Abx3UF3e0cExp7BoD2AwnRuDOI683Flhr6/ADRp3xiHk5c4XkX7A+oT4+Is+vP/z5z//OjluUP9GGpuksr5RyuobpPSuaZdlQ0LtlrJ1vg0li7PzDrr1hQVfnfhWXdNFV9XULBsr4msIBA5+Q/Wd5yFJoU7gxLNh7snQlAgj58GLj8CEDEi6CeoHA1VeO05U1dm79oMz3c16+H2N6ipE3gBOws3WiTTLpT2ZuJkfFuzbkpe7EpcSap9bkSxhj8XhxgUiXRwGglsDESKYUjoCSJr/2D9nrtscH+f3uY5aQrymJcRrWkoSw7IyUvl40X/508VT2XfEwTQ2NZz7o9t+uIfE1fv33//F+yori2XFiit+BOeXwAv/hQuOh3nHwxPXw6FlcMJv4KxLYdnf4fHfwJTngExL6fQdFvD7Ihf0p+F2vxqBy1l3tKAmHRfsNwJvoNqpUsumHW66p4Y9FqCtGUYtU1xDLwTBgD8Q2DJhzzH+JaviVzU20f+ah87NWLv5Kxoa67nxx08AMGnc8Ywb5YYX4jShsbZu8aEpKQ9eV1Gxd1V9/f51cNzL8OGxwH/hw8lw7Ctwrlc188an4ZL7oepJiLfY0AfZL7WvUi1D5HXcNLuDcIudwC27D87T9+F6lIILLG8Bq1G1la3dwV0MSryPL8OeLQEGyuSJ60B/AgxJ9BWVjx5Sl7Z8bfb9r8xaN33N5g2HZ6UPaViyyr80xR/oV1qxLgU0r7r6pJVffz3aS+/tUQzvD3CfV2XBqJBB6ZOLQeNhbn9otNLJfZAF/L5MNQCsBlYjko7rPQ6gZWZKNS6QlKLa0WCe6V6zcWMyZ+AK5I2ub4gr/XJ1cjkEmj5ZsvVr2Lrn+i2U/P6REc+6l2THgRwF7+XA6DXusVXZkOql+VK3wqqQKar/zQZpgtw6+Hgzps+xDVBihWoFqqtRnY/qLO9jHqqrLNj3fKpailtd/CAccDis9rtB13v2hMY29hxOCcCYWXDrFFiaDC9lw/tnwpEfuOePKIQZZ8C/B8FXfsi/EMbMhBSF8d/gSjZEnk1keiWbpWNMLyIiF4D8GuIPgLhaSN8IR/8XHn8PjvwFZJXA7GdaXjE/Fc65FFYfAvH1cOg78G7ILJ0TzoW5J7XM0pn+OByYBDwN8kfgMlzq79uq+nH3vGsTLRbwjemFRDgCGEf0V8QOAhaoUhTl85oewFI6xvROS3A972iOw/lwMWFZFM9pehAL+Mb0QqqU4moKDY7iaQcDs6wmft9lAd+Y3msxbhZWNIL+UGAF208HNX2IBXxjeilvH9p3cYvlhhK+qrdz4nBrNdYA76sSE5vHxCobtDWmlxPBB0zELbArhU7vTZuBq4g6D5irGnnjHNN3WMA3po8QYSiu5k4ObmOXCiC0Hr8Aflyg9+Fm+Hyo2vGOWaZvsIBvTB8iguDq7ozBbeoygJZUj+JWVq8BvlZlS7c00nQbC/jG9GEiJOB68wo0eHl/E6Ms4BtjTIywWTrGGBMjLOAbY0yMsIBvjDExwgK+McbECAv4xhgTIyzgG2NMjLCAb4wxMcICvjHGxAgL+MYYEyMs4BtjTIywgG+MMTHCAr4xxsQIC/jGGBMjLOAbY0yMsIBvjDExwgK+McbECAv4xhgTIyzgG2NMjLCAb4wxMcICvjHGxAgL+MYYEyMs4BtjTIywgG+MMTHCAr4xxsQIC/jGGBMjLOAbY0yMsIBvjDExwgK+McbEiP8PO1m4EGjqQqMAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "for i, graph in enumerate(results['graphs'][:1]):\n", + " plt.title('Result {0}, score: {1:.2f}'.format(1, 1. - graph['cost']))\n", + " draw(graph)\n", + " plt.xlim(-1.2,1.2)\n", + " plt.ylim(-1.2,1.2)\n", + " plt.axis('off')\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Results 2-4 have a lower score because `star` matches to a different node not adjacent to Guardians of the Galaxy. Further inspection would show that `star` has matched aliases of Star-Lord which are near Guardians of the Galaxy but not ajacent to it." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/networkx/drawing/nx_pylab.py:611: MatplotlibDeprecationWarning: isinstance(..., numbers.Number)\n", + " if cb.is_numlike(alpha):\n" + ] + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXwAAAEICAYAAABcVE8dAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDMuMC4yLCBodHRwOi8vbWF0cGxvdGxpYi5vcmcvOIA7rQAAIABJREFUeJzt3Xd8XMW1wPHfUW+WJduSi9xwbxRjUQPI9A6B0APEIQkkLxBagEDy3rKQkEAgCQRICCE4hBB6rwYMCsUFGWPcjbslF1lWb7akPe+PuWutZTXbK6vs+X4++7F1y+zclX3u7MzcM6KqGGOM6fmiOrsCxhhj9g8L+MYYEyEs4BtjTISwgG+MMRHCAr4xxkQIC/jGGBMhLOCbbklEpopIfmfXw5juxAK+2WcislZEakSkUkQ2i8h0EUnphDqc1Mr+CSKSJyIl3usDEZmwP+vYkcS5V0S2ea97RURaOPYO73cVfNWISEBE+nn7LxKRz0WkWkQ+3q8XYjqUBXwTLmeragpwCDAZuL2T69PURuACoA/QD3gdeLZTa+QRkZgwFHM18G3gYOAg4GzgmuYOVNV7VDUl+ALuBT5W1SLvkGLgT8DvwlAv04VYwDdhpaqbgfdwgR8AEYkXkftFZL2IbBGRv4pIorevn4i8KSKlIlIsIp+ISJS3T0VkVEg500Xk103fU0T+BQwF3vBarLc2U69SVV2r7tFyARqAUU2Pa47Xev6jiBSKSLmILBSRSd6+RBF5QETWiUiZiHwacm3niMhi79o+FpHxIWWuFZHbRORroEpEYkRkkIi8JCJbRWSNiPysPfXzfA94QFXzVbUAeACY1p5rA64E/hncpqofqOrzuJuk6UEs4JuwEpHBwOnAypDNvwPG4G4Co4As4P+8fTcD+UAG0B+4A9ijfB+qegWwHu9bhqre10r9SoFa4M/APe18i1OA47xr6A1cBGzz9t0PTAGOxn17uBUIiMgY4D/ADd61vY27IcWFlHspcCaQBgSAN4AFuM/nROAGETnVq/cxXt1bMtE7N2iBt60txwKZwEvtONZ0cxbwTbi8KiIVwAagEPDBzhbk1cCNqlqsqhW4QHuJd14dMBAYpqp1qvqJdmCCJ1VNwwXta4H57TytDugFjANEVZeq6ibvm8hVwPWqWqCqDar6uapuBy4G3lLV91W1DndjSMTdGIIeUtUNqloDHAZkqOpdqrpDVVcDj+N9Tqr6qVf3lqQAZSE/lwEpLfXjh/ge8KKqVrbzszDdmAV8Ey7fVtVewFRcYOznbc8AkoB5XtdGKfCutx3g97hvAzNEZLWI/KKjK6qqVcBfgadEJLMdx88EHgYeAQpF5G8ikoq7xgRgVTOnDQLWhZQRwN0Ms0KO2RDy92HAoOBn5H1Od+C+9bRHJZAa8nMqUNnazVNEkoALCenOMT2bBXwTVqqaC0zHtWgBioAaYKKqpnmv3t5gIapaoao3q+oI4BzgJhE50Tu3GnezCBrQ2lvvYVWjvLKz2jrQq+dDqjoFmIDr2rkFd221wMhmTtmIC+LAzm86Q4CCFuq8AVgT8hmlqWovVT2jndezGDdgG3Swt6015+EGaD9u53uYbs4CvukIfwJOFpGDvZbt48Afg61pEckK6Zs+S0RGeQGxDDeYGvDK+Qq4TESiReQ0IKeV99wCjGhpp4icLCKTvbJSgT8AJcBSb/80EVnbwrmHicgRIhILVOGCfMC7tn8Af/AGXKNF5CgRiQeeB84UkRO9824GtgOft1DFuUCFN5Cb6JU1SUQOa+WaQz2Fu1lmicgg7/2mt3HO94Cnmn4L8N47AYgBokQkwbsG081ZwDdhp6pbcQEoODB7G67bZraIlAMfAGO9faO9nyuBWcCjqvqRt+963PTCUuC7wKutvO1vgV953SE/b2Z/Gm4QtQzXBTMSOE1Va739Q4DPWig7FXfTKsF102zDdUUB/BxYCHyBay3fC0Sp6nLgctzgcJF3HWer6o7m3kBVG4CzcAPba7xz/o4bb0BEjhWR1vrZH8MN+i4EFgFvedvwzq8UkWNDfs4CTsD9npq6Avet7C+4Qd0a7/pNNye2AIoxICIzcIOvSzu7LsZ0FAv4xhgTIaxLxxhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIEY6Vdkw7iSC4NLbx3qYdQKXqztwxxhjTYSzgdzARYnAZGcd7f8bQmCVRgAYRNuMyG+arUtcpFTXG9HiWWqGDeK35EcAxuDS8Fd6raWs+CkjGJejajksgtsJa/caYcLOA3wFESMBlGRyDS9u7vZ2nxuEWvFgHfKxKVcfU0BgTiSzgh5kIibh1StOBzXtZTAYuJe2bqlSEq27GmMhms3TCyOuvPw2Xw3xvgz3AVlxr/wwR4to62Bhj2sMCfngdiOuSKQxDWcW4RTuyw1CWMcZYwA8XEdKBI9i3ln1Tm4FDRNq9kLUxxrTIAn74jAPqcGuyhksAt4bqpH0pRESmikh+eKpkjOmuLOC3QETOFZGvRKRcRIpEZKaIHODtu1NEnm48lnhgIm6t07100YmQeW8zO0qA0SIk733ZxhhjD141S0RG4RZ3Ph+YiXs69hRabr33AaJb2d9ERRT0au88++A0qn7Q9jRNEYlR1fp2lm2MiSDWwm/eIcAaVf1QnQpVfUlV14vIacAdwMUiUikiC4B0+NFRkPooxD4HKY/Duac1Fnf7JEh6Ek74DiQ8BVOu37PqfJ4EGU+JSLGIrBSRHwX3eN82XhSRp0WkHJgmIokiMl1ESkRkCXDYvn8kxpjuzgJ+874ExonIH0XkeBFJCe5Q1XeBe4DnVDVFVQ8G+kG/YvjbXVBzMVz/ILz1Q/jDiMYia9OhIgXWXwUzHzk5u2zwlacWHdi+6lzwI8gsBwYBFwD3iMgJIQecC7yIm9Xzb8AHjPRepwLf29sPwhjTc1jAb4aqrgam4nLfPA8UeS3mlBZOiYbfzodLNrtest8sgqz5MGNiyDEBeP0ZyKw/dHR6ytVnFfnPP67Ef8MFW45pvTZv9IPNY+Hlp1S1VlW/Av4OXBly0CxVfVVVA6paA1wE/EZVi1V1A/DQ3nwOxpiexfrwW6Cqs3GBExE5DHgO+CVwezOHN8AvD4bHzoLyLECgPh4GrW08JL4cBtYBXHd+4XXxcYEMgBMOLb81vVd9X9+TVDZfk4V9IK4SxoY+cbuOXefnb2hy0qAm29a1cbnGmAhgLfx2UNUvgJdpnB7ZJB/F8lK47zo45xUovAJ2XApD5uGyYXpk5zl/eT3jkeraqJ0BefLo6h+ceeSIFlr6BxbDjhSYE3pDGAoUhFaxyUmbgCFNjjfGRDgL+M0QkWNE5Ecikun9PA44B5jtHbIFGC4i3ueXVw0NsTCwHFIa4GdToGByS+XPXZpS9Mu/Z91WVhm9NLhtVFb6lMEZ/bKSEwoSYVNs4+vsIshaBWf9UEQSROQg4AfA0y2Vj+uGul1E0kVkMHDdPnwcxpgewgJ+80pxAX6hiFQC7wKvAPd5+1/w/twmIl/Cd9fDJf+BB26DpP/Au8fBsDmtvcGqjQmVNzw89H8LS2KDNxHyty5Nraod/BwMeqnxVRED//k7lGYCG716+FT1g1aK9+O6cdYAM4B/7cVnYIzpYSxbZpiIcASuy2eP8ujExQaiHvhJ/tVD+28/7f281N89/Er/2U0O6QusU+WjcNXVGBOZLOCHiQhpwMW4gL9HDz5FiXLOt0pHvvpp+qqmu3AzhV5QZWt4amqMiVTWpRMmqpTi+vgH7Om5ARWaCfZMGFZ94I0Xbt5uwd4YEw42LTO8FgHDcEF/n7JmDuu//YDbLtv800tOLOlNbv5WcrJfD0sNjTERy1r4YaRKA26QtAgYuA9FZd588eZp3zmudEBcjCYCr5Cbd3VYKmmMiVgW8MNMlVrgbeAb3Pz3xD04PR43f37DkROqrkhODKz2tkcBj5Gb5yc3T1o+3RhjWmaDth1IhKG4xcxTgWqgnN0HdGNw2ThTvGM+BVarouTm9QfeAqaEHP8E8GNysi0jpjFmj1jA72AiROG6d8YBg3Et/mBqZAF24J6aXQYUeN1CjXLzUnCJ0U4N2foWcDE52W2mSzbGmCAL+PuZCEm4BcqDwb5adbfUCLvKzYtl94Rpc4Czycm2GTzGmHaxgN9duL7737Br8rZvgOPIyQ7nOrrGmB7KBm27i5xsJSf7DlxenOBdehHYHH1jTPtYC787ys37DvBj4Bxysms6uzrGmO7BAn53lZsn5GTbL88Y024W8HuS3LwHcKmbf283A2NMUxbwe4rcvJuB+72fHgJuJCc70MoZxpgIY4O2PUFuXjRwVsiWnwHPkpuX0Ek1MsZ0QRbwe4Kc7AbgdNwDWkEXAu+Sm5fWOZUyxnQ1FvB7ipzsWuAS4M+hW4FPyM0b3DmVMsZ0JRbwexLX0r8euC1k6yRgFrl5EzunUsaYrsIGbXuq3LwrgH/QuOZBKS4Vw6edVyljTGeygN+T5eadDLyMy8QJbhH0kV73jzEmwliXTk+Wk/0+rh9/C1ALXGjB3pjIZS38SJCbdwAwhpzs9zq7KsaYzmMBP5Ll5o0B1pCTXdfZVTHGdDzr0olULth/BrzuLbJijOnhrIUfidzDWF8CB3hb8oAzycku7LxKGWM6mrXwI1MZ8K+Qn7OBz8nNG9VJ9THG7AfWwo9kuXnXAI/SeOPfCpxBTnZe51XKGNNRLOBHuty8c4FngWCitSrc9M13Oq9SxpiOYF06kS4n+zXgRKDY25IMvEFu3rQ9KkckChH792RMF2YtfOPk5o0D3gWGhWz9JfDbZhdTEekDDAUGA5lALCBADe6J3nxgPapVHVtxY0x7xbR9iIkIOdnLyM07GngbONjbOmC340QGAEcAg4B6XBfQNiC42EqMd94IQBBZAeShWt6h9TfGtMla+GZXuXm9cfl3ioFLvAycIBILTAEOxc3yaU8AF6AvrvX/X2A59g/OmE5jAd/sLjcvHpCdeXdEEoBTcK36TTS25tsrFhgIzAdmo2pLLxrTCSzgm9aJxACnIzKAn1x/Kh+9/xZLFxftTUnAEFz3zpzwVtIY0x42q8K05SCQLK696WwGD/kOl1x5P0ccPazt03ajuIHcKYhkhbmOxph2sIBvWibSFzicyVOiGTDwdABiY/pw1nn3csoZk/aixABubOAEROLCWFNjTDtYwDetORioZX7eev778Z00BGoAiI5KIueEu7jg0mP2oswq3Fz/4eGrpjGmPSzgm+aJJANjCD6Q9d6bX/P2a7dRV18McOeHM2Iu/2b5rUz70dl7UXoJMBkRCVt9jTFtsoBvWtLf+7NxVH/Wp2t57l+3sH17AeCGYceM+xH/c8M0oqP3JHhXV0MfIDVclTXGtM1m6ZjmiRwNjAWKToDvzIKz6yExEYp/nNLrmT9VV92iQHxMDCP79GXBZdM+vuTB+xa93dDw7RroGw/lJ8JLr7mnd7kdJj0INx8Jb34O546D5V/Bxaiu69TrNCaC2JO2piX9gerpkDUbznwNbjoFil+CzLrKiqijoqOf7z18xAmv/+DH/bzjp55x0mlp53zw7m8uamgo8MGke+HOP8CKm2A1QC2kV0DKergK9zRuOmAB35j9xLp0TEtigYY4CAQgdgYMKYXo70DhJbC5vqGhYdmqlYvZsvldAK2vLz9aGz6eOGlS+vakpLjfwKIsmD8DJoaUGXgdnsmE+ky3qHp8p1yZMRHKWvimJQEg6jLY9BY8/hRc9hAMHQJf/gWeAFBUeej+R/WqHxfmF24se3Tp4sxnt2y5sLiurk8AtB7iBsHaYIHxUD4QguvnCnv+xK4xZh9YwDctKcWlQ6j5t8uD898lkHgO/PRa+F6mS7EAqsgTf3mxon///n8uLPzLVQMHvnr5gAHLE0UaLvrqq7NoaNg5mCuhA8DuG0Tp/rwgYyKddemYlmwEkqZD1m1wUCHE9Ie6WNghoH2gtAz6b3ctdaK3bCmvV43OiIkpjxMJPFNYOGpDIHBQTEJCSwukKy4JmzFmP7EWvmlJESCVEPt3+N4fYLBAQ39Y+gQ8kgZ1Z8HxveCZFNhSDDecCY/dv379pb/fsCF+bFLSN2MSE5fHxsX1WjF06HBWrAidthmN684p6ZxLMyYy2bRM0zz3UNRF3k/Ve3LqjtjY6BVjx46uSUjoFdyWWFNTPmbFipVxdXUNuAVTlqA6K3wVNsa0xbp0TPNcS+BLXD77PRJXV9cwfsmS5anl5TuzatYkJqYumTBhQmVSUiIQBywPX2WNMe1hAd+0ZhWuL7/Pnp4YHQjo2BUr1mQUFeUHt9XFxiZsGjTomIJBg1agWtza+caY8LOAb1rmFirJxc2XT9ybIoavXbtpcH7+KlHVxOrqXlXJyTVPX3FFst/vtxTJxuxnFvBN61RLgLdwrfykvSli4ObNxWOWL98ssP3TY4+dV5uYqMChfr9/TDiraoxpnQ3amvYRGQicips/v3kPzozGpVEonHv44Z++c8YZE4HQqZr5wAKfz2cPYRnTwayFb9pHdRPwHLASGIybaRPdyhlxuAe3MoHZwGuHz5lTCHwKbA05bjBwlN/vtwVRjOlg1sI3e06kDy6T5gQan+VQvIewPNXAV8BqVKtCT/f7/VHAgcDQkM1VwFyfz1fZUdU2JtJZwDd7TyQK6IXLax/rbd2OS5lQTRv/uPx+/0jcTSOoDvjC5/Nt64DaGhPxLOCbTuX3+wcAh9LYPRQAvvb5fBs6r1bG9EwW8E2n8/v9acDh7Jou+Rtguc/ns3+gxoSJBXzTJfj9/kRc0A9d9nAj8JXP52vonFoZ07NYwDddht/vjwGm4Gb2BJXg+vW3d06tjOk5LOCbLsXv9wswCRgesrkaN4OnolMqZUwPYQHfdEl+v/8A3PKIwame9UCez+fb2vJZxpjWWMA3XZbf7++P6+IJzuBR3Aye9Z1XK2O6Lwv4pkvz+/2pwBFAQsjmVcBSm8FjzJ6xgG+6PL/fn4CbwdM7ZPNm4EubwWNM+1nAN92C3++Pxj2gNSBkcxluMLe2c2plTPdiAd90G94MngnAiJDNtcAcn89X3jm1Mqb7sIBvuh2/3z8Ml3wtdAbPPJ/PV9h5tTKm67OAb7olv9+fAWSza7bOxT6fb03n1cqYrs0Cvum2/H5/L9wMntDlF9fgAr/9wzamCQv4plvz+/3xuBk8aSGbt+Bm8NR3Tq2M6Zos4Jtuz5vBMxm3wlZQOW4w12bwGOOxgG96BG8GzzhgVMjmWty0zbLOqZUxXYsFfNOj+P3+ocBBNM7gacB17+zJwuvG9EgW8E2P4/f7++Fm8MSGbF7s8/lWt6sAEcGlcojCrcBV29ZyjcZ0BxbwTY/k9/tTcDN4kkI2rwMW+Xy+wG4niCQCB3ivATRO9wTYgUvlsBpYh6qNC5huyQK+6bH8fn8ccBjQJ2TzVtxDWnUAiCTgMnJO9PZX4vLvh+boiQaScQu2NwALgK9Q3dGR9Tcm3Czgmx7N7/dHAYcAWSGbK4C5vjvvTAdOAuKAQlz3TVuicStyVQIzUbWxAdNtWMA3EcHv948FxgR/PuTLL/ue9MEHfZOrq/NxLfo9lYLL3vk2qpaf33QLUZ1dAWP2B5/PtxyYDwTGLFvWb/zSpUesGD06fXP//vF7WWQlUASciciAtg42piuwFr6JKK+cf37W+CVLbqpJSNhRHxtbB5CxdWv+8HXrNu1lkcm4Ad4XULWF1k2XZgHfRBaRE2oSEsYuHzu2T11s7M7Wfe/y8q0jV65cFx0I7M1/iIHAQlRnha+ixoSfdemYyCGSCoxNrK3Nn7BkyeLE2tqK4K6y1NSMpePHj9kRGxvd3KnJ8Pefw8EAU+HCCXBdyO4twEGIJDV3rjFdhQV8E0lG4XLnE1dX1zB+yZLlqeXlRcGdNYmJqUsmTJhQmZzcar/+x/DCEvhzyKYALj3z8PBX2ZjwsYBvIskYoCT4Q3QgoGNXrFiTUVSUH9xWGR2duGLs2AnF6ekpe1h2OSGzgIzpiizgm25DRA4VkfkiUiEiL4jIcyLyaxGZJiKfNjlWRWSU9/czo0S+ioMnk+Av34JLg8e9BJkHrF372O8XLco6Yf786y9dvPjKhqiomJvi4i5KEXkyHv6dAxeFlv0tuHQ03BT8eQTclgB/iYdnROS/IjIxpB7TReQREXnLq/ccERnp7RMR+aOIFIpIuYgsFJFJHfX5GWMB33QLIhIHvAJMxz05+x/gvHaeXnUc/LQKbrwP7poHp1/t0i7s9FVt7QGfp6T87KmxY/85q6ys378LC8+8e/KUz7ddf8vitEGDx9dAv5YKPxTmLYBrNsMtibAQ+HeTQy4B/EA6sBL4jbf9FOA43DeD3rgby7Z2XpMxe8wCvukujsRNf3xIVetU9WVgbntOVNWPP4YVsRC4FtaOh/9+Abu0pO+GZw4sKys+fOXKBW8UFo4ek5T0zbQjv9Urpf+AI5+5+n+mxERHRx13waVXcdWPz+ud2js19NwX4YOxUJMOdTfBfcDBItI75JBXVHWuqtbjbgaHeNvrcOkaxuFmzC1V1b2dHmpMm2LaPsSYLmEQUKC7ziPe0J4TReSIOHggCg6uh6gAxI6EXbqATnUPUZFcXb1jEzT0SUsrT+qTMR4gOS6evklJJKWmHsCo0Qccnn0EyZs3VnP8yd/fvmzJF8fNnJG9WPXoHZBWB8G5+P2AYB7+0PQL1bindFHVmSLyMPAIMExEXgZ+rqrle/bRGNM+1sI33cUmIEtc6uKgId6fVYRkxZTdn3x9JgZez4fb6+CSg+AdbcyXD0C0m2UDQBps21xeruu+mvtq0fq1HxUXlxRsq941+0J8XFwSWYPPe75Pn3vK+vb79vvHnfBSDdxwCIwMVqM9F6WqD6nqFGACrmvnlvacZ8zesIBvuotZuEyV14pIjIici1vLFlz2yokicoi47Jd3Njm3VzVs6Qtlv4fxSyGntTe6Aj5fEwhM/ve8L+oXz/n083Me//P6+oaGwNpFX79CScnsQCCwM5NmxfbtJMTGRg0u2bY5H6q+grt3FpSbN7i19xGRw0TkCBGJxd20amlfAjdj9op16ZhuQVV3iMj5wN+B3wLvAG8C21V1hYjcBXwA1AC3A9eEnP4/wAMx0G84rBwDn9a4lAjNugbWz4S/3r9p0xX3btmSNDUtbXZKdHTFsq/mLWburCc/jo6+fEev1PFs2Zx/xaSDD39r6eJeIxcu+JW4KZ+3AT+5+qzzBgCfXJBzYimwhNy8o9h9zCEV+CMwAhfs3wN+v88fljEtsNQKptsSkTnAX1X1yXae0A+4EMhv69CgJePHj65KTk4DSK6uLp2wZMk3TcqEkaP7sHJFIvAMwf733LyfAI82KW4r8BbwOvA+OdmV7a2HMeFgXTqm2xCRHBEZ4HXpfA+3du277S5AtQgoYNcFUVqVVVCw8+ZQlZSUVpKevus3A1VYuSIKWM2ug62ZuFZ7qAxgGvAyUERu3jvk5v2kra4fY8LFAr7pTsbi+utLgZuBC/ZiGuMnQCLt7M7sXV5e06uiYufc+I2DBjUNznG4RVF2TZyWk+3HzdT5NvAELt9OqHjgNNy3gDvaX31j9p516ZjI455mzcF17bQ5SFqZnBy/bPz4g4L/U0asXr2sb3FxBS7QZwHvobqy1UJy86Jwyy2eDZwDHBiy93Ryst9tcvx1wBrgQ3Kya9pzWca0xQK+iTxuaufhQDawES+hWmtWjBkzvCw1NQMgoba28sBFi1bi0iJ/hupXe1yH3LzhwFnAqcCF5GTXhuxLxj1xG48bhH4feAN4k5xsW1LR7DUL+CYyuaB/IHA0bkpkaWuHVyUlxS0dP/4gFZHE6upeQ/Lz83uXl7+F6rKw1y0379u4NBLNmYsL/m8AX5OT3Wn/gUWIBdJwD5JF4Z5lqAJKVbHFYLogC/gmson0wXXvDMANspbg5vs3FZOflTWpKjl5YHlqatH8yZNnXf23v73TIXXKzRsHfB/X9TOulSPXA8+Sk31bh9SjGSJE4556PghobrBZccG/ELekZL4qdfurfqZ1FvCNca39TGA8bk58LC5whT4tu6MyOXndc5dc0j9/yJDgdMo8n8/XsblvcvNG0djvfyxu3CDUa+Rkf7vJOVHkZIf9AS4RMoHjcbOcKmn9W1Fwkfdq4CPV9qXBMB3LAr4xoVzwT8E9mBWFG9StAKpRVb/fP4HG9AkVQK7P59s//4ly89KB03E3gNNxAfUH5GT/o8lxf8Ulh3sDN+d/2b50/YgguIRvR+GCfEXrZ+wiETcddQEwS7XZb09mP7EnbY0J5VpAFbQc1FYCw3D/d3rhJXXbL3XLyS4BngGeITcvFjgG+HqXY9xsoHNxXVTfAn4HrCI3Lxj8PyUnu91dLF6wPwI4FHede/rNoQaX5G4SkCjCTAv6ncda+MbsIb/fP5bG1a2qgI99Pl/XyIGTmzcBl5O/pWdsSnEPq70OvOvdRFokwkRgKi5o72uwGAzMV2X2PpZj9pI9eGXMnlsNOwcik2l+8LJz5GQvwbXupwEv4fraQ6XhFmR5BtjidRM1S4Q03LeEjex7sMcr51ARBoahLLMXrIVvzF7w+/2jcIO84Gb3fNhlWvmhcvPicS30s73X0JC9c8nJPqLJ8cNxN7BZMjX7FKA/4V2FKwV383he1TKD7m/Wwjdm76yhcbGTBFy/focSkakikh/y82IRmdrqSTnZ28nJfo+c7GuB4bjB1//Fzed/rZkzrgI+Ka6ILvzFZRv/77rzt4wdnLEjsX01nJ4F6Q9C7HNw+lktHFSJW+pxgHcNO9ce7mzNrY3c01gL35h2EpFLgBtxA5BVMTExG0eMGDHrkksueTsqKmo7rpXfYQOSXnB/WlXD04WUmye7zd7JzZsPHPLF0iTmr0xiYN86VKkvrohZuGZT/NwZX6TOnb0kZWvzBY6/DhKqYf4Tze8fcA8c/RG8PA/YqMr7IqLAaG0rNUU7eOse/wL4Lu5bSiluUPuPqjqjHedPA36oqsfsa126KpulY0w7iMjNwK3AT3F56yuByYWFhb+vqamZkZycDHAAbhZPON4vxlsDt+PsHuxjcC19YkAqAAAW1klEQVT//uu2xA3sndzg1YWYvqn1k/um1k/OHlt1TVVN1Nr8rXFzX8hNf3fu0pSixgLKMmH8f9vxzmXAUG8GUDi9iMttdCXuoS+AE4AzgTYDfiSwLh1j2uAtSH4X8D+q+qKqVqiq1tXVfXnDDTdclZycXA9w3333vRwTE3NNyHm7dBGIyIMiskFEykVknogcG7LvThF5UUSeFpFyYJqIJIrIdBEpEZEluORrofVaKyIneX8/XERmiUipiGwSkYe9Fm/wWBWRH4vIN94xjwSXixSRUSKSK1MP2yZTD/uOTD3i0z+91P+etZvinq2siV4d+p5zln7IrY+dNvzO6RMvWrh6wh3whPdtY+CvYdOB8NqPIeZ5eGrQrp/ikZfDlgne/v/A0Zfjre0LnNRcvby6XSUiS73P4D0RabbrzPscTgbOVdU5qrrDe72rqteHHPcLEVklIhUiskREzmv+t97m7+ttEXkg5OdnReQfIhInIsUicmDIvkwRqRaRjJbea3+xFr4xbTsKl8isuT7vDcAoIElEJCMjo18r5XyBu3GUAdcDL4jIcFUNJk47F7dAy5Xe+/lwD3mNxM0Gai2VQwOuuykP153xDm6lrz+FHHMW7qaRCszDPZj1Lm5Zxhm4p2jjYNixny3sNeKzhb1mA88cPr6y36mHlR8eG73gmPufvWnSHZc/ypghRxZdetdPP4Xr/xe+/VPY9KuoqIH3BgJHfQgvN9Oanv00DJjgdem8j0s8F1yHuNl6ectY3oEbbP4G113zH1z+o6ZOAuaoaluL26zCPbG8GfdZPy0io1pIs93a7+sq4GsRecu7lsOBg72V2Z4FLsetfgZwKfChqrbQFbb/WAvfmLb1A4pCu1hE5HMRKb3zzjurpk+fnhbc3rt37wy/3x/XXCGq+rSqblPVelV9ABfUx4YcMktVX1XVgKrWABcBv1HVYlXdADzUUgVVdZ6qzvbKXgs8xu5r9/5OVUtVdT3wEW4AF9wU02HAIBfMVu8yT37u0pSiu58a9Pb/PfnbBfUNg2ZtLT39nnnL056Ct1+BhviYmPsPHD9+yejY2Nik5OSY3q19kE0EW/It1evHwG9Vdan32d8DHNJCK78fLoi7gkX6eN8YykRkZyZSVX1BVTd6n/FzuBvJ4c2U1+rvS1U3Az8B/gk8CFypqsGH9f4JXBryTeUK4F978Ll0GAv4xrRtG9BPRHZ+I1bVo1U1Ddi2bt26bXhP5opIFK7FvxsR+bnXPVEmIqW41Aih3wia5psZ1GTbupYqKCJjRORNEdnsdQnd06RsCAmIuBw3wS6VW3HBd66ILIa07zb/LiV9GgJpmx9+pf/s+58b8DHEKyRvTUgoGJKcXOWWgUyOTktMrG72hteM4A20pXoNAx70AncpUOzVM6uZsrZB4/x+7yaZBkzBBWoARORKEfkqpMxJ7P45BY9t6/f1Bi630XJV3dl1p6pzvOuYKiLjcP8eXm/js9gvLOAb07ZZuCmY5za305vptjw6Orq2vr4+Hhju9/sT8KYeAnj9v7fiWu3pXjAqY9cEbU2nzG0ChoT8PJSW/QVYhpvxkorrCmnXoKiqblbVH6nqIOAaKPsTvJHGbl2+6cVQHNIPXQ9UZVRWZq3ZsSOuJnilw4evbWmKauj1CW3n5NkAXKOqaSGvRFX9vJljPwQOE5EWZzB53wweB64F+nq/g0U08zm18/f1G2ApMFBELm1SxD9x3TpXAC+GdNt1Kgv4xrRBVUsBP/CoiFwgIr1EJEpEDsH1rQNsTkxMXLpp06ajS0tLE3Nzc48HfhBSTC9chNwKxIjI/+H6rFvzPHC7iKR7gey6Vo7tBZQDlV6r8iftvT4RuTAkUJYACuWbQ67Nc9WnsP4wuPUgKI2G086D6Dr4+dING4asDR6VnFyVlplZmMZukkuhYACuxV3Rjpz5f8Vd/0Svnr1F5MLmDvSmXX4EvCoiR3iDp7HAkaEVcNfGVq+87+Na+M1p9fclIsfhUlhfCXwP+LOIhH7zeBo4Dxf0n2rjOvcbC/jGtIOq3gfchGv1bfFej+EG5j73+Xw6fvz4e6Kiouofeuihf82ZM+eBmJiY50KKeA83QLoC1zVTy+5dOE35vWPX4AZVW+sH/jlwGa7V/DjwXCvHNnUYMEdEKnFdD9fDdz/BBb0Q0wrg+w/AY9dAxr9h4WHwwN3Qt76kpE9lQ0PDzqRsgwYVDI2Orm8SX6a9Dku+BfH/hAPObqtSqvoKcC/wrNdNtQiXJbQl5wFv4oJtKe5z+y5uVTFUdQnwAO4b2xbcAjiftVBWi78vEUnFBfFrVbVAVT/BrVv8ZLDf3htz+RJ3g/mkrWvdX+zBK2PCyO/3fwuXLx5gvc/nW9CZ9dlb3mpWV+L6zdv1PEBc3PaYSZMWHxgV1RADUFzcd+Pq1SOaZhIV3NjEv1X3KM1ytyMi/wA2quqvOrsuQdbCNya8lof8fYjf709u8cguzFulKo+QcYi27NgRX79lS+bOby3p6cUDU1IqEpoc1h9YHAHBfjhwPq7l32VYwDcmjHw+XxEQfPpU2HXaZXezCLdUYYsZNZvauHFQUW1tQiWAiMrw4euGh+xOxg1+fxHGOnY5InI37rP7vaqu6ez6hLKAb0z4hS5snuX3+3u1eGQX5i1UMhO35GNKG4d750Sxfv3QtcHJLAkJNb0GDtzYF5dgLg14X5UuMWOlo6jq/6pqiqr+prPr0pQFfGPCzOfzleAGBYNaW4i8S1OlBDeQmwj0bc855eW9a0pK0nbOrU9PLxkXHV2fAbylSseuAWxaZQHfmI4R2sof4Pf7m5mm2D2oshW3mMo23HMB8a2fAevWDSvYvj1ey8tT+8bG1sn557+y2BYy73wW8I3pAD6frxy3wlPQ7q38kCRhXZ0qZcBbuAec4nH5evrh8uGEXkci0Le+PnZgfv7gkhEjVi8744x3Pp84cUlqd77p9RSWPM2YjrMc97i/ABkzTzhh+AkffZSKSw3gHkBy+eArcU/VFgAbUK3urAq3xluharkI3+CuawhuiuUAXONRcQ9ufQPkl5T0KTjyyLlTgEyviIP8fv8nPp/P5oJ3Egv4xnQQn89X6ff784euWzdp0tdfj8koKjoOFwyrcA8GBee3x+FuAqMBRWQ58CWNybi6FC/wF3gvAERcwFfdNT2E389CXBbOKFwumuG4B6JMJ7AHr4zpKCKxpb17H1mYmXnp9ri4mtqEhKrh69YtzygqKm/lrChcV4kAucBKuvl/Ur/fP5rGLq164COfz9ejZ+p0VdaHb0xHEIkHTk0rK5skgcDy2sTEKkTYNHBgW8sTBnBz30uAU4AjulNffwtW4bqtwPUqTOjEukQ0C/jGhJtING5BjkFA/uCCgg3Br9Lb4+OTt2RmtmfwcgeQj0vvm91hdd0PfD5fAFgYsinL7/d3+upPkcgCvjHhdyBuQHMTQGJtbV1aaWlhcOfmAQMGB9rXaA/ggv5hiAxq6+CuzHsCOTSvzoF+v9/iz35mH7gx4STSB5eSN3RRDwbn528U1QDAjri4xM0DBvRp7vRmBHAJzE4gZI3abmoxjQPVybSwUIzpOBbwjQmvg3H5YhpCNyZs317fp6Rk502gMDOzva18cLN6UnAzXLotn8+3nV0fSBvdXZPLdVcW8I0JF5EkYAyuRb6bwRs2bI4KBBoA6mJj4zcNHNjagudNlQCTReRjEfnhvle206zFrRwFLv60tACJ6QAW8I0JnwG46ZSB5nbG1dU1JBUV7cyxU5iZmdUQFdXeZn41kBbj1lDttryHrr4O2ZTp9/sHtnS8CS8L+MaEgYgc2gveiYM/joDbRsKtR8Hlt8OkJHjyBPhOAjx17vr1F0cHAvV/WL9+8vELF96UFAg8MxR+NaNx0RTugnF94YFYeLYvPHCXN4f9FDi3Ho4GHhaRShF5uNMueB/4fL5SXEs/aJLf79/tIVARokWI8R7qMmFgH6Qx+0jcYOorx8InRfDDE+C/a0LWUq2F9ApIWQ9XzYSHn1+1KvXVoqIT7xg+/MWCG25Ze+TIMf1+ltr7Hk4+feKiwUOyfg2+U+DNYrjsdHjt1+CbC71mwMu9YT5uab0UVb228656ny2DnWvaJgBjRUgUYawIJ4vwPeCHuHWBfyDC+SIcKcJAkfYtzm52Z6kVjNl3RwIxb8CMaAj8HWa97lIoBAVeh2cyvRkqr5WVHZydmjrvrKwhxf0yB0z8x+XTSL/7V6w95NDfzk9L46DPPgn856przqK6evLTleUFn/97etn0lF5nHL61cGZUINAjgp3P56vz+/2LgUNLS3vHzZt36JmJidW1NTVJ23HdVxU0joUILinbBGAyUCrCbGBt01QOpnUW8I3Zd4OAgmjXdx8FkApbgzvjoXwg7FzguwLSR1VXLxgXl1AKkBIfT9+kZArKy9hYXs4BffpEkZQ0mqSk0fTrx+EjRjOo/4BL+fiDjzUQMjyQm9cX91DWcmA9OdnNjh10VT6fr+Dss984fNWqkceqStSIEasLliyZsEZ1t44Hxd0EgknlEnGLmX8jwmeqdMlkc12RBXxj9t0mIKseSmNcFsmacsjI8ObiC7u2QntB8eb6+rSo5UvXk5Z+X0ly0tCiqsqLM6Nj1g5ITslaV1qyy3z79WUlHD90WCn19dH1ITcO4BjgVe/vteTmrcQF/+BrBbCcnOySjrnsfSPCIfHxJx0wduzy6ri4unogedCgjf0KCgYXtXFqDbABGApkiPCmKq3lJzIeC/jG7LtZQMMUOH4W5N8IY4tg9Mhd0wnsdCb8989wy4Mb1uVeumHd7FNgfB9YNvrRP91WAr0WwOO3/e2RN+466phtj+WvP2zhpk2HnnnSafMADbibywivqND1chNwUxx3n+aYm+cWMMnJ/klYr3ofiDAR+Nb27YmrKytTq/v02TYIoH//LUO3bs0o3bEjvr6NIsDlHOoDnCXCq9bSb5tlyzQmDEQkOxamA6MGQ14AoobC6m/B4gfh5mr4fujx58BpM+H8HZDSH5Y+Bo+e4VaUwgfj/wxXV8DAXrDpOvib37XW+/WD5dvgCSDjiPGTvpj9lycTcIE/s2mdmphOTvYudSA3727gEpp+I3CvzeRkd0hwEKEPcBFuGcj66Oj6qAMPXDgpJqY+HqC8PLVoxYqxe5JCuT8u5fJM69NvnQV8Y8LFZbW8GAj0g7uPh3decCtEhUMmsAzVz5rdm5uXjnvoa6z3Cv59NK71fzs52b9rcs7LwHktvF85jTeAFcDr5GR/ta8X4U2xPA+3UlZpcHtmZmHa0KHrRgd/XrVq5NKSkj6VzRTRkiG4NXPX7WsdezLr0jEmDEQkB1i+ERbcDb8ogeHXwJdhKj4Kt0jKshaPcP30c7xXo9y8KFxfd3PdHWNaec9U3IBwMFPnZmDXgJ+bdyNuoDp4U1hHTvYuKSWaMRB388oP3VhYmFnat29RaXJyVRrA0KEbhpeV9V4cCES3t0VaDBwuwnpr5bfMAr4x4TEWeH4QJKdB0S3w8EkuHUI49Afmo7ptj890M3fWtrD3cNw3gKbfCsbiAn6o5c2cfyvu6eKg7S0NHMvUw04BboTogyF2ByRvgqNmwitvB8PQ2rXD102YsCRVRKNiY3ckDh6c33/9+mGbaZ8qYDBMO1Tkn3lArKq2ZxwgoljANyYMVPVvwN+AYMbM7+C6UvZ1Zad0XPdKuL4tNMrJrgYWeK9GuXmCa4WPDXktbnJMKrsGe3CLm0/0Xjs98NzTxERHb6tviPspLOsPg9ZEyZ9HJiX84zLVoplVtQNqAWpqknZs3ZpR0K9fwdCoqFjNyNiatXVrRnFNTdKOdl5RPUzpD/9s5+GRx/rwjekIIoOBs3BdDTV7WUofXJfJa6h2rWmHuXm9gR+x67eD/k0PK6usZNAFpxMfG3tZSUX5B8D5wMYjJ1Rm3HH5pidUCdz3n5u3NwSkctO2DYH8rYv6HjPZNyMmMXvtnPm3HFNV/c1I1ahqmPIevP8CxCtsFzj5Qph3KjTEwdAv4eXHYFIUJD0INRm4Fj/Ayao6az99Kl2etfCN6Qiq+Yi8DpwK9MJNIWyvaFzreRvwXpcL9gA52WXA/btsy81Lo7FbaAww9oN5c7O379hxQHVt7Qu4sQQAJg6vyQI3iBsfF5M4e/H7if837XHGDZlMfUPdGY+8+ivGDe4XOPKQR97+17v5H5aVXX0LXFYCL70P3z0RvjwR/nEHTCmDk26E86+BFQ/Bq7+HU+8D0qxLZ3cW8I3pKKobEXkOl/BsNC53zDZayKaJG5jti0slMBf4mu4UtHKyS3H1nhvcdMHUwy4H7lfVepFgps+M+6a/W3HAUzPgzmlPKCBHTDiRCcOmABBNDJ98/TZ/uva1qEX5A9dVVh61Bp59FT47HngfPpsKx78GF3uZR+98Cq56GCoehOjY/XjF3Y4FfGM6kmo18AEi83FZLyfgZt0I7DabpBYXLFeiuidTEruybUA/EYmBYP/x1lsbAtAQSHry108tfSguds0ZGWmDGxatSVyd3qshq66hYGh9Q93IzPRBLP6o/4KGhpgADC+EmX3d+VV9YFjIN6ZTC0Gj4bN0iLE+6lZYwDdmf3AzbD5DZBZuBkxvIBYX9LfjFgWppOcNqs3CXd+53t93UbOjoa5mR3VlWVXRtjseH/y825oRBfLijQ/P/+WWkoO8Vvy6DEj2ZiklF8O6kAfN3s8AaYBjaiG3DNMiS49szP6kGkC1FNV1qK5EdRWq+ahW9MBgj6qWAn7gUcg4HtbHu0HXBw+A+oTmz0oKwMhPC4ruPK++YUUCvJIBM78NR3/k9h+VCx+fCy/0h5UJ4LsSRn4CKQkwahmuy2xE82VHNpulY4zpcCLyXeB6Nw8/ZjukbIJj3ocnPoSjfwp9tsGspxvPmJ8MF10D6w+F6B0wZQZ88FzjLJ2TLoZ5pzTO0nn+MZicBrwLchXwE9w3qNNUdXanXHQXZAHfGLPfiDAOmAoUhLnoGNwzC/9S3SWjqAlhXTrGmP1pLW4hmLg2jttTGcACC/ats4BvjNlvVKkF/kszD2ntgxTcg1bNpqM2jSzgG2P2t2+A1eyemmFvxOK6cmaq0t4UDBHLAr4xZr/ysll+BBSxb0E/3jt/hiqbwlG3ns4GbY0xnUKEeCAHGIVbDGVPWuj9cCkoPlRlTxZLiWgW8I0xnUYEAUYCx+EGcktpTHzWVBQu9UQ8bvD3U1Uq9kM1ewwL+MaYTue19ocCB+Na7wFc+olQDbgc+8uAIlvoZM9ZwDfGdCle8E/DrScQhZvGWQ5UqLaYeM60gwV8Y4yJEDZLxxhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIYQHfGGMihAV8Y4yJEBbwjTEmQljAN8aYCGEB3xhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIYQHfGGMihAV8Y4yJEBbwjTEmQljAN8aYCGEB3xhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIYQHfGGMihAV8Y4yJEBbwjTEmQljAN8aYCGEB3xhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIYQHfGGMihAV8Y4yJEBbwjTEmQljAN8aYCGEB3xhjIoQFfGOMiRAW8I0xJkJYwDfGmAhhAd8YYyKEBXxjjIkQFvCNMSZCWMA3xpgIYQHfGGMihAV8Y4yJEBbwjTEmQvw/LJMg4ijLXy0AAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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40o9AdyqTltwZ8xuUf2p8+kbY0zT4cvAN8YYPzroO22NMcY4FvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMTFvjGGOMT/w/HkFPvghdexwAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "for i, graph in enumerate(results['graphs'][1:4]):\n", + " plt.title('Result {0}, score: {1:.2f}'.format(i+2, 1. - graph['cost']))\n", + " draw(graph)\n", + " plt.xlim(-1.2,1.2)\n", + " plt.ylim(-1.2,1.2)\n", + " plt.axis('off')\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The final match pairs `guardians` and `star` to two nodes that do not have similar edges to the target graph. `groot` is not found in the target graph. The result gets a much lower score than the preceding results and we can be sure that any additional results will also be poor because the result are ordered." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/networkx/drawing/nx_pylab.py:611: MatplotlibDeprecationWarning: isinstance(..., numbers.Number)\n", + " if cb.is_numlike(alpha):\n" + ] + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "for i, graph in enumerate(results['graphs'][4:]):\n", + " plt.title('Result {0}, score: {1:.2f}'.format(i+5, 1. - graph['cost']))\n", + " draw(graph)\n", + " plt.xlim(-1.2,1.2)\n", + " plt.ylim(-1.2,1.2)\n", + " plt.axis('off')\n", + " plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "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.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/tutorial/tutorial2.py b/docs/tutorial/tutorial2.py new file mode 100644 index 0000000..c793fdc --- /dev/null +++ b/docs/tutorial/tutorial2.py @@ -0,0 +1,363 @@ + +# coding: utf-8 + +# # Tutorial + +# In[1]: + + +import os +import sys +import pandas as pd +import json +import matplotlib.pyplot as plt +import networkx as nx +import fornax + +get_ipython().run_line_magic('matplotlib', 'inline') +from IPython.core.display import SVG + +# Add project root dir +ROOT_DIR = os.path.abspath("../../") +sys.path.append(ROOT_DIR) + + +# To install the use the dependencies for this notebook: +# +# ```bash +# conda env create -f environment.yml +# source activate fornax_tutorial +# ``` +# +# To run this notebook from the project root: +# +# ```bash +# cd docs/tutorial +# jupyter-notebook +# ``` + +# In this tutorial we will: +# +# * Load a graph of superheros and their teams from csv files +# +# * Search for nodes in the graph using a string similarity function +# +# * Use fornax to search for nodes using string similarity and fuzzy graph matching +# +# The data in this tutorial we be generated using the preceding notebook: `Tutorial1.ipynb`. +# +# ## Introduction +# +# `nodes.csv` and `edges.csv` contain a graph of superheros and their teams along with alternative names for those heros and groups (or aliases). +# +# The image below uses the example of Iron Man, who is known as "Tony" to his friends. +# Iron man is a member of the Avengers, a.k.a. Earth's Mightiest Superheros. +# Other heros are also members of The Avengers, and they will also have aliases. +# Other heros will also be members of other teams and so and so forth. +# +# +# All of these heros, teams and aliases together make our target graph, a graph which we will search using fornax. + +# In[2]: + + +SVG('../img/iron_man.svg') + + +# Let's load the data into the notebook using pandas. + +# In[3]: + + +# used for converting csv values in nodes.csv +mapping = { + '0': 'hero', + '1': 'team', + '2': 'hero_alias', + '3': 'team_alias' +} + +nodes_df = pd.read_csv( + './nodes.csv', + # rename the columns as targets as this will form the target graph + # (the graph which we will be searching) + names=['target_label', 'target_type', 'target_id'], + # ignore the header + header=0, + converters = { + # convert target_type from numeric values to + # literal string representations for ease of reading + 'target_type': lambda key: mapping.get(key) + } +) + +# contains pairs of target node ids +edges_df = pd.read_csv('./edges.csv') + + +# We can see that the target nodes have a label (the hero's primary name). +# The target_type column will be one of `hero`, `team`, `hero alias`, `team alias`, the four types of nodes in the graph. +# +# (Note that by hero we mean a person in a comic book who has superpowers regardless of them being good or bad) + +# In[4]: + + +nodes_df['target_label'].head() + + +# Edges are pairs of `target_id` values. +# Note that fornax deals with undirected graphs so there is no need to add the edge in the reverse direction. +# Doing so will cause an exception as the edge will be considered a duplicate. + +# In[5]: + + +edges_df.head() + + +# ## Label similarity +# +# For some motivation, before using fornax, let us search for nodes just using their labels. +# Let's search for nodes similar to `guardians`, `star` and `groot`. +# +# We will create a function that given a pair of labels, it will return a score where: +# +# $$0 <= score <= 1$$ +# +# Secondly we'll create a search function that returns rows from our table of target nodes that have a non zero similarity score. + +# In[6]: + + +def node_scoring_function(first: str, second: str): + """ node scoring function takes two strings and returns a + score in the range 0 <= score <= 1 + """ + first_, second_ = sorted((first.lower(), second.lower()), key=len) + # if first is not a substring of second: score = 0 + if not first_ in second_: + return 0 + # otherwise use the relative difference between + # the two lengths + score = len(second_) - len(first_) + score /= max(len(first_), len(second_)) + score = 1. - score + return score + + +# In[7]: + + +def search(query_id: int, query_label: str): + # compute all of the scores + scores = nodes_df['target_label'].apply( + node_scoring_function, + args=(query_label,) + ) + # create a boolean mask + mask = scores > 0 + # graph the non zero scoring nodes + matches = nodes_df[mask].copy() + # add extra columns + matches['score'] = scores[mask] + matches['query_label'] = query_label + matches['query_id'] = query_id + return matches + + +# ### Aside: +# Note that these string search functions are not terribly efficient. +# They involve repeated full scans of the target nodes table. +# If we were searching a larger graph we could use a search tree as an index, an external sting matching service or database. However, since this is a tutorial, the above functions are simpler and more reproducible. +# This is important as we will be using these search results with fornax. + +# In[8]: + + +query_labels = ['guardians', 'star', 'groot'] + + +# Examining the table below we can see that we have a conundrum. +# There are 22 nodes with varying similarity to `star` and 4 nodes similar to `galaxy`. + +# In[9]: + + +# find the nodes similar to 'guardians', 'star' and 'groot' +matches = pd.concat(search(id_, label) for id_, label in enumerate(query_labels)) +matches + + +# Fornax enables a more powerful type of search. +# By specifying 'guardians', 'star', 'groot' as nodes in a graph, +# and by specifying the relationships between them, +# we can search for nodes in our target graph with the same relationships. + +# ## Creating a target graph +# +# Fornax behaves much like a database. In fact it uses SQLite or Postgresql to store graph data and index it. +# To insert a new graph into fornax we can use the following three steps: +# 1. create a new graph +# 2. add nodes and node meta data +# 3. add edges and edge meta data +# +# The object `fornax.GraphHandle` is much like a file handle. It does not represent the graph but it is an accessor to it. +# If the `GraphHandle` goes out of scope the graph will still persist until it is explicitly deleted, much like a file. + +# In[10]: + + +conn = fornax.Connection() +conn.open() + + +# In[11]: + + +target_graph = fornax.GraphHandle.create(conn) +target_graph.add_nodes( + # use id_src to set a custom id on each node + id_src=nodes_df['target_id'], + # use other keyword arguments to attach arbitrary metadata to each node + label=nodes_df['target_label'], + # the type keyword is reserved to we use target_type + target_type=nodes_df['target_type'] + # meta data must be json serialisable +) +target_graph.add_edges(edges_df['start'], edges_df['end']) + + +# We can use the `graph_id` to access our graph in the future. + +# In[12]: + + +target_graph.graph_id +another_target_graph_handle = fornax.GraphHandle.read(conn, target_graph.graph_id) +another_target_graph_handle == target_graph + + +# ## Creating a query graph +# +# Let's imagine that we suspect `groot` is directly related to `guardians` and `star` is also directly related to `guardians`. +# For example `groot` and `star` could both be members of a team called `guardians`. +# Let's create another small graph that represents this situation: + +# In[13]: + + +# create a new graph +query_graph = fornax.GraphHandle.create(conn) + +# insert the three nodes: +# 'guardians' (id=0), 'star' (id=1), 'groot' (id=2) +query_graph.add_nodes(label=query_labels) + +# alternatively: +# query_graph.add_nodes(id_src=query_labels) +# since id_src can use any unique hashable items + +edges = [ + (0, 1), # edge between groot and guardians + (0, 2) # edge between star and guardians +] + +sources, targets = zip(*edges) +query_graph.add_edges(sources, targets) + + +# ## Search +# +# We can create a query in an analogous way to creating graphs using a `QueryHandle`, +# a handle to a query stored in the fornax database. +# To create a useful query we need to insert the string similarity scores we computed in part 1. +# Fornax will use these scores and the graph edges to execute the query. + +# In[14]: + + +query = fornax.QueryHandle.create(conn, query_graph, target_graph) +query.add_matches(matches['query_id'], matches['target_id'], matches['score']) + + +# Finally we can execute the query using a variety of options. +# We specify we want the top 5 best matches between the query graph and the target graph. + +# In[15]: + + +get_ipython().run_line_magic('time', 'results = query.execute(n=5)') + + +# ## Visualise +# +# `query.execute` returns an object describing the search result. +# Of primary interest is the `graph` field which contains a list of graphs in `node_link_graph` format. +# We can use networkx to draw these graphs and visualise the results. + +# In[16]: + + +def draw(graph): + """ function for drawing a graph using matplotlib and networkx""" + + # each graph is already in node_link_graph format + G = nx.json_graph.node_link_graph(graph) + + labels = {node['id']: node['label'] for node in graph['nodes']} + node_colour = ['r' if node['type'] == 'query' else 'b' for node in graph['nodes']] + pos = nx.spring_layout(G) + nx.draw_networkx_nodes(G, pos, node_size=600, node_color=node_colour, alpha=.3) + edgelist = [(e['source'], e['target']) for e in graph['links'] if e['type'] != 'match'] + nx.draw_networkx_edges(G, pos, width=3, edgelist=edgelist, edge_color='grey', alpha=.3) + edgelist = [(e['source'], e['target']) for e in graph['links'] if e['type'] == 'match'] + nx.draw_networkx_edges(G, pos, width=3, edgelist=edgelist, style='dashed', edge_color='pink') + nx.draw_networkx_labels(G, pos, font_size=12, font_family='sans-serif', labels=labels) + + +# Result 1 contains the best match. The three query nodes (in red) best match the three target nodes (in blue). The dashed lines show which pairs of query and target nodes matched each other. The blue nodes are a subgraph of the target graph. Note that the result does not describe the whole target graph because in principle it can be very large. +# +# Here we can see that the blue subgraph has exactly the same shape as the red query graph. However, the labels are not exactly the same (e.g. `guardians != Guardians of the Galaxy`) so the result scores less than the maximum score of 1. +# However, we can see that our query graph is really similar to Groot and Star-Lord from Guardians of the Galaxy. +# Since this is the best match we know that + +# In[17]: + + +for i, graph in enumerate(results['graphs'][:1]): + plt.title('Result {0}, score: {1:.2f}'.format(1, 1. - graph['cost'])) + draw(graph) + plt.xlim(-1.2,1.2) + plt.ylim(-1.2,1.2) + plt.axis('off') + plt.show() + + +# Results 2-4 have a lower score because `star` matches to a different node not adjacent to Guardians of the Galaxy. Further inspection would show that `star` has matched aliases of Star-Lord which are near Guardians of the Galaxy but not ajacent to it. + +# In[18]: + + +for i, graph in enumerate(results['graphs'][1:4]): + plt.title('Result {0}, score: {1:.2f}'.format(i+2, 1. - graph['cost'])) + draw(graph) + plt.xlim(-1.2,1.2) + plt.ylim(-1.2,1.2) + plt.axis('off') + plt.show() + + +# The final match pairs `guardians` and `star` to two nodes that do not have similar edges to the target graph. `groot` is not found in the target graph. The result gets a much lower score than the preceding results and we can be sure that any additional results will also be poor because the result are ordered. + +# In[19]: + + +for i, graph in enumerate(results['graphs'][4:]): + plt.title('Result {0}, score: {1:.2f}'.format(i+5, 1. - graph['cost'])) + draw(graph) + plt.xlim(-1.2,1.2) + plt.ylim(-1.2,1.2) + plt.axis('off') + plt.show() + diff --git a/environment.yml b/environment.yml index 9ff3a0c..05ce63c 100644 --- a/environment.yml +++ b/environment.yml @@ -3,33 +3,36 @@ channels: - conda-forge - defaults dependencies: -- keepalive=0.5=py_1 - appnope=0.1.0=py36hf537a9a_0 - backcall=0.1.0=py36_0 - blas=1.0=mkl - bleach=3.0.2=py36_0 - ca-certificates=2018.03.07=0 - certifi=2018.10.15=py36_0 +- cycler=0.10.0=py36hfc81398_0 - dbus=1.13.2=h760590f_1 - decorator=4.3.0=py36_0 - entrypoints=0.2.3=py36_2 - expat=2.2.6=h0a44026_0 +- freetype=2.9.1=hb4e5f40_0 - gettext=0.19.8.1=h15daf44_3 - glib=2.56.2=hd9629dc_0 - icu=58.2=h4b95b61_1 - intel-openmp=2019.0=118 - ipykernel=5.1.0=py36h39e3cac_0 -- ipython=7.1.1=py36h39e3cac_0 - ipython_genutils=0.2.0=py36h241746c_0 +- ipython=7.1.1=py36h39e3cac_0 - ipywidgets=7.4.2=py36_0 - jedi=0.13.1=py36_0 - jinja2=2.10=py36_0 - jpeg=9b=he5867d9_2 - jsonschema=2.6.0=py36hb385e00_0 -- jupyter=1.0.0=py36_7 - jupyter_client=5.2.3=py36_0 - jupyter_console=6.0.0=py36_0 - jupyter_core=4.4.0=py36_0 +- jupyter=1.0.0=py36_7 +- keepalive=0.5=py_1 +- kiwisolver=1.0.1=py36h0a44026_0 - libcxx=4.0.1=hcfea43d_1 - libcxxabi=4.0.1=hcfea43d_1 - libedit=3.1.20170329=hb402a30_2 @@ -39,17 +42,19 @@ dependencies: - libpng=1.6.35=ha441bb4_0 - libsodium=1.0.16=h3efe00b_0 - markupsafe=1.1.0=py36h1de35cc_0 +- matplotlib=3.0.1=py36h54f8f79_0 - mistune=0.8.4=py36h1de35cc_0 -- mkl=2019.0=118 - mkl_fft=1.0.6=py36hb8a8100_0 - mkl_random=1.0.1=py36h5d10147_1 +- mkl=2018.0.3=1 - nbconvert=5.3.1=py36_0 - nbformat=4.4.0=py36h827af21_0 - ncurses=6.1=h0a44026_0 +- networkx=2.2=py36_1 - notebook=5.7.1=py36_0 -- numpy=1.15.4=py36h6a91979_0 - numpy-base=1.15.4=py36h8a80b8c_0 -- openssl=1.1.1=h1de35cc_0 +- numpy=1.15.4=py36h6a91979_0 +- openssl=1.1.1a=h1de35cc_0 - pandas=0.23.4=py36h6440ff4_0 - pandoc=2.2.3.2=0 - pandocfilters=1.4.2=py36_1 @@ -62,9 +67,10 @@ dependencies: - prompt_toolkit=2.0.7=py36_0 - ptyprocess=0.6.0=py36_0 - pygments=2.2.0=py36h240cd3f_0 +- pyparsing=2.3.0=py36_0 - pyqt=5.9.2=py36h655552a_2 -- python=3.6.7=haf84260_0 - python-dateutil=2.7.5=py36_0 +- python=3.6.7=haf84260_0 - pytz=2018.7=py36_0 - pyzmq=17.1.2=py36h1de35cc_0 - qt=5.9.6=h45cd832_2 @@ -87,3 +93,7 @@ dependencies: - xz=5.2.4=h1de35cc_4 - zeromq=4.2.5=h0a44026_1 - zlib=1.2.11=hf3cbc9b_2 +- pip: + - sparqlwrapper + - psycopg2 + - SQLAlchemy diff --git a/fornax/__init__.py b/fornax/__init__.py index dca354d..5d65c4e 100644 --- a/fornax/__init__.py +++ b/fornax/__init__.py @@ -1,2 +1,2 @@ -from fornax.api import GraphHandle, QueryHandle +from fornax.api import Connection, GraphHandle, QueryHandle __version__ = '0.0.1' diff --git a/fornax/api.py b/fornax/api.py index 3de86e8..e101adb 100644 --- a/fornax/api.py +++ b/fornax/api.py @@ -1,29 +1,3 @@ -""" -Fornax API documentation -======================== - -Introduction ------------- - -Fornax performs fuzzy subgraph matching between graphs with labelled nodes. -Given a small graph (the query graph) and a large graph (the target graph) -fornax will approximate the top `n` subgraphs in the target graph that are most -similar to the query graph even if the node labels and graph relationships are -not exactly the same. - -Use this query API to specify query and target graphs and to seach for fuzzy -subgraph matches of the query graph to the target graph. - -fornax is designed to handle very large graphs of millions of nodes. -As such graphs are persisted in a database. -Rather than interacting directly with a graph, the API implements GraphHandles. -These are similar to file handles or file pointers for a file system. -They allow the user to Create, Read, Update and Delete graphs but much like a -file the graphs will still persist even if the handle goes out of scope. - -Similarly query objects, which define a search operation, can be created using -a QueryHandle. -""" import fornax.select import fornax.opt import sqlalchemy @@ -42,65 +16,111 @@ # TODO: sqlalchemy database integrity exceptions are not caught by the API -"""URL for a supported SQL database backend""" -FORNAX_DB_URL = os.environ.get('FORNAX_DB_URL') -if FORNAX_DB_URL is None: - FORNAX_DB_URL = 'sqlite://' -MAX_SIZE = sys.maxsize -SQLITE_MAX_SIZE = 2147483647 -if FORNAX_DB_URL == 'sqlite://': - MAX_SIZE = min(MAX_SIZE, SQLITE_MAX_SIZE) - -ECHO = False -ENGINE = sqlalchemy.create_engine(FORNAX_DB_URL, echo=ECHO) -CONNECTION = ENGINE.connect() -Session = sqlalchemy.orm.sessionmaker(bind=ENGINE) -fornax.model.Base.metadata.create_all(CONNECTION) +# enforce foreign key constrains in SQLite +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() -def _hash(item: str) -> int: - """An unsalted hash function with a range between 0 and MAX_SIZE +def _hash(item: str, maxsize=sys.maxsize) -> int: + """An unsalted hash function with a range between 0 and maxsize :param item: string or string like object that is accepted by builtin function `str` :type item: str - :return: hash between 0 and MAX_SIZE + param maxsize: maximum value of returned integer + :type maxsize: int + :return: hash between 0 and maxsize :rtype: int """ - if isinstance(item, int): - return item % MAX_SIZE + return item % maxsize else: return int( hashlib.sha256(str(item).encode('utf-8')).hexdigest(), 16 - ) % MAX_SIZE + ) % maxsize -# enforce foreign key constrains in SQLite -@event.listens_for(Engine, "connect") -def _set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() +class Connection: + """ + Create a new database connection. + If the database is empty :class:`Connection` will create + any missing schema. + Currrently sqlite and postgresql are activly supported + as backend databases. -@contextlib.contextmanager -def session_scope(): - """ - Provide a transactional scope around a series of db operations. - Transactions will be rolled back in the case of an exception. + In addition to the open, close syntax + Connection supports the context manager syntax:: + + with Connection("postgres:://user/0.0.0.0./mydb") as conn: + graph = fornax.GraphHandle.create(conn) + + :param url: dialect[+driver]://user:password@host/dbname[?key=value..] + :type url: str, optional """ - session = Session() - try: - yield session - session.commit() - except BaseException: - session.rollback() - raise - finally: - session.close() + SQLITE_MAX_SIZE = 2147483647 + + def __init__(self, url='sqlite://', **kwargs): + + self.url = url + self.engine = sqlalchemy.create_engine(self.url, **kwargs) + self.make_session = sqlalchemy.orm.sessionmaker(bind=self.engine) + self.maxsize = sys.maxsize + if self.url.startswith('sqlite'): + self.maxsize = self.SQLITE_MAX_SIZE + + def open(self): + """ Open the fornax database connection + and create any absent tables and indicies + """ + self.connection = self.engine.connect() + fornax.model.Base.metadata.create_all(self.connection) + + def close(self): + """ Close the fornax database connection + and free any connections in the connection pool + """ + + self.connection.close() + + def __enter__(self): + self.open() + return self + + def __exit__(self, *args): + self.close() + + @contextlib.contextmanager + def _get_session(self): + """ + Provide a transactional scope around a series of db operations. + Transactions will be rolled back in the case of an exception. + """ + session = self.make_session() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + def _hash(self, item: str) -> int: + """An unsalted hash function with a range between 0 and self.maxsize + + :param item: string or string like object that is accepted by builtin + function `str` + :type item: str + :return: hash between 0 and self.maxsize + :rtype: int + """ + return _hash(item, self.maxsize) class InvalidNodeError(Exception): @@ -180,23 +200,6 @@ def __repr__(self): def __lt__(self, other): return (self.type, self.id) < (other.type, other.id) - def to_dict(self) -> dict: - """Return self as a json serialisable dictionary - - :return: dictionary with keys `id`, `type` and `meta` - :rtype: dict - """ - - return { - # hash id with type so that the node id is unique to a given - # submatch result - **{ - 'id': _hash((self.id, self.type)), - 'type': self.type - }, - **self.meta - } - class Edge: """Representation of an Edge used internally be QueryHandle @@ -241,52 +244,22 @@ def __repr__(self): self.start, self.end, self.type, self.meta ) - def to_dict(self): - """Return self as a json serialisable dictionary - - Returns: - dict -- dictionart with keys start, end, type, metadata and weight - """ - if self.type == 'query' or self.type == 'target': - # hash start and end with the edge type - # to make id unique within a subgraph match - start = _hash((self.start, self.type)) - end = _hash((self.end, self.type)) - - elif self.type == 'match': - # hash start and end with the edge type - # to make id unique within a subgraph match - start = _hash((self.start, 'query')) - end = _hash((self.end, 'target')) - return { - **{ - 'source': start, - 'target': end, - 'type': self.type, - 'weight': self.weight - }, - **self.meta - } - class GraphHandle: - """Accessor for a graph - - Because fornax is designed to operate on very large graphs node and edges - are not stored in memory. - Rather, they are persisted using a database back end. - Currently sqlite and postgres are supported. + """ - GraphHandle is an interface to this persistent layer. - One can access an existing graph by - specifying it using the `graph_id` itentifier. + Create a handle to an existing graph with id *graph_id* + accessed via *connection*. + :param connection: fornax database connection + :type connection: Connection :param graph_id: unique id for an existing graph :type graph_id: int """ - def __init__(self, graph_id: int): + def __init__(self, connection: Connection, graph_id: int): self._graph_id = graph_id + self.conn = connection self._check_exists() def __len__(self): @@ -309,18 +282,23 @@ def __eq__(self, other): @property def graph_id(self): - """Unique identifier for a graph""" + """Get the unique id for this graph + + Graph id's are automaticly assigned at creation time. + """ return self._graph_id @classmethod - def create(cls): - """Create a new empy graph and return a GraphHandle to it + def create(cls, connection: Connection): + """Create a new empty graph via *connection* and return a GraphHandle to it + :param connection: a fornax database connection + :type connection: Connection :return: GraphHandle to a new graph :rtype: GraphHandle """ - with session_scope() as session: + with connection._get_session() as session: query = session.query( sqlalchemy.func.max(model.Graph.graph_id) @@ -332,24 +310,25 @@ def create(cls): else: graph_id += 1 session.add(model.Graph(graph_id=graph_id)) - session.commit() - return GraphHandle(graph_id) + return GraphHandle(connection, graph_id) @classmethod - def read(cls, graph_id: int): + def read(cls, connection: Connection, graph_id: int): """Create a new GraphHandle to an existing graph with unique identifier `graph_id` + :param connection: a fornax database connection + :type connection: Connection :param graph_id: unique identifier for an existing graph :type graph_id: int :return: A new graph handle to an existing graph :rtype: GraphHandle """ - return GraphHandle(graph_id) + return GraphHandle(connection, graph_id) def delete(self): - """Delete a graph. + """Delete this graph. Delete the graph accessed through graph handle and all of the associated nodes and edges. @@ -357,7 +336,7 @@ def delete(self): """ self._check_exists() - with session_scope() as session: + with self.conn._get_session() as session: session.query( model.Graph ).filter(model.Graph.graph_id == self._graph_id).delete() @@ -369,7 +348,7 @@ def delete(self): ).filter(model.Node.graph_id == self._graph_id).delete() def _check_exists(self): - with session_scope() as session: + with self.conn._get_session() as session: exists = session.query(sqlalchemy.exists().where( model.Graph.graph_id == self._graph_id )).scalar() @@ -381,24 +360,27 @@ def _check_exists(self): def add_nodes(self, **kwargs): """Append nodes to a graph - :param id_src: An iterable if Unique hashable identifiers - for each node, defaults to None - :raises ValueError: Raised if `id` is used as a keyword argument - :raises ValueError: Raised if no keyword arguments are provided + :param id_src: An iterable of unique hashable identifiers, default None + :type id_src: Iterable - If `id_src` is not provided, - each node will be indentifed by order of insertion - using a continuous range index starting at zero. + Keyword arguments can be used to attached arbitrary JSON serialised + metadata to each node:: - Metadata can be attached to each node - by specifying extra keyword arguments - (not that id is reserved). - For example, to attach a name to each node: + # create 3 nodes with ids: 0, 1, 2 + # and names 'Anne', 'Ben', 'Charles' + graph_handle.add_nodes(names=['Anne', 'Ben', 'Charles']) - :Example: + By default, each node will be assigned a sequential integer id + starting from 0. A custom id can be assigned using the *id_src* + keyword provided that all of the ids are hashable:: - graph_handle.add_node(id_src=[1,2,3], name=['a', 'b', 'c']) + # create 3 nodes with ids: 'Anne', 'Ben', 'Charles' + # and no explicit name field + graph_handle.add_nodes(id_src=['Anne', 'Ben', 'Charles']) + .. note:: + + *id* is a reserved keyword argument which will raise an exception """ keys = kwargs.keys() @@ -426,14 +408,14 @@ def add_nodes(self, **kwargs): nodes = ( model.Node( - node_id=_hash(node_id), + node_id=self.conn._hash(node_id), graph_id=self.graph_id, meta=json.dumps({key: val for key, val in zip(keys, values)}) ) for node_id, values in zipped ) nodes = self._check_nodes(nodes) - with session_scope() as session: + with self.conn._get_session() as session: session.add_all(nodes) session.commit() @@ -442,16 +424,32 @@ def add_edges( ): """Append edges to a graph representing relationships between nodes - :param sources: node `id_src` + :param sources: node id_src :type sources: typing.Iterable - :param targets: node `id_src` + :param targets: node id_src :type targets: typing.Iterable - keyword arguments can be used to attach metadata to the edges. + Keyword arguments can be used to attach metadata to the edges. + For example to add three edges with a relationship attribute friend or + foe:: - :Example: + graph_handle.add_edges( + sources=[0, 1, 2], + targets=[1, 2, 0], + relationship=['friend', 'friend', 'foe'] + ) + Keyword arguments can be used to attach any arbitrary JSON + serialisable data to edges. + + .. note:: + + The following reserved keywords are not reserved and will raise + an exception - graph_handle.add_edges([0, 0], [1, 1], relation=['friend', 'foe']) + * *start* + * *end* + * *type* + * *weight* """ @@ -469,8 +467,8 @@ def add_edges( if 'weight' in keys: raise(ValueError('weight is a reserved node attribute \ which cannot be assigned using kwargs')) - hashed_sources = map(_hash, sources) - hashed_targets = map(_hash, targets) + hashed_sources = map(self.conn._hash, sources) + hashed_targets = map(self.conn._hash, targets) zipped = itertools.zip_longest( hashed_sources, hashed_targets, *kwargs.values(), fillvalue=NullValue() @@ -491,12 +489,11 @@ def add_edges( for start, end, *values in zipped ) edges = self._check_edges(edges) - with session_scope() as session: + with self.conn._get_session() as session: session.add_all(edges) session.commit() - @staticmethod - def _check_nodes(nodes) -> typing.Generator: + def _check_nodes(self, nodes) -> typing.Generator: """Guard against invalid nodes by raising an InvalidNodeError for forbidden node parameters @@ -516,7 +513,7 @@ def _check_nodes(nodes) -> typing.Generator: raise InvalidNodeError( '{}, node_id must be an integer'.format(node) ) - if node_id > SQLITE_MAX_SIZE and FORNAX_DB_URL == 'sqlite://': + if node_id > self.conn.maxsize and self.conn.startswith('sqlite'): raise InvalidNodeError('node id {} is too large'.format(node)) yield node @@ -552,14 +549,18 @@ def _check_edges(edges: typing.Iterable[model.Edge]) -> typing.Generator: class QueryHandle: - """Accessor for a fuzzy subgraph matching query + """Create a handle to an existing query via *connection* with unique id + *query_id*. + :param connection: a fornax database connection + :type connection: Connection :param query_id: unique id for an existing query :type query_id: int """ - def __init__(self, query_id: int): + def __init__(self, connection: Connection, query_id: int): self.query_id = query_id + self.conn = connection self._check_exists() def __eq__(self, other): @@ -571,9 +572,8 @@ def __len__(self): Returns: {int} -- Count of matching edges """ - self._check_exists() - with session_scope() as session: + with self.conn._get_session() as session: count = session.query(model.Match).filter( model.Match.query_id == self.query_id).count() return count @@ -584,8 +584,7 @@ def _check_exists(self): Raises: ValueError -- Raised if the query had been deleted """ - - with session_scope() as session: + with self.conn._get_session() as session: exists = session.query(model.Query).filter( model.Query.query_id == self.query_id ).scalar() @@ -595,18 +594,22 @@ def _check_exists(self): ) @classmethod - def create(cls, query_graph: GraphHandle, target_graph: GraphHandle): + def create( + cls, connection: Connection, + query_graph: GraphHandle, target_graph: GraphHandle + ): """Create a new query and return a QueryHandle for it - :param query_graph: Subgraph to be search for in the target graph + :param connection: a fornax database connection + :type connection: Connection + :param query_graph: subgraph to find target graph :type query_graph: GraphHandle :param target_graph: Graph to be searched :type target_graph: GraphHandle :return: new QueryHandle :rtype: QueryHandle """ - - with session_scope() as session: + with connection._get_session() as session: query_id = session.query( sqlalchemy.func.max(model.Query.query_id) ).first()[0] @@ -620,26 +623,27 @@ def create(cls, query_graph: GraphHandle, target_graph: GraphHandle): end_graph_id=target_graph.graph_id ) session.add(new_query) - return QueryHandle(query_id) + return QueryHandle(connection, query_id) @classmethod - def read(cls, query_id: int): - """Create a new QueryHandle to an existing query with unique id `query_id` + def read(cls, connection: Connection, query_id: int): + """Create a new QueryHandle to an existing query with unique id *query_id* + via *connection*. + :param connection: a fornax database connection + :type connection: Connection :param query_id: unique identifier for a query :type query_id: int :return: new QueryHandle :rtype: QueryHandle """ - - return QueryHandle(query_id) + return QueryHandle(connection, query_id) def delete(self): """Delete this query and any associated matches """ - self._check_exists() - with session_scope() as session: + with self.conn._get_session() as session: session.query(model.Query).filter( model.Query.query_id == self.query_id ).delete() @@ -655,14 +659,14 @@ def query_graph(self) -> GraphHandle: """ self._check_exists() - with session_scope() as session: + with self.conn._get_session() as session: start_graph = session.query( model.Graph ).join( model.Query, model.Graph.graph_id == model.Query.start_graph_id ).filter(model.Query.query_id == self.query_id).first() graph_id = start_graph.graph_id - return GraphHandle(graph_id) + return GraphHandle(self.conn, graph_id) def target_graph(self) -> GraphHandle: """Get a QueryHandle for the target graph @@ -672,14 +676,14 @@ def target_graph(self) -> GraphHandle: """ self._check_exists() - with session_scope() as session: + with self.conn._get_session() as session: end_graph = session.query( model.Graph ).join( model.Query, model.Graph.graph_id == model.Query.end_graph_id ).filter(model.Query.query_id == self.query_id).first() graph_id = end_graph.graph_id - return GraphHandle(graph_id) + return GraphHandle(self.conn, graph_id) def add_matches( self, @@ -688,13 +692,7 @@ def add_matches( weights: typing.Iterable[float], **kwargs ): - """Add candidate matches between the query graph and the target graph - - Matches represent a pairwise node similarity - between all nodes in the query graph - and all nodes in the target graph. - Only similarities with non zero score need to be stated explicitly. - Matches with zero score are implicit. + """Add matches between the query graph and the target graph :param sources: Iterable of `src_id` in the query graph :type sources: typing.Iterable[int] @@ -703,6 +701,22 @@ def add_matches( :param weights: Iterable of weights between 0 and 1 :type weights: typing.Iterable[float] + For example, to add matches between + + * node *0* in the query graph and node *0* in the target graph \ + with weight *.9* + + * node *0* in the query graph and node *1* in the target graph \ + with weight *.1* + + then:: + + query.add_matches([0, 0], [0, 1], [.9, .1]) + + .. note:: + + Adding weights that compare equal to zero will raise an exception. + """ self._check_exists() @@ -719,8 +733,8 @@ def add_matches( if 'weight' in keys: raise(ValueError('weight is a reserved node attribute \ which cannot be assigned using kwargs')) - hashed_sources = map(_hash, sources) - hashed_targetes = map(_hash, targets) + hashed_sources = map(self.conn._hash, sources) + hashed_targetes = map(self.conn._hash, targets) zipped = itertools.zip_longest( hashed_sources, hashed_targetes, weights, *kwargs.values(), fillvalue=NullValue() @@ -740,7 +754,7 @@ def add_matches( for start, end, weight, *values in zipped ) matches = self._check_matches(matches) - with session_scope() as session: + with self.conn._get_session() as session: session.add_all(matches) session.commit() @@ -792,7 +806,7 @@ def _check_matches( yield match def _query_nodes(self): - with session_scope() as session: + with self.conn._get_session() as session: nodes = session.query(model.Node).join( model.Query, model.Node.graph_id == model.Query.start_graph_id ).filter(model.Query.query_id == self.query_id).all() @@ -802,7 +816,7 @@ def _query_nodes(self): return nodes def _query_edges(self): - with session_scope() as session: + with self.conn._get_session() as session: edges = session.query(model.Edge).join( model.Query, model.Edge.graph_id == model.Query.start_graph_id ).filter( @@ -817,7 +831,7 @@ def _query_edges(self): return edges def _target_nodes(self): - with session_scope() as session: + with self.conn._get_session() as session: nodes = session.query(model.Node).join( model.Query, model.Node.graph_id == model.Query.end_graph_id ).filter( @@ -836,7 +850,7 @@ def is_between(target_ids, edge): def _target_edges(self, target_nodes, target_edges_arr): # only include target edges that are between the target nodes above - with session_scope() as session: + with self.conn._get_session() as session: EndMatch = sqlalchemy.alias(model.Match, "end_match") EndNode = sqlalchemy.alias(model.Node, "end_node") StartNode = sqlalchemy.alias(model.Node, "start_node") @@ -859,7 +873,7 @@ def _target_edges(self, target_nodes, target_edges_arr): return edges def _optimise(self, hopping_distance, max_iters, offsets): - with session_scope() as session: + with self.conn._get_session() as session: sql_query = fornax.select.join( self.query_id, h=hopping_distance, offsets=offsets ) @@ -883,18 +897,60 @@ def _get_scores(cls, inference_costs, query_nodes, subgraphs, sz): scores.append(score) return scores + def _node_to_dict(self, node: Node) -> dict: + """Return self as a json serialisable dictionary + + :return: dictionary with keys `id`, `type` and `meta` + :rtype: dict + """ + + return { + # hash id with type so that the node id is unique to a given + # submatch result + **{ + 'id': self.conn._hash((node.id, node.type)), + 'type': node.type + }, + **node.meta + } + + def _edge_to_dict(self, edge: Edge): + """Return self as a json serialisable dictionary + + Returns: + dict -- dictionart with keys start, end, type, metadata and weight + """ + if edge.type == 'query' or edge.type == 'target': + # hash start and end with the edge type + # to make id unique within a subgraph match + start = self.conn._hash((edge.start, edge.type)) + end = self.conn._hash((edge.end, edge.type)) + + elif edge.type == 'match': + # hash start and end with the edge type + # to make id unique within a subgraph match + start = self.conn._hash((edge.start, 'query')) + end = self.conn._hash((edge.end, 'target')) + return { + **{ + 'source': start, + 'target': end, + 'type': edge.type, + 'weight': edge.weight + }, + **edge.meta + } + def execute(self, n=5, hopping_distance=2, max_iters=10): - """Execute a fuzzy subgraph matching query + """Execute a fuzzy subgraph matching query finding the top *n* subgraph + matches between the query graph and the target graph. - :param n: number of subgraph matches to return, defaults to 5 - :param n: int, optional + :param n: number of subgraph matches to return + :type n: int, optional :param hopping_distance: lengthscale hyperparameter, defaults to 2 - :param hopping_distance: int, optional - :param max_iters: maximum number of optimisation iterations, - defaults to 10 - :param max_iters: int, optional - :raises ValueError: Raised if there are no matches - between the query and target graph + :type hopping_distance: int, optional + :param max_iters: maximum number of optimisation iterations + :type max_iters: int, optional :return: query result :rtype: dict """ @@ -921,37 +977,64 @@ def execute(self, n=5, hopping_distance=2, max_iters=10): # sort graphs by score then deturministicly by hashing idxs = sorted( enumerate(scores), - key=lambda x: (x[1], _hash(tuple(subgraphs[x[0]]))) + key=lambda x: (x[1], self.conn._hash(tuple(subgraphs[x[0]]))) ) - query_nodes_payload = [node.to_dict() for node in query_nodes] - query_edges_payload = [edge.to_dict() for edge in query_edges] - target_nodes_payload = [node.to_dict() for node in target_nodes] - target_edges_payload = [edge.to_dict() for edge in target_edges] + query_nodes_payload = [ + self._node_to_dict(node) + for node in query_nodes + ] + + query_edges_payload = [ + self._edge_to_dict(edge) + for edge in query_edges + ] + + target_nodes_payload = [ + self._node_to_dict(node) + for node in target_nodes + ] + + target_edges_payload = [ + self._edge_to_dict(edge) + for edge in target_edges + ] for i, score in idxs[:min(n, len(idxs))]: + _, match_ends = zip(*subgraphs[i]) + matches = [ - Edge(s, e, 'match', {}, 1. - inference_costs[s, e]).to_dict() + self._edge_to_dict( + Edge(s, e, 'match', {}, 1. - inference_costs[s, e]) + ) for s, e in sorted(subgraphs[i]) ] - match_ends = set(_hash((i, 'target')) for i in match_ends) + + match_ends = set( + self.conn._hash((i, 'target')) + for i in match_ends + ) + nxt_graph = { 'is_multigraph': False, 'cost': score, 'nodes': list(query_nodes_payload), # make a copy 'links': matches + list(query_edges_payload) # make a copy } + nxt_graph['nodes'].extend([ n for n in target_nodes_payload if n['id'] in match_ends ]) + nxt_graph['links'].extend( [ e for e in target_edges_payload if e['source'] in match_ends and e['target'] in match_ends ] ) + graphs.append(nxt_graph) return { diff --git a/notebooks/tutorial/Tutorial 2 - Making a Query.ipynb b/notebooks/tutorial/Tutorial 2 - Making a Query.ipynb deleted file mode 100644 index b86a49f..0000000 --- a/notebooks/tutorial/Tutorial 2 - Making a Query.ipynb +++ /dev/null @@ -1,561 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import json\n", - "import fornax\n", - "import matplotlib\n", - "import networkx as nx\n", - "\n", - "%matplotlib inline" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Performing a Fuzzy Graph Query using Fornax\n", - "\n", - "Im this tutorial we will perform a fuzzy search on the dataset curated during the first tutorial.\n", - "\n", - "You need to run tutorial 1 before doing this tutorial otherwise you will not be able to load any of the data produced.\n", - "\n", - "## Step 1: Load the data from the first tutorial" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "nodes_df = pd.read_csv('./nodes.csv')\n", - "edges_df = pd.read_csv('./edges.csv')" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
labeltypeuid
0Jubilee01126900601
1Silver Surfer0440245546
2Beast01370118169
3Selene087770955
4Sabretooth01299099267
\n", - "
" - ], - "text/plain": [ - " label type uid\n", - "0 Jubilee 0 1126900601\n", - "1 Silver Surfer 0 440245546\n", - "2 Beast 0 1370118169\n", - "3 Selene 0 87770955\n", - "4 Sabretooth 0 1299099267" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "nodes_df.head()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
endstart
02729691141126900601
1741356524440245546
214496268731370118169
360991860287770955
414496268731299099267
\n", - "
" - ], - "text/plain": [ - " end start\n", - "0 272969114 1126900601\n", - "1 741356524 440245546\n", - "2 1449626873 1370118169\n", - "3 609918602 87770955\n", - "4 1449626873 1299099267" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "edges_df.head()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 2: Create a target graph\n", - "\n", - "We create a target graph by creating a graph handle and loading the nodes and edges into the graph.\n", - "\n", - "We can attach metadata to the nodes using keyword arguments of `GraphHandle.add_nodes`.\n", - "`id_src` is a special field which specifies a unique id for each node. By default `id_src` is a continuous range of integers starting at zero." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "target_graph = fornax.GraphHandle.create()\n", - "target_graph.add_nodes(id_src=nodes_df['uid'], label=nodes_df['label'], type_=nodes_df['type'])\n", - "target_graph.add_edges(edges_df['start'], edges_df['end'])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 3: Create a query graph\n", - "\n", - "Creating a query graph is exactly the same as creating a target graph. Because this query graph is small we don't load any values from file." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "query_graph = fornax.GraphHandle.create()\n", - "query_graph.add_nodes(id_src=[0, 1, 2], label=['hulk', 'lady', 'storm'])\n", - "query_graph.add_edges([0, 1], [1, 2])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 4: Create a query\n", - "\n", - "A query needs a set of correspondances between the query and target graph.\n", - "They can be anything provided that they are scored between 0 and 1.\n", - "Below we create a correspondance between a query node and a target node. If the query node label is a substring of a target node label the correspondance scores 1, otherwise it is zero we don't specify it explicitly.\n", - "\n", - "Just like graph edges, matches are specified using the `src_id` of source and target nodes respectivly." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "matches = []\n", - "for i in nodes_df[nodes_df['label'].str.contains(\"(?i)hulk\")]['uid']:\n", - " matches.append((0, i, 1))\n", - "for i in nodes_df[nodes_df['label'].str.contains('(?i)lady')]['uid']:\n", - " matches.append((1, i, 1))\n", - "for i in nodes_df[nodes_df['label'].str.contains('(?i)storm')]['uid']:\n", - " matches.append((2, i, 1))\n", - "sources, targets, weights = zip(*matches)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(0, 2020667501, 1),\n", - " (0, 2087196931, 1),\n", - " (0, 2142361735, 1),\n", - " (0, 1056123890, 1),\n", - " (0, 970313367, 1),\n", - " (0, 329519748, 1),\n", - " (0, 959673558, 1),\n", - " (0, 560425637, 1),\n", - " (1, 1639515098, 1),\n", - " (1, 995920086, 1)]" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "matches[:10]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A query needs a two graph handles.\n", - "Once created we can insert the matches." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "query = fornax.QueryHandle.create(query_graph, target_graph)\n", - "query.add_matches(sources, targets, weights)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Exacute the query\n", - "\n", - "The query produces a python dictionary that can be serialised to json.\n", - "Each `graph` in the result uses the [node link fromat](https://networkx.github.io/documentation/stable/reference/readwrite/generated/networkx.readwrite.json_graph.node_link_data.html#networkx.readwrite.json_graph.node_link_data) for graphs.\n", - "\n", - "The resulting graphs contain\n", - "* The query nodes\n", - "* The optimal target nodes matching the query nodes\n", - "* Edges between the query nodes\n", - "* Edges between the relavent target nodes\n", - "* The matches between the query graph and optimum target nodes" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/numpy/core/records.py:513: FutureWarning: Numpy has detected that you may be viewing or writing to an array returned by selecting multiple fields in a structured array. \n", - "\n", - "This code may break in numpy 1.15 because this will return a view instead of a copy -- see release notes for details.\n", - " return obj.view(dtype=(self.dtype.type, obj.dtype))\n" - ] - } - ], - "source": [ - "# get the n best subgraph matches\n", - "results = query.execute(n=1)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"graphs\": [\n", - " {\n", - " \"is_multigraph\": false,\n", - " \"cost\": 0.024416640711327393,\n", - " \"nodes\": [\n", - " {\n", - " \"id\": 1934515491,\n", - " \"type\": \"query\",\n", - " \"id_src\": 0,\n", - " \"label\": \"hulk\"\n", - " },\n", - " {\n", - " \"id\": 1325512080,\n", - " \"type\": \"query\",\n", - " \"id_src\": 1,\n", - " \"label\": \"lady\"\n", - " },\n", - " {\n", - " \"id\": 1876249886,\n", - " \"type\": \"query\",\n", - " \"id_src\": 2,\n", - " \"label\": \"storm\"\n", - " },\n", - " {\n", - " \"id\": 715503065,\n", - " \"type\": \"target\",\n", - " \"id_src\": 37644418,\n", - " \"label\": \" Susan Storm\",\n", - " \"type_\": 2\n", - " },\n", - " {\n", - " \"id\": 1936330398,\n", - " \"type\": \"target\",\n", - " \"id_src\": 995920086,\n", - " \"label\": \"Lady Liberators\",\n", - " \"type_\": 1\n", - " },\n", - " {\n", - " \"id\": 168902329,\n", - " \"type\": \"target\",\n", - " \"id_src\": 2142361735,\n", - " \"label\": \"She-Hulk\",\n", - " \"type_\": 0\n", - " }\n", - " ],\n", - " \"links\": [\n", - " {\n", - " \"source\": 1934515491,\n", - " \"target\": 168902329,\n", - " \"type\": \"match\",\n", - " \"weight\": 0.9869624795392156\n", - " },\n", - " {\n", - " \"source\": 1325512080,\n", - " \"target\": 1936330398,\n", - " \"type\": \"match\",\n", - " \"weight\": 0.9746778514236212\n", - " },\n", - " {\n", - " \"source\": 1876249886,\n", - " \"target\": 715503065,\n", - " \"type\": \"match\",\n", - " \"weight\": 0.9651097469031811\n", - " },\n", - " {\n", - " \"source\": 1934515491,\n", - " \"target\": 1325512080,\n", - " \"type\": \"query\",\n", - " \"weight\": 1.0\n", - " },\n", - " {\n", - " \"source\": 1325512080,\n", - " \"target\": 1876249886,\n", - " \"type\": \"query\",\n", - " \"weight\": 1.0\n", - " },\n", - " {\n", - " \"source\": 1936330398,\n", - " \"target\": 168902329,\n", - " \"type\": \"target\",\n", - " \"weight\": 1.0\n", - " }\n", - " ]\n", - " }\n", - " ],\n", - " \"iters\": 2,\n", - " \"hopping_distance\": 2,\n", - " \"max_iters\": 10\n", - "}\n" - ] - } - ], - "source": [ - "print(json.dumps(results, indent=4))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Draw the result using networkx" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "graph = results['graphs'][0]\n", - "G = nx.json_graph.node_link_graph(graph)\n", - "nodes = G.nodes()\n", - "cols = []\n", - "for k in G.nodes():\n", - " if nodes[k]['type'] == 'query':\n", - " cols.append('r')\n", - " elif nodes[k]['type'] == 'target':\n", - " cols.append('b')" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/dstaff/anaconda3/envs/fornax/lib/python3.6/site-packages/networkx/drawing/nx_pylab.py:611: MatplotlibDeprecationWarning: isinstance(..., numbers.Number)\n", - " if cb.is_numlike(alpha):\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "nx.draw(\n", - " G, \n", - " with_labels=True, \n", - " labels={n['id']:n['label'] for n in graph['nodes']}, \n", - " node_color=cols\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Write the resulting supgraph match using another graph format " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "nx.write_gexf(G, 'my_graph.gexf')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "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.5" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/requirements/dev.txt b/requirements/dev.txt index 046cae0..8013904 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,5 @@ -r common.txt xmlrunner coveralls +sphinx>=1.4 +nbsphinx \ No newline at end of file diff --git a/test/test_api.py b/test/test_api.py index c3f7431..188f7b9 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -5,84 +5,84 @@ from test_base import TestCaseDB from sqlalchemy.orm.session import Session from unittest import TestCase -from fornax.api import _hash class TestGraph(TestCaseDB): - @classmethod - def setUp(self): - """trick fornax into using the test database setup - """ - super().setUp(self) - fornax.api.Session = lambda: Session(self._connection) + def run(self, result=None): + with fornax.Connection() as conn: + self.conn = conn + self.conn.make_session = lambda: Session(self._connection) + super().run(result) def test_init_raises(self): """ raise an ValueError if a hadle to a graph is constructed that does not exist """ - self.assertRaises(ValueError, fornax.GraphHandle, 0) - self.assertRaises(ValueError, fornax.GraphHandle.read, 0) + self.assertRaises(ValueError, fornax.GraphHandle, self.conn, 0) + self.assertRaises(ValueError, fornax.GraphHandle.read, self.conn, 0) def test_create(self): """first graph has id zero """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) self.assertEqual(graph.graph_id, 0) def test_create_two(self): """auto increment graph id """ - _ = fornax.GraphHandle.create() - second = fornax.GraphHandle.create() + _ = fornax.GraphHandle.create(self.conn) + second = fornax.GraphHandle.create(self.conn) self.assertEqual(second.graph_id, 1) def test_read(self): """get a graph handle using graph id """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph_id = graph.graph_id - same_graph = fornax.GraphHandle.read(graph_id) + same_graph = fornax.GraphHandle.read(self.conn, graph_id) self.assertEqual(same_graph.graph_id, graph_id) def test_delete(self): """getting a deleted graph should raise a value error """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph.delete() - self.assertRaises(ValueError, fornax.GraphHandle.read, 0) + self.assertRaises(ValueError, fornax.GraphHandle.read, self.conn, 0) def test_add_nodes(self): """meta data is stored on a node """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] graph.add_nodes(name=names) - nodes = self.session.query(fornax.model.Node).filter( - fornax.model.Node.graph_id == 0).all() - nodes = sorted(nodes, key=lambda node: node.node_id) - self.assertListEqual( - names, [json.loads(node.meta)['name'] for node in nodes]) + with self.conn._get_session() as session: + nodes = self.session.query(fornax.model.Node).filter( + fornax.model.Node.graph_id == 0).all() + nodes = sorted(nodes, key=lambda node: node.node_id) + self.assertListEqual( + names, [json.loads(node.meta)['name'] for node in nodes]) def test_add_nodes_more_meta(self): """multiple metadata is stored on a node """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] ages = [9, 10, 11] graph.add_nodes(name=names, age=ages) - nodes = self.session.query(fornax.model.Node).filter( - fornax.model.Node.graph_id == 0).all() - nodes = sorted(nodes, key=lambda node: node.node_id) - self.assertListEqual( - names, [json.loads(node.meta)['name'] for node in nodes]) - self.assertListEqual( - ages, [json.loads(node.meta)['age'] for node in nodes]) + with self.conn._get_session() as session: + nodes = session.query(fornax.model.Node).filter( + fornax.model.Node.graph_id == 0).all() + nodes = sorted(nodes, key=lambda node: node.node_id) + self.assertListEqual( + names, [json.loads(node.meta)['name'] for node in nodes]) + self.assertListEqual( + ages, [json.loads(node.meta)['age'] for node in nodes]) def test_missing_attribute(self): """Null values for metadata must be explicit """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] ages = [9, 10] self.assertRaises(TypeError, graph.add_nodes, name=names, age=ages) @@ -90,40 +90,41 @@ def test_missing_attribute(self): def test_assign_id(self): """assigning node id is forbidden """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) ids = range(3) self.assertRaises(ValueError, graph.add_nodes, id=ids) def test_no_metadata(self): """Nodes must have some metadata associated with them """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) self.assertRaises(ValueError, graph.add_nodes) def test_add_edges(self): """store metadata on edges """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] ages = [9, 10, 11] graph.add_nodes(name=names, age=ages) relationships = ['is_friend', 'is_foe'] graph.add_edges([0, 0], [1, 2], relationship=relationships) - edges = self.session.query( - fornax.model.Edge - ).filter( - fornax.model.Edge.graph_id == graph.graph_id - ).filter( - fornax.model.Edge.start < fornax.model.Edge.end - ).all() - edges = sorted(edges, key=lambda edge: (edge.start, edge.end)) - self.assertListEqual(relationships, [json.loads( - edge.meta)['relationship'] for edge in edges]) + with self.conn._get_session() as session: + edges = session.query( + fornax.model.Edge + ).filter( + fornax.model.Edge.graph_id == graph.graph_id + ).filter( + fornax.model.Edge.start < fornax.model.Edge.end + ).all() + edges = sorted(edges, key=lambda edge: (edge.start, edge.end)) + self.assertListEqual(relationships, [json.loads( + edge.meta)['relationship'] for edge in edges]) def test_add_edges_more_meta(self): """store multiple metadata on edges """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] ages = [9, 10, 11] graph.add_nodes(name=names, age=ages) @@ -131,25 +132,26 @@ def test_add_edges_more_meta(self): types = [0, 1] graph.add_edges( [0, 0], [1, 2], relationship=relationships, type_=types) - edges = self.session.query( - fornax.model.Edge - ).filter( - fornax.model.Edge.graph_id == graph.graph_id - ).filter( - fornax.model.Edge.start < fornax.model.Edge.end - ).all() - edges = sorted(edges, key=lambda edge: (edge.start, edge.end)) - self.assertListEqual(relationships, [json.loads( - edge.meta)['relationship'] for edge in edges]) - self.assertListEqual( - types, [json.loads(edge.meta)['type_'] for edge in edges]) + with self.conn._get_session() as session: + edges = session.query( + fornax.model.Edge + ).filter( + fornax.model.Edge.graph_id == graph.graph_id + ).filter( + fornax.model.Edge.start < fornax.model.Edge.end + ).all() + edges = sorted(edges, key=lambda edge: (edge.start, edge.end)) + self.assertListEqual(relationships, [json.loads( + edge.meta)['relationship'] for edge in edges]) + self.assertListEqual( + types, [json.loads(edge.meta)['type_'] for edge in edges]) def test_simple_graph(self): """Test for a simple graph. A simple graph is a graph with no loops. A loop is an edge that connects a vertex to itself """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) names = ['adam', 'ben', 'chris'] ages = [9, 10, 11] graph.add_nodes(name=names, age=ages) @@ -157,110 +159,116 @@ def test_simple_graph(self): 1, 0], [1, 2], relationship=['is_friend', 'is_foe']) def test_add_nodes_id_src(self): - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph.add_nodes(id_src=['a', 'b', 'c', 'd']) graph.add_edges(['a', 'b'], ['b', 'c']) - nodes = self.session.query(fornax.model.Node).all() - self.assertEqual( - [n.node_id for n in nodes], - [_hash(item) for item in ('a', 'b', 'c', 'd')] - ) + with self.conn._get_session() as session: + nodes = session.query(fornax.model.Node).all() + self.assertEqual( + [n.node_id for n in nodes], + [self.conn._hash(item) for item in ('a', 'b', 'c', 'd')] + ) def test_add_nodes_id_src_meta(self): - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph.add_nodes(id_src=['a', 'b', 'c', 'd']) graph.add_edges(['a', 'b'], ['b', 'c']) - nodes = self.session.query(fornax.model.Node).all() - self.assertEqual( - [json.loads(n.meta)['id_src'] for n in nodes], - ['a', 'b', 'c', 'd'] - ) + with self.conn._get_session() as session: + nodes = session.query(fornax.model.Node).all() + self.assertEqual( + [json.loads(n.meta)['id_src'] for n in nodes], + ['a', 'b', 'c', 'd'] + ) def test_add_edges_id_src(self): - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph.add_nodes(id_src=['a', 'b', 'c', 'd']) graph.add_edges(['a', 'b'], ['b', 'c']) - edges = self.session.query( - fornax.model.Edge - ).filter( - fornax.model.Edge.start < fornax.model.Edge.end - ).all() - self.assertEqual( - sorted([e.start, e.end] for e in edges), - sorted( - sorted([_hash(start), _hash(end)]) - for start, end in [('a', 'b'), ('b', 'c')] + with self.conn._get_session() as session: + edges = session.query( + fornax.model.Edge + ).filter( + fornax.model.Edge.start < fornax.model.Edge.end + ).all() + self.assertEqual( + sorted([e.start, e.end] for e in edges), + sorted( + sorted([self.conn._hash(start), self.conn._hash(end)]) + for start, end in [('a', 'b'), ('b', 'c')] + ) ) - ) class TestQuery(TestCaseDB): - @classmethod - def setUp(self): - """trick fornax into using the test database setup - """ - super().setUp(self) - fornax.api.Session = lambda: Session(self._connection) + def run(self, result=None): + with fornax.Connection() as conn: + self.conn = conn + self.conn.make_session = lambda: Session(self._connection) + super().run(result) def test_init_query_raises(self): - self.assertRaises(ValueError, fornax.QueryHandle, 0) + self.assertRaises(ValueError, fornax.QueryHandle, self.conn, 0) def test_init_read_raises(self): - self.assertRaises(ValueError, fornax.QueryHandle.read, 0) + self.assertRaises(ValueError, fornax.QueryHandle.read, self.conn, 0) def test_create(self): - query_graphs = [fornax.GraphHandle.create() for _ in range(3)] - target_graphs = [fornax.GraphHandle.create() for _ in range(3)] + query_graphs = [fornax.GraphHandle.create(self.conn) for _ in range(3)] + target_graphs = [ + fornax.GraphHandle.create(self.conn) for _ in range(3) + ] queries = [fornax.QueryHandle.create( - q, t) for q, t in zip(query_graphs, target_graphs)] + self.conn, q, t) for q, t in zip(query_graphs, target_graphs)] self.assertEqual([q.query_id for q in queries], [0, 1, 2]) def test_create_query_target(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) - query_db = self.session.query(fornax.model.Query).filter( - fornax.model.Query.query_id == query.query_id).first() - self.assertEqual(query_db.start_graph_id, query_graph.graph_id) - self.assertEqual(query_db.end_graph_id, target_graph.graph_id) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) + with self.conn._get_session() as session: + query_db = session.query(fornax.model.Query).filter( + fornax.model.Query.query_id == query.query_id).first() + self.assertEqual(query_db.start_graph_id, query_graph.graph_id) + self.assertEqual(query_db.end_graph_id, target_graph.graph_id) def test_read(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - q1 = fornax.QueryHandle.create(query_graph, target_graph) - q2 = fornax.QueryHandle.read(q1.query_id) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + q1 = fornax.QueryHandle.create(self.conn, query_graph, target_graph) + q2 = fornax.QueryHandle.read(self.conn, q1.query_id) self.assertEqual(q1.query_id, q2.query_id) def test_delete(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) query_id = query.query_id query.delete() - query_exists = self.session.query(fornax.model.Query).filter( - fornax.model.Query.query_id == query_id).scalar() - matches_exists = self.session.query(fornax.model.Match).filter( - fornax.model.Match.query_id == query_id).scalar() + with self.conn._get_session() as session: + query_exists = session.query(fornax.model.Query).filter( + fornax.model.Query.query_id == query_id).scalar() + matches_exists = session.query(fornax.model.Match).filter( + fornax.model.Match.query_id == query_id).scalar() self.assertFalse(query_exists) self.assertFalse(matches_exists) def test_get_query_graph(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) self.assertEqual(query.query_graph(), query_graph) def test_get_target_graph(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) self.assertEqual(query.target_graph(), target_graph) def test_query_nodes(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) q_uids = [0, 1, 2] t_uids = [3, 4, 5] query_graph.add_nodes(uid=q_uids) @@ -273,9 +281,9 @@ def test_query_nodes(self): ) def test_query_edges(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) q_uids = [0, 1, 2] t_uids = [3, 4, 5] query_graph.add_nodes(uid=q_uids) @@ -289,9 +297,9 @@ def test_query_edges(self): ) def test_target_nodes(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) q_uids = [0, 1, 2] t_uids = [3, 4, 5] query_graph.add_nodes(id_src=q_uids) @@ -310,17 +318,20 @@ def test_target_nodes(self): def test_undirected_edges(self): """Each edge needs to be stored in both directions """ - graph = fornax.GraphHandle.create() + graph = fornax.GraphHandle.create(self.conn) graph.add_nodes(myid=[1, 2, 3]) graph.add_edges([0], [1]) - src = [(e.start, e.end) - for e in self.session.query(fornax.model.Edge).all()] + with self.conn._get_session() as session: + src = [ + (e.start, e.end) + for e in session.query(fornax.model.Edge).all() + ] self.assertListEqual(sorted(src), [(0, 1), (1, 0)]) def test_target_edges(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) uids = [0, 1] query_graph.add_nodes(uid=range(3)) target_graph.add_nodes(uid=range(3)) @@ -342,9 +353,9 @@ def test_target_edges(self): ) def test_add_matches(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) uids = [0, 1] query_graph.add_nodes(uid=range(3)) target_graph.add_nodes(uid=range(3)) @@ -361,9 +372,9 @@ def test_add_matches(self): self.assertEqual(uids, [json.loads(m.meta)['my_id'] for m in matches]) def test_execute_raises(self): - query_graph = fornax.GraphHandle.create() - target_graph = fornax.GraphHandle.create() - query = fornax.QueryHandle.create(query_graph, target_graph) + query_graph = fornax.GraphHandle.create(self.conn) + target_graph = fornax.GraphHandle.create(self.conn) + query = fornax.QueryHandle.create(self.conn, query_graph, target_graph) self.assertRaises(ValueError, query.execute) @@ -385,12 +396,6 @@ def test_eq(self): self.assertNotEqual(self.node, fornax.api.Node(1, 'query', {'a': 0})) self.assertNotEqual(self.node, fornax.api.Node(0, 'target', {'a': 1})) - def test_to_dict(self): - self.assertDictEqual( - self.node.to_dict(), {'id': _hash( - (0, 'query')), 'type': 'query', 'a': 1} - ) - def test_node_raises(self): self.assertRaises(ValueError, fornax.api.Node, 0, 'a', {}) @@ -431,49 +436,51 @@ def setUp(self): """trick fornax into using the test database setup """ super().setUp(self) - fornax.api.Session = lambda: Session(self._connection) - - query_graph = fornax.GraphHandle.create() - query_graph.add_nodes(my_id=range(1, 6)) - starts, ends = zip(*[(1, 3), (1, 2), (2, 4), (4, 5)]) - query_graph.add_edges( - [i - 1 for i in starts], - [i - 1 for i in ends] - ) + self.maxsize = fornax.Connection.SQLITE_MAX_SIZE + with fornax.Connection() as conn: + conn.make_session = lambda: Session(self._connection) + query_graph = fornax.GraphHandle.create(conn) + query_graph.add_nodes(my_id=range(1, 6)) + starts, ends = zip(*[(1, 3), (1, 2), (2, 4), (4, 5)]) + query_graph.add_edges( + [i - 1 for i in starts], + [i - 1 for i in ends] + ) - target_graph = fornax.GraphHandle.create() - target_graph.add_nodes(my_id=range(1, 14)) - starts, ends = zip(*[ - (1, 2), (1, 3), (1, 4), - (3, 7), (4, 5), (4, 6), - (5, 7), (6, 8), (7, 10), - (8, 9), (8, 12), (9, 10), - (10, 11), (11, 12), (11, 13), - ]) - target_graph.add_edges( - [s - 1 for s in starts], - [e - 1 for e in ends] - ) + target_graph = fornax.GraphHandle.create(conn) + target_graph.add_nodes(my_id=range(1, 14)) + starts, ends = zip(*[ + (1, 2), (1, 3), (1, 4), + (3, 7), (4, 5), (4, 6), + (5, 7), (6, 8), (7, 10), + (8, 9), (8, 12), (9, 10), + (10, 11), (11, 12), (11, 13), + ]) + target_graph.add_edges( + [s - 1 for s in starts], + [e - 1 for e in ends] + ) - query = fornax.QueryHandle.create( - query_graph, - target_graph - ) - starts, ends, weights = zip(*[ - (1, 1, 1), (1, 4, 1), (1, 8, 1), - (2, 2, 1), (2, 5, 1), (2, 9, 1), - (3, 3, 1), (3, 6, 1), (3, 12, 1), (3, 13, 1), - (4, 7, 1), (4, 10, 1), - (5, 11, 1) - ]) - - query.add_matches( - [s - 1 for s in starts], - [e - 1 for e in ends], - weights - ) + query = fornax.QueryHandle.create( + conn, + query_graph, + target_graph + ) + starts, ends, weights = zip(*[ + (1, 1, 1), (1, 4, 1), (1, 8, 1), + (2, 2, 1), (2, 5, 1), (2, 9, 1), + (3, 3, 1), (3, 6, 1), (3, 12, 1), (3, 13, 1), + (4, 7, 1), (4, 10, 1), + (5, 11, 1) + ]) + + query.add_matches( + [s - 1 for s in starts], + [e - 1 for e in ends], + weights + ) - self.payload = query.execute(n=2) + self.payload = query.execute(n=2) def test_iters(self): self.assertEqual(self.payload['max_iters'], 10) @@ -500,14 +507,19 @@ def test_first_graph_nodes(self): {"id": 11, "type": "target", "my_id": 12} ] for node in nodes: - node['id'] = _hash((node['id'], node['type'])) + node['id'] = fornax.api._hash( + (node['id'], node['type']), + self.maxsize + ) self.assertListEqual( graph['nodes'], nodes ) def test_first_graph_links(self): + graph = self.payload['graphs'][0] + matches = [ {"source": 0, "target": 7, "type": "match", "weight": 1.0}, {"source": 1, "target": 8, "type": "match", "weight": 1.0}, @@ -524,13 +536,33 @@ def test_first_graph_links(self): {"source": 9, "target": 10, "type": "target", "weight": 1.0}, {"source": 10, "target": 11, "type": "target", "weight": 1.0}, ] + for match in matches: + if match['type'] == 'query' or match['type'] == 'target': - match['source'] = _hash((match['source'], match['type'])) - match['target'] = _hash((match['target'], match['type'])) + + match['source'] = fornax.api._hash( + (match['source'], match['type']), + self.maxsize + ) + + match['target'] = fornax.api._hash( + (match['target'], match['type']), + self.maxsize + ) + else: - match['source'] = _hash((match['source'], 'query')) - match['target'] = _hash((match['target'], 'target')) + + match['source'] = fornax.api._hash( + (match['source'], 'query'), + self.maxsize + ) + + match['target'] = fornax.api._hash( + (match['target'], 'target'), + self.maxsize + ) + self.assertListEqual(graph['links'], matches) def test_second_graph_cost(self): @@ -552,7 +584,11 @@ def test_second_graph_nodes(self): {"id": 10, "type": "target", "my_id": 11}, ] for node in nodes: - node['id'] = _hash((node['id'], node['type'])) + node['id'] = fornax.api._hash( + (node['id'], node['type']), + self.maxsize + ) + self.assertListEqual( graph['nodes'], nodes @@ -575,11 +611,31 @@ def test_second_graph_links(self): {"source": 8, "target": 9, "type": "target", "weight": 1.0}, {"source": 9, "target": 10, "type": "target", "weight": 1.0}, ] + for match in matches: + if match['type'] == 'query' or match['type'] == 'target': - match['source'] = _hash((match['source'], match['type'])) - match['target'] = _hash((match['target'], match['type'])) + + match['source'] = fornax.api._hash( + (match['source'], match['type']), + self.maxsize + ) + + match['target'] = fornax.api._hash( + (match['target'], match['type']), + self.maxsize + ) + elif match['type'] == 'match': - match['source'] = _hash((match['source'], 'query')) - match['target'] = _hash((match['target'], 'target')) + + match['source'] = fornax.api._hash( + (match['source'], 'query'), + self.maxsize + ) + + match['target'] = fornax.api._hash( + (match['target'], 'target'), + self.maxsize + ) + self.assertListEqual(graph['links'], matches) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..347011d --- /dev/null +++ b/tox.ini @@ -0,0 +1,4 @@ +[tox] +envlist = py36 +[testenv] +commands=python3 -m unittest discover -v -s ./test -p "test_*.py" \ No newline at end of file